Commit 27c7b6d1 by DaiJiezhang

Merge branch 'codex/phone-account-sync-verify' into 'master'

企业微信与手机号资产相关改动

See merge request !2
parents 9a51f1ba 028e5bb6
package com.xyw.console.asset.config;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
import jakarta.servlet.MultipartConfigElement;
/** 文件用途(白话):把应用 multipart 请求上限设为可容纳两张 20MB 设备图片和少量表单字段的安全大小。 */
@Configuration
public class DeviceAssetMultipartConfig {
/** 代码作用(白话):创建 Spring 上传限制配置。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):浏览器 multipart 请求 -> Spring 大小检查 -> Controller 或错误 JSON。 */
@Bean public MultipartConfigElement multipartConfigElement() { MultipartConfigFactory factory=new MultipartConfigFactory(); factory.setMaxFileSize(DataSize.ofMegabytes(20)); factory.setMaxRequestSize(DataSize.ofMegabytes(42)); return factory.createMultipartConfig(); }
}
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/** 文件用途(白话):提供设备资产列表、增改删、使用人搜索和受控图片读取的浏览器接口。 */
@RestController
@RequestMapping("/api/device-assets")
public class DeviceAssetController {
private final DeviceAssetService service;
/** 代码作用(白话):接收设备业务服务。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP Controller -> Service -> Mapper/文件服务。 */
public DeviceAssetController(DeviceAssetService service) { this.service=service; }
/** 代码作用(白话):返回设备分页列表。关联文件:DeviceAssetPageQuery.java、DeviceAssetService.java。关联逻辑(调用链/数据流):GET 参数 -> page -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<DeviceAssetPageResponse> page(@Valid DeviceAssetPageQuery query) { return ApiResponse.success(service.page(query)); }
/** 代码作用(白话):创建设备及可选图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart POST -> create -> 文件/数据库 -> JSON 响应。 */
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> create(@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("新增成功",service.create(request)); }
/** 代码作用(白话):编辑有效设备并可替换或移除图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart PUT -> update -> 文件/数据库 -> JSON 响应。 */
@PutMapping(value="/{id}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> update(@PathVariable Long id,@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("编辑成功",service.update(id,request)); }
/** 代码作用(白话):在没有有效引用时软删除设备。关联文件:DeviceAssetService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):DELETE -> 引用检查 -> deleteTime 更新 -> 前端刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id) { service.softDelete(id); return ApiResponse.success("删除成功",null); }
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); }
}
package com.xyw.console.asset.controller;
import com.xyw.console.asset.exception.DeviceAssetNotFoundException;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
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;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/** 文件用途(白话):把设备资产的业务异常转换成前端可读的统一 JSON 错误。 */
@RestControllerAdvice(assignableTypes = DeviceAssetController.class)
public class DeviceAssetExceptionHandler {
/** 代码作用(白话):把字段、图片和引用保护失败转为 400。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):Service 校验异常 -> Advice -> ApiResponse -> 前端提示。 */
@ExceptionHandler(DeviceAssetValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> validation(DeviceAssetValidationException exception) { return ApiResponse.error(400, exception.getMessage()); }
/** 代码作用(白话):把不存在的设备转为 404。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):Service 查无设备 -> Advice -> ApiResponse -> 前端提示。 */
@ExceptionHandler(DeviceAssetNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> notFound(DeviceAssetNotFoundException exception) { return ApiResponse.error(404, exception.getMessage()); }
/** 代码作用(白话):把超过整个 multipart 请求限制的上传转为可读错误。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):浏览器上传 -> Spring 限制 -> Advice -> 前端提示。 */
@ExceptionHandler(MaxUploadSizeExceededException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> uploadTooLarge(MaxUploadSizeExceededException exception) { return ApiResponse.error(400, "两张图片及表单总大小不能超过 42MB"); }
}
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.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供企微资产列表的只读 HTTP 入口,让前端无需直接访问数据库。 */
/** File purpose (plain language): exposes enterprise WeChat asset list, creation, and read-only selector endpoints to the frontend. */
@RestController
@RequestMapping("/api/wecom-accounts")
public class WecomAccountController {
private final WecomAccountService service;
/**
* 代码作用(白话):接收企微资产查询服务,让 HTTP 请求能进入统一的分页查询逻辑。
* 关联文件:WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。
*/
public WecomAccountController(WecomAccountService service) {
this.service = service;
}
/**
* 代码作用(白话):接收浏览器的分页和筛选参数,返回统一 JSON 格式的企微资产列表。
* 关联文件:WecomAccountPageQuery.java、WecomAccountService.java、wecom-api-client.js。
* 关联逻辑(调用链/数据流):GET /api/wecom-accounts -> Service.page -> ApiResponse -> Vue 表格。
*/
/** Code purpose (plain language): receives the service that coordinates WeCom assets and referenced assets. Related files: WecomAccountService.java. Data flow: HTTP controller -> service -> mappers. */
public WecomAccountController(WecomAccountService service) { this.service = service; }
/** Code purpose (plain language): saves a new enterprise WeChat asset. Related files: WecomAccountSaveRequest.java, WecomAccountService.java. Data flow: create dialog -> POST -> transaction -> response. */
@PostMapping
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { return ApiResponse.success("新增成功", service.create(request)); }
/** 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) {
return ApiResponse.success(service.page(query));
}
}
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { return ApiResponse.success(service.page(query)); }
/** Code purpose (plain language): searches registration subjects by company name or short name. Related files: CompanyProfileLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-profiles")
public ApiResponse<List<CompanyProfileLookupResponse>> companyProfiles(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyProfiles(keyword)); }
/** Code purpose (plain language): searches reusable registration phone assets. Related files: PhoneAssetLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchPhoneAssets(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) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
}
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载企微号归属人的搜索结果,供企业微信资产表单选择公司人员。 */
public record CompanyPersonLookupResponse(Long id, String personName) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载注册主体搜索结果,避免把完整公司档案暴露给新增企业微信表单。 */
public record CompanyProfileLookupResponse(Long id, String companyName, String shortName) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收设备资产列表的分页与筛选条件,不承载新增或编辑表单数据。 */
public record DeviceAssetPageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
String deviceName,
Long userPersonId,
String userUsageStatus,
String assetRelationStatus) {
/** 代码作用(白话):未传页码时返回第 1 页。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedPage -> MyBatis 分页。 */
public int resolvedPage() { return page == null ? 1 : page; }
/** 代码作用(白话):未传每页数量时返回 20 条,避免一次读取过多设备。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedSize -> Mapper 分页。 */
public int resolvedSize() { return size == null ? 20 : size; }
}
package com.xyw.console.asset.dto;
import java.util.List;
/** 文件用途(白话):承载设备列表记录、总数和分页信息,供前端表格与分页器使用。 */
public record DeviceAssetPageResponse(List<DeviceAssetResponse> records, long total, int page, int size) {}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。 */
public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url,
Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus,
LocalDateTime createTime, LocalDateTime updateTime) {}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
/** 文件用途(白话):接收设备新增或编辑表单,包括设备字段、两张可选图片与单独移除图片的开关。 */
@Data
public class DeviceAssetSaveRequest {
@NotBlank(message = "设备名称不能为空") private String deviceName;
private Long userPersonId;
@NotBlank(message = "请选择使用状态") private String userUsageStatus;
@NotBlank(message = "请选择资产关联状态") private String assetRelationStatus;
private MultipartFile imageAttachment1;
private MultipartFile imageAttachment2;
private Boolean removeImageAttachment1;
private Boolean removeImageAttachment2;
}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载设备表单使用人搜索的最小信息,避免下拉框读取完整人员档案。 */
public record DevicePersonLookupResponse(Long id, String personName) {}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载注册手机号搜索结果,供企业微信资产选择已有手机号时复用。 */
public record PhoneAssetLookupResponse(Long id, String phoneNumber, String numberType) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
public record PhoneAssetResponse(Long id,String phoneNumber,String cardType,String iccid,String realNameOwner,String managementType,String disposalStatus,Long deviceId,LocalDateTime relationSyncedAt) {}
\ No newline at end of file
public record PhoneAssetResponse(Long id,String phoneNumber,String numberType,String sourceAssetType,Long sourceAssetId,String cardType,String iccid,String realNameOwner,String managementType,String disposalStatus,Long deviceId,LocalDateTime relationSyncedAt) {}
\ No newline at end of file
......@@ -3,28 +3,19 @@ package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收企微资产列表的分页和筛选参数,避免 Controller 直接处理零散 URL 参数。 */
/** 文件用途(白话):接收企业微信资产列表的分页与组合筛选参数。 */
public record WecomAccountPageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
String wecomName,
String wecomAccount) {
String keyword,
String wecomAccount,
Long phoneAssetId,
Long companyProfileId,
String realNameOwnerStatus) {
/**
* 代码作用(白话):在调用方没有传页码时返回第 1 页,保证列表可以直接打开。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedPage -> MyBatis Page -> 列表响应。
*/
public int resolvedPage() {
return page == null ? 1 : page;
}
/** 代码作用(白话):未传页码时返回第一页。关联文件:WecomAccountController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedPage -> MyBatis 分页。 */
public int resolvedPage() { return page == null ? 1 : page; }
/**
* 代码作用(白话):在调用方没有传每页数量时使用 20 条,并由注解阻止一次查询过多记录。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedSize -> MyBatis Page -> 列表响应。
*/
public int resolvedSize() {
return size == null ? 20 : size;
}
}
/** 代码作用(白话):未传每页条数时返回 20 条。关联文件:WecomAccountController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedSize -> MyBatis 分页。 */
public int resolvedSize() { return size == null ? 20 : size; }
}
\ No newline at end of file
......@@ -12,6 +12,7 @@ public record WecomAccountResponse(
String companyProfileName,
Long phoneAssetId,
String phoneNumber,
String phoneLinkMode,
String realNameOwner,
String realNameOwnerStatus,
String gender,
......
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
/** ???????????????????????? Controller ??????????? */
public record WecomAccountSaveRequest(
@NotBlank String wecomName,
String wecomAlias,
String wecomAccount,
Long companyProfileId,
@NotBlank String phoneNumber,
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long operatorPersonId) {
}
......@@ -21,6 +21,9 @@ public class PhoneAssetEntity extends AssetBaseEntity {
private String managementType;
private String disposalStatus;
private Long deviceId;
private String numberType;
private String sourceAssetType;
private Long sourceAssetId;
private String linkedWecomAccounts;
private String linkedWechatAccounts;
private String linkedDouyinAccounts;
......
......@@ -19,6 +19,7 @@ public class WecomAccountEntity extends AssetBaseEntity {
private Long companyProfileId;
private String wecomAccount;
private Long phoneAssetId;
private String phoneLinkMode;
private String realNameOwner;
private String realNameOwnerStatus;
private String gender;
......
package com.xyw.console.asset.exception;
/** 文件用途(白话):表示请求的设备不存在或已经软删除。 */
public class DeviceAssetNotFoundException extends RuntimeException {
/** 代码作用(白话):保存可返回给页面的设备不存在原因。关联文件:DeviceAssetService.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> HTTP 404 响应。 */
public DeviceAssetNotFoundException(String message) { super(message); }
}
package com.xyw.console.asset.exception;
/** 文件用途(白话):表示设备字段、图片或删除引用检查未通过。 */
public class DeviceAssetValidationException extends RuntimeException {
/** 代码作用(白话):保存可返回给页面的规则校验失败原因。关联文件:DeviceAssetService.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):表单/删除请求 -> Service 校验 -> HTTP 400 响应。 */
public DeviceAssetValidationException(String message) { super(message); }
}
package com.xyw.console.asset.service;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
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.Locale;
import java.util.Set;
import java.util.UUID;
import javax.imageio.ImageIO;
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");
private final Path root;
/** 代码作用(白话):解析并创建设备图片根目录。关联文件:DeviceAssetService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):配置/默认目录 -> 文件服务 -> 保存和读取图片。 */
public DeviceAssetFileStorageService(@Value("${xyw.device-assets.upload-dir:./uploads/device-assets}") String uploadDirectory) {
try { root = Path.of(uploadDirectory).toAbsolutePath().normalize(); Files.createDirectories(root); }
catch (IOException exception) { throw new IllegalStateException("设备图片目录无法创建", exception); }
}
/** 代码作用(白话):保存一张已通过校验的原图并返回不透明文件标识。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart 图片 -> store -> 文件标识 -> as_asset_device 附件列。 */
public String store(MultipartFile image) {
if (image == null || image.isEmpty()) return null;
validateImage(image);
String identifier = createOpaqueIdentifier(extensionOf(image.getOriginalFilename()));
Path target = resolveInsideRoot(identifier);
try (InputStream input = image.getInputStream()) { Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); return identifier; }
catch (IOException exception) { throw new DeviceAssetValidationException("设备图片保存失败"); }
}
/** 代码作用(白话):把不透明标识解析为受目录约束的可读取资源。关联文件: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("图片读取失败"); }
}
/** 代码作用(白话):保存替换图或保留旧标识,避免编辑未选图时丢失原图。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):编辑表单 -> replace -> 新/旧标识 -> 设备更新。 */
public String replace(String currentIdentifier, MultipartFile replacement, boolean remove) {
if (replacement != null && !replacement.isEmpty()) return store(replacement);
return remove ? null : currentIdentifier;
}
/** 代码作用(白话):清空数据库中的附件引用但保留物理原图,以支持软删除后追溯。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):移除图片标记 -> removeReference -> 数据库字段置空 -> 原图保留。 */
public String removeReference() { return null; }
/** 代码作用(白话):删除本次失败请求新写入的文件,不接收历史附件标识。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):保存失败 -> cleanupNewFile -> 删除临时新图。 */
public void cleanupNewFile(String identifier) {
if (identifier == null) return;
try { Files.deleteIfExists(resolveInsideRoot(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("图片内容无效"); }
}
/** 代码作用(白话):生成不包含原始文件名的随机标识,降低猜测路径风险。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):上传文件 -> 随机标识 -> 受控访问 URL。 */
private String createOpaqueIdentifier(String extension) { return UUID.randomUUID() + "." + extension; }
/** 代码作用(白话):从原始文件名提取允许的小写扩展名。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):文件名 -> extensionOf -> 类型校验与目标文件名。 */
private String extensionOf(String originalFilename) {
String name = originalFilename == null ? "" : originalFilename.trim(); int dot = name.lastIndexOf('.');
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
/** 代码作用(白话):只允许标准随机标识定位根目录内文件,阻止路径穿越。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):URL 文件标识 -> resolveInsideRoot -> 本地资源。 */
private Path resolveInsideRoot(String identifier) {
if (!identifier.matches("[0-9a-fA-F-]{36}\\.(jpg|jpeg|png|gif)")) throw new DeviceAssetValidationException("图片标识无效");
Path resolved = root.resolve(identifier).normalize();
if (!resolved.startsWith(root)) throw new DeviceAssetValidationException("图片标识无效");
return resolved;
}
}
package com.xyw.console.asset.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.entity.*;
import com.xyw.console.asset.exception.DeviceAssetNotFoundException;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.mapper.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
/** 文件用途(白话):实现设备资产查询、增改删、使用人解析、图片引用与关联保护规则。 */
@Service
public class DeviceAssetService {
private static final Set<String> USAGE_STATUSES = Set.of("使用中", "闲置", "维修中", "停用");
private static final Set<String> RELATION_STATUSES = Set.of("已关联", "未关联", "待确认");
private final AssetDeviceMapper deviceMapper; private final CompanyPersonMapper personMapper; private final PhoneAssetMapper phoneMapper;
private final WecomAccountMapper wecomMapper; private final WechatAccountMapper wechatMapper; private final DouyinAccountMapper douyinMapper;
private final DeviceAssetFileStorageService fileStorage;
/** 代码作用(白话):接收设备、人员、引用资产和文件服务入口。关联文件:DeviceAssetController.java、各 Mapper。关联逻辑(调用链/数据流):Controller -> Service -> Mapper/文件服务。 */
public DeviceAssetService(AssetDeviceMapper deviceMapper, CompanyPersonMapper personMapper, PhoneAssetMapper phoneMapper,
WecomAccountMapper wecomMapper, WechatAccountMapper wechatMapper, DouyinAccountMapper douyinMapper, DeviceAssetFileStorageService fileStorage) {
this.deviceMapper=deviceMapper; this.personMapper=personMapper; this.phoneMapper=phoneMapper; this.wecomMapper=wecomMapper; this.wechatMapper=wechatMapper; this.douyinMapper=douyinMapper; this.fileStorage=fileStorage;
}
/** 代码作用(白话):按条件读取未删除设备并补齐使用人名称。关联文件:DeviceAssetPageQuery.java、DeviceAssetController.java。关联逻辑(调用链/数据流):GET 参数 -> page -> Mapper -> Response -> Vue 表格。 */
public DeviceAssetPageResponse page(DeviceAssetPageQuery query) {
Page<AssetDeviceEntity> result=deviceMapper.selectPage(new Page<>(query.resolvedPage(),query.resolvedSize()),activeQuery(query));
Map<Long,String> names=personNames(collectIds(result.getRecords(),AssetDeviceEntity::getUserPersonId));
return new DeviceAssetPageResponse(result.getRecords().stream().map(item->toResponse(item,names)).toList(),result.getTotal(),query.resolvedPage(),query.resolvedSize());
}
/** 代码作用(白话):创建一条设备和其两张可选图片引用。关联文件:DeviceAssetSaveRequest.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):multipart POST -> create -> 文件保存 + Mapper.insert -> JSON 响应。 */
public DeviceAssetResponse create(DeviceAssetSaveRequest request) {
validateSaveRequest(request,null); String image1=null,image2=null;
try { image1=fileStorage.store(request.getImageAttachment1()); image2=fileStorage.store(request.getImageAttachment2());
AssetDeviceEntity entity=new AssetDeviceEntity(); applyEditableFields(entity,request,image1,image2); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L);
if(deviceMapper.insert(entity)!=1) throw new IllegalStateException("设备资产新增失败"); return toResponse(entity,personNames(singleId(entity.getUserPersonId())));
} catch (RuntimeException exception) { fileStorage.cleanupNewFile(image1); fileStorage.cleanupNewFile(image2); throw exception; }
}
/** 代码作用(白话):更新有效设备,未选择新图时保留旧图。关联文件:DeviceAssetSaveRequest.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):multipart PUT -> update -> 文件标识调整 + Mapper.update -> 列表刷新。 */
public DeviceAssetResponse update(Long id, DeviceAssetSaveRequest request) {
AssetDeviceEntity entity=requireActiveDevice(id); validateSaveRequest(request,id); String image1=null,image2=null;
try { image1=fileStorage.replace(entity.getImageAttachment1(),request.getImageAttachment1(),Boolean.TRUE.equals(request.getRemoveImageAttachment1())); image2=fileStorage.replace(entity.getImageAttachment2(),request.getImageAttachment2(),Boolean.TRUE.equals(request.getRemoveImageAttachment2()));
applyEditableFields(entity,request,image1,image2); entity.setUpdateTime(LocalDateTime.now()); if(deviceMapper.updateById(entity)!=1) throw new DeviceAssetNotFoundException("设备资产不存在或已删除"); return toResponse(entity,personNames(singleId(entity.getUserPersonId())));
} catch (RuntimeException exception) { if(image1!=null&&!image1.equals(entity.getImageAttachment1())) fileStorage.cleanupNewFile(image1); if(image2!=null&&!image2.equals(entity.getImageAttachment2())) fileStorage.cleanupNewFile(image2); throw exception; }
}
/** 代码作用(白话):在确认没有有效关联资产后软删除设备。关联文件:PhoneAssetEntity.java、WecomAccountEntity.java。关联逻辑(调用链/数据流):DELETE -> 引用检查 -> deleteTime 更新 -> 列表隐藏。 */
public void softDelete(Long id) { AssetDeviceEntity entity=requireActiveDevice(id); checkActiveReferences(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now()); if(deviceMapper.updateById(entity)!=1) throw new DeviceAssetNotFoundException("设备资产不存在或已删除"); }
/** 代码作用(白话):按姓名返回可选择的有效公司人员。关联文件:DeviceAssetController.java、CompanyPersonEntity.java。关联逻辑(调用链/数据流):远程下拉输入 -> lookup -> person Mapper -> option 列表。 */
public List<DevicePersonLookupResponse> searchCompanyPersons(String keyword) { return personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getDeleteTime,0L).like(hasText(keyword),CompanyPersonEntity::getPersonName,keyword).orderByDesc(CompanyPersonEntity::getId)).stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList(); }
/** 代码作用(白话):读取一张受控设备图片。关联文件:DeviceAssetController.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):图片 URL -> findImage -> 文件服务 -> Resource 响应。 */
public Resource findImage(String identifier) { return fileStorage.resolve(identifier); }
/** 代码作用(白话):组合列表筛选和未删除条件。关联文件:DeviceAssetPageQuery.java、AssetDeviceEntity.java。关联逻辑(调用链/数据流):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); }
/** 代码作用(白话):读取一条仍有效设备,供编辑和删除共用。关联文件:AssetDeviceMapper.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404 JSON。 */
private AssetDeviceEntity requireActiveDevice(Long id) { AssetDeviceEntity entity=deviceMapper.selectOne(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getId,id).eq(AssetDeviceEntity::getDeleteTime,0L)); if(entity==null) throw new DeviceAssetNotFoundException("设备资产不存在或已删除"); return entity; }
/** 代码作用(白话):校验设备名称、状态、使用人和有效名称唯一性。关联文件:DeviceAssetSaveRequest.java、CompanyPersonMapper.java。关联逻辑(调用链/数据流):POST/PUT 表单 -> validateSaveRequest -> 允许写库或返回 400。 */
private void validateSaveRequest(DeviceAssetSaveRequest request,Long currentId) { if(request.getDeviceName()==null||request.getDeviceName().isBlank()) throw new DeviceAssetValidationException("设备名称不能为空"); validateStatus(request.getUserUsageStatus(),USAGE_STATUSES,"使用状态"); validateStatus(request.getAssetRelationStatus(),RELATION_STATUSES,"资产关联状态"); validateUserPerson(request.getUserPersonId()); long sameName=deviceMapper.selectCount(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).eq(AssetDeviceEntity::getDeviceName,request.getDeviceName().trim()).ne(currentId!=null,AssetDeviceEntity::getId,currentId)); if(sameName>0) throw new DeviceAssetValidationException("设备名称已存在"); }
/** 代码作用(白话):确认选择的使用人仍有效。关联文件:CompanyPersonEntity.java、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):表单 personId -> Mapper 查询 -> 允许保存或错误提示。 */
private void validateUserPerson(Long userPersonId) { if(userPersonId==null) return; Long count=personMapper.selectCount(new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getId,userPersonId).eq(CompanyPersonEntity::getDeleteTime,0L)); if(count==0) throw new DeviceAssetValidationException("使用人不存在或已删除"); }
/** 代码作用(白话):限制状态只能来自已确认下拉选项。关联文件:DeviceAssetView.js、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):前端下拉/请求参数 -> validateStatus -> 安全写库。 */
private void validateStatus(String value,Set<String> allowed,String label) { if(value==null||!allowed.contains(value)) throw new DeviceAssetValidationException(label+"取值无效"); }
/** 代码作用(白话):查找仍引用设备的四类资产并阻止删除。关联文件:PhoneAssetEntity.java、WecomAccountEntity.java、WechatAccountEntity.java、DouyinAccountEntity.java。关联逻辑(调用链/数据流):DELETE -> 各 Mapper count -> 引用提示或软删除。 */
private void checkActiveReferences(Long deviceId) { List<String> types=new ArrayList<>(); if(phoneMapper.selectCount(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeviceId,deviceId).eq(PhoneAssetEntity::getDeleteTime,0L))>0) types.add("手机号资产"); if(wecomMapper.selectCount(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getDeviceId,deviceId).eq(WecomAccountEntity::getDeleteTime,0L))>0) types.add("企业微信资产"); if(wechatMapper.selectCount(new LambdaQueryWrapper<WechatAccountEntity>().eq(WechatAccountEntity::getDeviceId,deviceId).eq(WechatAccountEntity::getDeleteTime,0L))>0) types.add("微信资产"); if(douyinMapper.selectCount(new LambdaQueryWrapper<DouyinAccountEntity>().eq(DouyinAccountEntity::getDeviceId,deviceId).eq(DouyinAccountEntity::getDeleteTime,0L))>0) types.add("抖音资产"); if(!types.isEmpty()) throw new DeviceAssetValidationException("设备仍被"+String.join("、",types)+"引用,不能删除"); }
/** 代码作用(白话):收集当前页不为空的使用人 ID,供一次性查姓名。关联文件:DeviceAssetResponse.java、CompanyPersonMapper.java。关联逻辑(调用链/数据流):分页记录 -> collectIds -> 批量人员查询。 */
private <T> Set<Long> collectIds(List<T> items,Function<T,Long> getter) { Set<Long> ids=new HashSet<>(); items.forEach(item->{Long id=getter.apply(item);if(id!=null)ids.add(id);}); return ids; }
/** 代码作用(白话):把一个可空人员 ID 转成供批量查询复用的集合。关联文件:DeviceAssetService.java、CompanyPersonMapper.java。关联逻辑(调用链/数据流):单条保存结果 -> singleId -> personNames -> 返回姓名。 */
private Set<Long> singleId(Long id) { return id == null ? Set.of() : Set.of(id); }
/** 代码作用(白话):批量把人员 ID 转为人员姓名,避免逐行查询。关联文件:CompanyPersonEntity.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):设备 userPersonId 集合 -> IN 查询 -> 姓名映射 -> 响应。 */
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; }
/** 代码作用(白话):将数据库实体转换为页面安全字段。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):Entity + 人员名 -> toResponse -> ApiResponse -> Vue 行。 */
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()); }
/** 代码作用(白话):将内部图片标识转换为浏览器受控访问 URL。关联文件:DeviceAssetController.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):数据库标识 -> imageUrl -> 浏览器 img 请求。 */
private String imageUrl(String identifier) { return identifier==null||identifier.isBlank()?null:"/api/device-assets/files/"+identifier; }
/** 代码作用(白话):把可编辑表单字段写入实体。关联文件:DeviceAssetSaveRequest.java、AssetDeviceEntity.java。关联逻辑(调用链/数据流):DTO -> applyEditableFields -> Entity -> Mapper 写库。 */
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); }
/** 代码作用(白话):判断筛选文本是否有内容。关联文件:DeviceAssetPageQuery.java、DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP 参数 -> hasText -> 是否追加 SQL 条件。 */
private boolean hasText(String value) { return value!=null&&!value.isBlank(); }
}
......@@ -43,7 +43,7 @@ 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); 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); }
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); }
/** 代码作用(白话):更新有效资产的用户可写字段。关联文件: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); }
/** 代码作用(白话):将资产标记为删除,供 Controller 删除接口调用。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE 请求 -> Service -> Mapper.updateById。 */
......@@ -55,5 +55,5 @@ public class PhoneAssetService {
/** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件:PhoneAssetSaveRequest.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):前端输入 -> 去空格和 +86 -> 格式失败返回 400。 */
private String normalizePhoneNumber(String value){ String normalized=value==null?"":value.trim(); if(normalized.startsWith("+86")) normalized=normalized.substring(3); if(!normalized.matches("\\d{11}")) throw new PhoneAssetValidationException("手机号必须是 11 位数字"); return normalized; }
/** 代码作用(白话):只挑选列表需要展示的字段。关联文件:PhoneAssetResponse.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):实体 -> DTO -> ApiResponse -> 表格。 */
private PhoneAssetResponse toResponse(PhoneAssetEntity entity){ return new PhoneAssetResponse(entity.getId(),entity.getPhoneNumber(),entity.getCardType(),entity.getIccid(),entity.getRealNameOwner(),entity.getManagementType(),entity.getDisposalStatus(),entity.getDeviceId(),entity.getRelationSyncedAt()); }
private PhoneAssetResponse toResponse(PhoneAssetEntity entity){ return new PhoneAssetResponse(entity.getId(),entity.getPhoneNumber(),entity.getNumberType(),entity.getSourceAssetType(),entity.getSourceAssetId(),entity.getCardType(),entity.getIccid(),entity.getRealNameOwner(),entity.getManagementType(),entity.getDisposalStatus(),entity.getDeviceId(),entity.getRelationSyncedAt()); }
}
\ No newline at end of file
......@@ -2,10 +2,15 @@ package com.xyw.console.asset.service;
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.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
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.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
......@@ -15,6 +20,7 @@ import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
......@@ -22,6 +28,7 @@ import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 文件用途(白话):读取企微账号资产,并把页面看不懂的关联 ID 转成公司、手机号、设备和人员名称。 */
@Service
......@@ -55,6 +62,43 @@ public class WecomAccountService {
* 关联文件:WecomAccountController.java、WecomAccountPageQuery.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):GET 请求 -> page -> WecomAccountMapper + 关联 Mapper -> 分页 DTO -> Vue 表格。
*/
/** 代码作用(白话):创建企业微信资产并关联已有手机号或自动创建外部号码。关联文件:WecomAccountController.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):POST 表单 -> create -> 手机号查询或新增 -> 企微插入。 */
@Transactional
public WecomAccountResponse create(WecomAccountSaveRequest request) {
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());
}
/** 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);
if (hasText(keyword)) query.and(wrapper -> wrapper.like(CompanyProfileEntity::getCompanyName, keyword).or().like(CompanyProfileEntity::getShortName, keyword));
return companyProfileMapper.selectList(query.orderByDesc(CompanyProfileEntity::getId)).stream().limit(20).map(item -> new CompanyProfileLookupResponse(item.getId(), item.getCompanyName(), item.getShortName())).toList();
}
/** Code purpose (plain language): finds active phone assets which may be reused as registration phones. Related files: WecomAccountController.java, PhoneAssetEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<PhoneAssetLookupResponse> searchPhoneAssets(String keyword) {
LambdaQueryWrapper<PhoneAssetEntity> query = new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).like(hasText(keyword), PhoneAssetEntity::getPhoneNumber, keyword).orderByDesc(PhoneAssetEntity::getId);
return phoneAssetMapper.selectList(query).stream().limit(20).map(item -> new PhoneAssetLookupResponse(item.getId(), item.getPhoneNumber(), item.getNumberType())).toList();
}
/** Code purpose (plain language): finds active company people for the optional WeCom owner selector. Related files: WecomAccountController.java, CompanyPersonEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<CompanyPersonLookupResponse> searchCompanyPersons(String keyword) {
LambdaQueryWrapper<CompanyPersonEntity> query = new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getDeleteTime, 0L).like(hasText(keyword), CompanyPersonEntity::getPersonName, keyword).orderByDesc(CompanyPersonEntity::getId);
return companyPersonMapper.selectList(query).stream().limit(20).map(item -> new CompanyPersonLookupResponse(item.getId(), item.getPersonName())).toList();
}
/** Code purpose (plain language): removes the China country prefix and only accepts an eleven-digit mobile number before it is looked up or created. Related files: WecomAccountSaveRequest.java, PhoneAssetService.java. Data flow: create form -> normalization -> lookup/create. */
private String normalizePhoneNumber(String value) {
String normalized = value == null ? "" : value.trim();
if (normalized.startsWith("+86")) normalized = normalized.substring(3);
if (!normalized.matches("\\d{11}")) throw new PhoneAssetValidationException("手机号必须是 11 位数字");
return normalized;
}
public WecomAccountPageResponse page(WecomAccountPageQuery query) {
Page<WecomAccountEntity> page = wecomAccountMapper.selectPage(
new Page<>(query.resolvedPage(), query.resolvedSize()), activeQuery(query));
......@@ -74,14 +118,31 @@ public class WecomAccountService {
* 关联文件:WecomAccountEntity.java、WecomAccountPageQuery.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):查询参数 -> LambdaQueryWrapper -> as_wecom_account SQL -> 分页结果。
*/
/** 代码作用(白话):组合统一关键词、注册主体、实名状态和兼容筛选条件,只返回未删除的企业微信资产。关联文件:WecomAccountPageQuery.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):顶部条件 -> 手机号 ID 匹配(如有)-> 企业微信 SQL 条件 -> 分页结果。 */
private LambdaQueryWrapper<WecomAccountEntity> activeQuery(WecomAccountPageQuery query) {
return new LambdaQueryWrapper<WecomAccountEntity>()
LambdaQueryWrapper<WecomAccountEntity> wrapper = new LambdaQueryWrapper<WecomAccountEntity>()
.eq(WecomAccountEntity::getDeleteTime, 0L)
.eq(hasText(query.wecomName()), WecomAccountEntity::getWecomName, query.wecomName())
.eq(hasText(query.wecomAccount()), WecomAccountEntity::getWecomAccount, query.wecomAccount())
.orderByDesc(WecomAccountEntity::getId);
.eq(query.phoneAssetId() != null, WecomAccountEntity::getPhoneAssetId, query.phoneAssetId())
.eq(query.companyProfileId() != null, WecomAccountEntity::getCompanyProfileId, query.companyProfileId())
.eq(hasText(query.realNameOwnerStatus()), WecomAccountEntity::getRealNameOwnerStatus, query.realNameOwnerStatus());
if (hasText(query.keyword())) {
Set<Long> phoneIds = matchingPhoneAssetIds(query.keyword());
wrapper.and(nested -> nested.like(WecomAccountEntity::getWecomName, query.keyword())
.or(!phoneIds.isEmpty(), byPhone -> byPhone.in(WecomAccountEntity::getPhoneAssetId, phoneIds)));
}
return wrapper.orderByDesc(WecomAccountEntity::getId);
}
/** 代码作用(白话):找出手机号包含统一搜索词的有效手机号资产 ID。关联文件:PhoneAssetEntity.java、WecomAccountPageQuery.java。关联逻辑(调用链/数据流):统一搜索词 -> 手机号模糊查询 -> 企业微信关联手机号条件。 */
private Set<Long> matchingPhoneAssetIds(String keyword) {
return phoneAssetMapper.selectList(new LambdaQueryWrapper<PhoneAssetEntity>()
.eq(PhoneAssetEntity::getDeleteTime, 0L)
.like(PhoneAssetEntity::getPhoneNumber, keyword)).stream()
.map(PhoneAssetEntity::getId)
.filter(id -> id != null)
.collect(Collectors.toSet());
}
/**
* 代码作用(白话):判断筛选文本是否有实际内容,避免空字符串参与精确查询而把正常结果筛掉。
* 关联文件:WecomAccountPageQuery.java、WecomAccountService.java。
......@@ -170,6 +231,9 @@ public class WecomAccountService {
* 关联文件:WecomAccountEntity.java、WecomAccountResponse.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):实体 + 名称 Map -> Response -> ApiResponse -> Element Plus 表格。
*/
/** Code purpose (plain language): safely reads an optional relation name without passing a null key to immutable empty maps. Related files: WecomAccountResponse.java. Data flow: optional relation ID -> safe map lookup -> response. */
private String valueOf(Map<Long, String> names, Long id) { return id == null ? null : names.get(id); }
private WecomAccountResponse toResponse(
WecomAccountEntity entity,
Map<Long, String> companyNames,
......@@ -178,11 +242,11 @@ public class WecomAccountService {
Map<Long, String> operatorPersonNames) {
return new WecomAccountResponse(
entity.getId(), entity.getWecomName(), entity.getWecomAlias(), entity.getWecomAccount(),
entity.getCompanyProfileId(), companyNames.get(entity.getCompanyProfileId()),
entity.getPhoneAssetId(), phoneNumbers.get(entity.getPhoneAssetId()),
entity.getCompanyProfileId(), valueOf(companyNames, entity.getCompanyProfileId()),
entity.getPhoneAssetId(), valueOf(phoneNumbers, entity.getPhoneAssetId()), entity.getPhoneLinkMode(),
entity.getRealNameOwner(), entity.getRealNameOwnerStatus(), entity.getGender(),
entity.getDeviceId(), deviceNames.get(entity.getDeviceId()),
entity.getOperatorPersonId(), operatorPersonNames.get(entity.getOperatorPersonId()),
entity.getDeviceId(), valueOf(deviceNames, entity.getDeviceId()),
entity.getOperatorPersonId(), valueOf(operatorPersonNames, entity.getOperatorPersonId()),
entity.getCreateTime(), entity.getUpdateTime());
}
}
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.DeviceAssetPageQuery;
import com.xyw.console.asset.dto.DeviceAssetSaveRequest;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.mapper.*;
import java.util.List;
import org.junit.jupiter.api.Test;
/** 文件用途(白话):以 Mock Mapper 验证设备列表名称解析和关键校验规则,不访问真实数据库。 */
class DeviceAssetServiceTest {
/** 代码作用(白话):验证分页行会批量解析有效使用人姓名。关联文件:DeviceAssetService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):设备 Mapper -> page -> 人员 Mapper -> 响应行。 */
@Test void resolvesUserPersonNameForPageRecord() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class); CompanyPersonMapper people=mock(CompanyPersonMapper.class); DeviceAssetService service=service(devices,people);
AssetDeviceEntity device=new AssetDeviceEntity();device.setId(1L);device.setDeviceName("测试机");device.setUserPersonId(9L);device.setUserUsageStatus("使用中");device.setAssetRelationStatus("已关联");Page<AssetDeviceEntity> page=new Page<>(1,20);page.setRecords(List.of(device));page.setTotal(1);
CompanyPersonEntity person=new CompanyPersonEntity();person.setId(9L);person.setPersonName("张三");when(devices.selectPage(any(Page.class),any(Wrapper.class))).thenReturn(page);when(people.selectList(any(Wrapper.class))).thenReturn(List.of(person));
assertEquals("张三",service.page(new DeviceAssetPageQuery(null,null,null,null,null,null)).records().get(0).userPersonName());
}
/** 代码作用(白话):验证服务端不接受前端伪造的状态值。关联文件:DeviceAssetService.java、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):保存表单 -> validateStatus -> 400 业务异常。 */
@Test void rejectsUnsupportedUsageStatus() {
DeviceAssetService service=service(mock(AssetDeviceMapper.class),mock(CompanyPersonMapper.class));DeviceAssetSaveRequest request=new DeviceAssetSaveRequest();request.setDeviceName("测试机");request.setUserUsageStatus("未知");request.setAssetRelationStatus("已关联");
assertThrows(DeviceAssetValidationException.class,()->service.create(request));
}
/** 代码作用(白话):构造只含当前测试所需 Mock 的设备服务。关联文件:DeviceAssetService.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):测试 -> Mock Mapper/文件服务 -> Service 规则。 */
private DeviceAssetService service(AssetDeviceMapper devices,CompanyPersonMapper people){return new DeviceAssetService(devices,people,mock(PhoneAssetMapper.class),mock(WecomAccountMapper.class),mock(WechatAccountMapper.class),mock(DouyinAccountMapper.class),new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images"));}
}
......@@ -5,11 +5,13 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
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.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
......@@ -46,7 +48,7 @@ class WecomAccountServiceTest {
WecomAccountPageResponse result = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null));
.page(new WecomAccountPageQuery(null, null, null, null, null, null, null));
WecomAccountResponse record = result.records().get(0);
assertEquals(1L, record.id());
......@@ -83,7 +85,7 @@ class WecomAccountServiceTest {
WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null)).records().get(0);
.page(new WecomAccountPageQuery(1, 20, null, null, null, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId());
assertNull(record.companyProfileName());
......@@ -169,4 +171,21 @@ class WecomAccountServiceTest {
entity.setPersonName("王五");
return entity;
}
}
/** Code purpose (plain language): proves an entered existing number is reused and marked as an existing link. Related files: WecomAccountService.java, WecomAccountSaveRequest.java. Data flow: create request -> existing phone -> WeCom response. */
@Test
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));
assertEquals(20L, result.phoneAssetId()); assertEquals("EXISTING", result.phoneLinkMode());
}
/** Code purpose (plain language): proves a missing number creates an external phone asset and traces it back to the WeCom record. Related files: WecomAccountService.java, PhoneAssetEntity.java. Data flow: create request -> new phone -> new WeCom -> source update. */
@Test
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));
assertEquals("CREATED", result.phoneLinkMode()); verify(phoneMapper).updateById(any(PhoneAssetEntity.class));
}}
# 企业微信资产号码来源:受控数据库变更说明
本文件只定义待 DBA 在指定数据库和变更窗口执行的内容;本次没有连接、迁移或修改任何真实数据库。
## 变更字段
- `as_phone_asset.number_type``SELF`(自有号码)或 `EXTERNAL`(外部号码)。
- `as_phone_asset.source_asset_type`:创建外部号码的来源资产类型,例如 `WECOM`
- `as_phone_asset.source_asset_id`:来源资产主键。
- `as_wecom_account.phone_link_mode``EXISTING`(已有号码)或 `CREATED`(新建号码)。
## 回填与索引
1. 历史 `as_phone_asset` 记录回填为 `SELF`,来源字段保持空。
2.`as_phone_asset(source_asset_type, source_asset_id, delete_time)` 建立普通索引,支持来源跳转追溯。
3. 保留既有手机号与删除标记的唯一约束;部署前确认其可防止同一有效号码重复创建。
## 执行前检查与回滚
- 由 DBA 先在备份或预发布库验证列名、数据量和锁表影响。
- 应用已对空 `number_type` 兼容显示为“自有号码”,因此可先发布应用、后受控加列。
- 回滚时停止写入新字段并保留列,不删除已经写入的来源数据。
import { defineConfig } from '@playwright/test';
/** 文件用途(白话):为设备资产测试单独启动 5174 端口,避免复用其他工作树正在运行的 5173 开发服务。 */
export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://127.0.0.1:5174', browserName: 'chromium' },
webServer: { command: 'npm run dev -- --host 127.0.0.1 --port 5174', url: 'http://127.0.0.1:5174/', reuseExistingServer: false, timeout: 30_000 }
});
/** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */
export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` };
\ No newline at end of file
/** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js、DeviceAssetView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */
export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/device-assets">设备资产管理</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` };
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 './device-asset.css';
/** 文件用途(白话):渲染设备资产管理列表、表单、图片原图缩略预览和受保护删除操作。 */
export default {
/** 代码作用(白话):创建页面响应式数据与业务动作。关联文件:device-api-client.js、DeviceAssetController.java。关联逻辑(调用链/数据流):路由进入 -> setup -> API 请求/表单状态 -> Element Plus 页面。 */
setup() {
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 form=reactive({deviceName:'',userPersonId:null,userUsageStatus:'使用中',assetRelationStatus:'待确认',imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:'',imageAttachment2Url:''});
let searchTimer=null;
/** 代码作用(白话):读取当前筛选条件下的设备分页数据。关联文件:device-api-client.js、DeviceAssetService.java。关联逻辑(调用链/数据流):页面加载/筛选 -> GET -> records/total -> 表格。 */
async function loadPage(){loading.value=true;try{const result=await listDeviceAssets(filters);records.value=result.records;total.value=result.total;}catch(error){ElMessage.error(error.message);}finally{loading.value=false;}}
/** 代码作用(白话):恢复新增设备的默认表单。关联文件:DeviceAssetSaveRequest.java、DeviceAssetView.js。关联逻辑(调用链/数据流):新增/保存完成 -> resetForm -> 空表单。 */
function resetForm(){clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,{deviceName:'',userPersonId:null,userUsageStatus:'使用中',assetRelationStatus:'待确认',imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:'',imageAttachment2Url:''});personOptions.value=[];}
/** 代码作用(白话):打开空白新增弹窗。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):新增按钮 -> resetForm -> dialogVisible=true。 */
function openCreate(){editingId.value=null;resetForm();dialogVisible.value=true;}
/** 代码作用(白话):把列表设备回填到编辑弹窗。关联文件:DeviceAssetResponse.java、DeviceAssetView.js。关联逻辑(调用链/数据流):编辑按钮 -> row -> form -> PUT 保存。 */
function openEdit(row){editingId.value=row.id;clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,{deviceName:row.deviceName,userPersonId:row.userPersonId,userUsageStatus:row.userUsageStatus,assetRelationStatus:row.assetRelationStatus,imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:row.imageAttachment1Url||'',imageAttachment2Url:row.imageAttachment2Url||''});personOptions.value=row.userPersonId?[{id:row.userPersonId,personName:row.userPersonName||`人员 ${row.userPersonId}`}]:[];dialogVisible.value=true;}
/** 代码作用(白话):把表单转换成 multipart 数据并提交。关联文件:device-api-client.js、DeviceAssetController.java。关联逻辑(调用链/数据流):保存按钮 -> FormData -> POST/PUT -> 成功刷新表格。 */
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);});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?'新增成功':'编辑成功');dialogVisible.value=false;filters.page=1;await loadPage();}catch(error){ElMessage.error(error.message);}finally{saving.value=false;}}
/** 代码作用(白话):确认后请求软删除,并显示后端返回的关联保护原因。关联文件:device-api-client.js、DeviceAssetService.java。关联逻辑(调用链/数据流):删除按钮 -> 确认框 -> DELETE -> 列表刷新或错误提示。 */
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);}}
/** 代码作用(白话):根据关键字远程加载可选择的公司人员。关联文件:device-api-client.js、DeviceAssetController.java。关联逻辑(调用链/数据流):人员选择器输入 -> lookup API -> personOptions -> 下拉项。 */
async function fetchPersonSuggestions(keyword){try{personOptions.value=await searchDeviceCompanyPersons(keyword);}catch(error){ElMessage.error(error.message);}}
/** 代码作用(白话):在浏览器端先限制图片类型和单张 20MB,减少无效上传。关联文件:DeviceAssetFileStorageService.java、DeviceAssetView.js。关联逻辑(调用链/数据流):选择文件 -> validateImageBeforeSelect -> FormData 或错误提示。 */
function validateImageBeforeSelect(file){const raw=file.raw||file;const allowed=['image/jpeg','image/png','image/gif'];if(!allowed.includes(raw.type)){ElMessage.error('仅支持 JPG、PNG、GIF 图片');return false;}if(raw.size>20*1024*1024){ElMessage.error('每张图片不能超过 20MB');return false;}return true;}
/** 代码作用(白话):接收某个图片槽选择的新原图并生成浏览器预览地址。关联文件:DeviceAssetFileStorageService.java、DeviceAssetView.js。关联逻辑(调用链/数据流):上传组件 -> chooseImage -> form File/URL -> 缩略预览与保存。 */
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;}
/** 代码作用(白话):清理浏览器本地创建的预览 URL,避免重复编辑占用内存。关联文件:DeviceAssetView.js。关联逻辑(调用链/数据流):替换/关闭弹窗 -> clearPreview -> revokeObjectURL。 */
function clearPreview(slot){const url=form[`${slot}Url`];if(url&&url.startsWith('blob:'))URL.revokeObjectURL(url);}
/** 代码作用(白话):标记一个图片槽在保存时清空,同时隐藏其预览。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):移除按钮 -> removeImage -> multipart flag -> 数据库附件字段置空。 */
function removeImage(slot){clearPreview(slot);form[slot]=null;form[`${slot}Url`]='';form[`remove${slot.charAt(0).toUpperCase()+slot.slice(1)}`]=true;}
/** 代码作用(白话):打开原图查看器而非生成另一份缩略图文件。关联文件:DeviceAssetResponse.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):缩略图点击 -> previewImage -> 受控图片 URL -> 查看器。 */
function previewImage(url){if(!url)return;imageViewerUrl.value=url;imageViewerVisible.value=true;}
/** 代码作用(白话):延迟执行文本筛选,避免用户每输入一个字都请求接口。关联文件:device-api-client.js、DeviceAssetView.js。关联逻辑(调用链/数据流):输入事件 -> 定时器 -> loadPage -> 表格。 */
function scheduleSearch(){window.clearTimeout(searchTimer);searchTimer=window.setTimeout(()=>{filters.page=1;loadPage();},300);}
/** 代码作用(白话):立即按当前筛选条件查询第一页。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):筛选提交 -> page=1 -> GET -> 表格。 */
function submitSearch(){window.clearTimeout(searchTimer);filters.page=1;loadPage();}
/** 代码作用(白话):清空所有筛选条件并重新加载第一页。关联文件:DeviceAssetView.js、DeviceAssetPageQuery.java。关联逻辑(调用链/数据流):重置按钮 -> 默认 filters -> GET -> 表格。 */
function resetSearch(){window.clearTimeout(searchTimer);Object.assign(filters,{page:1,size:20,deviceName:'',userPersonId:null,userUsageStatus:'',assetRelationStatus:''});loadPage();}
/** 代码作用(白话):切换页码并读取对应设备数据。关联文件:DeviceAssetView.js、DeviceAssetPageQuery.java。关联逻辑(调用链/数据流):分页器 -> page -> GET -> 表格。 */
function changePage(page){filters.page=page;loadPage();}
/** 代码作用(白话):切换每页条数后回到第一页。关联文件:DeviceAssetView.js、DeviceAssetPageQuery.java。关联逻辑(调用链/数据流):分页器 -> size/page -> GET -> 表格。 */
function changePageSize(size){filters.size=size;filters.page=1;loadPage();}
/** 代码作用(白话):页面首次显示时加载设备列表。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):组件挂载 -> loadPage -> 表格初始数据。 */
onMounted(loadPage);
return {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};
},
template:`<section class="device-asset-page"><header class="device-asset-page__header"><div><h2>设备资产管理</h2><p>DEVICE ASSETS</p></div><el-button 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 label="使用中" value="使用中"/><el-option label="闲置" value="闲置"/><el-option label="维修中" value="维修中"/><el-option label="停用" value="停用"/></el-select><el-select v-model="filters.assetRelationStatus" clearable placeholder="资产关联状态" @change="submitSearch"><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="device-asset-page__panel"><el-table :data="records" v-loading="loading" empty-text="暂无匹配数据"><el-table-column prop="id" label="ID" width="90"/><el-table-column prop="deviceName" label="设备名称" min-width="180"/><el-table-column label="图片" 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="使用人" 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 prop="updateTime" label="更新时间" min-width="180"/><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><footer v-if="total" class="device-asset-page__pagination"><span>共 {{total}} 条</span><el-pagination layout="sizes, prev, pager, next" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize"/></footer></section><el-dialog v-model="dialogVisible" :title="editingId===null?'新增设备':'编辑设备'" width="640px"><el-form label-position="top" @submit.prevent="submitForm"><el-row :gutter="16"><el-col :span="12"><el-form-item label="设备名称" required><el-input v-model="form.deviceName"/></el-form-item></el-col><el-col :span="12"><el-form-item label="使用人"><el-select v-model="form.userPersonId" filterable remote clearable :remote-method="fetchPersonSuggestions" placeholder="输入人员姓名" 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="使用状态" required><el-select v-model="form.userUsageStatus" style="width:100%"><el-option label="使用中" value="使用中"/><el-option label="闲置" value="闲置"/><el-option label="维修中" value="维修中"/><el-option label="停用" value="停用"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="资产关联状态" required><el-select v-model="form.assetRelationStatus" style="width:100%"><el-option label="已关联" value="已关联"/><el-option label="未关联" value="未关联"/><el-option label="待确认" value="待确认"/></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'?'图片附件 1':'图片附件 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>暂无图片</span></div><el-upload :auto-upload="false" :show-file-list="false" :on-change="file=>chooseImage(slot,file)"><el-button size="small">选择图片</el-button></el-upload><el-button v-if="form[slot+'Url']" size="small" link type="danger" @click="removeImage(slot)">移除</el-button></div></div></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="[imageViewerUrl]" @close="imageViewerVisible=false"/></section>`
};
/** 文件用途(白话):统一调用设备资产接口,并将非成功响应变为页面可显示的错误。 */
async function request(path, options = {}) {
/** 代码作用(白话):发送一次设备接口请求并提取统一响应数据。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):页面动作 -> request -> ApiResponse -> 页面状态或错误提示。 */
const response = await fetch(path, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '设备资产请求失败');
return payload.data;
}
/** 代码作用(白话):按筛选条件读取设备列表。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):筛选器 -> GET -> 分页响应 -> 表格。 */
export function listDeviceAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key,value); }); return request('/api/device-assets?' + params); }
/** 代码作用(白话):用 multipart 表单新建设备。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 文件/设备写入 -> 响应。 */
export function createDeviceAsset(form) { return request('/api/device-assets', { method: 'POST', body: form }); }
/** 代码作用(白话):用 multipart 表单编辑设备。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 文件/设备更新 -> 响应。 */
export function updateDeviceAsset(id, form) { return request('/api/device-assets/' + id, { method: 'PUT', body: form }); }
/** 代码作用(白话):请求后端执行受引用保护的软删除。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 引用检查/软删除 -> 列表刷新。 */
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 || '')); }
/* 文件用途(白话):仅为设备资产管理页面提供样式,避免修改正在使用的全局 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__pagination{display:flex;justify-content:space-between;align-items:center;padding-top:16px}.device-asset-page__image-cell img{width:44px;height:44px;object-fit:cover;border-radius:6px;cursor:pointer}@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%}.device-asset-page__pagination{align-items:flex-start;flex-direction:column}}
......@@ -22,7 +22,7 @@ export default {
const total = ref(0);
const dialogVisible = ref(false);
const editingId = ref(null);
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: '' });
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' });
let searchTimer = null;
let latestRequest = 0;
const form = reactive({
......@@ -228,9 +228,11 @@ export default {
/** 代码作用(白话):按 Enter 时立刻执行当前筛选。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):Enter -> 清除计时 -> page=1 -> loadPage。 */
function submitSearch() { window.clearTimeout(searchTimer); filters.page = 1; loadPage(); }
/** 代码作用(白话):切换使用状态后马上查询,不等待输入防抖。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):状态 change -> page=1 -> loadPage。 */
/** Code purpose (plain language): restores the status filter to its explicit All selection after the clear icon is clicked. Related files: PhoneAssetView.js, phone-api-client.js. Data flow: clear icon -> ALL -> request omits status -> full list. */
function restoreAllDisposalStatuses() { filters.disposalStatus = 'ALL'; submitSearch(); }
function changeStatus() { submitSearch(); }
/** 代码作用(白话):清空所有筛选条件并恢复第一页列表。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):重置 -> filters 默认值 -> loadPage。 */
function resetSearch() { window.clearTimeout(searchTimer); Object.assign(filters, { page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: '' }); loadPage(); }
function resetSearch() { window.clearTimeout(searchTimer); Object.assign(filters, { page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' }); loadPage(); }
/** 代码作用(白话):切换页码并读取对应数据。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage。 */
function changePage(page) { filters.page = page; loadPage(); }
/** 代码作用(白话):切换每页条数后回到第一页查询。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):每页条数 -> size/page -> loadPage。 */
......@@ -241,6 +243,12 @@ export default {
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):组件挂载 -> onMounted 回调 -> loadPage -> 列表渲染。
*/
/** Code purpose (plain language): converts stored disposal status values into the shorter labels required by the list UI. Related files: PhoneAssetService.java, PhoneAssetView.js. Data flow: API record -> formatDisposalStatus -> table/status option label. */
function formatDisposalStatus(value) {
if (value === '正常使用') return '正常';
if (value === '已注销') return '注销';
return value || '未设置';
}
onMounted(() => {
loadPage();
});
......@@ -254,6 +262,7 @@ export default {
editingId,
elementLocale,
filters,
formatDisposalStatus,
form,
handleIccidPaste,
handlePhonePaste,
......@@ -266,6 +275,7 @@ export default {
records,
resetDialogScroll,
resetSearch,
restoreAllDisposalStatuses,
saving,
scheduleSearch,
submitForm,
......@@ -276,8 +286,8 @@ 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 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"><el-option label="全部状态" value="" /><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><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" show-overflow-tooltip /><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><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>{{ row.disposalStatus || '未设置' }}</span></template></el-table-column><el-table-column prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column 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><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></section>
<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><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" 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="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><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 prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column 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><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></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>
......@@ -295,7 +305,7 @@ export default {
</el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="实名归属"><el-input v-model="form.realNameOwner" maxlength="20" placeholder="请输入实名归属" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="管理方式"><el-select v-model="form.managementType" clearable placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="自有" value="自有" /><el-option label="租用" value="租用" /><el-option label="代运营" value="代运营" /></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="处置状态"><el-select v-model="form.disposalStatus" placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="正常使用" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="已注销" value="已注销" /></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="处置状态"><el-select v-model="form.disposalStatus" placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="正常" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="注销" value="已注销" /></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="设备 ID"><el-input v-model.number="form.deviceId" type="number" placeholder="请输入设备 ID" /></el-form-item>
</el-form>
<template #footer>
......
......@@ -10,7 +10,7 @@ async function request(path, options = {}) {
return payload.data;
}
/** 代码作用(白话):按筛选条件读取手机号资产列表。关联文件: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) params.set(key,value); }); return request('/api/phone-assets?' + params.toString()); }
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 -> 后端更新。 */
......
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { listWecomAccounts } from './wecom-api-client.js';
import { createWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchPhoneAssets } from './wecom-api-client.js';
/** 文件用途(白话):显示可筛选、可分页的企微资料,并把关联资源呈现成用户可读的名称和 ID。 */
/** File purpose (plain language): renders the enterprise WeChat asset list and its creation dialog with reusable asset searches. */
export default {
/**
* 代码作用(白话):创建页面加载、筛选、分页状态,并把操作方法交给模板中的按钮和表格使用。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化 -> loadPage -> API -> Element Plus 表格。
*/
/** Code purpose (plain language): owns list, dialog, and selector state for this page. Related files: wecom-api-client.js, WecomAccountController.java. Data flow: route -> setup -> API -> Element Plus controls. */
setup() {
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const records = ref([]);
const total = ref(0);
const filters = reactive({ page: 1, size: 20, wecomName: '', wecomAccount: '' });
const companyOptions = ref([]);
const ownerOptions = ref([]);
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 });
/**
* 代码作用(白话):按当前筛选条件读取一页企微资产,并同步更新表格记录和总条数。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):页面事件 -> listWecomAccounts -> GET API -> records/total -> 表格和分页器。
*/
/** 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() {
loading.value = true;
try {
const result = await listWecomAccounts(filters);
records.value = result.records;
total.value = result.total;
} catch (error) {
ElMessage.error(error.message);
} finally {
loading.value = false;
}
try { const result = await listWecomAccounts(filters); records.value = result.records; total.value = result.total; } catch (error) { ElMessage.error(error.message); } finally { loading.value = false; }
}
/**
* 代码作用(白话):在用户提交筛选时回到第一页,防止旧页码导致看不到匹配结果。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):查询按钮 -> page=1 -> loadPage -> 新筛选结果。
*/
function submitSearch() {
filters.page = 1;
loadPage();
}
/** 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 }); }
/**
* 代码作用(白话):清空企微名称和企微账号筛选,并恢复默认分页后重新查询。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):重置按钮 -> filters 默认值 -> loadPage -> 默认列表。
*/
function resetSearch() {
Object.assign(filters, { page: 1, size: 20, wecomName: '', wecomAccount: '' });
loadPage();
}
/** 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; }
/**
* 代码作用(白话):接收分页器选中的页码并加载该页的企微资产。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage -> 后端分页结果。
*/
function changePage(page) {
filters.page = page;
loadPage();
/** 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. */
async function submitCreate() {
if (!form.wecomName.trim() || !form.phoneNumber.trim()) { ElMessage.warning('请填写企微名称和注册手机号'); return; }
saving.value = true;
try { await createWecomAccount({ ...form, wecomName: form.wecomName.trim(), phoneNumber: form.phoneNumber.trim() }); ElMessage.success('新增成功'); dialogVisible.value = false; filters.page = 1; await loadPage(); } catch (error) { ElMessage.error(error.message); } finally { saving.value = false; }
}
/**
* 代码作用(白话):将关联资源的名称和 ID 组合成“名称(ID)”,关联缺失时保留 ID 并显示“—”。
* 关联文件:WecomAccountResponse.java、WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):接口关联字段 -> formatRelation -> 表格关联资源单元格。
*/
function formatRelation(name, id) {
if (id === null || id === undefined) {
return '—';
}
return `${name || '—'}(ID:${id})`;
/** 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. */
async function fetchPhoneSuggestions(keyword, callback) {
try { const rows = await searchPhoneAssets(keyword); callback(rows.map(row => ({ value: row.phoneNumber, label: `${row.phoneNumber}${row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码'})` }))); } catch (_) { callback([]); }
}
/** Code purpose (plain language): looks up registration subjects by name or abbreviation. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: remote select -> lookup -> option list. */
async function loadCompanies(keyword) { companyOptions.value = await searchCompanyProfiles(keyword); }
/** 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); }
/** 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); }
/** Code purpose (plain language): restores the registration-subject filter to its explicit All selection after the clear icon is clicked. Related files: WecomAccountView.js, wecom-api-client.js. Data flow: clear icon -> ALL -> query omits filter -> full list. */
function restoreAllCompanyProfiles() { filters.companyProfileId = 'ALL'; submitSearch(); }
/** Code purpose (plain language): restores the real-name-status filter to its explicit All selection after the clear icon is clicked. Related files: WecomAccountView.js, wecom-api-client.js. Data flow: clear icon -> ALL -> query omits filter -> full list. */
function restoreAllRealNameStatuses() { filters.realNameOwnerStatus = 'ALL'; submitSearch(); }
/** Code purpose (plain language): searches again from the first page. Related files: WecomAccountView.js. Data flow: filter change -> filters.page -> loadPage. */
function submitSearch() { filters.page = 1; loadPage(); }
/** Code purpose (plain language): clears list text filters but retains an incoming phone-asset route filter until the user explicitly removes it. Related files: WecomAccountView.js. Data flow: reset -> default filters -> loadPage. */
function resetSearch() { window.clearTimeout(searchTimer); Object.assign(filters, { page: 1, size: 20, keyword: '', wecomAccount: '', phoneAssetId: '', companyProfileId: 'ALL', realNameOwnerStatus: 'ALL' }); loadPage(); }
/** Code purpose (plain language): loads the chosen pagination page. Related files: WecomAccountView.js. Data flow: pagination -> filters.page -> loadPage. */
function changePage(page) { filters.page = page; 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})`; }
onMounted(loadPage);
return { changePage, filters, formatRelation, loading, records, resetSearch, submitSearch, total };
return { changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
},
template: `
<section class="wecom-account-page">
<header class="page-header">
<div><p class="eyebrow">WECOM ACCOUNTS</p><h2>企微资料</h2><p>查看企微账号资产、实名信息和关联资源。</p></div>
</header>
<article class="reference-card">
<el-form inline @submit.prevent="submitSearch">
<el-form-item label="企微名称"><el-input v-model="filters.wecomName" clearable /></el-form-item>
<el-form-item label="企微账号"><el-input v-model="filters.wecomAccount" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="submitSearch">查询</el-button><el-button @click="resetSearch">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="records">
<el-table-column prop="id" label="企微资产 ID" min-width="110" />
<el-table-column prop="wecomName" label="企微名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="wecomAlias" label="企微别名" min-width="120" show-overflow-tooltip />
<el-table-column prop="wecomAccount" label="企微账号" min-width="140" show-overflow-tooltip />
<el-table-column label="公司档案" min-width="220"><template #default="scope">{{ formatRelation(scope.row.companyProfileName, scope.row.companyProfileId) }}</template></el-table-column>
<el-table-column label="关联手机号资产" min-width="200"><template #default="scope">{{ formatRelation(scope.row.phoneNumber, scope.row.phoneAssetId) }}</template></el-table-column>
<el-table-column prop="realNameOwner" label="企微实名人" min-width="130" show-overflow-tooltip />
<el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="120" show-overflow-tooltip />
<el-table-column prop="gender" label="性别" min-width="90" />
<el-table-column label="关联设备" min-width="190"><template #default="scope">{{ formatRelation(scope.row.deviceName, scope.row.deviceId) }}</template></el-table-column>
<el-table-column label="经办人" min-width="170"><template #default="scope">{{ formatRelation(scope.row.operatorPersonName, scope.row.operatorPersonId) }}</template></el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" min-width="180" show-overflow-tooltip />
</el-table>
<el-pagination v-if="total" background layout="total, prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" />
</article>
<section class="phone-asset-list-page wecom-account-page">
<header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2><p class="wecom-account-page__eyebrow">WECOM ACCOUNTS</p></div><el-button 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><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><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" /></footer></section>
<el-dialog v-model="dialogVisible" title="新增企业微信资产" width="640px" :close-on-click-modal="false"><el-form label-position="top" @submit.prevent="submitCreate"><el-row :gutter="16"><el-col :span="12"><el-form-item label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item></el-col><el-col :span="12"><el-form-item label="注册手机号" required><el-autocomplete v-model="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" placeholder="输入或选择手机号" style="width:100%" /></el-form-item></el-col><el-col :span="12"><el-form-item 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-col><el-col :span="12"><el-form-item 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-col><el-col :span="12"><el-form-item label="实名人"><el-input v-model="form.realNameOwner" /></el-form-item></el-col><el-col :span="12"><el-form-item 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-col><el-col :span="12"><el-form-item 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-col></el-row></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>
`
};
\ No newline at end of file
/** 文件用途(白话):集中发送企微资产列表请求并统一解析后端的成功或失败响应。 */
/** File purpose (plain language): centralizes enterprise WeChat asset requests and turns API envelopes into usable data. */
/**
* 代码作用(白话):请求后端并从统一 JSON 响应中取出真正的列表数据,失败时抛出可显示的错误信息。
* 关联文件:WecomAccountView.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):列表页 -> request -> GET /api/wecom-accounts -> ApiResponse.data -> 表格数据。
*/
async function request(path) {
const response = await fetch(path);
/** Code purpose (plain language): sends an API request and exposes either its data or readable error. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: view action -> request -> ApiResponse -> view state. */
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...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;
}
/**
* 代码作用(白话):把页面的分页和筛选状态转换为 URL 参数,再读取对应的企微资产页。
* 关联文件:WecomAccountView.js、WecomAccountController.java、WecomAccountPageQuery.java。
* 关联逻辑(调用链/数据流):筛选条件 -> URLSearchParams -> GET 接口 -> records/total。
*/
/** Code purpose (plain language): loads a filtered enterprise WeChat asset page. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: filters -> URL params -> GET list -> table. */
export function listWecomAccounts(query) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params.set(key, value);
}
});
return request(`/api/wecom-accounts?${params.toString()}`);
}
\ No newline at end of file
Object.entries(query).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '' && value !== 'ALL') params.set(key, value); });
return request(`/api/wecom-accounts?${params}`);
}
/** 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) }); }
/** 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 || '')}`); }
/** Code purpose (plain language): searches existing phone assets for the registration-phone input. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: autocomplete -> GET lookup -> selectable numbers. */
export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts/lookups/phone-assets?keyword=${encodeURIComponent(keyword || '')}`); }
/** 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 || '')}`); }
\ No newline at end of file
......@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
import WecomAccountView from '../modules/wecom/WecomAccountView.js';
import DeviceAssetView from '../modules/device/DeviceAssetView.js';
/**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
......@@ -23,6 +24,7 @@ const router = createRouter({
{ path: '/', redirect: '/overview' },
{ path: '/overview', component: createPlaceholderView('资产总览') },
{ path: '/phone-assets', component: PhoneAssetView },
{ path: '/device-assets', component: DeviceAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wecom', component: WecomAccountView },
......
:root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px 1fr; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); }
.sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; }
.sidebar h1 { margin: 0 0 32px; font-size: 20px; }
.sidebar nav { display: grid; gap: 8px; }
.sidebar a { border-radius: 8px; color: #cbd5e1; padding: 10px 12px; text-decoration: none; }
.sidebar a.router-link-active, .sidebar a:hover { background: #1f3b59; color: white; }
.content { padding: 40px; }
.content { min-width: 0; padding: 40px; }
.eyebrow { margin: 0 0 8px; color: #5f8fcb; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.reference-page, .placeholder { max-width: 1180px; margin: 0 auto; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
......@@ -272,4 +272,5 @@ body { margin: 0; }
.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}
.phone-asset-list-page__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}.phone-asset-list-page__pagination .el-pagination{justify-content:flex-end}.phone-asset-list-page__pagination .el-pager li,.phone-asset-list-page__pagination .btn-prev,.phone-asset-list-page__pagination .btn-next{min-width:34px;height:34px;border:1px solid #e5e5e8;border-radius:6px;background:#fff}.phone-asset-list-page__pagination .el-pager li.is-active{background:#18181b;color:#fff}
@media(max-width:900px){.phone-asset-list-page{padding:26px 0 40px}.phone-asset-list-page__pagination{align-items:flex-start;flex-direction:column}.phone-asset-list-page__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,.phone-asset-list-page__pagination{padding-left:18px;padding-right:18px}.phone-asset-list-page__pagination .el-pagination__jump{display:none}}
\ No newline at end of file
@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,.phone-asset-list-page__pagination{padding-left:18px;padding-right:18px}.phone-asset-list-page__pagination .el-pagination__jump{display:none}}
.wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.wecom-account-page__eyebrow{margin:7px 0 0;color:#71717a;font-size:12px;font-weight:700;letter-spacing:.12em}.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}
import { expect, test } from '@playwright/test';
/** 文件用途(白话):验证设备资产管理路由、列表数据和状态筛选控件,不依赖真实后端数据库。 */
test('shows the device management page and readable device row', async ({ page }) => {
/** 代码作用(白话):为设备列表和人员搜索提供稳定模拟响应。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> route.fulfill -> Vue 表格与下拉项。 */
await page.route('**/api/device-assets**', async route => {
const url = route.request().url();
const data = url.includes('/lookups/company-persons') ? [{ id: 9, personName: '张三' }] : { records: [{ id: 1, deviceName: 'iPhone 15-01', imageAttachment1Url: null, imageAttachment2Url: null, userPersonId: 9, userPersonName: '张三', userUsageStatus: '使用中', assetRelationStatus: '已关联', createTime: '2026-08-01T10:00:00', updateTime: '2026-08-01T10:00:00' }], total: 1, page: 1, size: 20 };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data }) });
});
await page.goto('/asset/#/device-assets');
await expect(page.getByRole('heading', { name: '设备资产管理' })).toBeVisible();
await expect(page.getByText('iPhone 15-01')).toBeVisible();
await expect(page.getByText('张三(9)')).toBeVisible();
await expect(page.locator('.el-table').getByText('使用中', { exact: true })).toBeVisible();
await expect(page.locator('.el-table').getByText('已关联', { exact: true })).toBeVisible();
});
/** 文件用途(白话):验证图片选择在浏览器端拒绝超过 20MB 的文件,避免无效请求发送到服务器。 */
test('rejects an oversized image before device save', async ({ page }) => {
/** 代码作用(白话):模拟空设备列表,让测试直接打开新增弹窗。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):页面 GET -> route.fulfill -> 新增表单。 */
await page.route('**/api/device-assets**', async route => { await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { records: [], total: 0, page: 1, size: 20 } }) }); });
await page.goto('/asset/#/device-assets');
await page.getByRole('button', { name: '新增设备' }).click();
const file = { name: 'too-large.png', mimeType: 'image/png', buffer: Buffer.alloc(20 * 1024 * 1024 + 1) };
await page.locator('input[type=file]').first().setInputFiles(file);
await expect(page.getByText('每张图片不能超过 20MB')).toBeVisible();
});
import { expect, test } from '@playwright/test';
/**
* 代码作用(白话):验证企微资料真实页面会请求新接口,并把关联名称与 ID、企微实名人完整展示出来。
* 关联文件:WecomAccountView.js、wecom-api-client.js、router/index.js。
* 关联逻辑(调用链/数据流):访问 Hash 路由 -> 拦截 API 响应 -> Vue 表格 -> 页面断言。
*/
test('shows readable related names without a delete-time column', async ({ page }) => {
/**
* 代码作用(白话):提供完整的企微列表接口响应,避免测试依赖本机数据库或后端服务状态。
* 关联文件:WecomAccountResponse.java、wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):浏览器 API 请求 -> route.fulfill -> 前端 records -> 表格单元格。
*/
await page.route('**/api/wecom-accounts**', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: {
records: [{
id: 1,
wecomName: '张三',
wecomAlias: '销售一组',
wecomAccount: 'zhangsan',
companyProfileId: 10,
companyProfileName: '示例科技有限公司',
phoneAssetId: 20,
phoneNumber: '13812345678',
realNameOwner: '张三',
realNameOwnerStatus: '已实名',
gender: '男',
deviceId: 30,
deviceName: 'iPhone 15',
operatorPersonId: 40,
operatorPersonName: '王五',
createTime: '2026-07-31T10:00:00',
updateTime: '2026-07-31T11:00:00'
}],
total: 1,
page: 1,
size: 20
}
})
});
/** File purpose (plain language): checks the enterprise WeChat page's create flow, external-number navigation, and phone filter. */
test('creates an enterprise WeChat asset and exposes the required form', async ({ page }) => {
/** Code purpose (plain language): serves deterministic list and lookup API responses. Related files: WecomAccountView.js, wecom-api-client.js. Data flow: browser request -> route fixture -> rendered page. */
await page.route('**/api/wecom-accounts**', async route => {
const url = route.request().url();
if (url.includes('/lookups/phone-assets')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [{ id: 7, phoneNumber: '13812345678', numberType: 'SELF' }] }) });
if (route.request().method() === 'POST') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { id: 1 } }) });
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [], total: 0, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/reference/wecom');
await expect(page.getByRole('heading', { name: '企微资料' })).toBeVisible();
await expect(page.getByText('企微实名人')).toBeVisible();
await expect(page.getByText('示例科技有限公司(ID:10)')).toBeVisible();
await expect(page.getByText('13812345678(ID:20)')).toBeVisible();
await expect(page.getByText('iPhone 15(ID:30)')).toBeVisible();
await expect(page.getByText('王五(ID:40)')).toBeVisible();
await expect(page.getByText('删除标记')).toHaveCount(0);
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();
await page.getByLabel('企微名称').fill('测试企微');
await page.getByLabel('注册手机号').fill('13812345678');
await page.getByRole('button', { name: '保存' }).click();
await expect(page.getByText('新增成功')).toBeVisible();
});
/**
* 代码作用(白话):验证用户筛选和翻页时,页面会把新的名称、账号和页码参数提交给企微列表接口。
* 关联文件:WecomAccountView.js、wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):输入筛选 -> 查询按钮或下一页 -> URL 参数 -> 后端分页查询。
*/
test('sends current filters and page number to the wecom list API', async ({ page }) => {
const requestUrls = [];
/**
* 代码作用(白话):记录每次企微接口请求的 URL,并持续返回足够多的记录以显示分页器。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):前端请求 -> route 回调 -> requestUrls -> URL 参数断言。
*/
await page.route('**/api/wecom-accounts**', async (route) => {
requestUrls.push(route.request().url());
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: { records: [], total: 41, page: 1, size: 20 }
})
});
});
await page.goto('/asset/#/reference/wecom');
await page.locator('input').nth(0).fill('张三');
await page.locator('input').nth(1).fill('zhangsan');
await page.getByRole('button', { name: '查询' }).click();
await expect.poll(() => requestUrls.some((url) => url.includes('wecomName=%E5%BC%A0%E4%B8%89') && url.includes('wecomAccount=zhangsan'))).toBe(true);
await page.locator('.el-pagination .btn-next').click();
await expect.poll(() => requestUrls.some((url) => url.includes('page=2') && url.includes('wecomName=%E5%BC%A0%E4%B8%89'))).toBe(true);
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 } }) }));
await page.goto('/asset/#/phone-assets');
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
const link = page.getByRole('link', { name: '外部号码' });
await expect(link).toHaveAttribute('href', '#/reference/wecom?phoneAssetId=9');
});
\ No newline at end of file
## Context
企业微信资产目前只能分页查询;手机号资产仅能在自己的页面创建。`as_phone_asset` 尚未保存号码类型和外部来源,企业微信列表也不能按注册手机号筛选。仓库没有可执行的数据库迁移目录,因此数据库变更只作为受控 SQL 交付,绝不在应用启动或本次任务中直接执行。
## Goals / Non-Goals
**Goals:**
- 提供企业微信资产新增表单、关联查询和手机号自动创建。
- 在手机号资产中长期保存并显示“自有号码”或“外部号码”。
- 让外部号码链接到对应来源资产列表,并按手机号资产筛选。
- 用可复用的来源类型字段为后续微信、抖音等页面预留接入点。
**Non-Goals:**
- 不实现设备资产选择或企业微信资产编辑页。
- 不直接连接、修改或迁移任何数据库。
- 不在本次开发中实现尚无创建页面的微信、抖音等资产页。
## Decisions
### 手机号号码类型与来源分开保存
手机号资产新增 `number_type``source_asset_type``source_asset_id``number_type` 只表示号码归属:手机号资产页直接创建为 `SELF`(自有号码),其他资产流程自动创建为 `EXTERNAL`(外部号码);来源字段记录创建它的资产类型和主键。相比把“已有/新建”写入手机号资产,这能避免把一次关联动作误当成号码自身属性。
### 企业微信关联方式单独展示
企业微信资产新增 `phone_link_mode`。选择已有手机号资产时保存 `EXISTING`(已有号码);自动创建手机号资产时保存 `CREATED`(新建号码)。这与手机号资产的号码类型是两条不同维度的信息。
### 原子保存
企业微信资产服务使用事务(把自动创建手机号、创建企微资产、回填来源主键当成一组操作;任一步失败就全部撤销)。相比前端连续调用,可避免只有手机号、没有企微资产的孤立记录。
### 搜索接口按最小字段返回
新增注册主体、手机号和公司人员的只读搜索接口;注册主体返回 ID、名称和简称,手机号返回 ID、号码和号码类型,人员返回 ID 和姓名。前端不直接读取数据库,也不为选择器加载全部数据。
## Risks / Trade-offs
- [历史手机号没有来源] → 数据库交付将历史记录初始化为 `SELF`,与“非手机号资产创建才是外部号码”的规则一致。
- [并发提交同一新号码] → 复用手机号资产表既有“号码 + 删除状态”的唯一约束,并将重复键错误转换为重新读取已有号码。
- [未来来源页面尚未实现] → 当前仅企业微信具备完整跳转;来源类型字段和前端映射为未来页面预留扩展点。
- [数据库脚本不在仓库] → 交付明确的字段与回填要求,实际执行必须获得目标数据库和执行窗口的单独授权。
## Migration Plan
1. 在受控数据库变更中为手机号资产增加号码类型、来源资产类型、来源资产 ID,为企业微信资产增加手机号关联方式。
2. 将历史手机号资产初始化为 `SELF`,并为新字段建立筛选索引。
3. 发布后先验证现有列表仍可正常查询,再验证新建企微资产的已有号码和新建号码两条流程。
4. 回滚时保留新列但停止写入;前后端对空值回退为“自有号码”,避免旧数据不可读。
## Open Questions
- 无。数据库执行时间和目标库授权不属于本次代码实施范围。
## Why
企业微信资料页目前只有只读列表,且布局与手机号资产页不一致。登记企业微信账号时无法复用或安全地新增注册手机号,也无法区分手机号是自有号码还是由外部资产登记流程创建的号码。
## What Changes
- 将“企微资料”页面改为“企业微信资产”,复用手机号资产页的标题、按钮位置、面板宽度和表格内部横向滚动体验。
- 新增企业微信资产创建表单;注册手机号必填,支持搜索已有手机号资产,或在号码不存在时自动创建手机号资产。
- 新增手机号号码类型:直接在手机号资产页创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
- 为外部号码保存来源资产信息;手机号资产页将“外部号码”显示为无下划线的蓝色链接,点击后进入来源资产列表并按手机号筛选关联记录。
- 新增注册主体与企微号归属人的搜索选择能力;注册主体使用公司简称显示,企微号归属人复用公司人员记录。
- 企业微信资产列表新增注册手机号筛选,并展示“已有号码”或“新建号码”的关联方式。
## Capabilities
### New Capabilities
- `wecom-account-creation`: 创建企业微信资产,并完成注册主体、注册手机号和归属人员的选择与保存。
- `phone-asset-origin-tracking`: 保存手机号号码类型及来源资产,并支持从外部号码跳转到对应资产列表。
- `asset-reference-lookups`: 为企业微信资产表单提供注册主体、手机号资产和公司人员的搜索选择接口。
### Modified Capabilities
- 无;仓库没有可修改的主规格目录。
## Impact
- 前端:企业微信资产页、手机号资产页、对应 API 客户端、公共样式及 Playwright 测试。
- 后端:企业微信和手机号资产的 Controller、Service、DTO、响应对象与单元测试。
- 数据库:手机号资产表新增号码类型和来源资产字段;不在本次实现中直接执行数据库变更。
## ADDED Requirements
### Requirement: 资产引用搜索
系统 SHALL 提供公司档案、手机号资产和公司人员的只读搜索接口,供企业微信资产表单选择引用资产。
#### Scenario: 搜索注册主体
- **WHEN** 用户输入公司名称或简称
- **THEN** 系统 MUST 返回有效公司档案的 ID、公司名称和简称
#### Scenario: 搜索注册手机号
- **WHEN** 用户输入手机号片段
- **THEN** 系统 MUST 返回有效手机号资产的 ID、手机号与号码类型,且不返回已删除资产
## ADDED Requirements
### Requirement: 手机号号码类型
系统 SHALL 为手机号资产保存并显示号码类型;手机号资产页直接创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
#### Scenario: 显示外部号码
- **WHEN** 手机号资产的号码类型为外部号码
- **THEN** 手机号资产列表 MUST 以无下划线的蓝色文字显示“外部号码”
### Requirement: 外部号码来源跳转
系统 SHALL 为外部号码保存来源资产,并允许用户点击号码类型跳转到来源资产列表且按该手机号筛选。
#### Scenario: 跳转企业微信资产
- **WHEN** 用户点击来源为企业微信资产的外部号码
- **THEN** 系统 MUST 打开企业微信资产列表并仅显示关联该手机号资产的记录
## ADDED Requirements
### Requirement: 创建企业微信资产
系统 SHALL 在企业微信资产页提供“新增企业微信资产”按钮及表单;企微名称和注册手机号 MUST 必填,企微别名 MUST 默认填入“记忆力梅老师-助教老师”。
#### Scenario: 以已有号码创建
- **WHEN** 用户选择一个有效的已有手机号资产并保存企业微信资产
- **THEN** 系统 MUST 保存该手机号资产 ID,并在企业微信资产列表显示“已有号码”
#### Scenario: 以新号码创建
- **WHEN** 用户输入一个不存在的有效手机号并保存企业微信资产
- **THEN** 系统 MUST 原子地创建外部号码手机号资产与企业微信资产,并在列表显示“新建号码”
### Requirement: 企业微信资产表单字段
系统 SHALL 提供注册主体搜索、注册手机号搜索、实名状态单选、性别单选与企微号归属人搜索;注册主体和归属人不是必填项,设备 ID 不在表单中出现。
#### Scenario: 默认实名状态
- **WHEN** 用户打开新增企业微信资产表单
- **THEN** 实名状态 MUST 默认选择“在职”,并只允许“在职”或“离职”之一
## 1. 数据模型与后端接口
- [x] 1.1 为手机号资产和企业微信资产补充号码来源、关联方式及对应 DTO/响应字段。
- [x] 1.2 实现注册主体、手机号资产和公司人员的只读搜索接口。
- [x] 1.3 实现企业微信资产创建接口,并在新手机号场景中原子创建手机号资产。
- [x] 1.4 实现按手机号资产筛选企业微信资产的查询兼容逻辑。
## 2. 前端页面与交互
- [x] 2.1 将企业微信资产页改为手机号资产页一致的标题、按钮、面板和宽表布局。
- [x] 2.2 实现企业微信资产新增弹窗及注册主体、注册手机号、归属人员的搜索选择。
- [x] 2.3 在手机号资产列表显示号码类型,并实现外部号码蓝色无下划线跳转。
- [x] 2.4 实现企业微信资产页接收手机号筛选参数并显示关联记录。
## 3. 数据库交付与验证
- [x] 3.1 编写受控数据库变更说明:新增字段、历史数据回填和索引要求;不直接执行数据库变更。
- [x] 3.2 补充后端单元测试,覆盖已有号码、新建外部号码、搜索和筛选。
- [x] 3.3 补充 Playwright 测试,覆盖新增表单、号码类型展示与跳转筛选。
- [x] 3.4 运行 OpenSpec 校验及前后端相关测试。
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