Commit 9a51f1ba by DaiJiezhang

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

功能:改进手机号资产列表管理

See merge request !1
parents c95e91c1 45443f00
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- 前端已迁入 `frontend/`,开发入口为 Vite。 - 前端已迁入 `frontend/`,开发入口为 Vite。
- 旧 phone/wechat 后端接口与业务模块已移除。 - 旧 phone/wechat 后端接口与业务模块已移除。
- `#/reference/phone``#/reference/wechat` 是不可操作的旧界面参考页,不请求旧 API - `#/reference/phone` 是不可操作的旧界面参考页,不请求旧 API;`#/reference/wecom` 展示企微账号资产真实列表
- 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。 - 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。
## 前端开发 ## 前端开发
...@@ -15,7 +15,7 @@ npm install ...@@ -15,7 +15,7 @@ npm install
npm run dev npm run dev
``` ```
访问:`http://localhost:5173/#/reference/phone` 访问:`http://localhost:5173/asset/#/reference/wecom`
生产构建仍使用 `/assets/` 基础路径: 生产构建仍使用 `/assets/` 基础路径:
```powershell ```powershell
......
...@@ -7,7 +7,15 @@ import org.springframework.web.bind.annotation.*; ...@@ -7,7 +7,15 @@ import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/phone-assets") @RequestMapping("/api/phone-assets")
public class PhoneAssetController { public class PhoneAssetController {
private final PhoneAssetService service; public PhoneAssetController(PhoneAssetService service){this.service=service;} private final PhoneAssetService service;
/** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */
public PhoneAssetController(PhoneAssetService service){this.service=service;}
/** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */ /** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){return ApiResponse.success(service.page(query));} @GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){return ApiResponse.success(service.page(query));}
/** 代码作用(白话):接收新增表单并创建手机号资产。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):POST -> DTO -> Service.create -> ApiResponse -> 弹窗。 */
@PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){return ApiResponse.success("新增成功",service.create(request));}
/** 代码作用(白话):接收编辑表单并更新允许修改的字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):PUT -> Service.update -> ApiResponse -> 列表刷新。 */
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){return ApiResponse.success("编辑成功",service.update(id,request));}
/** 代码作用(白话):软删除没有关联阻止的手机号资产。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):DELETE -> Service.softDelete -> ApiResponse -> 列表刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){service.softDelete(id);return ApiResponse.success("删除成功",null);}
} }
\ No newline at end of file
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供企微资产列表的只读 HTTP 入口,让前端无需直接访问数据库。 */
@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 表格。
*/
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) {
return ApiResponse.success(service.page(query));
}
}
package com.xyw.console.asset.dto; package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
public record PhoneAssetPageQuery(@Min(1) Integer page, @Min(1) @Max(100) Integer size, String phoneNumber, String iccid) {
/** 代码作用(白话):在调用方未传分页参数时提供安全默认值。关联文件:PhoneAssetController.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认值 -> Service Page。 */ /** 文件用途(白话):接收手机号资产列表的分页和筛选条件,不改变新增、编辑接口的数据结构。 */
public int resolvedPage(){ return page == null ? 1 : page; } public record PhoneAssetPageQuery(
/** 代码作用(白话):限制每页返回数量,防止一次取走过多数据。关联文件:PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认值 -> Mapper limit。 */ @Min(1) Integer page,
public int resolvedSize(){ return size == null ? 20 : size; } @Min(1) @Max(100) Integer size,
String phoneNumber,
String iccid,
String realNameOwner,
String disposalStatus) {
/** 代码作用(白话):在调用方未传页码时提供安全默认值。关联文件:PhoneAssetController.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认页码 -> Service 分页查询。 */
public int resolvedPage() { return page == null ? 1 : page; }
/** 代码作用(白话):限制每页返回数量,防止一次取走过多数据。关联文件:PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认页大小 -> Mapper limit。 */
public int resolvedSize() { return size == null ? 20 : size; }
} }
\ No newline at end of file
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 代码作用(白话):限定新增和编辑时浏览器可以提交的手机号资产字段。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):弹窗表单 -> DTO 校验 -> Service 写入允许修改的实体字段。
*/
public record PhoneAssetSaveRequest(
@NotBlank(message = "手机号不能为空") String phoneNumber,
String cardType,
String iccid,
@Size(max = 20, message = "实名归属最多 20 个字符") String realNameOwner,
String managementType,
String disposalStatus,
Long deviceId
) {
}
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) {
/**
* 代码作用(白话):在调用方没有传页码时返回第 1 页,保证列表可以直接打开。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedPage -> MyBatis Page -> 列表响应。
*/
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;
}
}
package com.xyw.console.asset.dto;
import java.util.List;
/** 文件用途(白话):将企微资产列表、总条数和当前分页信息打包为统一的页面数据。 */
public record WecomAccountPageResponse(
List<WecomAccountResponse> records,
long total,
int page,
int size) {
}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义一条企微资产返回给页面的字段,包含关联 ID 及其可读名称,不暴露删除标记。 */
public record WecomAccountResponse(
Long id,
String wecomName,
String wecomAlias,
String wecomAccount,
Long companyProfileId,
String companyProfileName,
Long phoneAssetId,
String phoneNumber,
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long deviceId,
String deviceName,
Long operatorPersonId,
String operatorPersonName,
LocalDateTime createTime,
LocalDateTime updateTime) {
}
package com.xyw.console.asset.exception;
/** 代码作用(白话):表示请求的手机号资产不存在或已软删除。关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> 404 响应。 */
public class PhoneAssetNotFoundException extends RuntimeException { public PhoneAssetNotFoundException(String message){super(message);} }
\ No newline at end of file
package com.xyw.console.asset.exception;
/**
* 代码作用(白话):表示手机号规范化后仍不符合保存规则。
* 关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。
* 关联逻辑(调用链/数据流):Service 校验失败 -> 异常处理 -> 400 响应 -> 前端保留弹窗并提示。
*/
public class PhoneAssetValidationException extends RuntimeException {
public PhoneAssetValidationException(String message) {
super(message);
}
}
package com.xyw.console.asset.service; package com.xyw.console.asset.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.*; import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.entity.PhoneAssetEntity; import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.exception.PhoneAssetNotFoundException;
import com.xyw.console.asset.exception.PhoneAssetValidationException;
import com.xyw.console.asset.mapper.PhoneAssetMapper; import com.xyw.console.asset.mapper.PhoneAssetMapper;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class PhoneAssetService { public class PhoneAssetService {
private final PhoneAssetMapper mapper; private final PhoneAssetMapper mapper;
public PhoneAssetService(PhoneAssetMapper mapper){ this.mapper=mapper; } /** 代码作用(白话):接收手机号资产表的数据库访问入口。关联文件:PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_phone_asset。 */
/** 代码作用(白话):查询未删除手机号资产并转换成前端列表字段。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):GET 请求 -> page -> Mapper -> as_phone_asset -> Response。 */ public PhoneAssetService(PhoneAssetMapper mapper) { this.mapper = mapper; }
public PhoneAssetPageResponse page(PhoneAssetPageQuery query){ Page<PhoneAssetEntity> page=mapper.selectPage(new Page<>(query.resolvedPage(),query.resolvedSize()),new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime,0L).eq(query.phoneNumber()!=null&&!query.phoneNumber().isBlank(),PhoneAssetEntity::getPhoneNumber,query.phoneNumber()).eq(query.iccid()!=null&&!query.iccid().isBlank(),PhoneAssetEntity::getIccid,query.iccid()).orderByDesc(PhoneAssetEntity::getId)); List<PhoneAssetResponse> records=page.getRecords().stream().map(this::toResponse).toList(); return new PhoneAssetPageResponse(records,page.getTotal(),query.resolvedPage(),query.resolvedSize()); } /** 代码作用(白话):按分页、手机号片段、ICCID、实名人和使用状态读取未删除资产。关联文件:PhoneAssetPageQuery.java、PhoneAssetController.java。关联逻辑(调用链/数据流):GET 参数 -> 查询条件 -> Mapper -> Response。 */
/** 代码作用(白话):只挑选页面列表需要展示的字段,避免把关联快照直接暴露给列表。关联文件:PhoneAssetResponse.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):数据库实体 -> DTO -> ApiResponse -> 前端表格。 */ public PhoneAssetPageResponse page(PhoneAssetPageQuery query) {
private PhoneAssetResponse toResponse(PhoneAssetEntity e){ return new PhoneAssetResponse(e.getId(),e.getPhoneNumber(),e.getCardType(),e.getIccid(),e.getRealNameOwner(),e.getManagementType(),e.getDisposalStatus(),e.getDeviceId(),e.getRelationSyncedAt()); } Page<PhoneAssetEntity> page = mapper.selectPage(new Page<>(query.resolvedPage(), query.resolvedSize()), activeQuery(query));
List<PhoneAssetResponse> records = page.getRecords().stream().map(this::toResponse).toList();
return new PhoneAssetPageResponse(records, page.getTotal(), query.resolvedPage(), query.resolvedSize());
}
/** 代码作用(白话):组合现有资产筛选条件,保证旧的完整号码与 ICCID 参数继续可用。关联文件:PhoneAssetPageQuery.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):Query DTO -> LambdaQueryWrapper -> SQL where 条件。 */
private LambdaQueryWrapper<PhoneAssetEntity> activeQuery(PhoneAssetPageQuery query) {
LambdaQueryWrapper<PhoneAssetEntity> wrapper = new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L);
applyPhoneNumberFilter(wrapper, query.phoneNumber());
wrapper.eq(hasText(query.iccid()), PhoneAssetEntity::getIccid, query.iccid())
.like(hasText(query.realNameOwner()), PhoneAssetEntity::getRealNameOwner, query.realNameOwner())
.eq(hasText(query.disposalStatus()), PhoneAssetEntity::getDisposalStatus, query.disposalStatus())
.orderByDesc(PhoneAssetEntity::getId);
return wrapper;
}
/** 代码作用(白话):按 3、4、7、11 位手机号规则追加查询条件,其余长度保持原有精确查询。关联文件:PhoneAssetPageQuery.java、PhoneAssetView.js。关联逻辑(调用链/数据流):搜索输入 -> phoneNumber 参数 -> 前缀/后缀/精确 SQL 匹配。 */
private void applyPhoneNumberFilter(LambdaQueryWrapper<PhoneAssetEntity> wrapper, String phoneNumber) {
if (!hasText(phoneNumber)) return;
if (phoneNumber.length() == 3) wrapper.likeRight(PhoneAssetEntity::getPhoneNumber, phoneNumber);
else if (phoneNumber.length() == 4) wrapper.likeLeft(PhoneAssetEntity::getPhoneNumber, phoneNumber);
else if (phoneNumber.length() == 7) wrapper.likeRight(PhoneAssetEntity::getPhoneNumber, phoneNumber.substring(0, 3)).likeLeft(PhoneAssetEntity::getPhoneNumber, phoneNumber.substring(3));
else wrapper.eq(PhoneAssetEntity::getPhoneNumber, phoneNumber);
}
/** 代码作用(白话):判断筛选文本是否有内容,避免空字符串参与数据库筛选。关联文件: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); }
/** 代码作用(白话):更新有效资产的用户可写字段。关联文件: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。 */
public void softDelete(Long id) { PhoneAssetEntity entity=requireActiveEntity(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); }
/** 代码作用(白话):读取一条仍有效的资产,供编辑和删除共用。关联文件:PhoneAssetMapper.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404 异常。 */
private PhoneAssetEntity requireActiveEntity(Long id){ PhoneAssetEntity entity=mapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime,0L).eq(PhoneAssetEntity::getId,id)); if(entity==null) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); return entity; }
/** 代码作用(白话):把表单允许提交的字段写入实体,并统一处理手机号和默认使用状态。关联文件:PhoneAssetSaveRequest.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):弹窗表单 -> DTO -> Entity -> Mapper 保存。 */
private void applyEditableFields(PhoneAssetEntity entity, PhoneAssetSaveRequest request){ entity.setPhoneNumber(normalizePhoneNumber(request.phoneNumber())); entity.setCardType(request.cardType()); entity.setIccid(request.iccid()); entity.setRealNameOwner(request.realNameOwner()); entity.setManagementType(request.managementType()); entity.setDisposalStatus(request.disposalStatus()==null||request.disposalStatus().isBlank()?"正常使用":request.disposalStatus()); entity.setDeviceId(request.deviceId()); }
/** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件:PhoneAssetSaveRequest.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):前端输入 -> 去空格和 +86 -> 格式失败返回 400。 */
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()); }
} }
\ No newline at end of file
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.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountResponse;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
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.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
/** 文件用途(白话):读取企微账号资产,并把页面看不懂的关联 ID 转成公司、手机号、设备和人员名称。 */
@Service
public class WecomAccountService {
private final WecomAccountMapper wecomAccountMapper;
private final CompanyProfileMapper companyProfileMapper;
private final PhoneAssetMapper phoneAssetMapper;
private final AssetDeviceMapper assetDeviceMapper;
private final CompanyPersonMapper companyPersonMapper;
/**
* 代码作用(白话):接收企微和关联资产表的数据库访问入口,供列表查询一次性解析名称。
* 关联文件:WecomAccountMapper.java、CompanyProfileMapper.java、PhoneAssetMapper.java、AssetDeviceMapper.java、CompanyPersonMapper.java。
* 关联逻辑(调用链/数据流):Controller -> Service -> 五个 Mapper -> 资产表。
*/
public WecomAccountService(
WecomAccountMapper wecomAccountMapper,
CompanyProfileMapper companyProfileMapper,
PhoneAssetMapper phoneAssetMapper,
AssetDeviceMapper assetDeviceMapper,
CompanyPersonMapper companyPersonMapper) {
this.wecomAccountMapper = wecomAccountMapper;
this.companyProfileMapper = companyProfileMapper;
this.phoneAssetMapper = phoneAssetMapper;
this.assetDeviceMapper = assetDeviceMapper;
this.companyPersonMapper = companyPersonMapper;
}
/**
* 代码作用(白话):按分页和筛选条件读取未删除企微资产,并补齐关联名称后返回页面数据。
* 关联文件:WecomAccountController.java、WecomAccountPageQuery.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):GET 请求 -> page -> WecomAccountMapper + 关联 Mapper -> 分页 DTO -> Vue 表格。
*/
public WecomAccountPageResponse page(WecomAccountPageQuery query) {
Page<WecomAccountEntity> page = wecomAccountMapper.selectPage(
new Page<>(query.resolvedPage(), query.resolvedSize()), activeQuery(query));
List<WecomAccountEntity> entities = page.getRecords();
Map<Long, String> companyNames = companyNames(collectIds(entities, WecomAccountEntity::getCompanyProfileId));
Map<Long, String> phoneNumbers = phoneNumbers(collectIds(entities, WecomAccountEntity::getPhoneAssetId));
Map<Long, String> deviceNames = deviceNames(collectIds(entities, WecomAccountEntity::getDeviceId));
Map<Long, String> operatorPersonNames = operatorPersonNames(collectIds(entities, WecomAccountEntity::getOperatorPersonId));
List<WecomAccountResponse> records = entities.stream()
.map(entity -> toResponse(entity, companyNames, phoneNumbers, deviceNames, operatorPersonNames))
.toList();
return new WecomAccountPageResponse(records, page.getTotal(), query.resolvedPage(), query.resolvedSize());
}
/**
* 代码作用(白话):构造只含正常企微记录的筛选条件,并在有输入时附加名称和账号筛选。
* 关联文件:WecomAccountEntity.java、WecomAccountPageQuery.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):查询参数 -> LambdaQueryWrapper -> as_wecom_account SQL -> 分页结果。
*/
private LambdaQueryWrapper<WecomAccountEntity> activeQuery(WecomAccountPageQuery query) {
return 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);
}
/**
* 代码作用(白话):判断筛选文本是否有实际内容,避免空字符串参与精确查询而把正常结果筛掉。
* 关联文件:WecomAccountPageQuery.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):URL 筛选参数 -> hasText -> activeQuery 是否追加条件。
*/
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
/**
* 代码作用(白话):从当前页企微记录中收集某一种关联 ID 并去重,供后续一次性查询名称。
* 关联文件:WecomAccountEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):当前页 records -> ID 集合 -> 关联 Mapper IN 查询。
*/
private Set<Long> collectIds(List<WecomAccountEntity> entities, Function<WecomAccountEntity, Long> idGetter) {
return entities.stream().map(idGetter).filter(id -> id != null).collect(Collectors.toSet());
}
/**
* 代码作用(白话):批量读取公司档案名称,供公司档案 ID 在页面显示为名称(ID)。
* 关联文件:CompanyProfileEntity.java、CompanyProfileMapper.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):companyProfileId 集合 -> as_company_profile -> companyProfileName。
*/
private Map<Long, String> companyNames(Set<Long> ids) {
if (ids.isEmpty()) {
return Map.of();
}
return toNameMap(companyProfileMapper.selectList(new LambdaQueryWrapper<CompanyProfileEntity>()
.in(CompanyProfileEntity::getId, ids).eq(CompanyProfileEntity::getDeleteTime, 0L)),
CompanyProfileEntity::getId, CompanyProfileEntity::getCompanyName);
}
/**
* 代码作用(白话):批量读取手机号资产号码,供手机号资产 ID 在页面显示为手机号(ID)。
* 关联文件:PhoneAssetEntity.java、PhoneAssetMapper.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):phoneAssetId 集合 -> as_phone_asset -> phoneNumber。
*/
private Map<Long, String> phoneNumbers(Set<Long> ids) {
if (ids.isEmpty()) {
return Map.of();
}
return toNameMap(phoneAssetMapper.selectList(new LambdaQueryWrapper<PhoneAssetEntity>()
.in(PhoneAssetEntity::getId, ids).eq(PhoneAssetEntity::getDeleteTime, 0L)),
PhoneAssetEntity::getId, PhoneAssetEntity::getPhoneNumber);
}
/**
* 代码作用(白话):批量读取设备名称,供设备 ID 在页面显示为设备名称(ID)。
* 关联文件:AssetDeviceEntity.java、AssetDeviceMapper.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):deviceId 集合 -> as_asset_device -> deviceName。
*/
private Map<Long, String> deviceNames(Set<Long> ids) {
if (ids.isEmpty()) {
return Map.of();
}
return toNameMap(assetDeviceMapper.selectList(new LambdaQueryWrapper<AssetDeviceEntity>()
.in(AssetDeviceEntity::getId, ids).eq(AssetDeviceEntity::getDeleteTime, 0L)),
AssetDeviceEntity::getId, AssetDeviceEntity::getDeviceName);
}
/**
* 代码作用(白话):批量读取经办人员名称,供经办人 ID 在页面显示为人员名称(ID)。
* 关联文件:CompanyPersonEntity.java、CompanyPersonMapper.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):operatorPersonId 集合 -> as_company_person -> personName。
*/
private Map<Long, String> operatorPersonNames(Set<Long> ids) {
if (ids.isEmpty()) {
return Map.of();
}
return toNameMap(companyPersonMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>()
.in(CompanyPersonEntity::getId, ids).eq(CompanyPersonEntity::getDeleteTime, 0L)),
CompanyPersonEntity::getId, CompanyPersonEntity::getPersonName);
}
/**
* 代码作用(白话):把一批“主键和名称”实体整理成查找表,让每条企微记录不用再次查询数据库。
* 关联文件:CompanyProfileEntity.java、PhoneAssetEntity.java、AssetDeviceEntity.java、CompanyPersonEntity.java。
* 关联逻辑(调用链/数据流):关联 Mapper 结果 -> ID/名称 Map -> toResponse。
*/
private <T> Map<Long, String> toNameMap(Collection<T> entities, Function<T, Long> idGetter, Function<T, String> nameGetter) {
return entities.stream().collect(Collectors.toMap(idGetter, nameGetter));
}
/**
* 代码作用(白话):把企微数据库实体和已解析的关联名称组合成页面可直接使用的一行数据。
* 关联文件:WecomAccountEntity.java、WecomAccountResponse.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):实体 + 名称 Map -> Response -> ApiResponse -> Element Plus 表格。
*/
private WecomAccountResponse toResponse(
WecomAccountEntity entity,
Map<Long, String> companyNames,
Map<Long, String> phoneNumbers,
Map<Long, String> deviceNames,
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.getRealNameOwner(), entity.getRealNameOwnerStatus(), entity.getGender(),
entity.getDeviceId(), deviceNames.get(entity.getDeviceId()),
entity.getOperatorPersonId(), operatorPersonNames.get(entity.getOperatorPersonId()),
entity.getCreateTime(), entity.getUpdateTime());
}
}
...@@ -15,7 +15,7 @@ public class WebConfig implements WebMvcConfigurer { ...@@ -15,7 +15,7 @@ public class WebConfig implements WebMvcConfigurer {
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") registry.addMapping("/api/**")
.allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000") .allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:5173", "http://127.0.0.1:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*"); .allowedHeaders("*");
} }
......
package com.xyw.console.asset.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.service.PhoneAssetService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class PhoneAssetControllerTest {
/**
* 代码作用(白话):证明浏览器可以通过新增接口创建手机号资产。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetSaveRequest.java。
* 关联逻辑(调用链/数据流):POST /api/phone-assets -> Controller -> Service -> Mapper -> as_phone_asset。
*/
@Test
void createsPhoneAssetThroughPostEndpoint() throws Exception {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.insert(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
mockMvc.perform(post("/api/phone-assets").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13812345678\",\"realNameOwner\":\"张三\"}"))
.andExpect(status().isOk());
}
/**
* 代码作用(白话):证明列表中的编辑操作可以调用更新接口。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):PUT /api/phone-assets/1 -> Service.update -> Mapper.updateById -> 列表刷新。
*/
@Test
void updatesPhoneAssetThroughPutEndpoint() throws Exception {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
PhoneAssetEntity entity = activeEntity();
when(mapper.selectOne(any())).thenReturn(entity);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
mockMvc.perform(put("/api/phone-assets/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13912345678\",\"realNameOwner\":\"李四\"}"))
.andExpect(status().isOk());
}
/**
* 代码作用(白话):证明用户确认后可通过删除接口软删除无关联资产。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):DELETE /api/phone-assets/1 -> Service.softDelete -> Mapper.updateById -> 列表刷新。
*/
@Test
void softDeletesPhoneAssetThroughDeleteEndpoint() throws Exception {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectOne(any())).thenReturn(activeEntity());
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
mockMvc.perform(delete("/api/phone-assets/1"))
.andExpect(status().isOk());
}
/**
* 代码作用(白话):构造一条仍有效的资产,供编辑和删除接口测试使用。
* 关联文件:PhoneAssetService.java、PhoneAssetEntity.java。
* 关联逻辑(调用链/数据流):测试输入 -> Service 查询有效资产 -> 更新或软删除。
*/
private PhoneAssetEntity activeEntity() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(1L);
entity.setPhoneNumber("13812345678");
entity.setDeleteTime(0L);
return entity;
}
}
package com.xyw.console.asset.controller;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
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 com.xyw.console.asset.service.WecomAccountService;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class WecomAccountControllerTest {
/**
* 代码作用(白话):证明浏览器请求企微列表会得到统一成功响应、关联名称和不含删除标记的记录。
* 关联文件:WecomAccountController.java、WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):GET /api/wecom-accounts -> Controller -> Service -> Mapper -> JSON 表格数据。
*/
@Test
void returnsWecomAccountPageWithoutDeleteTime() throws Exception {
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller()).build();
mockMvc.perform(get("/api/wecom-accounts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data.page", is(1)))
.andExpect(jsonPath("$.data.size", is(20)))
.andExpect(jsonPath("$.data.records[0].companyProfileName", is("示例科技有限公司")))
.andExpect(jsonPath("$.data.records[0].phoneNumber", is("13812345678")))
.andExpect(jsonPath("$.data.records[0].deviceName", is("iPhone 15")))
.andExpect(jsonPath("$.data.records[0].operatorPersonName", is("王五")))
.andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist());
}
/**
* 代码作用(白话):构造使用真实 Service 的 Controller,避免只验证模拟返回值。
* 关联文件:WecomAccountController.java、WecomAccountService.java、各资产 Mapper。
* 关联逻辑(调用链/数据流):测试 HTTP 请求 -> Controller -> Service -> Mapper 模拟数据库结果。
*/
private WecomAccountController controller() {
WecomAccountMapper wecomMapper = org.mockito.Mockito.mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = org.mockito.Mockito.mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = org.mockito.Mockito.mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = org.mockito.Mockito.mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = org.mockito.Mockito.mock(CompanyPersonMapper.class);
Page<WecomAccountEntity> page = new Page<>(1, 20);
page.setRecords(List.of(wecomAccount()));
page.setTotal(1L);
org.mockito.Mockito.when(wecomMapper.selectPage(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(page);
org.mockito.Mockito.when(companyProfileMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(companyProfile()));
org.mockito.Mockito.when(phoneAssetMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(phoneAsset()));
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device()));
org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper));
}
/**
* 代码作用(白话):构造接口测试的企微主记录。
* 关联文件:WecomAccountEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):企微主记录 -> Service 关联名称填充 -> Controller JSON。
*/
private WecomAccountEntity wecomAccount() {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(1L);
entity.setWecomName("张三");
entity.setWecomAlias("销售一组");
entity.setWecomAccount("zhangsan");
entity.setCompanyProfileId(10L);
entity.setPhoneAssetId(20L);
entity.setRealNameOwner("张三");
entity.setRealNameOwnerStatus("已实名");
entity.setGender("男");
entity.setDeviceId(30L);
entity.setOperatorPersonId(40L);
entity.setCreateTime(LocalDateTime.of(2026, 7, 31, 10, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 7, 31, 11, 0));
entity.setDeleteTime(0L);
return entity;
}
/**
* 代码作用(白话):构造公司档案关联测试数据。
* 关联文件:CompanyProfileEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):公司 ID -> 公司名称 -> JSON 字段 companyProfileName。
*/
private CompanyProfileEntity companyProfile() {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L);
entity.setCompanyName("示例科技有限公司");
return entity;
}
/**
* 代码作用(白话):构造手机号资产关联测试数据。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号资产 ID -> 手机号 -> JSON 字段 phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(20L);
entity.setPhoneNumber("13812345678");
return entity;
}
/**
* 代码作用(白话):构造设备关联测试数据。
* 关联文件:AssetDeviceEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):设备 ID -> 设备名称 -> JSON 字段 deviceName。
*/
private AssetDeviceEntity device() {
AssetDeviceEntity entity = new AssetDeviceEntity();
entity.setId(30L);
entity.setDeviceName("iPhone 15");
return entity;
}
/**
* 代码作用(白话):构造经办人关联测试数据。
* 关联文件:CompanyPersonEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):人员 ID -> 人员名称 -> JSON 字段 operatorPersonName。
*/
private CompanyPersonEntity operatorPerson() {
CompanyPersonEntity entity = new CompanyPersonEntity();
entity.setId(40L);
entity.setPersonName("王五");
return entity;
}
}
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
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.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
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.List;
import org.junit.jupiter.api.Test;
class WecomAccountServiceTest {
/**
* 代码作用(白话):证明企微列表会把当前页关联 ID 转成可读名称,同时保留 ID 且不产生删除标记字段。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):分页请求 -> Service 查询企微及关联资产 -> Response -> 前端表格。
*/
@Test
void returnsReadableRelatedNamesForTheCurrentWecomAccountPage() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = mock(CompanyPersonMapper.class);
when(wecomMapper.selectPage(any(), any())).thenReturn(wecomPage());
when(companyProfileMapper.selectList(any())).thenReturn(List.of(companyProfile()));
when(phoneAssetMapper.selectList(any())).thenReturn(List.of(phoneAsset()));
when(assetDeviceMapper.selectList(any())).thenReturn(List.of(device()));
when(companyPersonMapper.selectList(any())).thenReturn(List.of(operatorPerson()));
WecomAccountPageResponse result = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null));
WecomAccountResponse record = result.records().get(0);
assertEquals(1L, record.id());
assertEquals("示例科技有限公司", record.companyProfileName());
assertEquals(10L, record.companyProfileId());
assertEquals("13812345678", record.phoneNumber());
assertEquals(20L, record.phoneAssetId());
assertEquals("iPhone 15", record.deviceName());
assertEquals(30L, record.deviceId());
assertEquals("王五", record.operatorPersonName());
assertEquals(40L, record.operatorPersonId());
assertEquals("张三", record.realNameOwner());
assertEquals(1, result.page());
assertEquals(20, result.size());
}
/**
* 代码作用(白话):证明关联 ID 没有对应资产时,企微记录仍能返回且名称为空。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):企微记录含关联 ID -> 批量查询无匹配 -> Response 保留 ID 与空名称。
*/
@Test
void keepsRelatedIdsWhenTheirNamesCannotBeResolved() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = mock(CompanyPersonMapper.class);
when(wecomMapper.selectPage(any(), any())).thenReturn(wecomPage());
when(companyProfileMapper.selectList(any())).thenReturn(List.of());
when(phoneAssetMapper.selectList(any())).thenReturn(List.of());
when(assetDeviceMapper.selectList(any())).thenReturn(List.of());
when(companyPersonMapper.selectList(any())).thenReturn(List.of());
WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId());
assertNull(record.companyProfileName());
assertEquals(20L, record.phoneAssetId());
assertNull(record.phoneNumber());
assertEquals(30L, record.deviceId());
assertNull(record.deviceName());
assertEquals(40L, record.operatorPersonId());
assertNull(record.operatorPersonName());
}
/**
* 代码作用(白话):构造一页正常企微资产记录,供关联名称转换场景使用。
* 关联文件:WecomAccountEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):测试记录 -> Mapper 分页结果 -> Service -> Response。
*/
private Page<WecomAccountEntity> wecomPage() {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(1L);
entity.setWecomName("张三");
entity.setWecomAlias("销售一组");
entity.setWecomAccount("zhangsan");
entity.setCompanyProfileId(10L);
entity.setPhoneAssetId(20L);
entity.setRealNameOwner("张三");
entity.setRealNameOwnerStatus("已实名");
entity.setGender("男");
entity.setDeviceId(30L);
entity.setOperatorPersonId(40L);
entity.setCreateTime(LocalDateTime.of(2026, 7, 31, 10, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 7, 31, 11, 0));
entity.setDeleteTime(0L);
Page<WecomAccountEntity> page = new Page<>(1, 20);
page.setRecords(List.of(entity));
page.setTotal(1L);
return page;
}
/**
* 代码作用(白话):构造公司档案名称,验证公司 ID 能转成页面可读名称。
* 关联文件:CompanyProfileEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):companyProfileId -> CompanyProfileMapper -> companyProfileName。
*/
private CompanyProfileEntity companyProfile() {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L);
entity.setCompanyName("示例科技有限公司");
return entity;
}
/**
* 代码作用(白话):构造手机号资产名称,验证手机号资产 ID 能转成手机号。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):phoneAssetId -> PhoneAssetMapper -> phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(20L);
entity.setPhoneNumber("13812345678");
return entity;
}
/**
* 代码作用(白话):构造设备名称,验证设备 ID 能转成设备名称。
* 关联文件:AssetDeviceEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):deviceId -> AssetDeviceMapper -> deviceName。
*/
private AssetDeviceEntity device() {
AssetDeviceEntity entity = new AssetDeviceEntity();
entity.setId(30L);
entity.setDeviceName("iPhone 15");
return entity;
}
/**
* 代码作用(白话):构造经办人名称,验证经办人 ID 能转成页面可读人员名称。
* 关联文件:CompanyPersonEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):operatorPersonId -> CompanyPersonMapper -> operatorPersonName。
*/
private CompanyPersonEntity operatorPerson() {
CompanyPersonEntity entity = new CompanyPersonEntity();
entity.setId(40L);
entity.setPersonName("王五");
return entity;
}
}
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"name": "xyw-console-frontend", "name": "xyw-console-frontend",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test:e2e": "playwright test" "test:e2e": "playwright test"
......
/** /** 代码作用(白话):提供前端外壳和中文导航。关联文件: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>` };
* 关联文件:frontend/src/router/index.js、frontend/src/reference/LegacyReferenceView.js、frontend/src/styles/app.css。 \ No newline at end of file
* 关联逻辑(调用链/数据流):导航点击 -> RouterLink -> Vue Router -> RouterView 渲染目标页面。
*/
export default {
template: `
<div class="app-shell">
<aside class="sidebar">
<p class="eyebrow">XYW ASSETS</p>
<h1>学有为资产后台</h1>
<nav aria-label="主导航">
<RouterLink to="/overview">总览</RouterLink>
<RouterLink to="/domain">域名资料</RouterLink>
<RouterLink to="/reference/wechat">企微资料(参考)</RouterLink>
<RouterLink to="/phone-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 { nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { listPhoneAssets } from './phone-api-client.js'; import zhCn from 'element-plus/es/locale/lang/zh-cn.mjs';
import { createPhoneAsset, deletePhoneAsset, listPhoneAssets, updatePhoneAsset } from './phone-api-client.js';
/** /**
* 代码作用(白话):显示手机号资产列表并管理筛选和分页状态。 * 代码作用(白话):显示手机号资产列表,并管理新增、编辑、保存和删除弹窗状态。
* 关联文件:phone-api-client.js、router/index.js、PhoneAssetController.java * 关联文件:phone-api-client.js、PhoneAssetController.java、app.css
* 关联逻辑(调用链/数据流):路由 -> setup -> loadPage -> API -> Element Plus 表格 * 关联逻辑(调用链/数据流):路由进入 -> 页面方法 -> 前端接口客户端 -> 后端接口 -> 列表或弹窗更新
*/ */
export default { export default {
/** /**
* 代码作用(白话):创建手机号资产列表的筛选、加载和分页状态,并把状态交给页面模板使用 * 代码作用(白话):创建列表、筛选、弹窗和表单所需的响应式数据及页面操作方法
* 关联文件:phone-api-client.js、PhoneAssetController.java。 * 关联文件:phone-api-client.js、PhoneAssetController.java、app.css
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化状态 -> loadPage 请求接口 -> 表格读取 records * 关联逻辑(调用链/数据流):路由进入 -> setup 初始化 -> 用户操作 -> 接口调用或弹窗状态变化
*/ */
setup() { setup() {
const loading = ref(false); const records = ref([]); const total = ref(0); const elementLocale = zhCn;
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '' }); const loading = ref(false);
/** 代码作用(白话):按当前条件加载一页记录。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):页面事件 -> API -> records/total。 */ const saving = ref(false);
async function loadPage() { loading.value = true; try { const result = await listPhoneAssets(filters); records.value = result.records; total.value = result.total; } catch (error) { ElMessage.error(error.message); } finally { loading.value = false; } } const records = ref([]);
/** 代码作用(白话):查询时回到第一页。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):点击查询 -> page=1 -> loadPage。 */ const total = ref(0);
function submitSearch() { filters.page = 1; loadPage(); } const dialogVisible = ref(false);
/** 代码作用(白话):清空筛选并重新加载默认列表。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):重置 -> filters -> loadPage。 */ const editingId = ref(null);
function resetSearch() { Object.assign(filters, { page: 1, size: 20, phoneNumber: '', iccid: '' }); loadPage(); } const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: '' });
/** 代码作用(白话):切换页码并重新加载。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):分页器 -> page -> loadPage。 */ let searchTimer = null;
let latestRequest = 0;
const form = reactive({
phoneNumber: '',
cardType: '',
iccid: '',
realNameOwner: '',
managementType: '',
disposalStatus: '正常使用',
deviceId: null
});
/**
* 代码作用(白话):加载当前筛选条件下的一页手机号资产。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):查询/分页/保存成功 -> listPhoneAssets -> GET 接口 -> records 与 total。
*/
async function loadPage() {
const requestId = ++latestRequest;
loading.value = true;
try {
const result = await listPhoneAssets(filters);
if (requestId !== latestRequest) return;
records.value = result.records;
total.value = result.total;
} catch (error) {
if (requestId === latestRequest) ElMessage.error(error.message);
} finally {
if (requestId === latestRequest) loading.value = false;
}
}
/**
* 代码作用(白话):把手机号变成最多 11 位数字,供输入和粘贴共用。
* 关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java。
* 关联逻辑(调用链/数据流):浏览器输入 -> 过滤数字 -> form.phoneNumber -> 保存请求 -> 后端格式校验。
*/
function normalizePhoneNumber(value) {
return String(value || '').replace(/\D/g, '').slice(0, 11);
}
/**
* 代码作用(白话):把 ICCID 变成最多 20 位的数字或英文字母,供输入和粘贴共用。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):浏览器输入 -> 过滤有效字符 -> form.iccid -> 保存请求。
*/
function normalizeIccid(value) {
return String(value || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 20);
}
/**
* 代码作用(白话):处理手机号实时输入,确保计数器只计算有效数字。
* 关联文件:PhoneAssetView.js、PhoneAssetService.java。
* 关联逻辑(调用链/数据流):手机号 input 事件 -> normalizePhoneNumber -> form.phoneNumber -> 输入框 suffix。
*/
function limitPhoneNumber(value) {
form.phoneNumber = normalizePhoneNumber(value);
}
/**
* 代码作用(白话):处理 ICCID 实时输入,确保计数器只计算有效的数字和英文字母。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):ICCID input 事件 -> normalizeIccid -> form.iccid -> 输入框 suffix。
*/
function limitIccid(value) {
form.iccid = normalizeIccid(value);
}
/**
* 代码作用(白话):将粘贴文字插入当前选择位置后再过滤,避免浏览器 maxlength 先截掉有效字符。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):paste 事件 -> 原始剪贴板文字 -> 过滤方法 -> form 字段 -> 框内计数器。
*/
function replaceSelectionWithPastedValue(event, fieldName, normalizeValue) {
const pastedText = event.clipboardData?.getData('text');
if (pastedText === undefined) return;
event.preventDefault();
const input = event.target;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
const nextValue = input.value.slice(0, start) + pastedText + input.value.slice(end);
form[fieldName] = normalizeValue(nextValue);
}
/**
* 代码作用(白话):处理手机号粘贴并保留前 11 位有效数字。
* 关联文件:PhoneAssetView.js、PhoneAssetService.java。
* 关联逻辑(调用链/数据流):手机号 paste -> replaceSelectionWithPastedValue -> form.phoneNumber -> 保存校验。
*/
function handlePhonePaste(event) {
replaceSelectionWithPastedValue(event, 'phoneNumber', normalizePhoneNumber);
}
/**
* 代码作用(白话):处理 ICCID 粘贴并保留前 20 个有效字符。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):ICCID paste -> replaceSelectionWithPastedValue -> form.iccid -> 保存请求。
*/
function handleIccidPaste(event) {
replaceSelectionWithPastedValue(event, 'iccid', normalizeIccid);
}
/**
* 代码作用(白话):把表单恢复成新增手机号资产时原有的默认值。
* 关联文件:PhoneAssetView.js、PhoneAssetSaveRequest.java。
* 关联逻辑(调用链/数据流):新增按钮 -> resetForm -> form -> 弹窗;保存成功后的下一次新增同样复用。
*/
function resetForm() {
Object.assign(form, {
phoneNumber: '',
cardType: '',
iccid: '',
realNameOwner: '',
managementType: '',
disposalStatus: '正常使用',
deviceId: null
});
}
/**
* 代码作用(白话):在弹窗显示完成后把内部内容滚动条移回顶部。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):Element Plus opened 事件 -> 弹窗专属类 -> 内容区域 scrollTop=0。
*/
async function resetDialogScroll() {
await nextTick();
const body = document.querySelector('.phone-asset-modal .el-dialog__body');
if (body) body.scrollTop = 0;
}
/**
* 代码作用(白话):打开空白的新增手机号资产弹窗。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):点击新增 -> resetForm -> dialogVisible=true -> Element Plus 弹窗显示。
*/
function openCreate() {
editingId.value = null;
resetForm();
dialogVisible.value = true;
}
/**
* 代码作用(白话):用当前表格行的数据回填同一个编辑弹窗。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):点击编辑 -> row -> form -> PUT 保存。
*/
function openEdit(row) {
editingId.value = row.id;
Object.assign(form, {
phoneNumber: normalizePhoneNumber(row.phoneNumber),
cardType: row.cardType || '',
iccid: normalizeIccid(row.iccid),
realNameOwner: row.realNameOwner || '',
managementType: row.managementType || '',
disposalStatus: row.disposalStatus || '正常使用',
deviceId: row.deviceId ?? null
});
dialogVisible.value = true;
}
/**
* 代码作用(白话):提交新增或编辑表单,成功后保持原有提示、关闭弹窗并刷新列表。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):确认保存 -> POST/PUT -> 成功提示 -> dialogVisible=false -> loadPage。
*/
async function submitForm() {
saving.value = true;
try {
const payload = { ...form, phoneNumber: normalizePhoneNumber(form.phoneNumber) };
if (editingId.value === null) await createPhoneAsset(payload);
else await updatePhoneAsset(editingId.value, payload);
ElMessage.success(editingId.value === null ? '新增成功' : '编辑成功');
dialogVisible.value = false;
await loadPage();
} catch (error) {
ElMessage.error(error.message);
} finally {
saving.value = false;
}
}
/**
* 代码作用(白话):要求用户确认后删除一条手机号资产,并按原逻辑刷新列表。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):删除按钮 -> 确认框 -> DELETE -> 成功提示 -> loadPage。
*/
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确认删除手机号 ${row.phoneNumber} 吗?`, '删除确认', { type: 'warning' });
await deletePhoneAsset(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);
}
}
/** 代码作用(白话):停止输入约 300 毫秒后自动查询,避免连续输入反复请求。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):输入事件 -> 防抖计时 -> loadPage -> GET 接口。 */
function scheduleSearch() { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { filters.page = 1; loadPage(); }, 300); }
/** 代码作用(白话):把列表手机号筛选值限定为 11 位数字,粘贴内容也按相同规则处理。关联文件:PhoneAssetView.js、PhoneAssetService.java。关联逻辑(调用链/数据流):手机号输入 -> limitSearchPhone -> scheduleSearch -> 后端号码匹配。 */
function limitSearchPhone(value) { filters.phoneNumber = String(value || '').replace(/\D/g, '').slice(0, 11); scheduleSearch(); }
/** 代码作用(白话):按 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。 */
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(); }
/** 代码作用(白话):切换页码并读取对应数据。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage。 */
function changePage(page) { filters.page = page; loadPage(); } function changePage(page) { filters.page = page; loadPage(); }
onMounted(loadPage); return { changePage, filters, loading, records, resetSearch, submitSearch, total }; /** 代码作用(白话):切换每页条数后回到第一页查询。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):每页条数 -> size/page -> loadPage。 */
function changePageSize(size) { filters.size = size; filters.page = 1; loadPage(); }
/**
* 代码作用(白话):页面首次显示时请求手机号资产列表。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):组件挂载 -> onMounted 回调 -> loadPage -> 列表渲染。
*/
onMounted(() => {
loadPage();
});
return {
changePage,
changePageSize,
changeStatus,
confirmDelete,
dialogVisible,
editingId,
elementLocale,
filters,
form,
handleIccidPaste,
handlePhonePaste,
limitIccid,
limitPhoneNumber,
limitSearchPhone,
loading,
openCreate,
openEdit,
records,
resetDialogScroll,
resetSearch,
saving,
scheduleSearch,
submitForm,
submitSearch,
total
};
}, },
template: `<section class="phone-asset-page"><header class="page-header"><div><p class="eyebrow">PHONE ASSETS</p><h2>手机号资产</h2><p>管理手机号卡的基础信息与关联状态。</p></div><el-button type="primary" disabled>新增手机号资产</el-button></header><article class="reference-card"><el-form inline @submit.prevent="submitSearch"><el-form-item label="手机号"><el-input v-model="filters.phoneNumber" maxlength="11" clearable /></el-form-item><el-form-item label="ICCID"><el-input v-model="filters.iccid" 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="phoneNumber" label="手机号" min-width="140" show-overflow-tooltip /><el-table-column prop="cardType" label="卡类型" min-width="120" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="180" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名归属" min-width="140" show-overflow-tooltip /><el-table-column prop="managementType" label="管理方式" min-width="120" show-overflow-tooltip /><el-table-column prop="disposalStatus" label="处置状态" min-width="120" show-overflow-tooltip /><el-table-column prop="deviceId" label="关联设备 ID" min-width="130" /><el-table-column prop="relationSyncedAt" 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>` 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>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
<el-input v-model="form.phoneNumber" class="phone-asset-modal__count-input" maxlength="11" inputmode="numeric" autocomplete="off" placeholder="请输入手机号" @input="limitPhoneNumber" @paste="handlePhonePaste">
<template #suffix><span class="phone-asset-modal__character-count" aria-live="polite">{{ form.phoneNumber.length }}/11</span></template>
</el-input>
</el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="卡类型">
<el-select v-model="form.cardType" clearable 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-option label="虚拟号码" value="虚拟号码" /></el-select>
</el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="ICCID">
<el-input v-model="form.iccid" class="phone-asset-modal__count-input" maxlength="20" autocomplete="off" placeholder="请输入 ICCID" @input="limitIccid" @paste="handleIccidPaste">
<template #suffix><span class="phone-asset-modal__character-count" aria-live="polite">{{ form.iccid.length }}/20</span></template>
</el-input>
</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="设备 ID"><el-input v-model.number="form.deviceId" type="number" placeholder="请输入设备 ID" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">确认保存</el-button>
</template>
</el-dialog>
</section></el-config-provider>
`
}; };
\ No newline at end of file
/** /**
* 代码作用(白话):统一发送手机号资产列表请求,避免页面自己处理返回格式 * 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。 * 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面加载 -> listPhoneAssets -> GET API -> 表格数据 * 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误
*/ */
async function request(path) { async function request(path, options = {}) {
const response = await fetch(path); const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const payload = await response.json(); 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; 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()); }
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。 /** 代码作用(白话):提交新增表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */
* 关联逻辑(调用链/数据流):筛选条件 -> URL 参数 -> 后端分页结果 -> 表格。 export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); }
*/ /** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */
export function listPhoneAssets(query) { export function updatePhoneAsset(id, form) { return request('/api/phone-assets/' + id, { method: 'PUT', body: JSON.stringify(form) }); }
const params = new URLSearchParams(); /** 代码作用(白话):确认后删除一条手机号资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 后端软删除。 */
Object.entries(query).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') params.set(key, value); }); export function deletePhoneAsset(id) { return request('/api/phone-assets/' + id, { method: 'DELETE' }); }
return request(`/api/phone-assets?${params.toString()}`);
}
\ No newline at end of file
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { listWecomAccounts } from './wecom-api-client.js';
/** 文件用途(白话):显示可筛选、可分页的企微资料,并把关联资源呈现成用户可读的名称和 ID。 */
export default {
/**
* 代码作用(白话):创建页面加载、筛选、分页状态,并把操作方法交给模板中的按钮和表格使用。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化 -> loadPage -> API -> Element Plus 表格。
*/
setup() {
const loading = ref(false);
const records = ref([]);
const total = ref(0);
const filters = reactive({ page: 1, size: 20, wecomName: '', wecomAccount: '' });
/**
* 代码作用(白话):按当前筛选条件读取一页企微资产,并同步更新表格记录和总条数。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):页面事件 -> listWecomAccounts -> GET API -> records/total -> 表格和分页器。
*/
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;
}
}
/**
* 代码作用(白话):在用户提交筛选时回到第一页,防止旧页码导致看不到匹配结果。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):查询按钮 -> page=1 -> loadPage -> 新筛选结果。
*/
function submitSearch() {
filters.page = 1;
loadPage();
}
/**
* 代码作用(白话):清空企微名称和企微账号筛选,并恢复默认分页后重新查询。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):重置按钮 -> filters 默认值 -> loadPage -> 默认列表。
*/
function resetSearch() {
Object.assign(filters, { page: 1, size: 20, wecomName: '', wecomAccount: '' });
loadPage();
}
/**
* 代码作用(白话):接收分页器选中的页码并加载该页的企微资产。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage -> 后端分页结果。
*/
function changePage(page) {
filters.page = page;
loadPage();
}
/**
* 代码作用(白话):将关联资源的名称和 ID 组合成“名称(ID)”,关联缺失时保留 ID 并显示“—”。
* 关联文件:WecomAccountResponse.java、WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):接口关联字段 -> formatRelation -> 表格关联资源单元格。
*/
function formatRelation(name, id) {
if (id === null || id === undefined) {
return '—';
}
return `${name || '—'}(ID:${id})`;
}
onMounted(loadPage);
return { changePage, filters, formatRelation, loading, records, resetSearch, 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>
`
};
\ No newline at end of file
/** 文件用途(白话):集中发送企微资产列表请求并统一解析后端的成功或失败响应。 */
/**
* 代码作用(白话):请求后端并从统一 JSON 响应中取出真正的列表数据,失败时抛出可显示的错误信息。
* 关联文件:WecomAccountView.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):列表页 -> request -> GET /api/wecom-accounts -> ApiResponse.data -> 表格数据。
*/
async function request(path) {
const response = await fetch(path);
const payload = await response.json();
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。
*/
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
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import LegacyReferenceView from '../reference/LegacyReferenceView.js'; import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js'; import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
import WecomAccountView from '../modules/wecom/WecomAccountView.js';
/** /**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。 * 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
...@@ -12,9 +13,9 @@ function createPlaceholderView(title) { ...@@ -12,9 +13,9 @@ function createPlaceholderView(title) {
} }
/** /**
* 代码作用(白话):定义清理旧 phone/wechat 模块后的新路由,只保留无接口依赖的参考页面。 * 代码作用(白话):定义资产后台 Hash 路由,将企微资料连接到真实列表,同时保留手机号静态参考页面。
* 关联文件:frontend/src/App.js、frontend/src/reference/LegacyReferenceView.js、frontend/tests/legacy-reference.spec.js。 * 关联文件:frontend/src/App.js、frontend/src/modules/wecom/WecomAccountView.js、frontend/tests/wecom-account.spec.js。
* 关联逻辑(调用链/数据流):Hash 地址 -> router -> LegacyReferenceView -> 静态示例数据与禁用操作按钮 * 关联逻辑(调用链/数据流):Hash 地址 -> router -> 真实企微列表或静态手机号参考页 -> 页面渲染
*/ */
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
...@@ -24,7 +25,7 @@ const router = createRouter({ ...@@ -24,7 +25,7 @@ const router = createRouter({
{ path: '/phone-assets', component: PhoneAssetView }, { path: '/phone-assets', component: PhoneAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') }, { path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') }, { path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wechat', component: LegacyReferenceView, props: { kind: 'wechat' } }, { path: '/reference/wecom', component: WecomAccountView },
{ path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } } { path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } }
] ]
}); });
......
...@@ -17,3 +17,259 @@ body { margin: 0; } ...@@ -17,3 +17,259 @@ body { margin: 0; }
.reference-card { padding: 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: white; box-shadow: 0 8px 28px rgba(15, 23, 42, .05); } .reference-card { padding: 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: white; box-shadow: 0 8px 28px rgba(15, 23, 42, .05); }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; } .toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
@media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar nav { grid-template-columns: repeat(2, 1fr); } .page-header { display: grid; } .content { padding: 24px; } } @media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar nav { grid-template-columns: repeat(2, 1fr); } .page-header { display: grid; } .content { padding: 24px; } }
/* 手机号资产弹窗:所有规则均以该弹窗类名开头,避免影响项目中的其他 Element Plus 弹窗。 */
.phone-asset-modal-mask {
background: rgba(15, 23, 42, 0.34);
}
.phone-asset-modal-mask .el-overlay-dialog {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 24px;
}
.phone-asset-modal {
width: min(560px, 100%) !important;
max-height: calc(100vh - 48px);
margin: 0 !important;
overflow: hidden;
border: 1px solid rgba(228, 228, 231, 0.9);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
}
.phone-asset-modal .el-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex: 0 0 auto;
margin: 0;
padding: 22px 24px 15px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
}
.phone-asset-modal .el-dialog__title {
color: #18181b;
font-size: 17px;
font-weight: 650;
line-height: 1.45;
letter-spacing: -0.01em;
white-space: nowrap;
}
.phone-asset-modal .el-dialog__headerbtn {
top: 18px;
right: 18px;
width: 28px;
height: 28px;
border-radius: 6px;
color: #71717a;
transition: background 0.16s ease, color 0.16s ease;
}
.phone-asset-modal .el-dialog__headerbtn:hover {
color: #18181b;
background: #f4f4f5;
}
.phone-asset-modal .el-dialog__close {
width: 15px;
height: 15px;
}
.phone-asset-modal .el-dialog__body {
max-height: calc(100vh - 206px);
min-height: 0;
padding: 4px 24px 8px;
overflow-y: auto;
}
.phone-asset-modal__form {
display: grid;
gap: 14px;
}
.phone-asset-modal__form-row.el-form-item {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 14px;
margin: 0;
}
.phone-asset-modal__form-row .el-form-item__label-wrap {
float: none;
min-width: 0;
}
.phone-asset-modal__form-row .el-form-item__label {
display: block;
width: 96px !important;
height: 36px;
padding: 0;
color: #52525b;
font-size: 14px;
line-height: 36px;
text-align: right;
white-space: nowrap;
}
.phone-asset-modal__form-row .el-form-item__content {
min-width: 0;
min-height: 36px;
margin-left: 0 !important;
line-height: 36px;
}
.phone-asset-modal__form-row .el-input,
.phone-asset-modal__form-row .el-select {
width: 100%;
}
.phone-asset-modal__form-row .el-input__wrapper,
.phone-asset-modal__form-row .el-select__wrapper {
min-height: 36px;
padding: 0 11px;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 0 0 1px #e4e4e7 inset;
transition: box-shadow 0.16s ease, background 0.16s ease;
}
.phone-asset-modal__form-row .el-input__wrapper:hover,
.phone-asset-modal__form-row .el-select__wrapper:hover {
box-shadow: 0 0 0 1px #c4c4cc inset;
}
.phone-asset-modal__form-row .el-input.is-focus .el-input__wrapper,
.phone-asset-modal__form-row .el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px #18181b inset, 0 0 0 2px rgba(24, 24, 27, 0.09);
}
.phone-asset-modal__form-row .el-input__inner,
.phone-asset-modal__form-row .el-select__selected-item,
.phone-asset-modal__form-row .el-select__placeholder {
color: #27272a;
font-size: 14px;
}
.phone-asset-modal__form-row .el-input__inner::placeholder,
.phone-asset-modal__form-row .el-select__placeholder {
color: #a1a1aa;
}
.phone-asset-modal__count-input .el-input__inner {
padding-right: 50px;
}
.phone-asset-modal__character-count {
color: #a1a1aa;
font-size: 12px;
line-height: 1;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.phone-asset-modal .el-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 9px;
flex: 0 0 auto;
margin-top: 22px;
padding: 16px 24px 20px;
border-top: 1px solid #eeeeef;
}
.phone-asset-modal .el-dialog__footer .el-button {
min-width: 74px;
height: 36px;
margin: 0;
padding: 0 17px;
border-radius: 6px;
font-size: 14px;
font-weight: 550;
}
.phone-asset-modal .el-dialog__footer .el-button--default {
border-color: #e4e4e7;
color: #52525b;
background: #ffffff;
}
.phone-asset-modal .el-dialog__footer .el-button--default:hover {
border-color: #d4d4d8;
background: #fafafa;
}
.phone-asset-modal .el-dialog__footer .el-button--primary {
border-color: #18181b;
background: #18181b;
}
.phone-asset-modal .el-dialog__footer .el-button--primary:hover {
border-color: #27272a;
background: #27272a;
}
.phone-asset-modal .el-dialog__footer .el-button:active {
transform: translateY(1px);
}
.phone-asset-modal .el-dialog__footer .el-button.is-disabled,
.phone-asset-modal .el-dialog__footer .el-button.is-disabled:hover {
border-color: #f4f4f5;
color: #a1a1aa;
background: #f4f4f5;
}
@media (max-width: 600px) {
.phone-asset-modal-mask .el-overlay-dialog {
align-items: flex-start;
padding: 14px;
}
.phone-asset-modal {
width: 100% !important;
max-height: calc(100vh - 52px);
margin-top: 12px !important;
}
.phone-asset-modal .el-dialog__header,
.phone-asset-modal .el-dialog__body,
.phone-asset-modal .el-dialog__footer {
padding-left: 18px;
padding-right: 18px;
}
.phone-asset-modal .el-dialog__body {
max-height: calc(100vh - 210px);
}
.phone-asset-modal__form-row.el-form-item {
grid-template-columns: minmax(0, 1fr);
gap: 7px;
}
.phone-asset-modal__form-row .el-form-item__label {
width: auto !important;
text-align: left;
}
}
/* 窄窗口中的资产表格在卡片内部处理宽度,避免宽列把整个页面撑出屏幕。 */
@media (max-width: 760px) {
.content {
min-width: 0;
}
.phone-asset-page .reference-card {
min-width: 0;
overflow-x: auto;
}
}
/* 手机号弹窗下拉菜单:由 popper-class 限定,不影响其他页面的下拉选项。 */
.phone-asset-modal__select-popper.el-popper {
border: 1px solid #e4e4e7;
border-radius: 6px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
}
.phone-asset-modal__select-popper .el-select-dropdown__item {
height: 36px;
padding: 0 11px;
color: #27272a;
font-size: 14px;
line-height: 36px;
}
.phone-asset-modal__select-popper .el-select-dropdown__item.is-hovering,
.phone-asset-modal__select-popper .el-select-dropdown__item:hover {
background: #fafafa;
}
.phone-asset-modal__select-popper .el-select-dropdown__item.is-selected {
color: #18181b;
font-weight: 550;
background: #f4f4f5;
}
/* 手机号资产列表:参考 mobile-number-asset-management-v4.html,仅作用于当前列表页。 */
.phone-asset-list-page { width:100%; max-width:1680px; margin:0 auto; padding:34px 4px 56px; }
.phone-asset-list-page__header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
.phone-asset-list-page__header h2 { margin:0; color:#111113; font-size:29px; font-weight:700; line-height:1.25; letter-spacing:-.035em; }
.phone-asset-list-page__add.el-button { height:38px; padding:0 16px; border-color:#18181b; border-radius:6px; background:#18181b; font-size:14px; font-weight:600; }
.phone-asset-list-page__panel { border:1px solid #e5e5e8; border-radius:12px; background:#fff; box-shadow:0 10px 30px rgba(15,23,42,.05); }
.phone-asset-list-page__search { margin-bottom:16px; padding:20px 22px; }
.phone-asset-list-page__filters { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
.phone-asset-list-page__filters .el-input { width:210px; }.phone-asset-list-page__filters .el-input:nth-child(2){width:240px}.phone-asset-list-page__filters .el-input:nth-child(3){width:190px}.phone-asset-list-page__filters .el-select{width:160px}
.phone-asset-list-page__filters .el-input__wrapper,.phone-asset-list-page__filters .el-select__wrapper { min-height:38px; border-radius:6px; box-shadow:0 0 0 1px #e5e5e8 inset; }.phone-asset-list-page__filters .el-button{height:38px;border-radius:6px;color:#52525b;border-color:#e5e5e8}.phone-asset-list-page__filters .el-input__wrapper:hover,.phone-asset-list-page__filters .el-select__wrapper:hover{box-shadow:0 0 0 1px #d4d4d8 inset}
.phone-asset-list-page__table { overflow:hidden; }.phone-asset-list-page__table-header{display:flex;align-items:center;gap:9px;min-height:62px;padding:0 22px;border-bottom:1px solid #e5e5e8}.phone-asset-list-page__table-header h3{margin:0;color:#18181b;font-size:15px;font-weight:650}.phone-asset-list-page__table-header span{color:#8b8b93;font-size:13px}
.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
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
const referencePages = [ const referencePages = [
{ path: '#/reference/phone', title: '手机号卡旧界面参考' }, { path: '#/reference/phone', title: '手机号卡旧界面参考' }
{ path: '#/reference/wechat', title: '企微资料旧界面参考' }
]; ];
for (const referencePage of referencePages) { for (const referencePage of referencePages) {
......
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
/** /**
* 代码作用(白话):模拟手机号资产列表接口,先证明新页面尚未实现时测试会失败,后续用于保护页面加载流程 * 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表
* 关联文件:frontend/src/modules/phone/PhoneAssetView.js、frontend/src/modules/phone/phone-api-client.js。 * 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/消息链/数据流):浏览器打开手机号资产路由 -> View 请求列表 API -> route.fulfill 返回测试数据 -> 页面表格断言 * 关联逻辑(调用链/数据流):页面打开 -> listPhoneAssets -> 浏览器路由拦截 -> 空列表 -> 新增弹窗
*/ */
test('phone asset workspace loads the first page from its API', async ({ page }) => { async function mockPhoneAssetList(page) {
await page.route('**/api/phone-assets?**', async (route) => { await page.route('**/api/phone-assets?**', async (route) => {
await route.fulfill({ await route.fulfill({
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
code: 200, code: 200,
message: 'success', message: 'success',
data: { data: { records: [], total: 0, page: 1, size: 20 }
records: [{ id: 1, phoneNumber: '13812345678', cardType: '中国移动', disposalStatus: '正常使用' }],
total: 1,
page: 1,
size: 20
}
}) })
}); });
}); });
}
/**
* 代码作用(白话):打开新增手机号资产弹窗,供多个验收场景复用。
* 关联文件:PhoneAssetView.js、phone-asset.spec.js。
* 关联逻辑(调用链/数据流):测试页面 -> 新增按钮 -> openCreate -> dialogVisible -> 弹窗。
*/
async function openCreateDialog(page) {
await mockPhoneAssetList(page);
await page.goto('/#/phone-assets'); await page.goto('/#/phone-assets');
await page.getByRole('button', { name: '新增手机号资产' }).click();
return page.getByRole('dialog', { name: '新增手机号资产' });
}
/**
* 代码作用(白话):在浏览器中派发带剪贴板文字的真实粘贴事件。
* 关联文件:PhoneAssetView.js、phone-asset.spec.js。
* 关联逻辑(调用链/数据流):测试数据 -> paste 事件 -> handlePhonePaste/handleIccidPaste -> 表单字段。
*/
async function pasteText(locator, value) {
await locator.evaluate((input, pastedValue) => {
const clipboardData = new DataTransfer();
clipboardData.setData('text', pastedValue);
input.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData }));
}, value);
}
/**
* 代码作用(白话):验证新增弹窗保留七个字段,并将两个字数计数器放在各自输入框内部。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):新增按钮 -> 弹窗渲染 -> 表单字段和 suffix 计数器。
*/
test('新增弹窗保留七个字段,并在输入框内显示初始计数器', async ({ page }) => {
const dialog = await openCreateDialog(page);
await expect(dialog.locator('.phone-asset-modal__form-row')).toHaveCount(7);
await expect(dialog.locator('.phone-asset-modal__character-count')).toHaveText(['0/11', '0/20']);
await expect(dialog.locator('.phone-asset-modal__character-count').first()).toBeVisible();
});
/**
* 代码作用(白话):验证手机号和 ICCID 的输入、粘贴、删除都会实时更新计数器且不会超过长度上限。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):输入事件 -> limitPhoneNumber/limitIccid -> form -> suffix 计数器。
*/
test('手机号和 ICCID 会过滤并截断超长输入,同时更新框内计数器', async ({ page }) => {
const dialog = await openCreateDialog(page);
const phoneInput = dialog.getByLabel('手机号');
const iccidInput = dialog.getByLabel('ICCID');
await pasteText(phoneInput, '138-1234567899');
await expect(phoneInput).toHaveValue('13812345678');
await expect(dialog.locator('.phone-asset-modal__character-count').first()).toHaveText('11/11');
await phoneInput.press('Control+A');
await phoneInput.press('Backspace');
await expect(dialog.locator('.phone-asset-modal__character-count').first()).toHaveText('0/11');
await pasteText(iccidInput, '8986032594020999545B-extra');
await expect(iccidInput).toHaveValue('8986032594020999545B');
await expect(dialog.locator('.phone-asset-modal__character-count').nth(1)).toHaveText('20/20');
});
await expect(page.getByRole('heading', { name: '手机号资产' })).toBeVisible(); /**
await expect(page.getByText('13812345678')).toBeVisible(); * 代码作用(白话):验证取消和右上角关闭按钮仍会关闭同一个业务弹窗。
await expect(page.getByRole('button', { name: '新增手机号资产' })).toBeVisible(); * 关联文件:PhoneAssetView.js。
* 关联逻辑(调用链/数据流):按钮点击 -> dialogVisible=false -> Element Plus 弹窗关闭。
*/
test('取消和右上角关闭按钮仍可关闭弹窗', async ({ page }) => {
let dialog = await openCreateDialog(page);
await dialog.getByRole('button', { name: '取消' }).click();
await expect(dialog).toBeHidden();
dialog = await openCreateDialog(page);
await dialog.locator('.el-dialog__headerbtn').click();
await expect(dialog).toBeHidden();
});
/**
* 代码作用(白话):验证确认保存仍将原有表单字段通过新增接口提交。
* 关联文件:PhoneAssetView.js、phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):确认保存 -> createPhoneAsset -> POST /api/phone-assets -> 成功提示和关闭弹窗。
*/
test('确认保存仍调用原有新增接口并提交表单数据', async ({ page }) => {
let submittedPayload;
await page.route('**/api/phone-assets', async (route) => {
if (route.request().method() !== 'POST') return route.fallback();
submittedPayload = route.request().postDataJSON();
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: '新增成功', data: { id: 1 } })
});
});
const dialog = await openCreateDialog(page);
await dialog.getByLabel('手机号').fill('13812345678');
await dialog.getByRole('button', { name: '确认保存' }).click();
await expect.poll(() => submittedPayload?.phoneNumber).toBe('13812345678');
await expect(dialog).toBeHidden();
});
/**
* 代码作用(白话):验证窄窗口里的弹窗保持在可视区域内,页面不出现横向滚动条。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):窄视口 -> 响应式 CSS -> 单列表单和受限弹窗宽度。
*/
test('窄窗口下弹窗不超出屏幕且页面没有横向滚动', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
const dialog = await openCreateDialog(page);
const bounds = await dialog.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(320);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}); });
\ No newline at end of file
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
}
})
});
});
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);
});
/**
* 代码作用(白话):验证用户筛选和翻页时,页面会把新的名称、账号和页码参数提交给企微列表接口。
* 关联文件: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);
});
\ No newline at end of file
schema: spec-driven
created: 2026-07-31
## Context
当前 `#/reference/wechat` 是不请求接口的静态旧界面参考,而 `as_wecom_account` 已经具备 `WecomAccountEntity``WecomAccountMapper`,尚未具备列表 Service、Controller、DTO 与真实前端页面。资产人员需要查看企微资料的全部业务字段,并将公司档案、手机号资产、设备、经办人等关联 ID 理解为可读名称。
约束:本次只读主数据源,不修改表结构、不新增编辑删除能力;`delete_time` 延续现有手机号资产规则,仅用于过滤;用户已确认它不应返回或展示。
## Goals / Non-Goals
**Goals:**
- 提供 `#/reference/wecom` 的可筛选、可分页企微资产列表。
- 提供 `GET /api/wecom-accounts`,返回企微字段、创建/更新时间、关联 ID 及关联名称。
-`realNameOwner` 以“企微实名人”展示。
- 对每页记录的关联 ID 批量解析公司名称、手机号、设备名称和经办人名称。
**Non-Goals:**
- 不显示或返回 `deleteTime`,不查询已删除记录。
- 不变更 `as_wecom_account` 或任何关联表的数据库结构与数据。
- 不实现新增、编辑、删除、导入、导出或权限模型。
- 不为不存在或已删除的关联记录自动修复数据。
## Decisions
### 新路由与旧路由
导航与真实页面使用 `#/reference/wecom`,并移除 `#/reference/wechat` 的静态企微参考页。采用直接替换而非保留别名,原因是需求明确要求将 wechat 换为 wecom;继续保留旧页会让用户进入过时且无真实数据的界面。
### 分页与筛选契约
接口使用现有手机号资产的 `page``size` 约定:默认第 1 页、每页 20 条、最大 100 条;支持精确筛选 `wecomName``wecomAccount`。查询显式限定 `delete_time = 0` 并按 `id` 倒序,保证与既有资产列表的正常记录定义一致。
### 关联 ID 与名称
响应同时返回原始 ID 和显示名称:`companyProfileId/companyProfileName``phoneAssetId/phoneNumber``deviceId/deviceName``operatorPersonId/operatorPersonName`。Service 收集当前页各类非空 ID 后,分别以 `IN` 条件批量查询关联表并构建 ID 到名称的映射。
批量查询(一次取得当前页所有关联记录)优于逐行查询,避免一页 20 条企微记录触发数十次数据库访问、导致列表加载变慢。关联 ID 未匹配记录时保留 ID,名称返回 `null`,前端显示“—”,不掩盖数据关系异常。
### 字段暴露与页面展示
响应和表格展示 `id`、企微名称、别名、账号、公司档案 ID/名称、手机号资产 ID/手机号、企微实名人、实名状态、性别、设备 ID/名称、经办人 ID/名称、创建时间和更新时间。`deleteTime` 只在 Service 查询条件中使用,既不写入响应 DTO,也不创建表格列。
## Risks / Trade-offs
- [历史数据的 `delete_time` 为 `NULL`] → 现有手机号列表以 `0` 表示正常,本期保持一致;上线前使用只读查询确认实际数据约定,必要时另行提出兼容变更。
- [关联记录缺失或已删除] → 保留原关联 ID,名称显示“—”,便于定位而不阻塞企微主记录显示。
- [个人信息展示范围扩大] → 当前需求明确展示企微实名人和手机号;后续如引入权限规则,再单独设计脱敏或字段授权。
- [路由书签失效] → 按确认的直接替换执行;发布说明标注新的 `#/reference/wecom` 地址。
## Migration Plan
1. 先添加后端 Controller 测试与前端路由/列表测试,定义新路由和只读响应。
2. 新增 DTO、Service、Controller 及前端列表页面,不改动既有实体、Mapper 和表。
3. 将导航和路由从 wechat 迁至 wecom,并移除企微静态参考测试。
4. 执行后端编译与测试、前端构建及 Playwright;失败时回退本变更的前端/后端代码和路由,不执行数据库回滚。
## Open Questions
- 无;字段展示、关联名称、路由替换和 `deleteTime` 不展示均已确认。
## Why
企微资料当前仅保留静态旧界面参考,无法查看主数据源中 `as_wecom_account` 的真实资产。资产人员还需要在同一列表中理解各关联 ID 代表的公司、手机号、设备和经办人,因此需要提供一个只读、可分页的企微资产列表。
## What Changes
- 新增 `#/reference/wecom` 企微资料列表路由,替代当前静态的 `#/reference/wechat` 参考入口。
- 新增只读分页接口 `GET /api/wecom-accounts`,按企微名称和企微账号筛选 `as_wecom_account` 的正常记录。
- 返回并展示企微账号的业务字段、创建/更新时间,以及关联 ID 对应的名称;保留关联 ID 便于追溯。
- 不返回、不展示 `deleteTime`;后端仅用它过滤已删除记录。
- **BREAKING**`#/reference/wechat` 不再提供旧静态企微参考页面,导航入口改为 `#/reference/wecom`
## Capabilities
### New Capabilities
- `wecom-account-api`: 提供企微账号资产的只读分页查询和关联名称解析。
- `wecom-account-workspace`: 提供企微资料列表页面、筛选、分页和完整字段展示。
### Modified Capabilities
- 无。
## Impact
- 前端:路由、导航、企微列表组件、接口客户端和 Playwright 测试。
- 后端:企微查询 DTO、Controller、Service、响应 DTO 和 Controller 测试;复用既有 `WecomAccountMapper`、公司档案、手机号、设备和人员 Mapper。
- API:新增 `GET /api/wecom-accounts`;不修改数据库结构,不写入或删除任何资产数据。
## ADDED Requirements
### Requirement: 企微资产分页查询
系统 MUST 提供 `GET /api/wecom-accounts`,接收可选的 `page``size``wecomName``wecomAccount` 参数,并以统一的 `code``message``data` 响应结构返回分页结果。
#### Scenario: 默认分页查询
- **WHEN** 客户端未提供分页或筛选参数而请求 `GET /api/wecom-accounts`
- **THEN** 系统返回第 1 页、每页 20 条、按 ID 倒序的正常企微资产记录
#### Scenario: 按企微名称和账号筛选
- **WHEN** 客户端提供非空的 `wecomName``wecomAccount`
- **THEN** 系统仅返回同时满足所提供精确筛选条件的正常企微资产记录
#### Scenario: 非法分页参数
- **WHEN** 客户端提供小于 1 的页码、页大小,或大于 100 的页大小
- **THEN** 系统拒绝请求并返回参数校验失败响应
### Requirement: 正常记录与删除标记隔离
系统 MUST 仅查询 `as_wecom_account.delete_time = 0` 的记录,且响应 `records` 和分页元数据 MUST 不包含 `deleteTime`
#### Scenario: 已删除记录不进入列表
- **WHEN** 数据库存在 `delete_time` 不等于 0 的企微账号记录
- **THEN** 该记录不计入接口的 `records``total`
### Requirement: 企微资产完整业务字段与关联名称
系统 MUST 在每条列表记录中返回 `id``wecomName``wecomAlias``wecomAccount``companyProfileId``phoneAssetId``realNameOwner``realNameOwnerStatus``gender``deviceId``operatorPersonId``createTime``updateTime`,并同时返回关联名称字段。
#### Scenario: 关联 ID 可读化
- **WHEN** 企微资产的公司档案、手机号资产、设备或经办人 ID 可以关联到正常的资产记录
- **THEN** 响应分别包含 `companyProfileName``phoneNumber``deviceName``operatorPersonName`,同时保留原始 ID
#### Scenario: 关联记录缺失
- **WHEN** 企微资产保存了关联 ID 但对应资产记录不存在或无法读取
- **THEN** 响应保留原始 ID,并将对应名称字段返回为 `null`
## ADDED Requirements
### Requirement: 企微资料真实列表路由
系统 MUST 在 `#/reference/wecom` 提供企微资料真实列表页面,并将导航入口指向该路由。
#### Scenario: 打开企微资料页面
- **WHEN** 用户访问 `#/reference/wecom`
- **THEN** 页面请求 `GET /api/wecom-accounts` 并展示返回的企微资产列表
#### Scenario: 旧企微参考路由替换
- **WHEN** 用户通过页面导航进入企微资料
- **THEN** 系统导航至 `#/reference/wecom`,且不展示旧的静态 wechat 参考页面
### Requirement: 企微资产完整字段展示
系统 MUST 在企微资料列表中展示企微资产 ID、企微名称、别名、账号、企微实名人、实名状态、性别、创建时间、更新时间,以及每个关联资源的“名称(ID)”。
#### Scenario: 关联名称与 ID 同时展示
- **WHEN** 列表记录包含公司档案、手机号资产、设备或经办人关联
- **THEN** 页面分别展示公司名称、手机号、设备名称、人员名称及对应 ID
#### Scenario: 关联名称不可用
- **WHEN** 接口返回关联 ID 但关联名称为 `null`
- **THEN** 页面保留该 ID 并以“—”展示名称位置
#### Scenario: 删除标记不展示
- **WHEN** 用户查看企微资料列表
- **THEN** 页面不包含 `deleteTime` 或“删除标记”列
### Requirement: 企微资产筛选与分页
系统 MUST 允许用户以企微名称、企微账号筛选,并使用后端返回的 `total``page``size` 呈现分页。
#### Scenario: 提交筛选条件
- **WHEN** 用户输入企微名称或企微账号并点击查询
- **THEN** 页面以第 1 页请求匹配记录并刷新表格和总数
#### Scenario: 切换页码
- **WHEN** 用户选择另一页
- **THEN** 页面以当前筛选条件和目标页码重新请求列表
## 1. 后端查询契约
- [x] 1.1 新增 `WecomAccountPageQuery.java`(文件用途:接收分页与筛选参数),为 `resolvedPage``resolvedSize` 写新手注释:代码作用(白话)、关联文件、关联逻辑;校验页码与页大小。
- [x] 1.2 新增 `WecomAccountResponse.java`(文件用途:限定企微列表单行返回字段),包含企微业务字段、创建/更新时间、关联 ID 与关联名称,不包含 `deleteTime`
- [x] 1.3 新增 `WecomAccountPageResponse.java`(文件用途:承载列表、总数、页码、页大小),复用当前手机号资产分页的返回结构。
## 2. 后端只读列表实现
- [x] 2.1 新增 `WecomAccountService.java`(文件用途:读取企微资产并解析关联名称),注入既有企微、公司档案、手机号、设备和人员 Mapper。
- [x] 2.2 在 `WecomAccountService` 实现 `page`(文件用途:按筛选、分页和 `delete_time = 0` 查询企微账号)与 `toResponse`(文件用途:将实体及关联名称转换为接口行);为构造方法和两个方法写完整新手注释:代码作用(白话)、关联文件、关联逻辑(调用链/数据流)。
- [x] 2.3 在 `WecomAccountService` 实现批量关联查询逻辑(文件用途:将当前页 ID 转换为名称),为每个辅助方法写完整新手注释;缺失名称返回 `null`,原 ID 保留。
- [x] 2.4 新增 `WecomAccountController.java`(文件用途:提供浏览器查询入口),实现 `GET /api/wecom-accounts`;为构造方法与 `page` 写完整新手注释:代码作用(白话)、关联文件、关联逻辑。
## 3. 前端企微资料页面
- [x] 3.1 新增 `frontend/src/modules/wecom/wecom-api-client.js`(文件用途:封装企微分页请求);实现 `request``listWecomAccounts`,并为每个方法写完整新手注释:代码作用(白话)、关联文件、关联逻辑。
- [x] 3.2 新增 `frontend/src/modules/wecom/WecomAccountView.js`(文件用途:渲染企微资料真实列表);实现 `setup``loadPage``submitSearch``resetSearch``changePage`,并为每个方法及业务回调写完整新手注释。
- [x] 3.3 在 `WecomAccountView.js` 展示企微字段、创建/更新时间,以及“名称(ID)”的关联字段;将 `realNameOwner` 标注为“企微实名人”,名称为空时显示“—”,不创建 `deleteTime` 列。
- [x] 3.4 修改 `frontend/src/router/index.js`(文件用途:管理 Hash 路由)并为新增或修改的方法写完整新手注释;用 `#/reference/wecom` 映射真实列表,移除静态 wechat 路由。
- [x] 3.5 修改 `frontend/src/App.js`(文件用途:提供全局导航)将企微导航入口更新为 `#/reference/wecom` 和真实页面文案;若新增方法或回调,写完整新手注释。
## 4. 自动化验证与文档
- [x] 4.1 新增 `backend/src/test/java/com/xyw/console/asset/controller/WecomAccountControllerTest.java`(文件用途:验证企微分页接口);覆盖默认分页、筛选、删除记录过滤、关联名称和 `deleteTime` 不返回;为测试方法及业务回调写完整新手注释。
- [x] 4.2 新增 `frontend/tests/wecom-account.spec.js`(文件用途:验证真实企微列表路由);覆盖接口请求、筛选参数、字段/关联名称展示、`deleteTime` 不展示和分页;为测试回调写完整新手注释。
- [x] 4.3 修改 `frontend/tests/legacy-reference.spec.js`(文件用途:验证仍保留的静态参考页)仅保留手机号参考页断言;为修改后的测试回调补齐完整新手注释。
- [x] 4.4 修改 `README.md`(文件用途:说明当前重构状态与本地入口)将企微页说明更新为真实列表和新的访问地址。
- [x] 4.5 执行 `mvn -q -DskipTests compile`、企微 Controller 测试、`npm run build` 与 Playwright;记录失败原因并先分析再修复。
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