Commit ec930cf8 by DaiJiezhang

feat: add wecom account list

parent 86d3179e
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- 前端已迁入 `frontend/`,开发入口为 Vite。 - 前端已迁入 `frontend/`,开发入口为 Vite。
- 旧 phone/wechat 后端接口与业务模块已移除。 - 旧 phone/wechat 后端接口与业务模块已移除。
- `#/reference/phone``#/reference/wechat` 是不可操作的旧界面参考页,不请求旧 API - `#/reference/phone` 是不可操作的旧界面参考页,不请求旧 API;`#/reference/wecom` 展示企微账号资产真实列表
- 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。 - 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。
## 前端开发 ## 前端开发
...@@ -15,7 +15,7 @@ npm install ...@@ -15,7 +15,7 @@ npm install
npm run dev npm run dev
``` ```
访问:`http://localhost:5173/#/reference/phone` 访问:`http://localhost:5173/asset/#/reference/wecom`
生产构建仍使用 `/assets/` 基础路径: 生产构建仍使用 `/assets/` 基础路径:
```powershell ```powershell
......
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供企微资产列表的只读 HTTP 入口,让前端无需直接访问数据库。 */
@RestController
@RequestMapping("/api/wecom-accounts")
public class WecomAccountController {
private final WecomAccountService service;
/**
* 代码作用(白话):接收企微资产查询服务,让 HTTP 请求能进入统一的分页查询逻辑。
* 关联文件:WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。
*/
public WecomAccountController(WecomAccountService service) {
this.service = service;
}
/**
* 代码作用(白话):接收浏览器的分页和筛选参数,返回统一 JSON 格式的企微资产列表。
* 关联文件:WecomAccountPageQuery.java、WecomAccountService.java、wecom-api-client.js。
* 关联逻辑(调用链/数据流):GET /api/wecom-accounts -> Service.page -> ApiResponse -> Vue 表格。
*/
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) {
return ApiResponse.success(service.page(query));
}
}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收企微资产列表的分页和筛选参数,避免 Controller 直接处理零散 URL 参数。 */
public record WecomAccountPageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
String wecomName,
String wecomAccount) {
/**
* 代码作用(白话):在调用方没有传页码时返回第 1 页,保证列表可以直接打开。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedPage -> MyBatis Page -> 列表响应。
*/
public int resolvedPage() {
return page == null ? 1 : page;
}
/**
* 代码作用(白话):在调用方没有传每页数量时使用 20 条,并由注解阻止一次查询过多记录。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedSize -> MyBatis Page -> 列表响应。
*/
public int resolvedSize() {
return size == null ? 20 : size;
}
}
package com.xyw.console.asset.dto;
import java.util.List;
/** 文件用途(白话):将企微资产列表、总条数和当前分页信息打包为统一的页面数据。 */
public record WecomAccountPageResponse(
List<WecomAccountResponse> records,
long total,
int page,
int size) {
}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义一条企微资产返回给页面的字段,包含关联 ID 及其可读名称,不暴露删除标记。 */
public record WecomAccountResponse(
Long id,
String wecomName,
String wecomAlias,
String wecomAccount,
Long companyProfileId,
String companyProfileName,
Long phoneAssetId,
String phoneNumber,
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long deviceId,
String deviceName,
Long operatorPersonId,
String operatorPersonName,
LocalDateTime createTime,
LocalDateTime updateTime) {
}
package com.xyw.console.asset.controller;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import com.xyw.console.asset.service.WecomAccountService;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class WecomAccountControllerTest {
/**
* 代码作用(白话):证明浏览器请求企微列表会得到统一成功响应、关联名称和不含删除标记的记录。
* 关联文件:WecomAccountController.java、WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):GET /api/wecom-accounts -> Controller -> Service -> Mapper -> JSON 表格数据。
*/
@Test
void returnsWecomAccountPageWithoutDeleteTime() throws Exception {
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller()).build();
mockMvc.perform(get("/api/wecom-accounts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data.page", is(1)))
.andExpect(jsonPath("$.data.size", is(20)))
.andExpect(jsonPath("$.data.records[0].companyProfileName", is("示例科技有限公司")))
.andExpect(jsonPath("$.data.records[0].phoneNumber", is("13812345678")))
.andExpect(jsonPath("$.data.records[0].deviceName", is("iPhone 15")))
.andExpect(jsonPath("$.data.records[0].operatorPersonName", is("王五")))
.andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist());
}
/**
* 代码作用(白话):构造使用真实 Service 的 Controller,避免只验证模拟返回值。
* 关联文件:WecomAccountController.java、WecomAccountService.java、各资产 Mapper。
* 关联逻辑(调用链/数据流):测试 HTTP 请求 -> Controller -> Service -> Mapper 模拟数据库结果。
*/
private WecomAccountController controller() {
WecomAccountMapper wecomMapper = org.mockito.Mockito.mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = org.mockito.Mockito.mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = org.mockito.Mockito.mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = org.mockito.Mockito.mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = org.mockito.Mockito.mock(CompanyPersonMapper.class);
Page<WecomAccountEntity> page = new Page<>(1, 20);
page.setRecords(List.of(wecomAccount()));
page.setTotal(1L);
org.mockito.Mockito.when(wecomMapper.selectPage(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(page);
org.mockito.Mockito.when(companyProfileMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(companyProfile()));
org.mockito.Mockito.when(phoneAssetMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(phoneAsset()));
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device()));
org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper));
}
/**
* 代码作用(白话):构造接口测试的企微主记录。
* 关联文件:WecomAccountEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):企微主记录 -> Service 关联名称填充 -> Controller JSON。
*/
private WecomAccountEntity wecomAccount() {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(1L);
entity.setWecomName("张三");
entity.setWecomAlias("销售一组");
entity.setWecomAccount("zhangsan");
entity.setCompanyProfileId(10L);
entity.setPhoneAssetId(20L);
entity.setRealNameOwner("张三");
entity.setRealNameOwnerStatus("已实名");
entity.setGender("男");
entity.setDeviceId(30L);
entity.setOperatorPersonId(40L);
entity.setCreateTime(LocalDateTime.of(2026, 7, 31, 10, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 7, 31, 11, 0));
entity.setDeleteTime(0L);
return entity;
}
/**
* 代码作用(白话):构造公司档案关联测试数据。
* 关联文件:CompanyProfileEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):公司 ID -> 公司名称 -> JSON 字段 companyProfileName。
*/
private CompanyProfileEntity companyProfile() {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L);
entity.setCompanyName("示例科技有限公司");
return entity;
}
/**
* 代码作用(白话):构造手机号资产关联测试数据。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号资产 ID -> 手机号 -> JSON 字段 phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(20L);
entity.setPhoneNumber("13812345678");
return entity;
}
/**
* 代码作用(白话):构造设备关联测试数据。
* 关联文件:AssetDeviceEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):设备 ID -> 设备名称 -> JSON 字段 deviceName。
*/
private AssetDeviceEntity device() {
AssetDeviceEntity entity = new AssetDeviceEntity();
entity.setId(30L);
entity.setDeviceName("iPhone 15");
return entity;
}
/**
* 代码作用(白话):构造经办人关联测试数据。
* 关联文件:CompanyPersonEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):人员 ID -> 人员名称 -> JSON 字段 operatorPersonName。
*/
private CompanyPersonEntity operatorPerson() {
CompanyPersonEntity entity = new CompanyPersonEntity();
entity.setId(40L);
entity.setPersonName("王五");
return entity;
}
}
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountResponse;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
class WecomAccountServiceTest {
/**
* 代码作用(白话):证明企微列表会把当前页关联 ID 转成可读名称,同时保留 ID 且不产生删除标记字段。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):分页请求 -> Service 查询企微及关联资产 -> Response -> 前端表格。
*/
@Test
void returnsReadableRelatedNamesForTheCurrentWecomAccountPage() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = mock(CompanyPersonMapper.class);
when(wecomMapper.selectPage(any(), any())).thenReturn(wecomPage());
when(companyProfileMapper.selectList(any())).thenReturn(List.of(companyProfile()));
when(phoneAssetMapper.selectList(any())).thenReturn(List.of(phoneAsset()));
when(assetDeviceMapper.selectList(any())).thenReturn(List.of(device()));
when(companyPersonMapper.selectList(any())).thenReturn(List.of(operatorPerson()));
WecomAccountPageResponse result = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null));
WecomAccountResponse record = result.records().get(0);
assertEquals(1L, record.id());
assertEquals("示例科技有限公司", record.companyProfileName());
assertEquals(10L, record.companyProfileId());
assertEquals("13812345678", record.phoneNumber());
assertEquals(20L, record.phoneAssetId());
assertEquals("iPhone 15", record.deviceName());
assertEquals(30L, record.deviceId());
assertEquals("王五", record.operatorPersonName());
assertEquals(40L, record.operatorPersonId());
assertEquals("张三", record.realNameOwner());
assertEquals(1, result.page());
assertEquals(20, result.size());
}
/**
* 代码作用(白话):证明关联 ID 没有对应资产时,企微记录仍能返回且名称为空。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):企微记录含关联 ID -> 批量查询无匹配 -> Response 保留 ID 与空名称。
*/
@Test
void keepsRelatedIdsWhenTheirNamesCannotBeResolved() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = mock(CompanyPersonMapper.class);
when(wecomMapper.selectPage(any(), any())).thenReturn(wecomPage());
when(companyProfileMapper.selectList(any())).thenReturn(List.of());
when(phoneAssetMapper.selectList(any())).thenReturn(List.of());
when(assetDeviceMapper.selectList(any())).thenReturn(List.of());
when(companyPersonMapper.selectList(any())).thenReturn(List.of());
WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId());
assertNull(record.companyProfileName());
assertEquals(20L, record.phoneAssetId());
assertNull(record.phoneNumber());
assertEquals(30L, record.deviceId());
assertNull(record.deviceName());
assertEquals(40L, record.operatorPersonId());
assertNull(record.operatorPersonName());
}
/**
* 代码作用(白话):构造一页正常企微资产记录,供关联名称转换场景使用。
* 关联文件:WecomAccountEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):测试记录 -> Mapper 分页结果 -> Service -> Response。
*/
private Page<WecomAccountEntity> wecomPage() {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(1L);
entity.setWecomName("张三");
entity.setWecomAlias("销售一组");
entity.setWecomAccount("zhangsan");
entity.setCompanyProfileId(10L);
entity.setPhoneAssetId(20L);
entity.setRealNameOwner("张三");
entity.setRealNameOwnerStatus("已实名");
entity.setGender("男");
entity.setDeviceId(30L);
entity.setOperatorPersonId(40L);
entity.setCreateTime(LocalDateTime.of(2026, 7, 31, 10, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 7, 31, 11, 0));
entity.setDeleteTime(0L);
Page<WecomAccountEntity> page = new Page<>(1, 20);
page.setRecords(List.of(entity));
page.setTotal(1L);
return page;
}
/**
* 代码作用(白话):构造公司档案名称,验证公司 ID 能转成页面可读名称。
* 关联文件:CompanyProfileEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):companyProfileId -> CompanyProfileMapper -> companyProfileName。
*/
private CompanyProfileEntity companyProfile() {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L);
entity.setCompanyName("示例科技有限公司");
return entity;
}
/**
* 代码作用(白话):构造手机号资产名称,验证手机号资产 ID 能转成手机号。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):phoneAssetId -> PhoneAssetMapper -> phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(20L);
entity.setPhoneNumber("13812345678");
return entity;
}
/**
* 代码作用(白话):构造设备名称,验证设备 ID 能转成设备名称。
* 关联文件:AssetDeviceEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):deviceId -> AssetDeviceMapper -> deviceName。
*/
private AssetDeviceEntity device() {
AssetDeviceEntity entity = new AssetDeviceEntity();
entity.setId(30L);
entity.setDeviceName("iPhone 15");
return entity;
}
/**
* 代码作用(白话):构造经办人名称,验证经办人 ID 能转成页面可读人员名称。
* 关联文件:CompanyPersonEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):operatorPersonId -> CompanyPersonMapper -> operatorPersonName。
*/
private CompanyPersonEntity operatorPerson() {
CompanyPersonEntity entity = new CompanyPersonEntity();
entity.setId(40L);
entity.setPersonName("王五");
return entity;
}
}
/** /** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */
* 代码作用(白话):提供新前端的稳定壳层和导航,只进入静态参考或重构中页面,不加载任何旧业务运行时。 export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` };
* 关联文件:frontend/src/router/index.js、frontend/src/reference/LegacyReferenceView.js、frontend/src/styles/app.css。 \ No newline at end of file
* 关联逻辑(调用链/数据流):导航点击 -> RouterLink -> Vue Router -> RouterView 渲染目标页面。
*/
export default {
template: `
<div class="app-shell">
<aside class="sidebar">
<p class="eyebrow">XYW ASSETS</p>
<h1>学有为资产后台</h1>
<nav aria-label="主导航">
<RouterLink to="/overview">总览</RouterLink>
<RouterLink to="/domain">域名资料</RouterLink>
<RouterLink to="/reference/wechat">企微资料(参考)</RouterLink>
<RouterLink to="/phone-assets">手机号资产</RouterLink>
<RouterLink to="/alerts">提醒中心</RouterLink>
</nav>
</aside>
<main class="content"><RouterView /></main>
</div>
`
};
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { listWecomAccounts } from './wecom-api-client.js';
/** 文件用途(白话):显示可筛选、可分页的企微资料,并把关联资源呈现成用户可读的名称和 ID。 */
export default {
/**
* 代码作用(白话):创建页面加载、筛选、分页状态,并把操作方法交给模板中的按钮和表格使用。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化 -> loadPage -> API -> Element Plus 表格。
*/
setup() {
const loading = ref(false);
const records = ref([]);
const total = ref(0);
const filters = reactive({ page: 1, size: 20, wecomName: '', wecomAccount: '' });
/**
* 代码作用(白话):按当前筛选条件读取一页企微资产,并同步更新表格记录和总条数。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):页面事件 -> listWecomAccounts -> GET API -> records/total -> 表格和分页器。
*/
async function loadPage() {
loading.value = true;
try {
const result = await listWecomAccounts(filters);
records.value = result.records;
total.value = result.total;
} catch (error) {
ElMessage.error(error.message);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):在用户提交筛选时回到第一页,防止旧页码导致看不到匹配结果。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):查询按钮 -> page=1 -> loadPage -> 新筛选结果。
*/
function submitSearch() {
filters.page = 1;
loadPage();
}
/**
* 代码作用(白话):清空企微名称和企微账号筛选,并恢复默认分页后重新查询。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):重置按钮 -> filters 默认值 -> loadPage -> 默认列表。
*/
function resetSearch() {
Object.assign(filters, { page: 1, size: 20, wecomName: '', wecomAccount: '' });
loadPage();
}
/**
* 代码作用(白话):接收分页器选中的页码并加载该页的企微资产。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage -> 后端分页结果。
*/
function changePage(page) {
filters.page = page;
loadPage();
}
/**
* 代码作用(白话):将关联资源的名称和 ID 组合成“名称(ID)”,关联缺失时保留 ID 并显示“—”。
* 关联文件:WecomAccountResponse.java、WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):接口关联字段 -> formatRelation -> 表格关联资源单元格。
*/
function formatRelation(name, id) {
if (id === null || id === undefined) {
return '—';
}
return `${name || '—'}(ID:${id})`;
}
onMounted(loadPage);
return { changePage, filters, formatRelation, loading, records, resetSearch, submitSearch, total };
},
template: `
<section class="wecom-account-page">
<header class="page-header">
<div><p class="eyebrow">WECOM ACCOUNTS</p><h2>企微资料</h2><p>查看企微账号资产、实名信息和关联资源。</p></div>
</header>
<article class="reference-card">
<el-form inline @submit.prevent="submitSearch">
<el-form-item label="企微名称"><el-input v-model="filters.wecomName" clearable /></el-form-item>
<el-form-item label="企微账号"><el-input v-model="filters.wecomAccount" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="submitSearch">查询</el-button><el-button @click="resetSearch">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="records">
<el-table-column prop="id" label="企微资产 ID" min-width="110" />
<el-table-column prop="wecomName" label="企微名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="wecomAlias" label="企微别名" min-width="120" show-overflow-tooltip />
<el-table-column prop="wecomAccount" label="企微账号" min-width="140" show-overflow-tooltip />
<el-table-column label="公司档案" min-width="220"><template #default="scope">{{ formatRelation(scope.row.companyProfileName, scope.row.companyProfileId) }}</template></el-table-column>
<el-table-column label="关联手机号资产" min-width="200"><template #default="scope">{{ formatRelation(scope.row.phoneNumber, scope.row.phoneAssetId) }}</template></el-table-column>
<el-table-column prop="realNameOwner" label="企微实名人" min-width="130" show-overflow-tooltip />
<el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="120" show-overflow-tooltip />
<el-table-column prop="gender" label="性别" min-width="90" />
<el-table-column label="关联设备" min-width="190"><template #default="scope">{{ formatRelation(scope.row.deviceName, scope.row.deviceId) }}</template></el-table-column>
<el-table-column label="经办人" min-width="170"><template #default="scope">{{ formatRelation(scope.row.operatorPersonName, scope.row.operatorPersonId) }}</template></el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" min-width="180" show-overflow-tooltip />
</el-table>
<el-pagination v-if="total" background layout="total, prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" />
</article>
</section>
`
};
\ No newline at end of file
/** 文件用途(白话):集中发送企微资产列表请求并统一解析后端的成功或失败响应。 */
/**
* 代码作用(白话):请求后端并从统一 JSON 响应中取出真正的列表数据,失败时抛出可显示的错误信息。
* 关联文件:WecomAccountView.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):列表页 -> request -> GET /api/wecom-accounts -> ApiResponse.data -> 表格数据。
*/
async function request(path) {
const response = await fetch(path);
const payload = await response.json();
if (!response.ok || payload.code !== 200) {
throw new Error(payload.message || '企微资产请求失败');
}
return payload.data;
}
/**
* 代码作用(白话):把页面的分页和筛选状态转换为 URL 参数,再读取对应的企微资产页。
* 关联文件:WecomAccountView.js、WecomAccountController.java、WecomAccountPageQuery.java。
* 关联逻辑(调用链/数据流):筛选条件 -> URLSearchParams -> GET 接口 -> records/total。
*/
export function listWecomAccounts(query) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params.set(key, value);
}
});
return request(`/api/wecom-accounts?${params.toString()}`);
}
\ No newline at end of file
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import LegacyReferenceView from '../reference/LegacyReferenceView.js'; import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js'; import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
import WecomAccountView from '../modules/wecom/WecomAccountView.js';
/** /**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。 * 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
...@@ -12,9 +13,9 @@ function createPlaceholderView(title) { ...@@ -12,9 +13,9 @@ function createPlaceholderView(title) {
} }
/** /**
* 代码作用(白话):定义清理旧 phone/wechat 模块后的新路由,只保留无接口依赖的参考页面。 * 代码作用(白话):定义资产后台 Hash 路由,将企微资料连接到真实列表,同时保留手机号静态参考页面。
* 关联文件:frontend/src/App.js、frontend/src/reference/LegacyReferenceView.js、frontend/tests/legacy-reference.spec.js。 * 关联文件:frontend/src/App.js、frontend/src/modules/wecom/WecomAccountView.js、frontend/tests/wecom-account.spec.js。
* 关联逻辑(调用链/数据流):Hash 地址 -> router -> LegacyReferenceView -> 静态示例数据与禁用操作按钮 * 关联逻辑(调用链/数据流):Hash 地址 -> router -> 真实企微列表或静态手机号参考页 -> 页面渲染
*/ */
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
...@@ -24,7 +25,7 @@ const router = createRouter({ ...@@ -24,7 +25,7 @@ const router = createRouter({
{ path: '/phone-assets', component: PhoneAssetView }, { path: '/phone-assets', component: PhoneAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') }, { path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') }, { path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wechat', component: LegacyReferenceView, props: { kind: 'wechat' } }, { path: '/reference/wecom', component: WecomAccountView },
{ path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } } { path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } }
] ]
}); });
......
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
const referencePages = [ const referencePages = [
{ path: '#/reference/phone', title: '手机号卡旧界面参考' }, { path: '#/reference/phone', title: '手机号卡旧界面参考' }
{ path: '#/reference/wechat', title: '企微资料旧界面参考' }
]; ];
for (const referencePage of referencePages) { for (const referencePage of referencePages) {
......
import { expect, test } from '@playwright/test';
/**
* 代码作用(白话):验证企微资料真实页面会请求新接口,并把关联名称与 ID、企微实名人完整展示出来。
* 关联文件:WecomAccountView.js、wecom-api-client.js、router/index.js。
* 关联逻辑(调用链/数据流):访问 Hash 路由 -> 拦截 API 响应 -> Vue 表格 -> 页面断言。
*/
test('shows readable related names without a delete-time column', async ({ page }) => {
/**
* 代码作用(白话):提供完整的企微列表接口响应,避免测试依赖本机数据库或后端服务状态。
* 关联文件:WecomAccountResponse.java、wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):浏览器 API 请求 -> route.fulfill -> 前端 records -> 表格单元格。
*/
await page.route('**/api/wecom-accounts**', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: {
records: [{
id: 1,
wecomName: '张三',
wecomAlias: '销售一组',
wecomAccount: 'zhangsan',
companyProfileId: 10,
companyProfileName: '示例科技有限公司',
phoneAssetId: 20,
phoneNumber: '13812345678',
realNameOwner: '张三',
realNameOwnerStatus: '已实名',
gender: '男',
deviceId: 30,
deviceName: 'iPhone 15',
operatorPersonId: 40,
operatorPersonName: '王五',
createTime: '2026-07-31T10:00:00',
updateTime: '2026-07-31T11:00:00'
}],
total: 1,
page: 1,
size: 20
}
})
});
});
await page.goto('/asset/#/reference/wecom');
await expect(page.getByRole('heading', { name: '企微资料' })).toBeVisible();
await expect(page.getByText('企微实名人')).toBeVisible();
await expect(page.getByText('示例科技有限公司(ID:10)')).toBeVisible();
await expect(page.getByText('13812345678(ID:20)')).toBeVisible();
await expect(page.getByText('iPhone 15(ID:30)')).toBeVisible();
await expect(page.getByText('王五(ID:40)')).toBeVisible();
await expect(page.getByText('删除标记')).toHaveCount(0);
});
/**
* 代码作用(白话):验证用户筛选和翻页时,页面会把新的名称、账号和页码参数提交给企微列表接口。
* 关联文件:WecomAccountView.js、wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):输入筛选 -> 查询按钮或下一页 -> URL 参数 -> 后端分页查询。
*/
test('sends current filters and page number to the wecom list API', async ({ page }) => {
const requestUrls = [];
/**
* 代码作用(白话):记录每次企微接口请求的 URL,并持续返回足够多的记录以显示分页器。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):前端请求 -> route 回调 -> requestUrls -> URL 参数断言。
*/
await page.route('**/api/wecom-accounts**', async (route) => {
requestUrls.push(route.request().url());
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: { records: [], total: 41, page: 1, size: 20 }
})
});
});
await page.goto('/asset/#/reference/wecom');
await page.locator('input').nth(0).fill('张三');
await page.locator('input').nth(1).fill('zhangsan');
await page.getByRole('button', { name: '查询' }).click();
await expect.poll(() => requestUrls.some((url) => url.includes('wecomName=%E5%BC%A0%E4%B8%89') && url.includes('wecomAccount=zhangsan'))).toBe(true);
await page.locator('.el-pagination .btn-next').click();
await expect.poll(() => requestUrls.some((url) => url.includes('page=2') && url.includes('wecomName=%E5%BC%A0%E4%B8%89'))).toBe(true);
});
\ No newline at end of file
schema: spec-driven
created: 2026-07-31
## Context
当前 `#/reference/wechat` 是不请求接口的静态旧界面参考,而 `as_wecom_account` 已经具备 `WecomAccountEntity``WecomAccountMapper`,尚未具备列表 Service、Controller、DTO 与真实前端页面。资产人员需要查看企微资料的全部业务字段,并将公司档案、手机号资产、设备、经办人等关联 ID 理解为可读名称。
约束:本次只读主数据源,不修改表结构、不新增编辑删除能力;`delete_time` 延续现有手机号资产规则,仅用于过滤;用户已确认它不应返回或展示。
## Goals / Non-Goals
**Goals:**
- 提供 `#/reference/wecom` 的可筛选、可分页企微资产列表。
- 提供 `GET /api/wecom-accounts`,返回企微字段、创建/更新时间、关联 ID 及关联名称。
-`realNameOwner` 以“企微实名人”展示。
- 对每页记录的关联 ID 批量解析公司名称、手机号、设备名称和经办人名称。
**Non-Goals:**
- 不显示或返回 `deleteTime`,不查询已删除记录。
- 不变更 `as_wecom_account` 或任何关联表的数据库结构与数据。
- 不实现新增、编辑、删除、导入、导出或权限模型。
- 不为不存在或已删除的关联记录自动修复数据。
## Decisions
### 新路由与旧路由
导航与真实页面使用 `#/reference/wecom`,并移除 `#/reference/wechat` 的静态企微参考页。采用直接替换而非保留别名,原因是需求明确要求将 wechat 换为 wecom;继续保留旧页会让用户进入过时且无真实数据的界面。
### 分页与筛选契约
接口使用现有手机号资产的 `page``size` 约定:默认第 1 页、每页 20 条、最大 100 条;支持精确筛选 `wecomName``wecomAccount`。查询显式限定 `delete_time = 0` 并按 `id` 倒序,保证与既有资产列表的正常记录定义一致。
### 关联 ID 与名称
响应同时返回原始 ID 和显示名称:`companyProfileId/companyProfileName``phoneAssetId/phoneNumber``deviceId/deviceName``operatorPersonId/operatorPersonName`。Service 收集当前页各类非空 ID 后,分别以 `IN` 条件批量查询关联表并构建 ID 到名称的映射。
批量查询(一次取得当前页所有关联记录)优于逐行查询,避免一页 20 条企微记录触发数十次数据库访问、导致列表加载变慢。关联 ID 未匹配记录时保留 ID,名称返回 `null`,前端显示“—”,不掩盖数据关系异常。
### 字段暴露与页面展示
响应和表格展示 `id`、企微名称、别名、账号、公司档案 ID/名称、手机号资产 ID/手机号、企微实名人、实名状态、性别、设备 ID/名称、经办人 ID/名称、创建时间和更新时间。`deleteTime` 只在 Service 查询条件中使用,既不写入响应 DTO,也不创建表格列。
## Risks / Trade-offs
- [历史数据的 `delete_time` 为 `NULL`] → 现有手机号列表以 `0` 表示正常,本期保持一致;上线前使用只读查询确认实际数据约定,必要时另行提出兼容变更。
- [关联记录缺失或已删除] → 保留原关联 ID,名称显示“—”,便于定位而不阻塞企微主记录显示。
- [个人信息展示范围扩大] → 当前需求明确展示企微实名人和手机号;后续如引入权限规则,再单独设计脱敏或字段授权。
- [路由书签失效] → 按确认的直接替换执行;发布说明标注新的 `#/reference/wecom` 地址。
## Migration Plan
1. 先添加后端 Controller 测试与前端路由/列表测试,定义新路由和只读响应。
2. 新增 DTO、Service、Controller 及前端列表页面,不改动既有实体、Mapper 和表。
3. 将导航和路由从 wechat 迁至 wecom,并移除企微静态参考测试。
4. 执行后端编译与测试、前端构建及 Playwright;失败时回退本变更的前端/后端代码和路由,不执行数据库回滚。
## Open Questions
- 无;字段展示、关联名称、路由替换和 `deleteTime` 不展示均已确认。
## Why
企微资料当前仅保留静态旧界面参考,无法查看主数据源中 `as_wecom_account` 的真实资产。资产人员还需要在同一列表中理解各关联 ID 代表的公司、手机号、设备和经办人,因此需要提供一个只读、可分页的企微资产列表。
## What Changes
- 新增 `#/reference/wecom` 企微资料列表路由,替代当前静态的 `#/reference/wechat` 参考入口。
- 新增只读分页接口 `GET /api/wecom-accounts`,按企微名称和企微账号筛选 `as_wecom_account` 的正常记录。
- 返回并展示企微账号的业务字段、创建/更新时间,以及关联 ID 对应的名称;保留关联 ID 便于追溯。
- 不返回、不展示 `deleteTime`;后端仅用它过滤已删除记录。
- **BREAKING**`#/reference/wechat` 不再提供旧静态企微参考页面,导航入口改为 `#/reference/wecom`
## Capabilities
### New Capabilities
- `wecom-account-api`: 提供企微账号资产的只读分页查询和关联名称解析。
- `wecom-account-workspace`: 提供企微资料列表页面、筛选、分页和完整字段展示。
### Modified Capabilities
- 无。
## Impact
- 前端:路由、导航、企微列表组件、接口客户端和 Playwright 测试。
- 后端:企微查询 DTO、Controller、Service、响应 DTO 和 Controller 测试;复用既有 `WecomAccountMapper`、公司档案、手机号、设备和人员 Mapper。
- API:新增 `GET /api/wecom-accounts`;不修改数据库结构,不写入或删除任何资产数据。
## ADDED Requirements
### Requirement: 企微资产分页查询
系统 MUST 提供 `GET /api/wecom-accounts`,接收可选的 `page``size``wecomName``wecomAccount` 参数,并以统一的 `code``message``data` 响应结构返回分页结果。
#### Scenario: 默认分页查询
- **WHEN** 客户端未提供分页或筛选参数而请求 `GET /api/wecom-accounts`
- **THEN** 系统返回第 1 页、每页 20 条、按 ID 倒序的正常企微资产记录
#### Scenario: 按企微名称和账号筛选
- **WHEN** 客户端提供非空的 `wecomName``wecomAccount`
- **THEN** 系统仅返回同时满足所提供精确筛选条件的正常企微资产记录
#### Scenario: 非法分页参数
- **WHEN** 客户端提供小于 1 的页码、页大小,或大于 100 的页大小
- **THEN** 系统拒绝请求并返回参数校验失败响应
### Requirement: 正常记录与删除标记隔离
系统 MUST 仅查询 `as_wecom_account.delete_time = 0` 的记录,且响应 `records` 和分页元数据 MUST 不包含 `deleteTime`
#### Scenario: 已删除记录不进入列表
- **WHEN** 数据库存在 `delete_time` 不等于 0 的企微账号记录
- **THEN** 该记录不计入接口的 `records``total`
### Requirement: 企微资产完整业务字段与关联名称
系统 MUST 在每条列表记录中返回 `id``wecomName``wecomAlias``wecomAccount``companyProfileId``phoneAssetId``realNameOwner``realNameOwnerStatus``gender``deviceId``operatorPersonId``createTime``updateTime`,并同时返回关联名称字段。
#### Scenario: 关联 ID 可读化
- **WHEN** 企微资产的公司档案、手机号资产、设备或经办人 ID 可以关联到正常的资产记录
- **THEN** 响应分别包含 `companyProfileName``phoneNumber``deviceName``operatorPersonName`,同时保留原始 ID
#### Scenario: 关联记录缺失
- **WHEN** 企微资产保存了关联 ID 但对应资产记录不存在或无法读取
- **THEN** 响应保留原始 ID,并将对应名称字段返回为 `null`
## ADDED Requirements
### Requirement: 企微资料真实列表路由
系统 MUST 在 `#/reference/wecom` 提供企微资料真实列表页面,并将导航入口指向该路由。
#### Scenario: 打开企微资料页面
- **WHEN** 用户访问 `#/reference/wecom`
- **THEN** 页面请求 `GET /api/wecom-accounts` 并展示返回的企微资产列表
#### Scenario: 旧企微参考路由替换
- **WHEN** 用户通过页面导航进入企微资料
- **THEN** 系统导航至 `#/reference/wecom`,且不展示旧的静态 wechat 参考页面
### Requirement: 企微资产完整字段展示
系统 MUST 在企微资料列表中展示企微资产 ID、企微名称、别名、账号、企微实名人、实名状态、性别、创建时间、更新时间,以及每个关联资源的“名称(ID)”。
#### Scenario: 关联名称与 ID 同时展示
- **WHEN** 列表记录包含公司档案、手机号资产、设备或经办人关联
- **THEN** 页面分别展示公司名称、手机号、设备名称、人员名称及对应 ID
#### Scenario: 关联名称不可用
- **WHEN** 接口返回关联 ID 但关联名称为 `null`
- **THEN** 页面保留该 ID 并以“—”展示名称位置
#### Scenario: 删除标记不展示
- **WHEN** 用户查看企微资料列表
- **THEN** 页面不包含 `deleteTime` 或“删除标记”列
### Requirement: 企微资产筛选与分页
系统 MUST 允许用户以企微名称、企微账号筛选,并使用后端返回的 `total``page``size` 呈现分页。
#### Scenario: 提交筛选条件
- **WHEN** 用户输入企微名称或企微账号并点击查询
- **THEN** 页面以第 1 页请求匹配记录并刷新表格和总数
#### Scenario: 切换页码
- **WHEN** 用户选择另一页
- **THEN** 页面以当前筛选条件和目标页码重新请求列表
## 1. 后端查询契约
- [x] 1.1 新增 `WecomAccountPageQuery.java`(文件用途:接收分页与筛选参数),为 `resolvedPage``resolvedSize` 写新手注释:代码作用(白话)、关联文件、关联逻辑;校验页码与页大小。
- [x] 1.2 新增 `WecomAccountResponse.java`(文件用途:限定企微列表单行返回字段),包含企微业务字段、创建/更新时间、关联 ID 与关联名称,不包含 `deleteTime`
- [x] 1.3 新增 `WecomAccountPageResponse.java`(文件用途:承载列表、总数、页码、页大小),复用当前手机号资产分页的返回结构。
## 2. 后端只读列表实现
- [x] 2.1 新增 `WecomAccountService.java`(文件用途:读取企微资产并解析关联名称),注入既有企微、公司档案、手机号、设备和人员 Mapper。
- [x] 2.2 在 `WecomAccountService` 实现 `page`(文件用途:按筛选、分页和 `delete_time = 0` 查询企微账号)与 `toResponse`(文件用途:将实体及关联名称转换为接口行);为构造方法和两个方法写完整新手注释:代码作用(白话)、关联文件、关联逻辑(调用链/数据流)。
- [x] 2.3 在 `WecomAccountService` 实现批量关联查询逻辑(文件用途:将当前页 ID 转换为名称),为每个辅助方法写完整新手注释;缺失名称返回 `null`,原 ID 保留。
- [x] 2.4 新增 `WecomAccountController.java`(文件用途:提供浏览器查询入口),实现 `GET /api/wecom-accounts`;为构造方法与 `page` 写完整新手注释:代码作用(白话)、关联文件、关联逻辑。
## 3. 前端企微资料页面
- [x] 3.1 新增 `frontend/src/modules/wecom/wecom-api-client.js`(文件用途:封装企微分页请求);实现 `request``listWecomAccounts`,并为每个方法写完整新手注释:代码作用(白话)、关联文件、关联逻辑。
- [x] 3.2 新增 `frontend/src/modules/wecom/WecomAccountView.js`(文件用途:渲染企微资料真实列表);实现 `setup``loadPage``submitSearch``resetSearch``changePage`,并为每个方法及业务回调写完整新手注释。
- [x] 3.3 在 `WecomAccountView.js` 展示企微字段、创建/更新时间,以及“名称(ID)”的关联字段;将 `realNameOwner` 标注为“企微实名人”,名称为空时显示“—”,不创建 `deleteTime` 列。
- [x] 3.4 修改 `frontend/src/router/index.js`(文件用途:管理 Hash 路由)并为新增或修改的方法写完整新手注释;用 `#/reference/wecom` 映射真实列表,移除静态 wechat 路由。
- [x] 3.5 修改 `frontend/src/App.js`(文件用途:提供全局导航)将企微导航入口更新为 `#/reference/wecom` 和真实页面文案;若新增方法或回调,写完整新手注释。
## 4. 自动化验证与文档
- [x] 4.1 新增 `backend/src/test/java/com/xyw/console/asset/controller/WecomAccountControllerTest.java`(文件用途:验证企微分页接口);覆盖默认分页、筛选、删除记录过滤、关联名称和 `deleteTime` 不返回;为测试方法及业务回调写完整新手注释。
- [x] 4.2 新增 `frontend/tests/wecom-account.spec.js`(文件用途:验证真实企微列表路由);覆盖接口请求、筛选参数、字段/关联名称展示、`deleteTime` 不展示和分页;为测试回调写完整新手注释。
- [x] 4.3 修改 `frontend/tests/legacy-reference.spec.js`(文件用途:验证仍保留的静态参考页)仅保留手机号参考页断言;为修改后的测试回调补齐完整新手注释。
- [x] 4.4 修改 `README.md`(文件用途:说明当前重构状态与本地入口)将企微页说明更新为真实列表和新的访问地址。
- [x] 4.5 执行 `mvn -q -DskipTests compile`、企微 Controller 测试、`npm run build` 与 Playwright;记录失败原因并先分析再修复。
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