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"
......
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { listPhoneAssets } from './phone-api-client.js';
import { nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage, ElMessageBox } from 'element-plus';
import { createPhoneAsset, deletePhoneAsset, listPhoneAssets, updatePhoneAsset } from './phone-api-client.js';
/**
* 代码作用(白话):显示手机号资产列表并管理筛选和分页状态。
* 关联文件:phone-api-client.js、router/index.js、PhoneAssetController.java
* 关联逻辑(调用链/数据流):路由 -> setup -> loadPage -> API -> Element Plus 表格
* 代码作用(白话):显示手机号资产列表,并管理新增、编辑、保存和删除弹窗状态。
* 关联文件:phone-api-client.js、PhoneAssetController.java、app.css
* 关联逻辑(调用链/数据流):路由进入 -> 页面方法 -> 前端接口客户端 -> 后端接口 -> 列表或弹窗更新
*/
export default {
/**
* 代码作用(白话):创建手机号资产列表的筛选、加载和分页状态,并把状态交给页面模板使用
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化状态 -> loadPage 请求接口 -> 表格读取 records
* 代码作用(白话):创建列表、筛选、弹窗和表单所需的响应式数据及页面操作方法
* 关联文件:phone-api-client.js、PhoneAssetController.java、app.css
* 关联逻辑(调用链/数据流):路由进入 -> setup 初始化 -> 用户操作 -> 接口调用或弹窗状态变化
*/
setup() {
const loading = ref(false); const records = ref([]); const total = ref(0);
const loading = ref(false);
const saving = ref(false);
const records = ref([]);
const total = ref(0);
const dialogVisible = ref(false);
const editingId = ref(null);
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '' });
/** 代码作用(白话):按当前条件加载一页记录。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):页面事件 -> API -> records/total。 */
async function loadPage() { loading.value = true; try { const result = await listPhoneAssets(filters); records.value = result.records; total.value = result.total; } catch (error) { ElMessage.error(error.message); } finally { loading.value = false; } }
/** 代码作用(白话):查询时回到第一页。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):点击查询 -> page=1 -> loadPage。 */
function submitSearch() { filters.page = 1; loadPage(); }
/** 代码作用(白话):清空筛选并重新加载默认列表。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):重置 -> filters -> loadPage。 */
function resetSearch() { Object.assign(filters, { page: 1, size: 20, phoneNumber: '', iccid: '' }); loadPage(); }
/** 代码作用(白话):切换页码并重新加载。关联文件:phone-api-client.js。关联逻辑(调用链/数据流):分页器 -> page -> loadPage。 */
function changePage(page) { filters.page = page; loadPage(); }
onMounted(loadPage); return { changePage, filters, loading, records, resetSearch, submitSearch, total };
const form = reactive({
phoneNumber: '',
cardType: '',
iccid: '',
realNameOwner: '',
managementType: '',
disposalStatus: '正常使用',
deviceId: null
});
/**
* 代码作用(白话):加载当前筛选条件下的一页手机号资产。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):查询/分页/保存成功 -> listPhoneAssets -> GET 接口 -> records 与 total。
*/
async function loadPage() {
loading.value = true;
try {
const result = await listPhoneAssets(filters);
records.value = result.records;
total.value = result.total;
} catch (error) {
ElMessage.error(error.message);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):把手机号变成最多 11 位数字,供输入和粘贴共用。
* 关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java。
* 关联逻辑(调用链/数据流):浏览器输入 -> 过滤数字 -> form.phoneNumber -> 保存请求 -> 后端格式校验。
*/
function normalizePhoneNumber(value) {
return String(value || '').replace(/\D/g, '').slice(0, 11);
}
/**
* 代码作用(白话):把 ICCID 变成最多 20 位的数字或英文字母,供输入和粘贴共用。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):浏览器输入 -> 过滤有效字符 -> form.iccid -> 保存请求。
*/
function normalizeIccid(value) {
return String(value || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 20);
}
/**
* 代码作用(白话):处理手机号实时输入,确保计数器只计算有效数字。
* 关联文件:PhoneAssetView.js、PhoneAssetService.java。
* 关联逻辑(调用链/数据流):手机号 input 事件 -> normalizePhoneNumber -> form.phoneNumber -> 输入框 suffix。
*/
function limitPhoneNumber(value) {
form.phoneNumber = normalizePhoneNumber(value);
}
/**
* 代码作用(白话):处理 ICCID 实时输入,确保计数器只计算有效的数字和英文字母。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):ICCID input 事件 -> normalizeIccid -> form.iccid -> 输入框 suffix。
*/
function limitIccid(value) {
form.iccid = normalizeIccid(value);
}
/**
* 代码作用(白话):将粘贴文字插入当前选择位置后再过滤,避免浏览器 maxlength 先截掉有效字符。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):paste 事件 -> 原始剪贴板文字 -> 过滤方法 -> form 字段 -> 框内计数器。
*/
function replaceSelectionWithPastedValue(event, fieldName, normalizeValue) {
const pastedText = event.clipboardData?.getData('text');
if (pastedText === undefined) return;
event.preventDefault();
const input = event.target;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
const nextValue = input.value.slice(0, start) + pastedText + input.value.slice(end);
form[fieldName] = normalizeValue(nextValue);
}
/**
* 代码作用(白话):处理手机号粘贴并保留前 11 位有效数字。
* 关联文件:PhoneAssetView.js、PhoneAssetService.java。
* 关联逻辑(调用链/数据流):手机号 paste -> replaceSelectionWithPastedValue -> form.phoneNumber -> 保存校验。
*/
function handlePhonePaste(event) {
replaceSelectionWithPastedValue(event, 'phoneNumber', normalizePhoneNumber);
}
/**
* 代码作用(白话):处理 ICCID 粘贴并保留前 20 个有效字符。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):ICCID paste -> replaceSelectionWithPastedValue -> form.iccid -> 保存请求。
*/
function handleIccidPaste(event) {
replaceSelectionWithPastedValue(event, 'iccid', normalizeIccid);
}
/**
* 代码作用(白话):把表单恢复成新增手机号资产时原有的默认值。
* 关联文件:PhoneAssetView.js、PhoneAssetSaveRequest.java。
* 关联逻辑(调用链/数据流):新增按钮 -> resetForm -> form -> 弹窗;保存成功后的下一次新增同样复用。
*/
function resetForm() {
Object.assign(form, {
phoneNumber: '',
cardType: '',
iccid: '',
realNameOwner: '',
managementType: '',
disposalStatus: '正常使用',
deviceId: null
});
}
/**
* 代码作用(白话):在弹窗显示完成后把内部内容滚动条移回顶部。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):Element Plus opened 事件 -> 弹窗专属类 -> 内容区域 scrollTop=0。
*/
async function resetDialogScroll() {
await nextTick();
const body = document.querySelector('.phone-asset-modal .el-dialog__body');
if (body) body.scrollTop = 0;
}
/**
* 代码作用(白话):打开空白的新增手机号资产弹窗。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):点击新增 -> resetForm -> dialogVisible=true -> Element Plus 弹窗显示。
*/
function openCreate() {
editingId.value = null;
resetForm();
dialogVisible.value = true;
}
/**
* 代码作用(白话):用当前表格行的数据回填同一个编辑弹窗。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):点击编辑 -> row -> form -> PUT 保存。
*/
function openEdit(row) {
editingId.value = row.id;
Object.assign(form, {
phoneNumber: normalizePhoneNumber(row.phoneNumber),
cardType: row.cardType || '',
iccid: normalizeIccid(row.iccid),
realNameOwner: row.realNameOwner || '',
managementType: row.managementType || '',
disposalStatus: row.disposalStatus || '正常使用',
deviceId: row.deviceId ?? null
});
dialogVisible.value = true;
}
/**
* 代码作用(白话):提交新增或编辑表单,成功后保持原有提示、关闭弹窗并刷新列表。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):确认保存 -> POST/PUT -> 成功提示 -> dialogVisible=false -> loadPage。
*/
async function submitForm() {
saving.value = true;
try {
const payload = { ...form, phoneNumber: normalizePhoneNumber(form.phoneNumber) };
if (editingId.value === null) await createPhoneAsset(payload);
else await updatePhoneAsset(editingId.value, payload);
ElMessage.success(editingId.value === null ? '新增成功' : '编辑成功');
dialogVisible.value = false;
await loadPage();
} catch (error) {
ElMessage.error(error.message);
} finally {
saving.value = false;
}
}
/**
* 代码作用(白话):要求用户确认后删除一条手机号资产,并按原逻辑刷新列表。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):删除按钮 -> 确认框 -> DELETE -> 成功提示 -> loadPage。
*/
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确认删除手机号 ${row.phoneNumber} 吗?`, '删除确认', { type: 'warning' });
await deletePhoneAsset(row.id);
ElMessage.success('删除成功');
if (records.value.length === 1 && filters.page > 1) filters.page -= 1;
await loadPage();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message);
}
}
/**
* 代码作用(白话):按当前筛选值从第一页重新查询。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):查询按钮或表单提交 -> page=1 -> loadPage -> GET 接口。
*/
function submitSearch() {
filters.page = 1;
loadPage();
}
/**
* 代码作用(白话):清空筛选条件并重新加载默认列表。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):重置按钮 -> filters 默认值 -> loadPage -> GET 接口。
*/
function resetSearch() {
Object.assign(filters, { page: 1, size: 20, phoneNumber: '', iccid: '' });
loadPage();
}
/**
* 代码作用(白话):切换页码并读取该页的数据。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage -> GET 接口。
*/
function changePage(page) {
filters.page = page;
loadPage();
}
/**
* 代码作用(白话):页面首次显示时请求手机号资产列表。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):组件挂载 -> onMounted 回调 -> loadPage -> 列表渲染。
*/
onMounted(() => {
loadPage();
});
return {
changePage,
confirmDelete,
dialogVisible,
editingId,
filters,
form,
handleIccidPaste,
handlePhonePaste,
limitIccid,
limitPhoneNumber,
loading,
openCreate,
openEdit,
records,
resetDialogScroll,
resetSearch,
saving,
submitForm,
submitSearch,
total
};
},
template: `<section class="phone-asset-page"><header class="page-header"><div><p class="eyebrow">PHONE ASSETS</p><h2>手机号资产</h2><p>管理手机号卡的基础信息与关联状态。</p></div><el-button type="primary" disabled>新增手机号资产</el-button></header><article class="reference-card"><el-form inline @submit.prevent="submitSearch"><el-form-item label="手机号"><el-input v-model="filters.phoneNumber" maxlength="11" clearable /></el-form-item><el-form-item label="ICCID"><el-input v-model="filters.iccid" clearable /></el-form-item><el-form-item><el-button type="primary" @click="submitSearch">查询</el-button><el-button @click="resetSearch">重置</el-button></el-form-item></el-form><el-table v-loading="loading" :data="records"><el-table-column prop="phoneNumber" label="手机号" min-width="140" show-overflow-tooltip /><el-table-column prop="cardType" label="卡类型" min-width="120" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="180" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名归属" min-width="140" show-overflow-tooltip /><el-table-column prop="managementType" label="管理方式" min-width="120" show-overflow-tooltip /><el-table-column prop="disposalStatus" label="处置状态" min-width="120" show-overflow-tooltip /><el-table-column prop="deviceId" label="关联设备 ID" min-width="130" /><el-table-column prop="relationSyncedAt" label="关联同步时间" min-width="180" show-overflow-tooltip /></el-table><el-pagination v-if="total" background layout="total, prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" /></article></section>`
template: `
<section class="phone-asset-page">
<header class="page-header">
<div>
<p class="eyebrow">手机号资产</p>
<h2>手机号资产</h2>
<p>管理手机号卡的基础信息与关联状态。</p>
</div>
<el-button type="primary" @click="openCreate">新增手机号资产</el-button>
</header>
<article class="reference-card">
<el-form inline @submit.prevent="submitSearch">
<el-form-item label="手机号"><el-input v-model="filters.phoneNumber" maxlength="11" clearable /></el-form-item>
<el-form-item label="ICCID"><el-input v-model="filters.iccid" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="submitSearch">查询</el-button><el-button @click="resetSearch">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="records" empty-text="暂无数据">
<el-table-column prop="phoneNumber" label="手机号" min-width="140" />
<el-table-column prop="cardType" label="卡类型" min-width="120" />
<el-table-column prop="iccid" label="ICCID" min-width="180" />
<el-table-column prop="realNameOwner" label="实名归属" min-width="140" />
<el-table-column prop="managementType" label="管理方式" min-width="120" />
<el-table-column prop="disposalStatus" label="处置状态" min-width="120" />
<el-table-column prop="deviceId" label="关联设备 ID" min-width="130" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="confirmDelete(row)">删除</el-button>
</template>
</el-table-column>
</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>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
<el-input v-model="form.phoneNumber" class="phone-asset-modal__count-input" maxlength="11" inputmode="numeric" autocomplete="off" placeholder="请输入手机号" @input="limitPhoneNumber" @paste="handlePhonePaste">
<template #suffix><span class="phone-asset-modal__character-count" aria-live="polite">{{ form.phoneNumber.length }}/11</span></template>
</el-input>
</el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="卡类型">
<el-select v-model="form.cardType" clearable placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="移动" value="移动" /><el-option label="电信" value="电信" /><el-option label="联通" value="联通" /><el-option label="广电" value="广电" /><el-option label="虚拟号码" value="虚拟号码" /></el-select>
</el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="ICCID">
<el-input v-model="form.iccid" class="phone-asset-modal__count-input" maxlength="20" autocomplete="off" placeholder="请输入 ICCID" @input="limitIccid" @paste="handleIccidPaste">
<template #suffix><span class="phone-asset-modal__character-count" aria-live="polite">{{ form.iccid.length }}/20</span></template>
</el-input>
</el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="实名归属"><el-input v-model="form.realNameOwner" maxlength="20" placeholder="请输入实名归属" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="管理方式"><el-select v-model="form.managementType" clearable placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="自有" value="自有" /><el-option label="租用" value="租用" /><el-option label="代运营" value="代运营" /></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="处置状态"><el-select v-model="form.disposalStatus" placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option label="正常使用" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="已注销" value="已注销" /></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="设备 ID"><el-input v-model.number="form.deviceId" type="number" placeholder="请输入设备 ID" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">确认保存</el-button>
</template>
</el-dialog>
</section>
`
};
\ No newline at end of file
/**
* 代码作用(白话):统一发送手机号资产列表请求,避免页面自己处理返回格式
* 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字
* 关联文件: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