Commit 13400512 by DaiJiezhang

feat: 手机号资产按设备名称关联并统一列表与弹窗字段

关联设备原本只显示和输入设备 ID,用户得自己去设备资产页记数字。
改为按设备名称搜索选择,列表也直接显示名称。

- 新增 GET /api/phone-assets/lookups/devices,走手机号页 READ 权限而不是设备页的
  管理员权限,避免只有手机号权限的用户搜不到设备
- PhoneAssetResponse 增加 deviceName,分页时一次 IN 查询批量解析,不逐行查库
- 弹窗设备 ID 输入框改为可搜索下拉,编辑时预置当前设备保证名称回显
- 弹窗字段名统一为列表叫法:卡类型→运营商、实名归属→实名人、管理方式→管理模式、
  处置状态→使用状态、设备 ID→关联设备
- 运营商增加「电销卡「联通」」
- 列表与弹窗字段顺序调整为 手机号、实名人、运营商
- ICCID 不再单独占列,收进手机号单元格由图标点击弹出,图标用内联 SVG 以免引入
  不在 package.json 里的 @element-plus/icons-vue
- 修复下拉已选中的值被 placeholder 灰色规则一起染灰
- 修复筛选框写死宽度导致手机号提示语截断、重置按钮换行

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 10a82a20
......@@ -4,6 +4,7 @@ import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/phone-assets")
......@@ -21,4 +22,6 @@ public class PhoneAssetController {
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");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){permissions.require(PagePermissionService.PHONE,"EDIT");service.softDelete(id);return ApiResponse.success("删除成功",null);}
/** 代码作用(白话):按设备名称关键字搜索可关联的设备,供弹窗"关联设备"下拉使用。这里用手机号页面的 READ 权限而不是设备页的管理员权限,避免只有手机号权限的用户搜不到设备。关联文件:DeviceAssetLookupResponse.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):下拉输入 -> GET /lookups/devices -> Service 模糊查询 -> 选项列表。 */
@GetMapping("/lookups/devices") public ApiResponse<List<DeviceAssetLookupResponse>> devices(@RequestParam(defaultValue="") String keyword){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.searchDevices(keyword));}
}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载设备资产的搜索结果,供手机号资产弹窗按设备名称选择关联设备时使用。 */
public record DeviceAssetLookupResponse(Long id, String deviceName) {}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
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
/** 说明:deviceName 由 Service 按 deviceId 批量解析后填入,列表只展示设备名称,deviceId 仅供编辑弹窗回填选中项。 */
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,String deviceName,LocalDateTime relationSyncedAt) {}
\ No newline at end of file
......@@ -4,12 +4,17 @@ 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.get;
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import java.util.List;
import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import org.junit.jupiter.api.Test;
......@@ -28,7 +33,7 @@ class PhoneAssetControllerTest {
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), mock(PagePermissionService.class))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper, mock(AssetDeviceMapper.class)), mock(PagePermissionService.class))).build();
mockMvc.perform(post("/api/phone-assets").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13812345678\",\"realNameOwner\":\"张三\"}"))
......@@ -47,7 +52,7 @@ class PhoneAssetControllerTest {
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), mock(PagePermissionService.class))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper, mock(AssetDeviceMapper.class)), mock(PagePermissionService.class))).build();
mockMvc.perform(put("/api/phone-assets/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13912345678\",\"realNameOwner\":\"李四\"}"))
......@@ -64,13 +69,34 @@ class PhoneAssetControllerTest {
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), mock(PagePermissionService.class))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper, mock(AssetDeviceMapper.class)), mock(PagePermissionService.class))).build();
mockMvc.perform(delete("/api/phone-assets/1"))
.andExpect(status().isOk());
}
/**
* 代码作用(白话):证明弹窗的关联设备下拉可以按设备名称搜到有效设备。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):GET /api/phone-assets/lookups/devices -> Service.searchDevices -> as_asset_device -> 下拉选项。
*/
@Test
void searchesLinkableDevicesByDeviceName() throws Exception {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
AssetDeviceMapper deviceMapper = mock(AssetDeviceMapper.class);
AssetDeviceEntity device = new AssetDeviceEntity();
device.setId(7L);
device.setDeviceName("测试机A");
when(deviceMapper.selectList(any())).thenReturn(List.of(device));
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper, deviceMapper), mock(PagePermissionService.class))).build();
mockMvc.perform(get("/api/phone-assets/lookups/devices").param("keyword", "测试"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].id").value(7))
.andExpect(jsonPath("$.data[0].deviceName").value("测试机A"));
}
/**
* 代码作用(白话):构造一条仍有效的资产,供编辑和删除接口测试使用。
* 关联文件:PhoneAssetService.java、PhoneAssetEntity.java。
* 关联逻辑(调用链/数据流):测试输入 -> Service 查询有效资产 -> 更新或软删除。
......
......@@ -19,3 +19,5 @@ export function createPhoneAsset(form) { return request('/api/phone-assets', { m
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' }); }
/** 代码作用(白话):按设备名称搜索可关联的设备资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):关联设备下拉输入 -> GET lookups/devices -> 设备选项。 */
export function searchLinkableDevices(keyword) { return request('/api/phone-assets/lookups/devices?keyword=' + encodeURIComponent(keyword || '')); }
......@@ -160,8 +160,10 @@ body { margin: 0; }
color: #27272a;
font-size: 14px;
}
/* Element Plus 的下拉把"已选中的值"和"占位文字"渲染在同一个 .el-select__placeholder 上,只有真占位才多带 is-transparent。
这里必须限定 .is-transparent,否则已选中的值会跟着变灰,和输入框里深色的输入值对不上。 */
.phone-asset-modal__form-row .el-input__inner::placeholder,
.phone-asset-modal__form-row .el-select__placeholder {
.phone-asset-modal__form-row .el-select__placeholder.is-transparent {
color: #a1a1aa;
}
.phone-asset-modal__count-input .el-input__inner {
......@@ -287,8 +289,28 @@ body { margin: 0; }
.phone-asset-list-page__panel { border:1px solid #e5e5e8; border-radius:12px; background:#fff; box-shadow:0 10px 30px rgba(15,23,42,.05); }
.phone-asset-list-page__search { margin-bottom:16px; padding:20px 22px; }
.phone-asset-list-page__filters { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
.phone-asset-list-page__filters .el-input { width:210px; }.phone-asset-list-page__filters .el-input:nth-child(2){width:240px}.phone-asset-list-page__filters .el-input:nth-child(3){width:190px}.phone-asset-list-page__filters .el-select{width:160px}
/* 筛选框宽度按文案长度分配,别再写死等宽:手机号提示语最长("手机号前3位、后4位或完整号码"),
固定给足 250px 保证一次显示完整;ICCID 与实名人提示语短,改成弹性平分剩余空间,窗口变窄时优先压缩它们,
这样"重置"按钮不会因为总宽超出面板而被挤到第二行。 */
.phone-asset-list-page__filters .el-input{flex:0 1 auto;width:auto}
.phone-asset-list-page__filters .el-input:nth-child(1){width:250px;min-width:250px}
.phone-asset-list-page__filters .el-input:nth-child(2){width:220px;min-width:140px}
.phone-asset-list-page__filters .el-input:nth-child(3){width:180px;min-width:140px}
.phone-asset-list-page__filters .el-select{flex:0 0 168px;width:auto}
.phone-asset-list-page__filters .el-button{flex:0 0 auto}
.phone-asset-list-page__filters .el-input__wrapper,.phone-asset-list-page__filters .el-select__wrapper { min-height:38px; border-radius:6px; box-shadow:0 0 0 1px #e5e5e8 inset; }.phone-asset-list-page__filters .el-button{height:38px;border-radius:6px;color:#52525b;border-color:#e5e5e8}.phone-asset-list-page__filters .el-input__wrapper:hover,.phone-asset-list-page__filters .el-select__wrapper:hover{box-shadow:0 0 0 1px #d4d4d8 inset}
/* 手机号单元格内嵌 ICCID 触发按钮:ICCID 有 20 位,单独占一列会挤掉后面的字段,
收进手机号格里按需查看。图标用内联 SVG,避免为一个图标引入 @element-plus/icons-vue(它不在 package.json 依赖里)。 */
.phone-asset-list-page__phone{display:inline-flex;align-items:center;gap:7px;min-width:0}
.phone-asset-list-page__phone-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.phone-asset-list-page__iccid-btn{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:22px;height:22px;padding:0;border:0;border-radius:5px;background:transparent;color:#a1a1aa;cursor:pointer;transition:color .16s ease,background .16s ease}
.phone-asset-list-page__iccid-btn:hover{background:#f1f1f3;color:#52525b}
.phone-asset-list-page__iccid-btn svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.35;stroke-linecap:round}
.phone-asset-list-page__iccid-popper{padding:12px 14px!important}
.phone-asset-list-page__iccid-pop{display:flex;flex-direction:column;gap:5px}
.phone-asset-list-page__iccid-pop-label{color:#8b8b93;font-size:12px;letter-spacing:.02em}
/* ICCID 是长串数字,等宽字体便于逐位核对;allow-select 让用户能直接框选复制。 */
.phone-asset-list-page__iccid-pop-value{color:#18181b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.5;word-break:break-all;user-select:text}
.phone-asset-list-page__table { overflow:hidden; }.phone-asset-list-page__table-header{display:flex;align-items:center;gap:9px;min-height:62px;padding:0 22px;border-bottom:1px solid #e5e5e8}.phone-asset-list-page__table-header h3{margin:0;color:#18181b;font-size:15px;font-weight:650}.phone-asset-list-page__table-header span{color:#8b8b93;font-size:13px}
.phone-asset-list-page__grid .el-table__header th.el-table__cell{height:46px;background:#fafafa;color:#71717a;font-size:13px;font-weight:600}.phone-asset-list-page__grid .el-table__cell{height:56px;color:#3f3f46;font-size:14px}.phone-asset-list-page__grid .el-table__row:hover>td.el-table__cell{background:#fafafa}.phone-asset-list-page__status{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}.phone-asset-list-page__status-dot{width:7px;height:7px;border-radius:50%;background:#a1a1aa;box-shadow:0 0 0 3px rgba(161,161,170,.12)}.phone-asset-list-page__status-dot.正常使用{background:#2f9e68;box-shadow:0 0 0 3px rgba(47,158,104,.1)}.phone-asset-list-page__status-dot.闲置,.phone-asset-list-page__status-dot.停机{background:#d28b24;box-shadow:0 0 0 3px rgba(210,139,36,.11)}.phone-asset-list-page__actions{display:inline-flex;gap:14px}.phone-asset-list-page__actions .el-button{padding:0;color:#3f3f46;font-size:13px}.phone-asset-list-page__actions .el-button--danger{color:#e5484d}
/* 列表页统一分页器(AppPagination.js):容器高度恒定,空数据时按钮灰化但不塌陷,四个列表页共用。 */
......
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