Commit 028e5bb6 by DaiJiezhang

feat: add wecom account asset creation

parent 6c6363e4
package com.xyw.console.asset.controller; package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.CompanyPersonLookupResponse;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse;
import com.xyw.console.asset.dto.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery; import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse; import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.service.WecomAccountService; import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供企微资产列表的只读 HTTP 入口,让前端无需直接访问数据库。 */ /** File purpose (plain language): exposes enterprise WeChat asset list, creation, and read-only selector endpoints to the frontend. */
@RestController @RestController
@RequestMapping("/api/wecom-accounts") @RequestMapping("/api/wecom-accounts")
public class WecomAccountController { public class WecomAccountController {
private final WecomAccountService service; private final WecomAccountService service;
/** /** Code purpose (plain language): receives the service that coordinates WeCom assets and referenced assets. Related files: WecomAccountService.java. Data flow: HTTP controller -> service -> mappers. */
* 代码作用(白话):接收企微资产查询服务,让 HTTP 请求能进入统一的分页查询逻辑。 public WecomAccountController(WecomAccountService service) { this.service = service; }
* 关联文件:WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 /** Code purpose (plain language): saves a new enterprise WeChat asset. Related files: WecomAccountSaveRequest.java, WecomAccountService.java. Data flow: create dialog -> POST -> transaction -> response. */
*/ @PostMapping
public WecomAccountController(WecomAccountService service) { public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { return ApiResponse.success("新增成功", service.create(request)); }
this.service = service;
} /** Code purpose (plain language): returns the paged enterprise WeChat asset list and optional registration-phone filter. Related files: WecomAccountPageQuery.java, WecomAccountService.java. Data flow: list query -> GET -> service.page -> table. */
/**
* 代码作用(白话):接收浏览器的分页和筛选参数,返回统一 JSON 格式的企微资产列表。
* 关联文件:WecomAccountPageQuery.java、WecomAccountService.java、wecom-api-client.js。
* 关联逻辑(调用链/数据流):GET /api/wecom-accounts -> Service.page -> ApiResponse -> Vue 表格。
*/
@GetMapping @GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { return ApiResponse.success(service.page(query)); }
return ApiResponse.success(service.page(query));
} /** Code purpose (plain language): searches registration subjects by company name or short name. Related files: CompanyProfileLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-profiles")
public ApiResponse<List<CompanyProfileLookupResponse>> companyProfiles(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyProfiles(keyword)); }
/** Code purpose (plain language): searches reusable registration phone assets. Related files: PhoneAssetLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchPhoneAssets(keyword)); }
/** Code purpose (plain language): searches optional WeCom owners from company people. Related files: CompanyPersonLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-persons")
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
} }
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载企微号归属人的搜索结果,供企业微信资产表单选择公司人员。 */
public record CompanyPersonLookupResponse(Long id, String personName) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载注册主体搜索结果,避免把完整公司档案暴露给新增企业微信表单。 */
public record CompanyProfileLookupResponse(Long id, String companyName, String shortName) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载注册手机号搜索结果,供企业微信资产选择已有手机号时复用。 */
public record PhoneAssetLookupResponse(Long id, String phoneNumber, String numberType) {}
\ No newline at end of file
package com.xyw.console.asset.dto; package com.xyw.console.asset.dto;
import java.time.LocalDateTime; import java.time.LocalDateTime;
public record PhoneAssetResponse(Long id,String phoneNumber,String cardType,String iccid,String realNameOwner,String managementType,String disposalStatus,Long deviceId,LocalDateTime relationSyncedAt) {} public record PhoneAssetResponse(Long id,String phoneNumber,String numberType,String sourceAssetType,Long sourceAssetId,String cardType,String iccid,String realNameOwner,String managementType,String disposalStatus,Long deviceId,LocalDateTime relationSyncedAt) {}
\ No newline at end of file \ No newline at end of file
...@@ -3,28 +3,19 @@ package com.xyw.console.asset.dto; ...@@ -3,28 +3,19 @@ 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;
/** 文件用途(白话):接收企微资产列表的分页和筛选参数,避免 Controller 直接处理零散 URL 参数。 */ /** 文件用途(白话):接收企业微信资产列表的分页与组合筛选参数。 */
public record WecomAccountPageQuery( public record WecomAccountPageQuery(
@Min(1) Integer page, @Min(1) Integer page,
@Min(1) @Max(100) Integer size, @Min(1) @Max(100) Integer size,
String wecomName, String keyword,
String wecomAccount) { String wecomAccount,
Long phoneAssetId,
Long companyProfileId,
String realNameOwnerStatus) {
/** /** 代码作用(白话):未传页码时返回第一页。关联文件:WecomAccountController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedPage -> MyBatis 分页。 */
* 代码作用(白话):在调用方没有传页码时返回第 1 页,保证列表可以直接打开。 public int resolvedPage() { return page == null ? 1 : page; }
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedPage -> MyBatis Page -> 列表响应。
*/
public int resolvedPage() {
return page == null ? 1 : page;
}
/** /** 代码作用(白话):未传每页条数时返回 20 条。关联文件:WecomAccountController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedSize -> MyBatis 分页。 */
* 代码作用(白话):在调用方没有传每页数量时使用 20 条,并由注解阻止一次查询过多记录。 public int resolvedSize() { return size == null ? 20 : size; }
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedSize -> MyBatis Page -> 列表响应。
*/
public int resolvedSize() {
return size == null ? 20 : size;
}
} }
\ No newline at end of file
...@@ -12,6 +12,7 @@ public record WecomAccountResponse( ...@@ -12,6 +12,7 @@ public record WecomAccountResponse(
String companyProfileName, String companyProfileName,
Long phoneAssetId, Long phoneAssetId,
String phoneNumber, String phoneNumber,
String phoneLinkMode,
String realNameOwner, String realNameOwner,
String realNameOwnerStatus, String realNameOwnerStatus,
String gender, String gender,
......
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
/** ???????????????????????? Controller ??????????? */
public record WecomAccountSaveRequest(
@NotBlank String wecomName,
String wecomAlias,
String wecomAccount,
Long companyProfileId,
@NotBlank String phoneNumber,
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long operatorPersonId) {
}
...@@ -21,6 +21,9 @@ public class PhoneAssetEntity extends AssetBaseEntity { ...@@ -21,6 +21,9 @@ public class PhoneAssetEntity extends AssetBaseEntity {
private String managementType; private String managementType;
private String disposalStatus; private String disposalStatus;
private Long deviceId; private Long deviceId;
private String numberType;
private String sourceAssetType;
private Long sourceAssetId;
private String linkedWecomAccounts; private String linkedWecomAccounts;
private String linkedWechatAccounts; private String linkedWechatAccounts;
private String linkedDouyinAccounts; private String linkedDouyinAccounts;
......
...@@ -19,6 +19,7 @@ public class WecomAccountEntity extends AssetBaseEntity { ...@@ -19,6 +19,7 @@ public class WecomAccountEntity extends AssetBaseEntity {
private Long companyProfileId; private Long companyProfileId;
private String wecomAccount; private String wecomAccount;
private Long phoneAssetId; private Long phoneAssetId;
private String phoneLinkMode;
private String realNameOwner; private String realNameOwner;
private String realNameOwnerStatus; private String realNameOwnerStatus;
private String gender; private String gender;
......
...@@ -43,7 +43,7 @@ public class PhoneAssetService { ...@@ -43,7 +43,7 @@ public class PhoneAssetService {
/** 代码作用(白话):判断筛选文本是否有内容,避免空字符串参与数据库筛选。关联文件:PhoneAssetPageQuery.java、PhoneAssetService.java。关联逻辑(调用链/数据流):请求参数 -> hasText -> 是否追加条件。 */ /** 代码作用(白话):判断筛选文本是否有内容,避免空字符串参与数据库筛选。关联文件:PhoneAssetPageQuery.java、PhoneAssetService.java。关联逻辑(调用链/数据流):请求参数 -> hasText -> 是否追加条件。 */
private boolean hasText(String value) { return value != null && !value.isBlank(); } 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。 */ /** 代码作用(白话):新增手机号资产并初始化审计字段和内部关联快照。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):POST 请求 -> DTO -> Service.create -> Mapper.insert -> as_phone_asset。 */
public PhoneAssetResponse create(PhoneAssetSaveRequest request) { PhoneAssetEntity entity=new PhoneAssetEntity(); applyEditableFields(entity,request); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); entity.setLinkedWecomAccounts("[]"); entity.setLinkedWechatAccounts("[]"); entity.setLinkedDouyinAccounts("[]"); entity.setLinkedDomainAccounts("[]"); entity.setLinkedMerchants("[]"); if(mapper.insert(entity)!=1) throw new IllegalStateException("手机号资产新增失败"); return toResponse(entity); } public PhoneAssetResponse create(PhoneAssetSaveRequest request) { PhoneAssetEntity entity=new PhoneAssetEntity(); applyEditableFields(entity,request); entity.setNumberType("SELF"); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); entity.setLinkedWecomAccounts("[]"); entity.setLinkedWechatAccounts("[]"); entity.setLinkedDouyinAccounts("[]"); entity.setLinkedDomainAccounts("[]"); entity.setLinkedMerchants("[]"); if(mapper.insert(entity)!=1) throw new IllegalStateException("手机号资产新增失败"); return toResponse(entity); }
/** 代码作用(白话):更新有效资产的用户可写字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):PUT 请求 -> Service 查找 -> Mapper.updateById -> Response。 */ /** 代码作用(白话):更新有效资产的用户可写字段。关联文件: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); } 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。 */ /** 代码作用(白话):将资产标记为删除,供 Controller 删除接口调用。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE 请求 -> Service -> Mapper.updateById。 */
...@@ -55,5 +55,5 @@ public class PhoneAssetService { ...@@ -55,5 +55,5 @@ public class PhoneAssetService {
/** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件:PhoneAssetSaveRequest.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):前端输入 -> 去空格和 +86 -> 格式失败返回 400。 */ /** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件: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; } 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 -> 表格。 */ /** 代码作用(白话):只挑选列表需要展示的字段。关联文件:PhoneAssetResponse.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):实体 -> DTO -> ApiResponse -> 表格。 */
private PhoneAssetResponse toResponse(PhoneAssetEntity entity){ return new PhoneAssetResponse(entity.getId(),entity.getPhoneNumber(),entity.getCardType(),entity.getIccid(),entity.getRealNameOwner(),entity.getManagementType(),entity.getDisposalStatus(),entity.getDeviceId(),entity.getRelationSyncedAt()); } private PhoneAssetResponse toResponse(PhoneAssetEntity entity){ return new PhoneAssetResponse(entity.getId(),entity.getPhoneNumber(),entity.getNumberType(),entity.getSourceAssetType(),entity.getSourceAssetId(),entity.getCardType(),entity.getIccid(),entity.getRealNameOwner(),entity.getManagementType(),entity.getDisposalStatus(),entity.getDeviceId(),entity.getRelationSyncedAt()); }
} }
\ No newline at end of file
...@@ -2,10 +2,15 @@ package com.xyw.console.asset.service; ...@@ -2,10 +2,15 @@ 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.CompanyPersonLookupResponse;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse;
import com.xyw.console.asset.dto.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery; import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse; import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountResponse; import com.xyw.console.asset.dto.WecomAccountResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.entity.AssetDeviceEntity; import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.exception.PhoneAssetValidationException;
import com.xyw.console.asset.entity.CompanyPersonEntity; import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity; import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity; import com.xyw.console.asset.entity.PhoneAssetEntity;
...@@ -15,6 +20,7 @@ import com.xyw.console.asset.mapper.CompanyPersonMapper; ...@@ -15,6 +20,7 @@ import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper; import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper; import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper; import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
...@@ -22,6 +28,7 @@ import java.util.Set; ...@@ -22,6 +28,7 @@ import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 文件用途(白话):读取企微账号资产,并把页面看不懂的关联 ID 转成公司、手机号、设备和人员名称。 */ /** 文件用途(白话):读取企微账号资产,并把页面看不懂的关联 ID 转成公司、手机号、设备和人员名称。 */
@Service @Service
...@@ -55,6 +62,43 @@ public class WecomAccountService { ...@@ -55,6 +62,43 @@ public class WecomAccountService {
* 关联文件:WecomAccountController.java、WecomAccountPageQuery.java、WecomAccountResponse.java。 * 关联文件:WecomAccountController.java、WecomAccountPageQuery.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):GET 请求 -> page -> WecomAccountMapper + 关联 Mapper -> 分页 DTO -> Vue 表格。 * 关联逻辑(调用链/数据流):GET 请求 -> page -> WecomAccountMapper + 关联 Mapper -> 分页 DTO -> Vue 表格。
*/ */
/** 代码作用(白话):创建企业微信资产并关联已有手机号或自动创建外部号码。关联文件:WecomAccountController.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):POST 表单 -> create -> 手机号查询或新增 -> 企微插入。 */
@Transactional
public WecomAccountResponse create(WecomAccountSaveRequest request) {
String phoneNumber = normalizePhoneNumber(request.phoneNumber());
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).eq(PhoneAssetEntity::getPhoneNumber, phoneNumber));
boolean created = phone == null;
LocalDateTime now = LocalDateTime.now();
if (created) { phone = new PhoneAssetEntity(); phone.setPhoneNumber(phoneNumber); phone.setNumberType("EXTERNAL"); phone.setSourceAssetType("WECOM"); phone.setDisposalStatus("正常使用"); phone.setCreateTime(now); phone.setUpdateTime(now); phone.setDeleteTime(0L); phone.setLinkedWecomAccounts("[]"); phone.setLinkedWechatAccounts("[]"); phone.setLinkedDouyinAccounts("[]"); phone.setLinkedDomainAccounts("[]"); phone.setLinkedMerchants("[]"); if (phoneAssetMapper.insert(phone) != 1) throw new IllegalStateException("手机号资产新增失败"); }
WecomAccountEntity entity = new WecomAccountEntity(); entity.setWecomName(request.wecomName()); entity.setWecomAlias(request.wecomAlias()); entity.setWecomAccount(request.wecomAccount()); entity.setCompanyProfileId(request.companyProfileId()); entity.setPhoneAssetId(phone.getId()); entity.setPhoneLinkMode(created ? "CREATED" : "EXISTING"); entity.setRealNameOwner(request.realNameOwner()); entity.setRealNameOwnerStatus(request.realNameOwnerStatus() == null ? "在职" : request.realNameOwnerStatus()); entity.setGender(request.gender()); entity.setOperatorPersonId(request.operatorPersonId()); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); if (wecomAccountMapper.insert(entity) != 1) throw new IllegalStateException("企业微信资产新增失败"); if (created) { phone.setSourceAssetId(entity.getId()); phoneAssetMapper.updateById(phone); } return toResponse(entity, Map.of(), Map.of(phone.getId(), phone.getPhoneNumber()), Map.of(), Map.of());
}
/** Code purpose (plain language): finds active company profiles for the registration-subject selector. Related files: WecomAccountController.java, CompanyProfileEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<CompanyProfileLookupResponse> searchCompanyProfiles(String keyword) {
LambdaQueryWrapper<CompanyProfileEntity> query = new LambdaQueryWrapper<CompanyProfileEntity>().eq(CompanyProfileEntity::getDeleteTime, 0L);
if (hasText(keyword)) query.and(wrapper -> wrapper.like(CompanyProfileEntity::getCompanyName, keyword).or().like(CompanyProfileEntity::getShortName, keyword));
return companyProfileMapper.selectList(query.orderByDesc(CompanyProfileEntity::getId)).stream().limit(20).map(item -> new CompanyProfileLookupResponse(item.getId(), item.getCompanyName(), item.getShortName())).toList();
}
/** Code purpose (plain language): finds active phone assets which may be reused as registration phones. Related files: WecomAccountController.java, PhoneAssetEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<PhoneAssetLookupResponse> searchPhoneAssets(String keyword) {
LambdaQueryWrapper<PhoneAssetEntity> query = new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).like(hasText(keyword), PhoneAssetEntity::getPhoneNumber, keyword).orderByDesc(PhoneAssetEntity::getId);
return phoneAssetMapper.selectList(query).stream().limit(20).map(item -> new PhoneAssetLookupResponse(item.getId(), item.getPhoneNumber(), item.getNumberType())).toList();
}
/** Code purpose (plain language): finds active company people for the optional WeCom owner selector. Related files: WecomAccountController.java, CompanyPersonEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<CompanyPersonLookupResponse> searchCompanyPersons(String keyword) {
LambdaQueryWrapper<CompanyPersonEntity> query = new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getDeleteTime, 0L).like(hasText(keyword), CompanyPersonEntity::getPersonName, keyword).orderByDesc(CompanyPersonEntity::getId);
return companyPersonMapper.selectList(query).stream().limit(20).map(item -> new CompanyPersonLookupResponse(item.getId(), item.getPersonName())).toList();
}
/** Code purpose (plain language): removes the China country prefix and only accepts an eleven-digit mobile number before it is looked up or created. Related files: WecomAccountSaveRequest.java, PhoneAssetService.java. Data flow: create form -> normalization -> lookup/create. */
private String normalizePhoneNumber(String value) {
String normalized = value == null ? "" : value.trim();
if (normalized.startsWith("+86")) normalized = normalized.substring(3);
if (!normalized.matches("\\d{11}")) throw new PhoneAssetValidationException("手机号必须是 11 位数字");
return normalized;
}
public WecomAccountPageResponse page(WecomAccountPageQuery query) { public WecomAccountPageResponse page(WecomAccountPageQuery query) {
Page<WecomAccountEntity> page = wecomAccountMapper.selectPage( Page<WecomAccountEntity> page = wecomAccountMapper.selectPage(
new Page<>(query.resolvedPage(), query.resolvedSize()), activeQuery(query)); new Page<>(query.resolvedPage(), query.resolvedSize()), activeQuery(query));
...@@ -74,14 +118,31 @@ public class WecomAccountService { ...@@ -74,14 +118,31 @@ public class WecomAccountService {
* 关联文件:WecomAccountEntity.java、WecomAccountPageQuery.java、WecomAccountMapper.java。 * 关联文件:WecomAccountEntity.java、WecomAccountPageQuery.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):查询参数 -> LambdaQueryWrapper -> as_wecom_account SQL -> 分页结果。 * 关联逻辑(调用链/数据流):查询参数 -> LambdaQueryWrapper -> as_wecom_account SQL -> 分页结果。
*/ */
/** 代码作用(白话):组合统一关键词、注册主体、实名状态和兼容筛选条件,只返回未删除的企业微信资产。关联文件:WecomAccountPageQuery.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):顶部条件 -> 手机号 ID 匹配(如有)-> 企业微信 SQL 条件 -> 分页结果。 */
private LambdaQueryWrapper<WecomAccountEntity> activeQuery(WecomAccountPageQuery query) { private LambdaQueryWrapper<WecomAccountEntity> activeQuery(WecomAccountPageQuery query) {
return new LambdaQueryWrapper<WecomAccountEntity>() LambdaQueryWrapper<WecomAccountEntity> wrapper = new LambdaQueryWrapper<WecomAccountEntity>()
.eq(WecomAccountEntity::getDeleteTime, 0L) .eq(WecomAccountEntity::getDeleteTime, 0L)
.eq(hasText(query.wecomName()), WecomAccountEntity::getWecomName, query.wecomName())
.eq(hasText(query.wecomAccount()), WecomAccountEntity::getWecomAccount, query.wecomAccount()) .eq(hasText(query.wecomAccount()), WecomAccountEntity::getWecomAccount, query.wecomAccount())
.orderByDesc(WecomAccountEntity::getId); .eq(query.phoneAssetId() != null, WecomAccountEntity::getPhoneAssetId, query.phoneAssetId())
.eq(query.companyProfileId() != null, WecomAccountEntity::getCompanyProfileId, query.companyProfileId())
.eq(hasText(query.realNameOwnerStatus()), WecomAccountEntity::getRealNameOwnerStatus, query.realNameOwnerStatus());
if (hasText(query.keyword())) {
Set<Long> phoneIds = matchingPhoneAssetIds(query.keyword());
wrapper.and(nested -> nested.like(WecomAccountEntity::getWecomName, query.keyword())
.or(!phoneIds.isEmpty(), byPhone -> byPhone.in(WecomAccountEntity::getPhoneAssetId, phoneIds)));
}
return wrapper.orderByDesc(WecomAccountEntity::getId);
} }
/** 代码作用(白话):找出手机号包含统一搜索词的有效手机号资产 ID。关联文件:PhoneAssetEntity.java、WecomAccountPageQuery.java。关联逻辑(调用链/数据流):统一搜索词 -> 手机号模糊查询 -> 企业微信关联手机号条件。 */
private Set<Long> matchingPhoneAssetIds(String keyword) {
return phoneAssetMapper.selectList(new LambdaQueryWrapper<PhoneAssetEntity>()
.eq(PhoneAssetEntity::getDeleteTime, 0L)
.like(PhoneAssetEntity::getPhoneNumber, keyword)).stream()
.map(PhoneAssetEntity::getId)
.filter(id -> id != null)
.collect(Collectors.toSet());
}
/** /**
* 代码作用(白话):判断筛选文本是否有实际内容,避免空字符串参与精确查询而把正常结果筛掉。 * 代码作用(白话):判断筛选文本是否有实际内容,避免空字符串参与精确查询而把正常结果筛掉。
* 关联文件:WecomAccountPageQuery.java、WecomAccountService.java。 * 关联文件:WecomAccountPageQuery.java、WecomAccountService.java。
...@@ -170,6 +231,9 @@ public class WecomAccountService { ...@@ -170,6 +231,9 @@ public class WecomAccountService {
* 关联文件:WecomAccountEntity.java、WecomAccountResponse.java、WecomAccountView.js。 * 关联文件:WecomAccountEntity.java、WecomAccountResponse.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):实体 + 名称 Map -> Response -> ApiResponse -> Element Plus 表格。 * 关联逻辑(调用链/数据流):实体 + 名称 Map -> Response -> ApiResponse -> Element Plus 表格。
*/ */
/** Code purpose (plain language): safely reads an optional relation name without passing a null key to immutable empty maps. Related files: WecomAccountResponse.java. Data flow: optional relation ID -> safe map lookup -> response. */
private String valueOf(Map<Long, String> names, Long id) { return id == null ? null : names.get(id); }
private WecomAccountResponse toResponse( private WecomAccountResponse toResponse(
WecomAccountEntity entity, WecomAccountEntity entity,
Map<Long, String> companyNames, Map<Long, String> companyNames,
...@@ -178,11 +242,11 @@ public class WecomAccountService { ...@@ -178,11 +242,11 @@ public class WecomAccountService {
Map<Long, String> operatorPersonNames) { Map<Long, String> operatorPersonNames) {
return new WecomAccountResponse( return new WecomAccountResponse(
entity.getId(), entity.getWecomName(), entity.getWecomAlias(), entity.getWecomAccount(), entity.getId(), entity.getWecomName(), entity.getWecomAlias(), entity.getWecomAccount(),
entity.getCompanyProfileId(), companyNames.get(entity.getCompanyProfileId()), entity.getCompanyProfileId(), valueOf(companyNames, entity.getCompanyProfileId()),
entity.getPhoneAssetId(), phoneNumbers.get(entity.getPhoneAssetId()), entity.getPhoneAssetId(), valueOf(phoneNumbers, entity.getPhoneAssetId()), entity.getPhoneLinkMode(),
entity.getRealNameOwner(), entity.getRealNameOwnerStatus(), entity.getGender(), entity.getRealNameOwner(), entity.getRealNameOwnerStatus(), entity.getGender(),
entity.getDeviceId(), deviceNames.get(entity.getDeviceId()), entity.getDeviceId(), valueOf(deviceNames, entity.getDeviceId()),
entity.getOperatorPersonId(), operatorPersonNames.get(entity.getOperatorPersonId()), entity.getOperatorPersonId(), valueOf(operatorPersonNames, entity.getOperatorPersonId()),
entity.getCreateTime(), entity.getUpdateTime()); entity.getCreateTime(), entity.getUpdateTime());
} }
} }
...@@ -5,11 +5,13 @@ import static org.junit.jupiter.api.Assertions.assertNull; ...@@ -5,11 +5,13 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.WecomAccountPageQuery; import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse; import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountResponse; import com.xyw.console.asset.dto.WecomAccountResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.entity.AssetDeviceEntity; import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity; import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity; import com.xyw.console.asset.entity.CompanyProfileEntity;
...@@ -46,7 +48,7 @@ class WecomAccountServiceTest { ...@@ -46,7 +48,7 @@ class WecomAccountServiceTest {
WecomAccountPageResponse result = new WecomAccountService( WecomAccountPageResponse result = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper) wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null)); .page(new WecomAccountPageQuery(null, null, null, null, null, null, null));
WecomAccountResponse record = result.records().get(0); WecomAccountResponse record = result.records().get(0);
assertEquals(1L, record.id()); assertEquals(1L, record.id());
...@@ -83,7 +85,7 @@ class WecomAccountServiceTest { ...@@ -83,7 +85,7 @@ class WecomAccountServiceTest {
WecomAccountResponse record = new WecomAccountService( WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper) wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null)).records().get(0); .page(new WecomAccountPageQuery(1, 20, null, null, null, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId()); assertEquals(10L, record.companyProfileId());
assertNull(record.companyProfileName()); assertNull(record.companyProfileName());
...@@ -169,4 +171,21 @@ class WecomAccountServiceTest { ...@@ -169,4 +171,21 @@ class WecomAccountServiceTest {
entity.setPersonName("王五"); entity.setPersonName("王五");
return entity; return entity;
} }
}
/** Code purpose (plain language): proves an entered existing number is reused and marked as an existing link. Related files: WecomAccountService.java, WecomAccountSaveRequest.java. Data flow: create request -> existing phone -> WeCom response. */
@Test
void createsWithAnExistingPhoneAsset() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
PhoneAssetEntity phone = phoneAsset(); when(phoneMapper.selectOne(any())).thenReturn(phone); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenReturn(1);
WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", "别名", null, null, "13812345678", null, null, null, null));
assertEquals(20L, result.phoneAssetId()); assertEquals("EXISTING", result.phoneLinkMode());
}
/** Code purpose (plain language): proves a missing number creates an external phone asset and traces it back to the WeCom record. Related files: WecomAccountService.java, PhoneAssetEntity.java. Data flow: create request -> new phone -> new WeCom -> source update. */
@Test
void createsAnExternalPhoneAssetForANewNumber() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(phoneMapper.selectOne(any())).thenReturn(null); when(phoneMapper.insert(any(PhoneAssetEntity.class))).thenAnswer(call -> { ((PhoneAssetEntity) call.getArgument(0)).setId(70L); return 1; }); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenAnswer(call -> { ((WecomAccountEntity) call.getArgument(0)).setId(80L); return 1; });
WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", null, null, null, "13912345678", null, null, null, null));
assertEquals("CREATED", result.phoneLinkMode()); verify(phoneMapper).updateById(any(PhoneAssetEntity.class));
}}
# 企业微信资产号码来源:受控数据库变更说明
本文件只定义待 DBA 在指定数据库和变更窗口执行的内容;本次没有连接、迁移或修改任何真实数据库。
## 变更字段
- `as_phone_asset.number_type``SELF`(自有号码)或 `EXTERNAL`(外部号码)。
- `as_phone_asset.source_asset_type`:创建外部号码的来源资产类型,例如 `WECOM`
- `as_phone_asset.source_asset_id`:来源资产主键。
- `as_wecom_account.phone_link_mode``EXISTING`(已有号码)或 `CREATED`(新建号码)。
## 回填与索引
1. 历史 `as_phone_asset` 记录回填为 `SELF`,来源字段保持空。
2.`as_phone_asset(source_asset_type, source_asset_id, delete_time)` 建立普通索引,支持来源跳转追溯。
3. 保留既有手机号与删除标记的唯一约束;部署前确认其可防止同一有效号码重复创建。
## 执行前检查与回滚
- 由 DBA 先在备份或预发布库验证列名、数据量和锁表影响。
- 应用已对空 `number_type` 兼容显示为“自有号码”,因此可先发布应用、后受控加列。
- 回滚时停止写入新字段并保留列,不删除已经写入的来源数据。
...@@ -22,7 +22,7 @@ export default { ...@@ -22,7 +22,7 @@ export default {
const total = ref(0); const total = ref(0);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const editingId = ref(null); const editingId = ref(null);
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: '' }); const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' });
let searchTimer = null; let searchTimer = null;
let latestRequest = 0; let latestRequest = 0;
const form = reactive({ const form = reactive({
...@@ -228,9 +228,11 @@ export default { ...@@ -228,9 +228,11 @@ export default {
/** 代码作用(白话):按 Enter 时立刻执行当前筛选。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):Enter -> 清除计时 -> page=1 -> loadPage。 */ /** 代码作用(白话):按 Enter 时立刻执行当前筛选。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):Enter -> 清除计时 -> page=1 -> loadPage。 */
function submitSearch() { window.clearTimeout(searchTimer); filters.page = 1; loadPage(); } function submitSearch() { window.clearTimeout(searchTimer); filters.page = 1; loadPage(); }
/** 代码作用(白话):切换使用状态后马上查询,不等待输入防抖。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):状态 change -> page=1 -> loadPage。 */ /** 代码作用(白话):切换使用状态后马上查询,不等待输入防抖。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):状态 change -> page=1 -> loadPage。 */
/** Code purpose (plain language): restores the status filter to its explicit All selection after the clear icon is clicked. Related files: PhoneAssetView.js, phone-api-client.js. Data flow: clear icon -> ALL -> request omits status -> full list. */
function restoreAllDisposalStatuses() { filters.disposalStatus = 'ALL'; submitSearch(); }
function changeStatus() { submitSearch(); } function changeStatus() { submitSearch(); }
/** 代码作用(白话):清空所有筛选条件并恢复第一页列表。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):重置 -> filters 默认值 -> loadPage。 */ /** 代码作用(白话):清空所有筛选条件并恢复第一页列表。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):重置 -> filters 默认值 -> loadPage。 */
function resetSearch() { window.clearTimeout(searchTimer); Object.assign(filters, { page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: '' }); loadPage(); } function resetSearch() { window.clearTimeout(searchTimer); Object.assign(filters, { page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' }); loadPage(); }
/** 代码作用(白话):切换页码并读取对应数据。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage。 */ /** 代码作用(白话):切换页码并读取对应数据。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage。 */
function changePage(page) { filters.page = page; loadPage(); } function changePage(page) { filters.page = page; loadPage(); }
/** 代码作用(白话):切换每页条数后回到第一页查询。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):每页条数 -> size/page -> loadPage。 */ /** 代码作用(白话):切换每页条数后回到第一页查询。关联文件:PhoneAssetView.js、phone-api-client.js。关联逻辑(调用链/数据流):每页条数 -> size/page -> loadPage。 */
...@@ -241,6 +243,12 @@ export default { ...@@ -241,6 +243,12 @@ export default {
* 关联文件:PhoneAssetView.js、phone-api-client.js。 * 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):组件挂载 -> onMounted 回调 -> loadPage -> 列表渲染。 * 关联逻辑(调用链/数据流):组件挂载 -> onMounted 回调 -> loadPage -> 列表渲染。
*/ */
/** Code purpose (plain language): converts stored disposal status values into the shorter labels required by the list UI. Related files: PhoneAssetService.java, PhoneAssetView.js. Data flow: API record -> formatDisposalStatus -> table/status option label. */
function formatDisposalStatus(value) {
if (value === '正常使用') return '正常';
if (value === '已注销') return '注销';
return value || '未设置';
}
onMounted(() => { onMounted(() => {
loadPage(); loadPage();
}); });
...@@ -254,6 +262,7 @@ export default { ...@@ -254,6 +262,7 @@ export default {
editingId, editingId,
elementLocale, elementLocale,
filters, filters,
formatDisposalStatus,
form, form,
handleIccidPaste, handleIccidPaste,
handlePhonePaste, handlePhonePaste,
...@@ -266,6 +275,7 @@ export default { ...@@ -266,6 +275,7 @@ export default {
records, records,
resetDialogScroll, resetDialogScroll,
resetSearch, resetSearch,
restoreAllDisposalStatuses,
saving, saving,
scheduleSearch, scheduleSearch,
submitForm, submitForm,
...@@ -276,8 +286,8 @@ export default { ...@@ -276,8 +286,8 @@ export default {
template: ` template: `
<el-config-provider :locale="elementLocale"><section class="phone-asset-page phone-asset-list-page"> <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> <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__search" aria-label="筛选手机号资产"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.phoneNumber" maxlength="11" inputmode="numeric" placeholder="手机号前3位、后4位或完整号码" @input="limitSearchPhone" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.iccid" maxlength="20" placeholder="请输入 ICCID" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.realNameOwner" placeholder="请输入实名人" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-select v-model="filters.disposalStatus" placeholder="使用状态:" clearable @change="changeStatus" @clear="restoreAllDisposalStatuses"><template #prefix>使用状态:</template><el-option label="全部" value="ALL" /><el-option label="正常" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="注销" value="已注销" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" show-overflow-tooltip /><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ row.disposalStatus || '未设置' }}</span></template></el-table-column><el-table-column prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></section> <section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" show-overflow-tooltip /><el-table-column label="号码类型" min-width="120"><template #default="{ row }"><a v-if="row.numberType === 'EXTERNAL' && row.sourceAssetType === 'WECOM'" class="phone-asset-list-page__external-link" :href="'#/reference/wecom?phoneAssetId=' + row.id">外部号码</a><span v-else>{{ row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码' }}</span></template></el-table-column><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ formatDisposalStatus(row.disposalStatus) }}</span></template></el-table-column><el-table-column prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></section>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll"> <el-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 class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required> <el-form-item class="phone-asset-modal__form-row" label="手机号" required>
...@@ -295,7 +305,7 @@ export default { ...@@ -295,7 +305,7 @@ export default {
</el-form-item> </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-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.managementType" clearable placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="自有" value="自有" /><el-option label="租用" value="租用" /><el-option label="代运营" value="代运营" /></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="处置状态"><el-select v-model="form.disposalStatus" placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="正常使用" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="已注销" value="已注销" /></el-select></el-form-item> <el-form-item class="phone-asset-modal__form-row" label="处置状态"><el-select v-model="form.disposalStatus" placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="正常" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="注销" value="已注销" /></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="设备 ID"><el-input v-model.number="form.deviceId" type="number" placeholder="请输入设备 ID" /></el-form-item> <el-form-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> </el-form>
<template #footer> <template #footer>
......
...@@ -10,7 +10,7 @@ async function request(path, options = {}) { ...@@ -10,7 +10,7 @@ async function request(path, options = {}) {
return payload.data; return payload.data;
} }
/** 代码作用(白话):按筛选条件读取手机号资产列表。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):筛选条件 -> GET -> 表格。 */ /** 代码作用(白话):按筛选条件读取手机号资产列表。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):筛选条件 -> GET -> 表格。 */
export function listPhoneAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key,value); }); return request('/api/phone-assets?' + params.toString()); } export function listPhoneAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined && value !== 'ALL') params.set(key,value); }); return request('/api/phone-assets?' + params.toString()); }
/** 代码作用(白话):提交新增表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */ /** 代码作用(白话):提交新增表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */
export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); } export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */ /** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */
......
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js'; import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { listWecomAccounts } from './wecom-api-client.js'; import { createWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchPhoneAssets } from './wecom-api-client.js';
/** 文件用途(白话):显示可筛选、可分页的企微资料,并把关联资源呈现成用户可读的名称和 ID。 */ /** File purpose (plain language): renders the enterprise WeChat asset list and its creation dialog with reusable asset searches. */
export default { export default {
/** /** Code purpose (plain language): owns list, dialog, and selector state for this page. Related files: wecom-api-client.js, WecomAccountController.java. Data flow: route -> setup -> API -> Element Plus controls. */
* 代码作用(白话):创建页面加载、筛选、分页状态,并把操作方法交给模板中的按钮和表格使用。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化 -> loadPage -> API -> Element Plus 表格。
*/
setup() { setup() {
const loading = ref(false); const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const records = ref([]); const records = ref([]);
const total = ref(0); const total = ref(0);
const filters = reactive({ page: 1, size: 20, wecomName: '', wecomAccount: '' }); const companyOptions = ref([]);
const ownerOptions = ref([]);
let searchTimer;
const filters = reactive({ page: 1, size: 20, keyword: '', wecomAccount: '', phoneAssetId: new URLSearchParams(window.location.hash.split('?')[1] || '').get('phoneAssetId') || '', companyProfileId: 'ALL', realNameOwnerStatus: 'ALL' });
const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null });
/** /** Code purpose (plain language): reloads the table using the active filters. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: page action -> GET -> records/total -> table. */
* 代码作用(白话):按当前筛选条件读取一页企微资产,并同步更新表格记录和总条数。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):页面事件 -> listWecomAccounts -> GET API -> records/total -> 表格和分页器。
*/
async function loadPage() { async function loadPage() {
loading.value = true; loading.value = true;
try { try { const result = await listWecomAccounts(filters); records.value = result.records; total.value = result.total; } catch (error) { ElMessage.error(error.message); } finally { loading.value = false; }
const result = await listWecomAccounts(filters);
records.value = result.records;
total.value = result.total;
} catch (error) {
ElMessage.error(error.message);
} finally {
loading.value = false;
}
} }
/** /** Code purpose (plain language): resets the create form to the business defaults. Related files: WecomAccountSaveRequest.java. Data flow: add button -> reset -> dialog form. */
* 代码作用(白话):在用户提交筛选时回到第一页,防止旧页码导致看不到匹配结果。 function resetForm() { Object.assign(form, { wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null }); }
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):查询按钮 -> page=1 -> loadPage -> 新筛选结果。
*/
function submitSearch() {
filters.page = 1;
loadPage();
}
/** /** Code purpose (plain language): opens a clean creation dialog. Related files: WecomAccountView.js. Data flow: add button -> resetForm -> dialog visible. */
* 代码作用(白话):清空企微名称和企微账号筛选,并恢复默认分页后重新查询。 function openCreate() { resetForm(); dialogVisible.value = true; }
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):重置按钮 -> filters 默认值 -> loadPage -> 默认列表。
*/
function resetSearch() {
Object.assign(filters, { page: 1, size: 20, wecomName: '', wecomAccount: '' });
loadPage();
}
/** /** Code purpose (plain language): saves a new asset and refreshes the first page. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: dialog submit -> POST -> transaction -> table refresh. */
* 代码作用(白话):接收分页器选中的页码并加载该页的企微资产。 async function submitCreate() {
* 关联文件:wecom-api-client.js、WecomAccountView.js。 if (!form.wecomName.trim() || !form.phoneNumber.trim()) { ElMessage.warning('请填写企微名称和注册手机号'); return; }
* 关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage -> 后端分页结果。 saving.value = true;
*/ try { await createWecomAccount({ ...form, wecomName: form.wecomName.trim(), phoneNumber: form.phoneNumber.trim() }); ElMessage.success('新增成功'); dialogVisible.value = false; filters.page = 1; await loadPage(); } catch (error) { ElMessage.error(error.message); } finally { saving.value = false; }
function changePage(page) {
filters.page = page;
loadPage();
} }
/** /** Code purpose (plain language): supplies existing phone assets to the autocomplete while leaving a typed new number usable. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: phone input -> lookup -> autocomplete suggestions. */
* 代码作用(白话):将关联资源的名称和 ID 组合成“名称(ID)”,关联缺失时保留 ID 并显示“—”。 async function fetchPhoneSuggestions(keyword, callback) {
* 关联文件:WecomAccountResponse.java、WecomAccountService.java、WecomAccountView.js。 try { const rows = await searchPhoneAssets(keyword); callback(rows.map(row => ({ value: row.phoneNumber, label: `${row.phoneNumber}${row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码'})` }))); } catch (_) { callback([]); }
* 关联逻辑(调用链/数据流):接口关联字段 -> formatRelation -> 表格关联资源单元格。
*/
function formatRelation(name, id) {
if (id === null || id === undefined) {
return '—';
}
return `${name || '—'}(ID:${id})`;
} }
/** Code purpose (plain language): looks up registration subjects by name or abbreviation. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: remote select -> lookup -> option list. */
async function loadCompanies(keyword) { companyOptions.value = await searchCompanyProfiles(keyword); }
/** Code purpose (plain language): looks up possible WeCom owners. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: remote select -> lookup -> option list. */
async function loadOwners(keyword) { ownerOptions.value = await searchCompanyPersons(keyword); }
/** Code purpose (plain language): waits briefly for typing to stop before querying unified WeCom-name-or-phone keywords. Related files: WecomAccountView.js, WecomAccountService.java. Data flow: input -> 300ms timer -> submitSearch -> filtered table. */
function scheduleSearch() { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(submitSearch, 300); }
/** Code purpose (plain language): restores the registration-subject filter to its explicit All selection after the clear icon is clicked. Related files: WecomAccountView.js, wecom-api-client.js. Data flow: clear icon -> ALL -> query omits filter -> full list. */
function restoreAllCompanyProfiles() { filters.companyProfileId = 'ALL'; submitSearch(); }
/** Code purpose (plain language): restores the real-name-status filter to its explicit All selection after the clear icon is clicked. Related files: WecomAccountView.js, wecom-api-client.js. Data flow: clear icon -> ALL -> query omits filter -> full list. */
function restoreAllRealNameStatuses() { filters.realNameOwnerStatus = 'ALL'; submitSearch(); }
/** Code purpose (plain language): searches again from the first page. Related files: WecomAccountView.js. Data flow: filter change -> filters.page -> loadPage. */
function submitSearch() { filters.page = 1; loadPage(); }
/** Code purpose (plain language): clears list text filters but retains an incoming phone-asset route filter until the user explicitly removes it. Related files: WecomAccountView.js. Data flow: reset -> default filters -> loadPage. */
function resetSearch() { window.clearTimeout(searchTimer); Object.assign(filters, { page: 1, size: 20, keyword: '', wecomAccount: '', phoneAssetId: '', companyProfileId: 'ALL', realNameOwnerStatus: 'ALL' }); loadPage(); }
/** Code purpose (plain language): loads the chosen pagination page. Related files: WecomAccountView.js. Data flow: pagination -> filters.page -> loadPage. */
function changePage(page) { filters.page = page; loadPage(); }
/** Code purpose (plain language): formats an optional referenced ID for table display. Related files: WecomAccountResponse.java. Data flow: API record -> formatter -> table cell. */
function formatRelation(name, id) { return id === null || id === undefined ? '—' : `${name || '—'}(ID:${id})`; }
onMounted(loadPage); onMounted(loadPage);
return { changePage, filters, formatRelation, loading, records, resetSearch, submitSearch, total }; return { changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
}, },
template: ` template: `
<section class="wecom-account-page"> <section class="phone-asset-list-page wecom-account-page">
<header class="page-header"> <header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2><p class="wecom-account-page__eyebrow">WECOM ACCOUNTS</p></div><el-button class="phone-asset-list-page__add" type="primary" @click="openCreate">新增企业微信资产</el-button></header>
<div><p class="eyebrow">WECOM ACCOUNTS</p><h2>企微资料</h2><p>查看企微账号资产、实名信息和关联资源。</p></div> <section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="企微名称或手机号" clearable @input="scheduleSearch" @clear="scheduleSearch" /><el-select v-model="filters.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="注册主体:" @change="submitSearch" @clear="restoreAllCompanyProfiles"><template #prefix>注册主体:</template><el-option label="全部" value="ALL" /><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select><el-select v-model="filters.realNameOwnerStatus" clearable placeholder="实名状态:" @change="submitSearch" @clear="restoreAllRealNameStatuses"><template #prefix>实名状态:</template><el-option label="全部" value="ALL" /><el-option label="在职" value="在职" /><el-option label="离职" value="离职" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
</header> <section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid wecom-account-page__grid"><el-table-column prop="id" label="企业微信资产 ID" min-width="140" /><el-table-column prop="wecomName" label="企微名称" min-width="150" show-overflow-tooltip /><el-table-column prop="wecomAlias" label="企微别名" min-width="180" show-overflow-tooltip /><el-table-column prop="wecomAccount" label="企微账号" min-width="160" show-overflow-tooltip /><el-table-column label="注册主体" min-width="180"><template #default="{ row }">{{ formatRelation(row.companyProfileName, row.companyProfileId) }}</template></el-table-column><el-table-column label="注册手机号" min-width="180"><template #default="{ row }">{{ formatRelation(row.phoneNumber, row.phoneAssetId) }}</template></el-table-column><el-table-column label="关联方式" min-width="120"><template #default="{ row }">{{ row.phoneLinkMode === 'CREATED' ? '新建号码' : '已有号码' }}</template></el-table-column><el-table-column prop="realNameOwner" label="实名人" min-width="120" /><el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="110" /><el-table-column prop="gender" label="性别" min-width="90" /><el-table-column label="企微号归属人" min-width="180"><template #default="{ row }">{{ formatRelation(row.operatorPersonName, row.operatorPersonId) }}</template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" /></footer></section>
<article class="reference-card"> <el-dialog v-model="dialogVisible" title="新增企业微信资产" width="640px" :close-on-click-modal="false"><el-form label-position="top" @submit.prevent="submitCreate"><el-row :gutter="16"><el-col :span="12"><el-form-item label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item></el-col><el-col :span="12"><el-form-item label="注册手机号" required><el-autocomplete v-model="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" placeholder="输入或选择手机号" style="width:100%" /></el-form-item></el-col><el-col :span="12"><el-form-item label="注册主体"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="企微号归属人"><el-select v-model="form.operatorPersonId" filterable remote clearable :remote-method="loadOwners" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in ownerOptions" :key="item.id" :label="item.personName" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="实名人"><el-input v-model="form.realNameOwner" /></el-form-item></el-col><el-col :span="12"><el-form-item label="实名状态"><el-radio-group v-model="form.realNameOwnerStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item></el-col><el-col :span="12"><el-form-item label="性别"><el-radio-group v-model="form.gender"><el-radio value="男">男</el-radio><el-radio value="女">女</el-radio></el-radio-group></el-form-item></el-col></el-row></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button></template></el-dialog>
<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> </section>
` `
}; };
\ No newline at end of file
/** 文件用途(白话):集中发送企微资产列表请求并统一解析后端的成功或失败响应。 */ /** File purpose (plain language): centralizes enterprise WeChat asset requests and turns API envelopes into usable data. */
/** /** Code purpose (plain language): sends an API request and exposes either its data or readable error. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: view action -> request -> ApiResponse -> view state. */
* 代码作用(白话):请求后端并从统一 JSON 响应中取出真正的列表数据,失败时抛出可显示的错误信息。 async function request(path, options = {}) {
* 关联文件:WecomAccountView.js、WecomAccountController.java。 const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
* 关联逻辑(调用链/数据流):列表页 -> request -> GET /api/wecom-accounts -> ApiResponse.data -> 表格数据。
*/
async function request(path) {
const response = await fetch(path);
const payload = await response.json(); const payload = await response.json();
if (!response.ok || payload.code !== 200) { if (!response.ok || payload.code !== 200) throw new Error(payload.message || '企业微信资产请求失败');
throw new Error(payload.message || '企微资产请求失败');
}
return payload.data; return payload.data;
} }
/** /** Code purpose (plain language): loads a filtered enterprise WeChat asset page. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: filters -> URL params -> GET list -> table. */
* 代码作用(白话):把页面的分页和筛选状态转换为 URL 参数,再读取对应的企微资产页。
* 关联文件:WecomAccountView.js、WecomAccountController.java、WecomAccountPageQuery.java。
* 关联逻辑(调用链/数据流):筛选条件 -> URLSearchParams -> GET 接口 -> records/total。
*/
export function listWecomAccounts(query) { export function listWecomAccounts(query) {
const params = new URLSearchParams(); const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => { Object.entries(query).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '' && value !== 'ALL') params.set(key, value); });
if (value !== null && value !== undefined && value !== '') { return request(`/api/wecom-accounts?${params}`);
params.set(key, value);
}
});
return request(`/api/wecom-accounts?${params.toString()}`);
} }
/** Code purpose (plain language): saves the create-dialog values. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: dialog submit -> POST -> transaction -> refreshed table. */
export function createWecomAccount(form) { return request('/api/wecom-accounts', { method: 'POST', body: JSON.stringify(form) }); }
/** Code purpose (plain language): searches company profiles for the registration-subject picker. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyProfiles(keyword) { return request(`/api/wecom-accounts/lookups/company-profiles?keyword=${encodeURIComponent(keyword || '')}`); }
/** Code purpose (plain language): searches existing phone assets for the registration-phone input. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: autocomplete -> GET lookup -> selectable numbers. */
export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts/lookups/phone-assets?keyword=${encodeURIComponent(keyword || '')}`); }
/** Code purpose (plain language): searches optional WeCom owners. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); }
\ No newline at end of file
:root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; } :root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; } body { margin: 0; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px 1fr; } .app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); }
.sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; } .sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; }
.sidebar h1 { margin: 0 0 32px; font-size: 20px; } .sidebar h1 { margin: 0 0 32px; font-size: 20px; }
.sidebar nav { display: grid; gap: 8px; } .sidebar nav { display: grid; gap: 8px; }
.sidebar a { border-radius: 8px; color: #cbd5e1; padding: 10px 12px; text-decoration: none; } .sidebar a { border-radius: 8px; color: #cbd5e1; padding: 10px 12px; text-decoration: none; }
.sidebar a.router-link-active, .sidebar a:hover { background: #1f3b59; color: white; } .sidebar a.router-link-active, .sidebar a:hover { background: #1f3b59; color: white; }
.content { padding: 40px; } .content { min-width: 0; padding: 40px; }
.eyebrow { margin: 0 0 8px; color: #5f8fcb; font-size: 12px; font-weight: 700; letter-spacing: .08em; } .eyebrow { margin: 0 0 8px; color: #5f8fcb; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.reference-page, .placeholder { max-width: 1180px; margin: 0 auto; } .reference-page, .placeholder { max-width: 1180px; margin: 0 auto; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; } .page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
...@@ -273,3 +273,4 @@ body { margin: 0; } ...@@ -273,3 +273,4 @@ body { margin: 0; }
.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} .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: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}} @media(max-width:640px){.phone-asset-list-page{padding:20px 0 32px}.phone-asset-list-page__header{align-items:stretch;flex-direction:column;margin-bottom:18px}.phone-asset-list-page__add.el-button{width:100%}.phone-asset-list-page__header h2{font-size:25px}.phone-asset-list-page__search{padding:18px}.phone-asset-list-page__filters,.phone-asset-list-page__filters .el-input,.phone-asset-list-page__filters .el-select,.phone-asset-list-page__filters .el-button{width:100%!important}.phone-asset-list-page__table-header,.phone-asset-list-page__pagination{padding-left:18px;padding-right:18px}.phone-asset-list-page__pagination .el-pagination__jump{display:none}}
.wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.wecom-account-page__eyebrow{margin:7px 0 0;color:#71717a;font-size:12px;font-weight:700;letter-spacing:.12em}.wecom-account-page .phone-asset-list-page__table,.wecom-account-page__grid{min-width:0;max-width:100%}.wecom-account-page__grid .el-scrollbar__wrap{overflow-x:auto!important}.phone-asset-list-page__external-link{color:#409eff;text-decoration:none}.phone-asset-list-page__external-link:hover{color:#337ecc;text-decoration:none}
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
/** /** File purpose (plain language): checks the enterprise WeChat page's create flow, external-number navigation, and phone filter. */
* 代码作用(白话):验证企微资料真实页面会请求新接口,并把关联名称与 ID、企微实名人完整展示出来。 test('creates an enterprise WeChat asset and exposes the required form', async ({ page }) => {
* 关联文件:WecomAccountView.js、wecom-api-client.js、router/index.js。 /** Code purpose (plain language): serves deterministic list and lookup API responses. Related files: WecomAccountView.js, wecom-api-client.js. Data flow: browser request -> route fixture -> rendered page. */
* 关联逻辑(调用链/数据流):访问 Hash 路由 -> 拦截 API 响应 -> Vue 表格 -> 页面断言。 await page.route('**/api/wecom-accounts**', async route => {
*/ const url = route.request().url();
test('shows readable related names without a delete-time column', async ({ page }) => { if (url.includes('/lookups/phone-assets')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [{ id: 7, phoneNumber: '13812345678', numberType: 'SELF' }] }) });
/** if (route.request().method() === 'POST') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { id: 1 } }) });
* 代码作用(白话):提供完整的企微列表接口响应,避免测试依赖本机数据库或后端服务状态。 return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [], total: 0, page: 1, size: 20 } }) });
* 关联文件: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 page.goto('/asset/#/reference/wecom');
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
await expect(page.getByRole('heading', { name: '企微资料' })).toBeVisible(); await expect(page.getByRole('heading', { name: '企业微信资产' })).toBeVisible();
await expect(page.getByText('企微实名人')).toBeVisible(); await page.getByRole('button', { name: '新增企业微信资产' }).click();
await expect(page.getByText('示例科技有限公司(ID:10)')).toBeVisible(); await page.getByLabel('企微名称').fill('测试企微');
await expect(page.getByText('13812345678(ID:20)')).toBeVisible(); await page.getByLabel('注册手机号').fill('13812345678');
await expect(page.getByText('iPhone 15(ID:30)')).toBeVisible(); await page.getByRole('button', { name: '保存' }).click();
await expect(page.getByText('王五(ID:40)')).toBeVisible(); await expect(page.getByText('新增成功')).toBeVisible();
await expect(page.getByText('删除标记')).toHaveCount(0);
}); });
/** test('external number links to the related enterprise WeChat asset filter', async ({ page }) => {
* 代码作用(白话):验证用户筛选和翻页时,页面会把新的名称、账号和页码参数提交给企微列表接口。 /** Code purpose (plain language): returns one external WeCom-created number. Related files: PhoneAssetView.js, WecomAccountView.js. Data flow: phone list fixture -> external link -> WeCom URL filter. */
* 关联文件:WecomAccountView.js、wecom-api-client.js、WecomAccountController.java。 await page.route('**/api/phone-assets**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [{ id: 9, phoneNumber: '13912345678', numberType: 'EXTERNAL', sourceAssetType: 'WECOM', cardType: '', iccid: '', realNameOwner: '', managementType: '', disposalStatus: '正常使用' }], total: 1, page: 1, size: 20 } }) }));
* 关联逻辑(调用链/数据流):输入筛选 -> 查询按钮或下一页 -> URL 参数 -> 后端分页查询。 await page.goto('/asset/#/phone-assets');
*/ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
test('sends current filters and page number to the wecom list API', async ({ page }) => { const link = page.getByRole('link', { name: '外部号码' });
const requestUrls = []; await expect(link).toHaveAttribute('href', '#/reference/wecom?phoneAssetId=9');
/**
* 代码作用(白话):记录每次企微接口请求的 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
## Context
企业微信资产目前只能分页查询;手机号资产仅能在自己的页面创建。`as_phone_asset` 尚未保存号码类型和外部来源,企业微信列表也不能按注册手机号筛选。仓库没有可执行的数据库迁移目录,因此数据库变更只作为受控 SQL 交付,绝不在应用启动或本次任务中直接执行。
## Goals / Non-Goals
**Goals:**
- 提供企业微信资产新增表单、关联查询和手机号自动创建。
- 在手机号资产中长期保存并显示“自有号码”或“外部号码”。
- 让外部号码链接到对应来源资产列表,并按手机号资产筛选。
- 用可复用的来源类型字段为后续微信、抖音等页面预留接入点。
**Non-Goals:**
- 不实现设备资产选择或企业微信资产编辑页。
- 不直接连接、修改或迁移任何数据库。
- 不在本次开发中实现尚无创建页面的微信、抖音等资产页。
## Decisions
### 手机号号码类型与来源分开保存
手机号资产新增 `number_type``source_asset_type``source_asset_id``number_type` 只表示号码归属:手机号资产页直接创建为 `SELF`(自有号码),其他资产流程自动创建为 `EXTERNAL`(外部号码);来源字段记录创建它的资产类型和主键。相比把“已有/新建”写入手机号资产,这能避免把一次关联动作误当成号码自身属性。
### 企业微信关联方式单独展示
企业微信资产新增 `phone_link_mode`。选择已有手机号资产时保存 `EXISTING`(已有号码);自动创建手机号资产时保存 `CREATED`(新建号码)。这与手机号资产的号码类型是两条不同维度的信息。
### 原子保存
企业微信资产服务使用事务(把自动创建手机号、创建企微资产、回填来源主键当成一组操作;任一步失败就全部撤销)。相比前端连续调用,可避免只有手机号、没有企微资产的孤立记录。
### 搜索接口按最小字段返回
新增注册主体、手机号和公司人员的只读搜索接口;注册主体返回 ID、名称和简称,手机号返回 ID、号码和号码类型,人员返回 ID 和姓名。前端不直接读取数据库,也不为选择器加载全部数据。
## Risks / Trade-offs
- [历史手机号没有来源] → 数据库交付将历史记录初始化为 `SELF`,与“非手机号资产创建才是外部号码”的规则一致。
- [并发提交同一新号码] → 复用手机号资产表既有“号码 + 删除状态”的唯一约束,并将重复键错误转换为重新读取已有号码。
- [未来来源页面尚未实现] → 当前仅企业微信具备完整跳转;来源类型字段和前端映射为未来页面预留扩展点。
- [数据库脚本不在仓库] → 交付明确的字段与回填要求,实际执行必须获得目标数据库和执行窗口的单独授权。
## Migration Plan
1. 在受控数据库变更中为手机号资产增加号码类型、来源资产类型、来源资产 ID,为企业微信资产增加手机号关联方式。
2. 将历史手机号资产初始化为 `SELF`,并为新字段建立筛选索引。
3. 发布后先验证现有列表仍可正常查询,再验证新建企微资产的已有号码和新建号码两条流程。
4. 回滚时保留新列但停止写入;前后端对空值回退为“自有号码”,避免旧数据不可读。
## Open Questions
- 无。数据库执行时间和目标库授权不属于本次代码实施范围。
## Why
企业微信资料页目前只有只读列表,且布局与手机号资产页不一致。登记企业微信账号时无法复用或安全地新增注册手机号,也无法区分手机号是自有号码还是由外部资产登记流程创建的号码。
## What Changes
- 将“企微资料”页面改为“企业微信资产”,复用手机号资产页的标题、按钮位置、面板宽度和表格内部横向滚动体验。
- 新增企业微信资产创建表单;注册手机号必填,支持搜索已有手机号资产,或在号码不存在时自动创建手机号资产。
- 新增手机号号码类型:直接在手机号资产页创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
- 为外部号码保存来源资产信息;手机号资产页将“外部号码”显示为无下划线的蓝色链接,点击后进入来源资产列表并按手机号筛选关联记录。
- 新增注册主体与企微号归属人的搜索选择能力;注册主体使用公司简称显示,企微号归属人复用公司人员记录。
- 企业微信资产列表新增注册手机号筛选,并展示“已有号码”或“新建号码”的关联方式。
## Capabilities
### New Capabilities
- `wecom-account-creation`: 创建企业微信资产,并完成注册主体、注册手机号和归属人员的选择与保存。
- `phone-asset-origin-tracking`: 保存手机号号码类型及来源资产,并支持从外部号码跳转到对应资产列表。
- `asset-reference-lookups`: 为企业微信资产表单提供注册主体、手机号资产和公司人员的搜索选择接口。
### Modified Capabilities
- 无;仓库没有可修改的主规格目录。
## Impact
- 前端:企业微信资产页、手机号资产页、对应 API 客户端、公共样式及 Playwright 测试。
- 后端:企业微信和手机号资产的 Controller、Service、DTO、响应对象与单元测试。
- 数据库:手机号资产表新增号码类型和来源资产字段;不在本次实现中直接执行数据库变更。
## ADDED Requirements
### Requirement: 资产引用搜索
系统 SHALL 提供公司档案、手机号资产和公司人员的只读搜索接口,供企业微信资产表单选择引用资产。
#### Scenario: 搜索注册主体
- **WHEN** 用户输入公司名称或简称
- **THEN** 系统 MUST 返回有效公司档案的 ID、公司名称和简称
#### Scenario: 搜索注册手机号
- **WHEN** 用户输入手机号片段
- **THEN** 系统 MUST 返回有效手机号资产的 ID、手机号与号码类型,且不返回已删除资产
## ADDED Requirements
### Requirement: 手机号号码类型
系统 SHALL 为手机号资产保存并显示号码类型;手机号资产页直接创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
#### Scenario: 显示外部号码
- **WHEN** 手机号资产的号码类型为外部号码
- **THEN** 手机号资产列表 MUST 以无下划线的蓝色文字显示“外部号码”
### Requirement: 外部号码来源跳转
系统 SHALL 为外部号码保存来源资产,并允许用户点击号码类型跳转到来源资产列表且按该手机号筛选。
#### Scenario: 跳转企业微信资产
- **WHEN** 用户点击来源为企业微信资产的外部号码
- **THEN** 系统 MUST 打开企业微信资产列表并仅显示关联该手机号资产的记录
## ADDED Requirements
### Requirement: 创建企业微信资产
系统 SHALL 在企业微信资产页提供“新增企业微信资产”按钮及表单;企微名称和注册手机号 MUST 必填,企微别名 MUST 默认填入“记忆力梅老师-助教老师”。
#### Scenario: 以已有号码创建
- **WHEN** 用户选择一个有效的已有手机号资产并保存企业微信资产
- **THEN** 系统 MUST 保存该手机号资产 ID,并在企业微信资产列表显示“已有号码”
#### Scenario: 以新号码创建
- **WHEN** 用户输入一个不存在的有效手机号并保存企业微信资产
- **THEN** 系统 MUST 原子地创建外部号码手机号资产与企业微信资产,并在列表显示“新建号码”
### Requirement: 企业微信资产表单字段
系统 SHALL 提供注册主体搜索、注册手机号搜索、实名状态单选、性别单选与企微号归属人搜索;注册主体和归属人不是必填项,设备 ID 不在表单中出现。
#### Scenario: 默认实名状态
- **WHEN** 用户打开新增企业微信资产表单
- **THEN** 实名状态 MUST 默认选择“在职”,并只允许“在职”或“离职”之一
## 1. 数据模型与后端接口
- [x] 1.1 为手机号资产和企业微信资产补充号码来源、关联方式及对应 DTO/响应字段。
- [x] 1.2 实现注册主体、手机号资产和公司人员的只读搜索接口。
- [x] 1.3 实现企业微信资产创建接口,并在新手机号场景中原子创建手机号资产。
- [x] 1.4 实现按手机号资产筛选企业微信资产的查询兼容逻辑。
## 2. 前端页面与交互
- [x] 2.1 将企业微信资产页改为手机号资产页一致的标题、按钮、面板和宽表布局。
- [x] 2.2 实现企业微信资产新增弹窗及注册主体、注册手机号、归属人员的搜索选择。
- [x] 2.3 在手机号资产列表显示号码类型,并实现外部号码蓝色无下划线跳转。
- [x] 2.4 实现企业微信资产页接收手机号筛选参数并显示关联记录。
## 3. 数据库交付与验证
- [x] 3.1 编写受控数据库变更说明:新增字段、历史数据回填和索引要求;不直接执行数据库变更。
- [x] 3.2 补充后端单元测试,覆盖已有号码、新建外部号码、搜索和筛选。
- [x] 3.3 补充 Playwright 测试,覆盖新增表单、号码类型展示与跳转筛选。
- [x] 3.4 运行 OpenSpec 校验及前后端相关测试。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment