Commit 86d3179e by DaiJiezhang

feat: implement phone asset management modal

parent c95e91c1
......@@ -7,7 +7,15 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/phone-assets")
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 表格。 */
@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.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.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);
}
}
......@@ -3,15 +3,44 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.exception.PhoneAssetNotFoundException;
import com.xyw.console.asset.exception.PhoneAssetValidationException;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class PhoneAssetService {
private final PhoneAssetMapper mapper;
public PhoneAssetService(PhoneAssetMapper mapper){ this.mapper=mapper; }
/** 代码作用(白话):接收手机号资产表的数据库访问入口。关联文件:PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_phone_asset。 */
public PhoneAssetService(PhoneAssetMapper mapper) { this.mapper = mapper; }
/** 代码作用(白话):查询未删除手机号资产并转换成前端列表字段。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):GET 请求 -> page -> Mapper -> as_phone_asset -> Response。 */
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()); }
/** 代码作用(白话):只挑选页面列表需要展示的字段,避免把关联快照直接暴露给列表。关联文件:PhoneAssetResponse.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):数据库实体 -> DTO -> ApiResponse -> 前端表格。 */
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()); }
public PhoneAssetPageResponse page(PhoneAssetPageQuery query) {
Page<PhoneAssetEntity> page = mapper.selectPage(new Page<>(query.resolvedPage(), query.resolvedSize()), activeQuery().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());
}
/** 代码作用(白话):新增手机号资产并初始化审计字段和内部关联快照。关联文件: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 -> delete_time 更新。 */
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("手机号资产不存在或已删除");
}
/** 代码作用(白话):构造只包含未删除资产的查询条件。关联文件:PhoneAssetEntity.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):Service 查询 -> delete_time = 0 -> Mapper。 */
private LambdaQueryWrapper<PhoneAssetEntity> activeQuery(){ return new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime,0L); }
/** 代码作用(白话):读取一条仍有效的资产,供编辑和删除共用。关联文件:PhoneAssetMapper.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404 异常。 */
private PhoneAssetEntity requireActiveEntity(Long id){ PhoneAssetEntity entity=mapper.selectOne(activeQuery().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
......@@ -15,7 +15,7 @@ public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
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")
.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;
}
}
......@@ -2,7 +2,7 @@
"name": "xyw-console-frontend",
"private": true,
"scripts": {
"dev": "vite",
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"test:e2e": "playwright test"
......
/**
* 代码作用(白话):统一发送手机号资产列表请求,避免页面自己处理返回格式
* 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面加载 -> listPhoneAssets -> GET API -> 表格数据
* 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误
*/
async function request(path) {
const response = await fetch(path);
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败');
return payload.data;
}
/**
* 代码作用(白话):根据筛选条件读取手机号资产分页数据。
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):筛选条件 -> URL 参数 -> 后端分页结果 -> 表格。
*/
export function listPhoneAssets(query) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') params.set(key, value); });
return request(`/api/phone-assets?${params.toString()}`);
}
\ No newline at end of file
/** 代码作用(白话):按筛选条件读取手机号资产列表。关联文件: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。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */
export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */
export function updatePhoneAsset(id, form) { return request('/api/phone-assets/' + id, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):确认后删除一条手机号资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 后端软删除。 */
export function deletePhoneAsset(id) { return request('/api/phone-assets/' + id, { method: 'DELETE' }); }
......@@ -17,3 +17,244 @@ body { margin: 0; }
.reference-card { padding: 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: white; box-shadow: 0 8px 28px rgba(15, 23, 42, .05); }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
@media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar nav { grid-template-columns: repeat(2, 1fr); } .page-header { display: grid; } .content { padding: 24px; } }
/* 手机号资产弹窗:所有规则均以该弹窗类名开头,避免影响项目中的其他 Element Plus 弹窗。 */
.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;
}
\ No newline at end of file
import { expect, test } from '@playwright/test';
/**
* 代码作用(白话):模拟手机号资产列表接口,先证明新页面尚未实现时测试会失败,后续用于保护页面加载流程
* 关联文件:frontend/src/modules/phone/PhoneAssetView.js、frontend/src/modules/phone/phone-api-client.js。
* 关联逻辑(调用链/消息链/数据流):浏览器打开手机号资产路由 -> View 请求列表 API -> route.fulfill 返回测试数据 -> 页面表格断言
* 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):页面打开 -> 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 route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: {
records: [{ id: 1, phoneNumber: '13812345678', cardType: '中国移动', disposalStatus: '正常使用' }],
total: 1,
page: 1,
size: 20
}
data: { records: [], total: 0, 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.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 expect(page.getByRole('heading', { name: '手机号资产' })).toBeVisible();
await expect(page.getByText('13812345678')).toBeVisible();
await expect(page.getByRole('button', { name: '新增手机号资产' })).toBeVisible();
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');
});
/**
* 代码作用(白话):验证取消和右上角关闭按钮仍会关闭同一个业务弹窗。
* 关联文件: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
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