Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
X
xyw_asset_console
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
DaiJiezhang
xyw_asset_console
Commits
86d3179e
Commit
86d3179e
authored
Jul 31, 2026
by
DaiJiezhang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement phone asset management modal
parent
c95e91c1
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
866 additions
and
55 deletions
+866
-55
PhoneAssetController.java
...om/xyw/console/asset/controller/PhoneAssetController.java
+10
-1
PhoneAssetSaveRequest.java
...java/com/xyw/console/asset/dto/PhoneAssetSaveRequest.java
+20
-0
PhoneAssetNotFoundException.java
.../console/asset/exception/PhoneAssetNotFoundException.java
+4
-0
PhoneAssetValidationException.java
...onsole/asset/exception/PhoneAssetValidationException.java
+12
-0
PhoneAssetService.java
...java/com/xyw/console/asset/service/PhoneAssetService.java
+34
-4
WebConfig.java
backend/src/main/java/com/xyw/console/config/WebConfig.java
+1
-1
PhoneAssetControllerTest.java
...yw/console/asset/controller/PhoneAssetControllerTest.java
+84
-0
package.json
frontend/package.json
+1
-1
PhoneAssetView.js
frontend/src/modules/phone/PhoneAssetView.js
+334
-20
phone-api-client.js
frontend/src/modules/phone/phone-api-client.js
+12
-15
app.css
frontend/src/styles/app.css
+242
-0
phone-asset.spec.js
frontend/tests/phone-asset.spec.js
+112
-13
No files found.
backend/src/main/java/com/xyw/console/asset/controller/PhoneAssetController.java
View file @
86d3179e
...
...
@@ -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
backend/src/main/java/com/xyw/console/asset/dto/PhoneAssetSaveRequest.java
0 → 100644
View file @
86d3179e
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
)
{
}
backend/src/main/java/com/xyw/console/asset/exception/PhoneAssetNotFoundException.java
0 → 100644
View file @
86d3179e
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
backend/src/main/java/com/xyw/console/asset/exception/PhoneAssetValidationException.java
0 → 100644
View file @
86d3179e
package
com
.
xyw
.
console
.
asset
.
exception
;
/**
* 代码作用(白话):表示手机号规范化后仍不符合保存规则。
* 关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。
* 关联逻辑(调用链/数据流):Service 校验失败 -> 异常处理 -> 400 响应 -> 前端保留弹窗并提示。
*/
public
class
PhoneAssetValidationException
extends
RuntimeException
{
public
PhoneAssetValidationException
(
String
message
)
{
super
(
message
);
}
}
backend/src/main/java/com/xyw/console/asset/service/PhoneAssetService.java
View file @
86d3179e
...
...
@@ -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
backend/src/main/java/com/xyw/console/config/WebConfig.java
View file @
86d3179e
...
...
@@ -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
(
"*"
);
}
...
...
backend/src/test/java/com/xyw/console/asset/controller/PhoneAssetControllerTest.java
0 → 100644
View file @
86d3179e
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
;
}
}
frontend/package.json
View file @
86d3179e
...
...
@@ -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"
...
...
frontend/src/modules/phone/PhoneAssetView.js
View file @
86d3179e
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
frontend/src/modules/phone/phone-api-client.js
View file @
86d3179e
/**
* 代码作用(白话):统一发送手机号资产
列表请求,避免页面自己处理返回格式
。
* 代码作用(白话):统一发送手机号资产
请求并把后端错误转换为页面可提示的文字
。
* 关联文件: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'
});
}
frontend/src/styles/app.css
View file @
86d3179e
...
...
@@ -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
:
1
fr
;
}
.sidebar
nav
{
grid-template-columns
:
repeat
(
2
,
1
fr
);
}
.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
,
1
fr
);
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
,
1
fr
);
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
frontend/tests/phone-asset.spec.js
View file @
86d3179e
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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment