Commit c95e91c1 by DaiJiezhang

feat: rebuild asset console foundation

parent 06e7d9f9
# 学有为资产后台
## 本地开发
## 当前重构状态
本项目的前端联调入口为 Vite;请使用两个终端分别启动后端与前端。
- 前端已迁入 `frontend/`,开发入口为 Vite。
- 旧 phone/wechat 后端接口与业务模块已移除。
- `#/reference/phone``#/reference/wechat` 是不可操作的旧界面参考页,不请求旧 API。
- 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。
```powershell
# 终端 1:启动本地 Spring Boot 后端(端口 8888)
.\start-backend-jdk17.bat
## 前端开发
# 终端 2:安装依赖并启动 Vite(端口 5173)
npm ci
```powershell
cd frontend
npm install
npm run dev
```
开发访问地址:`http://localhost:5173/assets/#/phone-card`
支持的 Hash 路由:`overview``domain``wechat``phone-card``alerts`。页面发出的 `/api/...` 请求由 Vite 转发到本机后端 `http://localhost:8888`
## 构建与预览
访问:`http://localhost:5173/#/reference/phone`
生产构建仍使用 `/assets/` 基础路径:
```powershell
cd frontend
npm run build
npm run preview
npm run test:e2e
```
构建产物位于 `dist/`。预览地址固定为 `http://localhost:4173/assets/#/phone-card`;该预览只用于本机检查,不代表生产部署。
## 后端编译
## 端口与排障
- `5173`:Vite 开发服务。
- `4173`:Vite 构建预览。
- `8888`:本地 Spring Boot 后端。
- 端口已启用严格模式:若被占用,服务会直接启动失败。请先释放占用端口,再重试,不要改用其他端口。
- `start.bat``start-frontend.bat` 保留以兼容既有脚本,但不再用于前端联调;旧 Python `8000` 入口无法代理相对 `/api` 请求。
## 验收测试数据
```powershell
cd backend
mvn -q -DskipTests compile
```
本地受控写入验证只允许创建名称或备注以 `VITE-SMOKE-` 开头的手机号卡。请记录创建接口返回的 ID,并在验证新增、编辑和列表刷新后,仅使用同一 ID 删除该记录。
\ No newline at end of file
运行 Maven 前需将 `JAVA_HOME` 配置为可用的 JDK 17 路径。
\ No newline at end of file
This diff is collapsed. Click to expand it.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--
Resource base: resolve assets entry requests against the repository root so existing styles, images, scripts, and dependencies remain available.
Related files: F:/Project/xyw_console/assets/index.html and F:/Project/xyw_console/start-frontend.bat.
Flow: /assets/#/page -> base resolution -> root static resources -> Vue Router page render.
-->
<base href="../" />
<title>学有为资产管理台</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f766e'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='26' font-family='Arial' fill='white'%3EXY%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="node_modules/element-plus/dist/index.css" />
<link rel="stylesheet" href="styles-v2.css?v=20260720-fix-list" />
</head>
<body>
<div class="workspace" id="appShell">
<aside class="shell-nav" id="sidebar">
<div class="brand-panel">
<div class="brand-mark">
<img class="brand-logo" src="img/学有为logo.jpg" alt="学有为 LOGO" />
</div>
<div class="brand-copy">
<h1>学有为数据资产后台</h1>
</div>
</div>
<div class="nav-main">
<nav class="primary-nav" aria-label="主导航">
<button class="nav-link" type="button" data-module="overview">
<span class="nav-glyph"><img class="nav-icon" src="img/账户总览.png" alt="总览" /></span>
<span class="nav-label">总览</span>
</button>
<button class="nav-link" type="button" data-module="domain">
<span class="nav-glyph"><img class="nav-icon" src="img/域名申请.png" alt="域名资料" /></span>
<span class="nav-label">域名资料</span>
</button>
<button class="nav-link" type="button" data-module="wechat">
<span class="nav-glyph"><img class="nav-icon" src="img/企微_企微.png" alt="企微资料" /></span>
<span class="nav-label">企微资料</span>
</button>
<button class="nav-link" type="button" data-module="phone">
<span class="nav-glyph"><img class="nav-icon" src="img/电话号码 (1).png" alt="手机号卡" /></span>
<span class="nav-label">手机号卡</span>
</button>
<button class="nav-link" type="button" data-module="alerts">
<span class="nav-glyph"><img class="nav-icon" src="img/提醒中心.png" alt="提醒中心" /></span>
<span class="nav-label">提醒中心</span>
</button>
</nav>
</div>
<div class="nav-bottom">
<div class="nav-toggle-wrap">
<button class="nav-toggle-button" id="sidebarToggle" type="button" aria-label="收起侧边导航" aria-expanded="true">
<span class="nav-toggle-glyph" aria-hidden="true">
<span></span>
<span></span>
</span>
<span class="nav-toggle-label">收起</span>
</button>
</div>
<div class="nav-footer">
<div class="nav-user-avatar"></div>
<div class="nav-user-copy">
<p class="nav-user-name">管理员</p>
<p class="nav-user-role">超级管理员</p>
</div>
<button class="nav-settings" type="button" aria-label="设置"></button>
</div>
</div>
</aside>
<main class="shell-main" id="vue-router-app">
<router-view></router-view>
</main>
</div>
<div id="addDomainDialogRoot"></div>
<div id="wechatDialogRoot"></div>
<div id="wechatDetailDrawerRoot"></div>
<div id="phoneDialogRoot"></div>
<div id="phoneDetailDrawerRoot"></div>
<script src="node_modules/vue/dist/vue.global.prod.js"></script>
<script src="node_modules/vue-router/dist/vue-router.global.prod.js"></script>
<script src="node_modules/element-plus/dist/index.full.min.js"></script>
<script src="src/modules/domain/domain-add-dialog.js"></script>
<script src="src/modules/domain/domain-list-runtime.js"></script>
<script src="src/modules/overview/overview-list-runtime.js"></script>
<script src="src/modules/wechat/wechat-add-dialog.js"></script>
<script src="src/modules/wechat/wechat-detail-drawer.js"></script>
<script src="src/modules/wechat/wechat-list-runtime.js"></script>
<script src="src/modules/phone/phone-add-dialog.js"></script>
<script src="src/modules/phone/phone-detail-drawer.js"></script>
<script src="src/modules/phone/phone-image-cell.js?v=20260707"></script>
<script src="src/modules/phone/phone-list-runtime.js?v=20260707"></script>
<script src="src/modules/shared/record-adapters.js"></script>
<script src="src/modules/shared/wechat-api-client.js"></script>
<script src="src/modules/shared/phone-api-client.js"></script>
<script src="src/router/index.js"></script>
<script src="app-v2.js"></script>
</body>
</html>
......@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.xyw.console.wechat.mapper")
@MapperScan("com.xyw.console.asset.mapper")
public class XywConsoleBackendApplication {
/**
......
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
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;}
/** 代码作用(白话):接收浏览器分页查询并返回统一 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));}
}
\ No newline at end of file
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
public record PhoneAssetPageQuery(@Min(1) Integer page, @Min(1) @Max(100) Integer size, String phoneNumber, String iccid) {
/** 代码作用(白话):在调用方未传分页参数时提供安全默认值。关联文件:PhoneAssetController.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认值 -> Service Page。 */
public int resolvedPage(){ return page == null ? 1 : page; }
/** 代码作用(白话):限制每页返回数量,防止一次取走过多数据。关联文件:PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认值 -> Mapper limit。 */
public int resolvedSize(){ return size == null ? 20 : size; }
}
\ No newline at end of file
package com.xyw.console.asset.dto;
import java.util.List;
public record PhoneAssetPageResponse(List<PhoneAssetResponse> records,long total,int page,int size) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
public record PhoneAssetResponse(Long id,String phoneNumber,String cardType,String iccid,String realNameOwner,String managementType,String disposalStatus,Long deviceId,LocalDateTime relationSyncedAt) {}
\ No newline at end of file
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 代码作用(白话):统一保存所有资产表共有的主键、创建/更新时间和软删除时间,避免 11 个实体重复且漏写同一套字段。
* 关联文件:asset/entity/*Entity.java、asset/mapper/*Mapper.java。
* 关联逻辑(调用链/数据流):Mapper CRUD -> AssetBaseEntity 通用字段 -> as_* 表的 id/create_time/update_time/delete_time 列。
*/
@Data
public class AssetBaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_time")
private LocalDateTime updateTime;
@TableField("delete_time")
private Long deleteTime;
}
\ No newline at end of file
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_asset_device 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/AssetDeviceMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> AssetDeviceMapper -> AssetDeviceEntity -> as_asset_device。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_asset_device")
public class AssetDeviceEntity extends AssetBaseEntity {
private String deviceName;
private String imageAttachment1;
private String imageAttachment2;
private Long userPersonId;
private String userUsageStatus;
private String assetRelationStatus;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_company_person 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/CompanyPersonMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> CompanyPersonMapper -> CompanyPersonEntity -> as_company_person。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_company_person")
public class CompanyPersonEntity extends AssetBaseEntity {
private Long companyProfileId;
private String personName;
private String employmentStatus;
private LocalDateTime resignedAt;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_company_profile 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/CompanyProfileMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> CompanyProfileMapper -> CompanyProfileEntity -> as_company_profile。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_company_profile")
public class CompanyProfileEntity extends AssetBaseEntity {
private String companyName;
private String shortName;
private String unifiedSocialCreditCode;
private String address;
private String contactName;
private String contactValue;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_domain_account 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/DomainAccountMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> DomainAccountMapper -> DomainAccountEntity -> as_domain_account。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_domain_account")
public class DomainAccountEntity extends AssetBaseEntity {
private String accountIdentifier;
private Long phoneAssetId;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_domain_asset 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/DomainAssetMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> DomainAssetMapper -> DomainAssetEntity -> as_domain_asset。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_domain_asset")
public class DomainAssetEntity extends AssetBaseEntity {
private String domainName;
private Long domainAccountId;
private Long companyProfileId;
private LocalDateTime expiresAt;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_douyin_account 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/DouyinAccountMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> DouyinAccountMapper -> DouyinAccountEntity -> as_douyin_account。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_douyin_account")
public class DouyinAccountEntity extends AssetBaseEntity {
private String douyinId;
private String realNameOwner;
private Long companyProfileId;
private Long phoneAssetId;
private Long deviceId;
private Long operatorPersonId;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_merchant 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/MerchantMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> MerchantMapper -> MerchantEntity -> as_merchant。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_merchant")
public class MerchantEntity extends AssetBaseEntity {
private String merchantNumber;
private Long companyProfileId;
private Long phoneAssetId;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_phone_asset 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/PhoneAssetMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> PhoneAssetMapper -> PhoneAssetEntity -> as_phone_asset。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_phone_asset")
public class PhoneAssetEntity extends AssetBaseEntity {
private String phoneNumber;
private String cardType;
private String iccid;
private String realNameOwner;
private String managementType;
private String disposalStatus;
private Long deviceId;
private String linkedWecomAccounts;
private String linkedWechatAccounts;
private String linkedDouyinAccounts;
private String linkedDomainAccounts;
private String linkedMerchants;
private LocalDateTime relationSyncedAt;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_system_user 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/SystemUserMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> SystemUserMapper -> SystemUserEntity -> as_system_user。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_system_user")
public class SystemUserEntity extends AssetBaseEntity {
private String username;
private String passwordHash;
private String roleCode;
private String status;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_wechat_account 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/WechatAccountMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> WechatAccountMapper -> WechatAccountEntity -> as_wechat_account。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_wechat_account")
public class WechatAccountEntity extends AssetBaseEntity {
private String wechatId;
private String realNameOwner;
private Long phoneAssetId;
private Long deviceId;
private Long operatorPersonId;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 代码作用(白话):把数据库表 as_wecom_account 的一行资产数据映射成 Java 对象,供后续 Mapper 和 Service 使用。
* 关联文件:asset/mapper/WecomAccountMapper.java、openspec/changes/rebuild-asset-application-foundation/design.md。
* 关联逻辑(调用链/数据流):后续 Service -> WecomAccountMapper -> WecomAccountEntity -> as_wecom_account。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("as_wecom_account")
public class WecomAccountEntity extends AssetBaseEntity {
private String wecomName;
private String wecomAlias;
private Long companyProfileId;
private String wecomAccount;
private Long phoneAssetId;
private String realNameOwner;
private String realNameOwnerStatus;
private String gender;
private Long deviceId;
private Long operatorPersonId;
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.AssetDeviceEntity;
/**
* 代码作用(白话):提供 as_asset_device 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/AssetDeviceEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> AssetDeviceMapper -> MyBatis-Plus BaseMapper -> as_asset_device 表。
*/
public interface AssetDeviceMapper extends BaseMapper<AssetDeviceEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.CompanyPersonEntity;
/**
* 代码作用(白话):提供 as_company_person 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/CompanyPersonEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> CompanyPersonMapper -> MyBatis-Plus BaseMapper -> as_company_person 表。
*/
public interface CompanyPersonMapper extends BaseMapper<CompanyPersonEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.CompanyProfileEntity;
/**
* 代码作用(白话):提供 as_company_profile 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/CompanyProfileEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> CompanyProfileMapper -> MyBatis-Plus BaseMapper -> as_company_profile 表。
*/
public interface CompanyProfileMapper extends BaseMapper<CompanyProfileEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.DomainAccountEntity;
/**
* 代码作用(白话):提供 as_domain_account 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/DomainAccountEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> DomainAccountMapper -> MyBatis-Plus BaseMapper -> as_domain_account 表。
*/
public interface DomainAccountMapper extends BaseMapper<DomainAccountEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.DomainAssetEntity;
/**
* 代码作用(白话):提供 as_domain_asset 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/DomainAssetEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> DomainAssetMapper -> MyBatis-Plus BaseMapper -> as_domain_asset 表。
*/
public interface DomainAssetMapper extends BaseMapper<DomainAssetEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.DouyinAccountEntity;
/**
* 代码作用(白话):提供 as_douyin_account 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/DouyinAccountEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> DouyinAccountMapper -> MyBatis-Plus BaseMapper -> as_douyin_account 表。
*/
public interface DouyinAccountMapper extends BaseMapper<DouyinAccountEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.MerchantEntity;
/**
* 代码作用(白话):提供 as_merchant 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/MerchantEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> MerchantMapper -> MyBatis-Plus BaseMapper -> as_merchant 表。
*/
public interface MerchantMapper extends BaseMapper<MerchantEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.PhoneAssetEntity;
/**
* 代码作用(白话):提供 as_phone_asset 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/PhoneAssetEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> PhoneAssetMapper -> MyBatis-Plus BaseMapper -> as_phone_asset 表。
*/
public interface PhoneAssetMapper extends BaseMapper<PhoneAssetEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.SystemUserEntity;
/**
* 代码作用(白话):提供 as_system_user 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/SystemUserEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> SystemUserMapper -> MyBatis-Plus BaseMapper -> as_system_user 表。
*/
public interface SystemUserMapper extends BaseMapper<SystemUserEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.WechatAccountEntity;
/**
* 代码作用(白话):提供 as_wechat_account 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/WechatAccountEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> WechatAccountMapper -> MyBatis-Plus BaseMapper -> as_wechat_account 表。
*/
public interface WechatAccountMapper extends BaseMapper<WechatAccountEntity> {
}
package com.xyw.console.asset.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.asset.entity.WecomAccountEntity;
/**
* 代码作用(白话):提供 as_wecom_account 的基础新增、查询、更新和删除数据库入口,不包含业务规则。
* 关联文件:asset/entity/WecomAccountEntity.java、后续 asset Service。
* 关联逻辑(调用链/数据流):后续 Service -> WecomAccountMapper -> MyBatis-Plus BaseMapper -> as_wecom_account 表。
*/
public interface WecomAccountMapper extends BaseMapper<WecomAccountEntity> {
}
package com.xyw.console.asset.service;
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.mapper.PhoneAssetMapper;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class PhoneAssetService {
private final PhoneAssetMapper mapper;
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()); }
}
\ No newline at end of file
package com.xyw.console.config;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.annotation.DbType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
/** 代码作用(白话):启用数据库分页,让列表不会一次读取全部手机号。关联文件:PhoneAssetService.java。关联逻辑(调用链/数据流):Controller -> Service Page -> Mapper SQL。 */
@Bean public MybatisPlusInterceptor paginationInterceptor() { MybatisPlusInterceptor i=new MybatisPlusInterceptor(); i.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return i; }
}
\ No newline at end of file
package com.xyw.console.phone.config;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.apache.ibatis.logging.stdout.StdOutImpl;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan(basePackages = "com.xyw.console.phone.mapper", sqlSessionTemplateRef = "phoneSqlSessionTemplate")
public class PhoneMybatisConfig {
/**
* 代码作用(白话):读取手机号模块独立数据库的连接配置,和主库配置分开,避免 phone 表误连到别的库。
* 关联文件:F:/Project/xyw_console/backend/src/main/resources/application.yml。
* 关联逻辑(调用链/消息链/数据流):application.yml -> app.datasource.phone -> phoneDataSourceProperties() -> 后续数据源初始化。
*/
@Bean
@ConfigurationProperties(prefix = "app.datasource.phone")
public DataSourceProperties phoneDataSourceProperties() {
return new DataSourceProperties();
}
/**
* 代码作用(白话):按 phone 独立配置创建 MySQL 连接池,让 wx_phone 走 xyw_data_test 库而不影响主库。
* 关联文件:F:/Project/xyw_console/backend/src/main/resources/application.yml、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/mapper/WxPhoneMapper.java。
* 关联逻辑(调用链/消息链/数据流):phoneDataSourceProperties() -> phoneDataSource() -> phoneSqlSessionFactory() -> WxPhoneMapper。
*/
@Bean(name = "phoneDataSource")
public DataSource phoneDataSource(@Qualifier("phoneDataSourceProperties") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
/**
* 代码作用(白话):给手机号模块单独建 MyBatis-Plus 会话工厂,让 phone Mapper 只连自己的数据源。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/mapper/WxPhoneMapper.java、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/entity/WxPhoneEntity.java。
* 关联逻辑(调用链/消息链/数据流):phoneDataSource() -> phoneSqlSessionFactory() -> MyBatis-Plus BaseMapper -> CRUD SQL。
*/
@Bean(name = "phoneSqlSessionFactory")
public SqlSessionFactory phoneSqlSessionFactory(@Qualifier("phoneDataSource") DataSource phoneDataSource)
throws Exception {
MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
factoryBean.setDataSource(phoneDataSource);
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.setLogImpl(StdOutImpl.class);
factoryBean.setConfiguration(configuration);
factoryBean.setTypeAliasesPackage("com.xyw.console.phone.entity");
SqlSessionFactory sqlSessionFactory = factoryBean.getObject();
if (sqlSessionFactory == null) {
throw new IllegalStateException("手机号数据源 SqlSessionFactory 初始化失败");
}
return sqlSessionFactory;
}
/**
* 代码作用(白话):把手机号模块的 SqlSessionFactory 包成模板对象,供 @MapperScan 指向 phone mapper 使用。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/mapper/WxPhoneMapper.java。
* 关联逻辑(调用链/消息链/数据流):phoneSqlSessionFactory() -> phoneSqlSessionTemplate() -> WxPhoneMapper CRUD 执行。
*/
@Bean(name = "phoneSqlSessionTemplate")
public SqlSessionTemplate phoneSqlSessionTemplate(@Qualifier("phoneSqlSessionFactory") SqlSessionFactory phoneSqlSessionFactory) {
return new SqlSessionTemplate(phoneSqlSessionFactory);
}
@Bean
public org.springframework.boot.ApplicationRunner phoneDatabaseInitializer(@Qualifier("phoneDataSource") DataSource phoneDataSource) {
return args -> {
try (java.sql.Connection conn = phoneDataSource.getConnection();
java.sql.Statement stmt = conn.createStatement()) {
stmt.execute("ALTER TABLE wx_phone MODIFY COLUMN image_attachment_1 LONGTEXT");
stmt.execute("ALTER TABLE wx_phone MODIFY COLUMN image_attachment_2 LONGTEXT");
} catch (Exception e) {
e.printStackTrace();
}
};
}
}
package com.xyw.console.phone.controller;
import com.xyw.console.common.ApiResponse;
import com.xyw.console.phone.dto.WxPhoneSaveRequest;
import com.xyw.console.phone.entity.WxPhoneEntity;
import com.xyw.console.phone.service.WxPhoneService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/wx-phones")
@RequiredArgsConstructor
public class WxPhoneController {
private final WxPhoneService wxPhoneService;
/**
* 代码作用(白话):返回手机号卡列表,给前端列表页和筛选页直接用。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/service/WxPhoneService.java、F:/Project/xyw_console/src/modules/shared/phone-api-client.js。
* 关联逻辑(调用链/消息链/数据流):GET /api/wx-phones -> listRecords() -> ApiResponse.success(data) -> 前端列表渲染。
*/
@GetMapping
public ResponseEntity<ApiResponse<List<WxPhoneEntity>>> listRecords() {
return ResponseEntity.ok(ApiResponse.success(wxPhoneService.listRecords()));
}
/**
* 代码作用(白话):按 id 读取单条手机号卡,给详情抽屉和编辑弹窗用。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/service/WxPhoneService.java、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js。
* 关联逻辑(调用链/消息链/数据流):GET /api/wx-phones/{id} -> getRecordById() -> ApiResponse.success(data) -> 详情/编辑打开。
*/
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<WxPhoneEntity>> getRecord(@PathVariable Long id) {
WxPhoneEntity entity = wxPhoneService.getRecordById(id);
if (entity == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.<WxPhoneEntity>error(404, "未找到对应手机号卡"));
}
return ResponseEntity.ok(ApiResponse.success(entity));
}
/**
* 代码作用(白话):校验并新增手机号卡,新增成功后返回最新入库结果。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js。
* 关联逻辑(调用链/消息链/数据流):POST /api/wx-phones -> validateSaveRequest() -> createRecord() -> 列表刷新。
*/
@PostMapping
public ResponseEntity<ApiResponse<WxPhoneEntity>> createRecord(@RequestBody WxPhoneSaveRequest request) {
String validationMessage = validateSaveRequest(request);
if (validationMessage != null) {
return ResponseEntity.badRequest().body(ApiResponse.<WxPhoneEntity>error(400, validationMessage));
}
WxPhoneEntity entity = wxPhoneService.createRecord(request);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success("新增成功", entity));
}
/**
* 代码作用(白话):校验并编辑指定手机号卡,编辑成功后返回最新数据库记录。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js。
* 关联逻辑(调用链/消息链/数据流):PUT /api/wx-phones/{id} -> validateSaveRequest() -> updateRecord() -> 列表刷新。
*/
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<WxPhoneEntity>> updateRecord(@PathVariable Long id, @RequestBody WxPhoneSaveRequest request) {
String validationMessage = validateSaveRequest(request);
if (validationMessage != null) {
return ResponseEntity.badRequest().body(ApiResponse.<WxPhoneEntity>error(400, validationMessage));
}
WxPhoneEntity entity = wxPhoneService.updateRecord(id, request);
if (entity == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.<WxPhoneEntity>error(404, "未找到对应手机号卡"));
}
return ResponseEntity.ok(ApiResponse.success("编辑成功", entity));
}
/**
* 代码作用(白话):删除指定手机号卡,删除成功后给前端一个明确结果。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/service/WxPhoneService.java、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js。
* 关联逻辑(调用链/消息链/数据流):DELETE /api/wx-phones/{id} -> deleteRecord() -> ApiResponse.success() -> 列表重新加载。
*/
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Boolean>> deleteRecord(@PathVariable Long id) {
boolean deleted = wxPhoneService.deleteRecord(id);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.<Boolean>error(404, "未找到对应手机号卡"));
}
return ResponseEntity.ok(ApiResponse.success("删除成功", Boolean.TRUE));
}
/**
* 代码作用(白话):集中校验新增和编辑的必填项,避免空手机号或空运营商直接写进数据库。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js。
* 关联逻辑(调用链/消息链/数据流):createRecord()/updateRecord() -> validateSaveRequest() -> 返回 400 或继续保存。
*/
public String validateSaveRequest(WxPhoneSaveRequest request) {
if (request == null) {
return "请求体不能为空";
}
if (!hasText(request.getPhoneNumber())) {
return "手机号不能为空";
}
if (!hasText(request.getChannelOperator())) {
return "渠道运营商不能为空";
}
return null;
}
/**
* 代码作用(白话):判断字符串是不是有效内容,给表单必填校验复用。
* 关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/service/WxPhoneService.java。
* 关联逻辑(调用链/消息链/数据流):validateSaveRequest() -> hasText() -> 决定返回 400 还是继续保存。
*/
public boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
}
package com.xyw.console.phone.dto;
import lombok.Data;
@Data
public class WxPhoneSaveRequest {
private Long id;
private String phoneNumber;
private String realPerson;
private String iccid;
private String city;
private String imageAttachment1;
private String imageAttachment2;
private String cardStatus;
private String cardUsageLocation;
private Boolean wecom;
private Boolean wechat;
private Boolean outboundCall;
private String douyinAccount;
private Boolean miniProgramFiling;
private Boolean packageChange5yuan;
private String channelOperator;
private Boolean numberStatus;
private String linkedWecom;
}
package com.xyw.console.phone.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("wx_phone")
public class WxPhoneEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("phone_number")
private String phoneNumber;
@TableField("real_person")
private String realPerson;
@TableField("iccid")
private String iccid;
@TableField("city")
private String city;
@TableField(value = "image_attachment_1", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment1;
@TableField(value = "image_attachment_2", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment2;
@TableField("card_status")
private String cardStatus;
@TableField("card_usage_location")
private String cardUsageLocation;
@TableField("wecom")
private Boolean wecom;
@TableField("wechat")
private Boolean wechat;
@TableField("outbound_call")
private Boolean outboundCall;
@TableField("douyin_account")
private String douyinAccount;
@TableField("mini_program_filing")
private Boolean miniProgramFiling;
@TableField("package_change_5yuan")
private Boolean packageChange5yuan;
@TableField("channel_operator")
private String channelOperator;
@TableField("mobile_status")
private Boolean numberStatus;
@TableField("linked_wecom")
private String linkedWecom;
@TableField(exist = false)
private String wechatStatus;
@TableField(exist = false)
private String outbound;
}
package com.xyw.console.phone.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.phone.entity.WxPhoneEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface WxPhoneMapper extends BaseMapper<WxPhoneEntity> {
/**
* 显式更新图片字段,确保 null 值能写入 DB。
* FieldStrategy.ALWAYS 在自定义 SqlSessionFactory 下可能不生效,
* 这里用原生 SQL 绕过,保证删除图片时字段被置为 NULL。
*/
@Update("UPDATE wx_phone SET image_attachment_1 = #{imageAttachment1}, image_attachment_2 = #{imageAttachment2} WHERE id = #{id}")
int updateImages(@Param("id") Long id,
@Param("imageAttachment1") String imageAttachment1,
@Param("imageAttachment2") String imageAttachment2);
}
package com.xyw.console.wechat.controller;
import com.xyw.console.common.ApiResponse;
import com.xyw.console.wechat.dto.WxDataSaveRequest;
import com.xyw.console.wechat.entity.WxDataEntity;
import com.xyw.console.wechat.service.WxDataService;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/wechat-records")
public class WxDataController {
private final WxDataService wxDataService;
public WxDataController(WxDataService wxDataService) {
this.wxDataService = wxDataService;
}
/**
* 代码作用(白话):返回企微资料列表给前端页面首屏和刷新动作使用;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/service/WxDataService.java、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):前端 listWechatRecords() -> GET /api/wechat-records -> listRecords() -> ApiResponse.success(data)。
*/
@GetMapping
public ResponseEntity<ApiResponse<List<WxDataEntity>>> listRecords() {
return ResponseEntity.ok(ApiResponse.success(wxDataService.listRecords()));
}
/**
* 代码作用(白话):返回单条企微资料给详情抽屉和编辑弹窗读取最新值;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/service/WxDataService.java、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):前端 getWechatRecordById() -> GET /api/wechat-records/{id} -> Service.getRecordById() -> 详情/编辑打开。
*/
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<WxDataEntity>> getRecord(@PathVariable Long id) {
WxDataEntity entity = wxDataService.getRecordById(id);
if (entity == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(404, "未找到对应企微资料"));
}
return ResponseEntity.ok(ApiResponse.success(entity));
}
/**
* 代码作用(白话):校验新增表单并写入一条新的企微资料;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/dto/WxDataSaveRequest.java、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):前端保存新增 -> POST /api/wechat-records -> validateSaveRequest() -> Service.createRecord() -> 列表刷新。
*/
@PostMapping
public ResponseEntity<ApiResponse<WxDataEntity>> createRecord(@RequestBody WxDataSaveRequest request) {
String validationMessage = validateSaveRequest(request);
if (validationMessage != null) {
return ResponseEntity.badRequest().body(ApiResponse.error(400, validationMessage));
}
WxDataEntity entity = wxDataService.createRecord(request);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success("新增成功", entity));
}
/**
* 代码作用(白话):校验编辑表单并更新指定企微资料;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/dto/WxDataSaveRequest.java、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):前端保存编辑 -> PUT /api/wechat-records/{id} -> validateSaveRequest() -> Service.updateRecord() -> 列表刷新。
*/
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<WxDataEntity>> updateRecord(@PathVariable Long id, @RequestBody WxDataSaveRequest request) {
String validationMessage = validateSaveRequest(request);
if (validationMessage != null) {
return ResponseEntity.badRequest().body(ApiResponse.error(400, validationMessage));
}
WxDataEntity entity = wxDataService.updateRecord(id, request);
if (entity == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(404, "未找到对应企微资料"));
}
return ResponseEntity.ok(ApiResponse.success("编辑成功", entity));
}
/**
* 代码作用(白话):集中校验新增和编辑必填项,避免空数据直接写进数据库;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/dto/WxDataSaveRequest.java、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):createRecord()/updateRecord() -> validateSaveRequest() -> 返回 400 或继续保存。
*/
public String validateSaveRequest(WxDataSaveRequest request) {
if (request == null) {
return "请求体不能为空";
}
if (!hasText(request.getRealName())) {
return "真实姓名不能为空";
}
if (!hasText(request.getAccount())) {
return "企微账号不能为空";
}
if (!hasText(request.getDepartmentName())) {
return "部门不能为空";
}
if (!hasText(request.getPhoneNumber())) {
return "绑定手机号不能为空";
}
if (!hasText(request.getRealNameOwner())) {
return "实名人不能为空";
}
return null;
}
/**
* 代码作用(白话):判断字符串是否含有有效文本,给表单必填校验复用;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/controller/WxDataController.java、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/service/WxDataService.java;关联逻辑(调用链/消息链/数据流):validateSaveRequest() -> hasText() -> 决定返回 400 还是继续保存。
*/
public boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
}
package com.xyw.console.wechat.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class WxDataSaveRequest {
@JsonProperty("real_name")
private String realName;
@JsonProperty("account")
private String account;
@JsonProperty("alias_name")
private String aliasName;
@JsonProperty("department_name")
private String departmentName;
@JsonProperty("gender")
private String gender;
@JsonProperty("phone_number")
private String phoneNumber;
@JsonProperty("real_name_owner")
private String realNameOwner;
@JsonProperty("account_location")
private String accountLocation;
@JsonProperty("phone_image")
private String phoneImage;
@JsonProperty("real_name_owner_status")
private Integer realNameOwnerStatus;
}
package com.xyw.console.wechat.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@TableName("wx_data")
public class WxDataEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@JsonProperty("real_name")
@TableField("real_name")
private String realName;
@JsonProperty("account")
@TableField("account")
private String account;
@JsonProperty("alias_name")
@TableField("alias_name")
private String aliasName;
@JsonProperty("department_name")
@TableField("department_name")
private String departmentName;
@JsonProperty("gender")
@TableField("gender")
private String gender;
@JsonProperty("phone_number")
@TableField("phone_number")
private String phoneNumber;
@JsonProperty("real_name_owner")
@TableField("real_name_owner")
private String realNameOwner;
@JsonProperty("account_location")
@TableField("account_position")
private String accountLocation;
@JsonProperty("phone_image")
@TableField("phone_image_url")
private String phoneImage;
@JsonProperty("real_name_owner_status")
@TableField("real_name_owner_status")
private Integer realNameOwnerStatus;
}
package com.xyw.console.wechat.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.wechat.entity.WxDataEntity;
public interface WxDataMapper extends BaseMapper<WxDataEntity> {
}
package com.xyw.console.wechat.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.xyw.console.wechat.dto.WxDataSaveRequest;
import com.xyw.console.wechat.entity.WxDataEntity;
import com.xyw.console.wechat.mapper.WxDataMapper;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class WxDataService {
private final WxDataMapper wxDataMapper;
public WxDataService(WxDataMapper wxDataMapper) {
this.wxDataMapper = wxDataMapper;
}
/**
* 代码作用(白话):查询企微资料全量列表,给前端当前的本地筛选和分页继续使用;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/controller/WxDataController.java、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):GET /api/wechat-records -> listRecords() -> Mapper.selectList() -> 前端列表渲染。
*/
public List<WxDataEntity> listRecords() {
QueryWrapper<WxDataEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.orderByDesc("id");
return wxDataMapper.selectList(queryWrapper);
}
/**
* 代码作用(白话):按主键读取单条企微资料,给详情抽屉和编辑弹窗拿最新数据;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/controller/WxDataController.java、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):GET /api/wechat-records/{id} -> getRecordById() -> Mapper.selectById() -> 前端详情/编辑打开。
*/
public WxDataEntity getRecordById(Long id) {
return wxDataMapper.selectById(id);
}
/**
* 代码作用(白话):把前端新增表单写入 wx_data 表,并返回带主键的新记录;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/controller/WxDataController.java、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):POST /api/wechat-records -> createRecord() -> copyRequestToEntity() -> Mapper.insert() -> 前端刷新列表。
*/
public WxDataEntity createRecord(WxDataSaveRequest request) {
WxDataEntity entity = new WxDataEntity();
copyRequestToEntity(request, entity);
wxDataMapper.insert(entity);
return wxDataMapper.selectById(entity.getId());
}
/**
* 代码作用(白话):更新已有企微资料,并把数据库里最新记录返回给前端;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/controller/WxDataController.java、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):PUT /api/wechat-records/{id} -> updateRecord() -> Mapper.updateById() -> 前端刷新列表。
*/
public WxDataEntity updateRecord(Long id, WxDataSaveRequest request) {
WxDataEntity entity = wxDataMapper.selectById(id);
if (entity == null) {
return null;
}
copyRequestToEntity(request, entity);
wxDataMapper.updateById(entity);
return wxDataMapper.selectById(id);
}
/**
* 代码作用(白话):把接口入参安全地拷贝到数据库实体,集中处理默认值和空字符串清洗;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/dto/WxDataSaveRequest.java、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/entity/WxDataEntity.java;关联逻辑(调用链/消息链/数据流):createRecord()/updateRecord() -> copyRequestToEntity() -> Entity -> Mapper 持久化。
*/
public void copyRequestToEntity(WxDataSaveRequest request, WxDataEntity entity) {
entity.setRealName(normalizeText(request.getRealName()));
entity.setAccount(normalizeText(request.getAccount()));
entity.setAliasName(normalizeText(request.getAliasName()));
entity.setDepartmentName(normalizeText(request.getDepartmentName()));
entity.setGender(normalizeText(request.getGender()));
entity.setPhoneNumber(normalizeText(request.getPhoneNumber()));
entity.setRealNameOwner(normalizeText(request.getRealNameOwner()));
entity.setAccountLocation(normalizeText(request.getAccountLocation()));
entity.setPhoneImage(normalizeText(request.getPhoneImage()));
entity.setRealNameOwnerStatus(request.getRealNameOwnerStatus() == null ? 1 : request.getRealNameOwnerStatus());
}
/**
* 代码作用(白话):把前端传来的空白文本裁掉首尾空格,并把空字符串转成 null,减少数据库里出现一堆无意义空格;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/service/WxDataService.java、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/entity/WxDataEntity.java;关联逻辑(调用链/消息链/数据流):copyRequestToEntity() -> normalizeText() -> Entity 字段写入数据库。
*/
public String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmedValue = value.trim();
return trimmedValue.isEmpty() ? null : trimmedValue;
}
}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>学有为资产管理台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"name": "xyw-console-frontend",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test"
},
"engines": {
"node": ">=20.19.0 || >=22.12.0"
},
"dependencies": {
"element-plus": "^2.14.2",
"jsdom": "^29.1.1",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"vite": "^7.3.6"
}
}
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://127.0.0.1:5173', browserName: 'chromium' },
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173/', reuseExistingServer: true, timeout: 30_000 }
});
/**
* 代码作用(白话):提供新前端的稳定壳层和导航,只进入静态参考或重构中页面,不加载任何旧业务运行时。
* 关联文件:frontend/src/router/index.js、frontend/src/reference/LegacyReferenceView.js、frontend/src/styles/app.css。
* 关联逻辑(调用链/数据流):导航点击 -> 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 { createApp } from 'vue/dist/vue.esm-bundler.js';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.js';
import router from './router/index.js';
import './styles/app.css';
/**
* 代码作用(白话):创建重构后的唯一 Vue 应用入口,把路由和界面组件挂到页面根节点。
* 关联文件:frontend/index.html、frontend/src/App.js、frontend/src/router/index.js。
* 关联逻辑(调用链/数据流):index.html -> main.js -> createApp() -> router -> 静态参考页或重构占位页。
*/
createApp(App).use(router).use(ElementPlus).mount('#app');
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { listPhoneAssets } from './phone-api-client.js';
/**
* 代码作用(白话):显示手机号资产列表并管理筛选和分页状态。
* 关联文件:phone-api-client.js、router/index.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):路由 -> setup -> loadPage -> API -> Element Plus 表格。
*/
export default {
/**
* 代码作用(白话):创建手机号资产列表的筛选、加载和分页状态,并把状态交给页面模板使用。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化状态 -> loadPage 请求接口 -> 表格读取 records。
*/
setup() {
const loading = ref(false); const records = ref([]); const total = ref(0);
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 };
},
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>`
};
\ No newline at end of file
/**
* 代码作用(白话):统一发送手机号资产列表请求,避免页面自己处理返回格式。
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面加载 -> listPhoneAssets -> GET API -> 表格数据。
*/
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;
}
/**
* 代码作用(白话):根据筛选条件读取手机号资产分页数据。
* 关联文件: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
const pageContent = {
phone: {
title: '手机号卡旧界面参考',
description: '保留原手机号卡列表的视觉层级,后续以 as_phone_asset 接口重新实现。',
columns: ['手机号', '实名人', 'ICCID', '卡类型', '状态'],
rows: [['138****8888', '张三', '8986 0117 0230', '移动', '正常'], ['176****1234', '王五', '8986 0117 8844', '电信', '异常停机']]
},
wechat: {
title: '企微资料旧界面参考',
description: '保留原企微资料页面的列表布局,后续以 as_wecom_account 接口重新实现。',
columns: ['名称', '账号', '所属公司', '实名人', '状态'],
rows: [['张三', 'zhangsan_88', '示例公司', '张三', '正常'], ['李四', 'lisi_2023', '示例公司', '李四', '待复核']]
}
};
/**
* 代码作用(白话):展示已经删除业务实现的 phone/wechat 界面参考,全部数据固定在前端,防止访问旧 API 或写入旧数据。
* 关联文件:frontend/src/router/index.js、frontend/tests/legacy-reference.spec.js、frontend/src/styles/app.css。
* 关联逻辑(调用链/数据流):参考路由 props.kind -> pageContent -> 表格渲染;禁用按钮 -> 仅显示不可操作提示。
*/
export default {
props: { kind: { type: String, required: true } },
setup(props) {
return { content: pageContent[props.kind] };
},
template: `
<section class="reference-page">
<header class="page-header">
<div><p class="eyebrow">LEGACY REFERENCE</p><h2>{{ content.title }}</h2><p>{{ content.description }}</p></div>
<span class="reference-badge">旧界面参考,不可操作</span>
</header>
<article class="reference-card">
<div class="toolbar"><strong>示例记录</strong><el-button disabled type="primary">新增(参考)</el-button></div>
<el-table :data="content.rows.map((row) => Object.fromEntries(content.columns.map((column, index) => [column, row[index]])))">
<el-table-column v-for="column in content.columns" :key="column" :prop="column" :label="column" />
</el-table>
</article>
</section>
`
};
import { createRouter, createWebHashHistory } from 'vue-router';
import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
/**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
* 关联文件:frontend/src/App.js、frontend/src/router/index.js。
* 关联逻辑(调用链/数据流):导航到 overview/domain/alerts -> createPlaceholderView() -> RouterView 输出重构提示。
*/
function createPlaceholderView(title) {
return { template: `<section class="placeholder"><h2>${title}</h2><p>该模块将在新资产表接口完成后重构。</p></section>` };
}
/**
* 代码作用(白话):定义清理旧 phone/wechat 模块后的新路由,只保留无接口依赖的参考页面。
* 关联文件:frontend/src/App.js、frontend/src/reference/LegacyReferenceView.js、frontend/tests/legacy-reference.spec.js。
* 关联逻辑(调用链/数据流):Hash 地址 -> router -> LegacyReferenceView -> 静态示例数据与禁用操作按钮。
*/
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', redirect: '/overview' },
{ path: '/overview', component: createPlaceholderView('资产总览') },
{ path: '/phone-assets', component: PhoneAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wechat', component: LegacyReferenceView, props: { kind: 'wechat' } },
{ path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } }
]
});
export default router;
:root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px 1fr; }
.sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; }
.sidebar h1 { margin: 0 0 32px; font-size: 20px; }
.sidebar nav { display: grid; gap: 8px; }
.sidebar a { border-radius: 8px; color: #cbd5e1; padding: 10px 12px; text-decoration: none; }
.sidebar a.router-link-active, .sidebar a:hover { background: #1f3b59; color: white; }
.content { padding: 40px; }
.eyebrow { margin: 0 0 8px; color: #5f8fcb; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.reference-page, .placeholder { max-width: 1180px; margin: 0 auto; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
.page-header h2, .placeholder h2 { margin: 0 0 8px; font-size: 28px; }
.page-header p, .placeholder p { margin: 0; color: #64748b; }
.reference-badge { padding: 8px 12px; border-radius: 999px; color: #8a5a00; background: #fff3d6; white-space: nowrap; }
.reference-card { padding: 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: white; box-shadow: 0 8px 28px rgba(15, 23, 42, .05); }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
@media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar nav { grid-template-columns: repeat(2, 1fr); } .page-header { display: grid; } .content { padding: 24px; } }
import { expect, test } from '@playwright/test';
const referencePages = [
{ path: '#/reference/phone', title: '手机号卡旧界面参考' },
{ path: '#/reference/wechat', title: '企微资料旧界面参考' }
];
for (const referencePage of referencePages) {
/**
* 代码作用(白话):规定旧业务移除后仍可查看的静态参考页面,并确保浏览器不会偷偷访问已经删除的旧接口。
* 关联文件:frontend/src/reference/LegacyReferenceView.js、frontend/src/router/index.js、frontend/tests/legacy-reference.spec.js。
* 关联逻辑(调用链/数据流):访问 Hash 路由 -> 静态参考视图渲染 -> Playwright 记录网络请求 -> 断言标题、提示与禁用按钮。
*/
test(`${referencePage.path} shows a non-interactive reference without legacy API requests`, async ({ page }) => {
const legacyRequests = [];
/**
* 代码作用(白话):监听被移除的旧接口,测试结束前用空数组断言它们没有被新的静态参考页调用。
* 关联文件:frontend/src/reference/LegacyReferenceView.js、frontend/tests/legacy-reference.spec.js。
* 关联逻辑(调用链/数据流):浏览器请求 -> page.on('request') -> legacyRequests -> 最终断言。
*/
page.on('request', (request) => {
if (['/api/wx-phones', '/api/wechat-records'].some((path) => request.url().includes(path))) {
legacyRequests.push(request.url());
}
});
await page.goto(`/${referencePage.path}`);
await expect(page.getByRole('heading', { name: referencePage.title })).toBeVisible();
await expect(page.getByText('旧界面参考,不可操作')).toBeVisible();
await expect(page.getByRole('button', { name: '新增(参考)' })).toBeDisabled();
expect(legacyRequests).toEqual([]);
});
}
import { expect, test } from '@playwright/test';
/**
* 代码作用(白话):模拟手机号资产列表接口,先证明新页面尚未实现时测试会失败,后续用于保护页面加载流程。
* 关联文件:frontend/src/modules/phone/PhoneAssetView.js、frontend/src/modules/phone/phone-api-client.js。
* 关联逻辑(调用链/消息链/数据流):浏览器打开手机号资产路由 -> View 请求列表 API -> route.fulfill 返回测试数据 -> 页面表格断言。
*/
test('phone asset workspace loads the first page from its API', async ({ 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
}
})
});
});
await page.goto('/#/phone-assets');
await expect(page.getByRole('heading', { name: '手机号资产' })).toBeVisible();
await expect(page.getByText('13812345678')).toBeVisible();
await expect(page.getByRole('button', { name: '新增手机号资产' })).toBeVisible();
});
import { defineConfig } from 'vite';
/**
* 代码作用(白话):把前端固定部署在 /asset/ 前缀下,并把浏览器的 /api 请求转发给本机后端,避免接口请求被 Vite 回退成 HTML 页面。
* 关联文件:frontend/src/router/index.js、frontend/src/modules/phone/phone-api-client.js、backend/src/main/resources/application.yml。
* 关联逻辑(调用链/消息链/数据流):/asset/#/phone-assets -> Vue Hash 路由;/api/phone-assets -> Vite proxy -> 127.0.0.1:7689 后端接口。
*/
export default defineConfig({
base: '/asset/',
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:7689',
changeOrigin: true
}
}
},
preview: { port: 4173, strictPort: true }
});
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>学有为资产管理台</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f766e'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='26' font-family='Arial' fill='white'%3EXY%3C/text%3E%3C/svg%3E" />
</head>
<body>
<div class="workspace" id="appShell">
<aside class="shell-nav" id="sidebar">
<div class="brand-panel">
<div class="brand-mark">
<img class="brand-logo" src="/img/学有为logo.jpg" alt="学有为 LOGO" />
</div>
<div class="brand-copy">
<h1>学有为数据资产后台</h1>
</div>
</div>
<div class="nav-main">
<nav class="primary-nav" aria-label="主导航">
<button class="nav-link" type="button" data-module="overview">
<span class="nav-glyph"><img class="nav-icon" src="/img/账户总览.png" alt="总览" /></span>
<span class="nav-label">总览</span>
</button>
<button class="nav-link" type="button" data-module="domain">
<span class="nav-glyph"><img class="nav-icon" src="/img/域名申请.png" alt="域名资料" /></span>
<span class="nav-label">域名资料</span>
</button>
<button class="nav-link" type="button" data-module="wechat">
<span class="nav-glyph"><img class="nav-icon" src="/img/企微_企微.png" alt="企微资料" /></span>
<span class="nav-label">企微资料</span>
</button>
<button class="nav-link" type="button" data-module="phone">
<span class="nav-glyph"><img class="nav-icon" src="/img/电话号码 (1).png" alt="手机号卡" /></span>
<span class="nav-label">手机号卡</span>
</button>
<button class="nav-link" type="button" data-module="alerts">
<span class="nav-glyph"><img class="nav-icon" src="/img/提醒中心.png" alt="提醒中心" /></span>
<span class="nav-label">提醒中心</span>
</button>
</nav>
</div>
<div class="nav-bottom">
<div class="nav-toggle-wrap">
<button class="nav-toggle-button" id="sidebarToggle" type="button" aria-label="收起侧边导航" aria-expanded="true">
<span class="nav-toggle-glyph" aria-hidden="true">
<span></span>
<span></span>
</span>
<span class="nav-toggle-label">收起</span>
</button>
</div>
<div class="nav-footer">
<div class="nav-user-avatar"></div>
<div class="nav-user-copy">
<p class="nav-user-name">管理员</p>
<p class="nav-user-role">超级管理员</p>
</div>
<button class="nav-settings" type="button" aria-label="设置"></button>
</div>
</div>
</aside>
<main class="shell-main" id="vue-router-app">
<router-view></router-view>
</main>
</div>
<div id="addDomainDialogRoot"></div>
<div id="wechatDialogRoot"></div>
<div id="wechatDetailDrawerRoot"></div>
<div id="phoneDialogRoot"></div>
<div id="phoneDetailDrawerRoot"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
## Why
`xyw_data_test` 已完成 `as_phone_asset` 表创建,后端也已有对应的 Entity 与 Mapper,但系统尚不能查询或维护手机号资产;前端仅保留不可操作的旧界面参考。先完成手机号资产的独立闭环,可以验证新表、MyBatis-Plus 和新 Vue 工作区的整合方式,并为后续企微、微信、抖音、域名与商户资产复用。
## What Changes
- 新增以 `as_phone_asset` 为唯一写入目标的手机号资产 API:分页查询、单条查询、新增、编辑和软删除。
- 新增手机号规范化与唯一性保护:保存前去除首尾空格和 `+86` 前缀,最终必须为 11 位数字。
- 将卡类型、管理类型和处置状态做成已确认选项的下拉框;处置状态必填且默认“正常使用”,其余非必填。
- 由后端在新增时初始化五个关联快照字段为 JSON 空数组;本期不自动写入或同步其他资产表的数据。
- 新增可操作的“手机号卡”前端工作页面,支持筛选、分页、新增、编辑、删除确认和关联快照的只读展示;存在有效关联账号时拒绝删除并显示原因。
- 将导航从“手机号卡(参考)”切换到新页面;旧参考路由作为静态回退入口保留,不删除。
## Capabilities
### New Capabilities
- `phone-asset-api`: 以软删除和字段校验保护 `as_phone_asset` 的手机号资产 REST API。
- `phone-asset-workspace`: 用于浏览和维护手机号资产的 Vue 工作页面,以及与 API 的交互状态。
### Modified Capabilities
- 无。
## Impact
- 后端新增 `asset` 模块的 Controller、Service、请求/响应 DTO、异常处理及对应测试;复用既有 `PhoneAssetEntity``PhoneAssetMapper``ApiResponse`,不改表结构或 Mapper XML。
- 前端新增手机号资产页面和 API 客户端,修改路由、导航与共享样式,并新增端到端测试。
- 新增 `/api/phone-assets` 系列接口;不修改既有接口、DTO、数据库结构、认证或配置,也不新增依赖。本期不做登录权限和关联快照自动同步,`deviceId` 可为空且不校验设备是否存在。
## ADDED Requirements
### Requirement: Page through active phone assets
The system SHALL provide `GET /api/phone-assets` and return only records whose `delete_time` equals `0`, ordered by descending ID, inside `ApiResponse.data` as `records`, `total`, `page`, and `size`.
#### Scenario: Default page is requested
- **WHEN** a client requests `GET /api/phone-assets` without paging parameters
- **THEN** the system returns page `1`, size `20`, and only active phone-asset records
#### Scenario: Indexed filters are supplied
- **WHEN** a client supplies valid `phoneNumber`, `iccid`, `cardType`, `managementType`, `disposalStatus`, or `deviceId` query parameters
- **THEN** the system returns only active records matching every supplied condition
#### Scenario: Page parameters are invalid
- **WHEN** a client supplies a page below `1` or a size outside `1` through `100`
- **THEN** the system returns HTTP `400` with the standard error response and performs no query write
### Requirement: Read one active phone asset
The system SHALL provide `GET /api/phone-assets/{id}` and return the requested active record or a standard not-found response.
#### Scenario: Active record exists
- **WHEN** a client requests an existing active phone-asset ID
- **THEN** the response is HTTP `200` and contains the editable fields, audit times, read-only relation snapshots, and relation sync time
#### Scenario: Record is deleted or absent
- **WHEN** a client requests an absent ID or a soft-deleted ID
- **THEN** the system returns HTTP `404` and does not reveal the deleted record
### Requirement: Create a phone asset safely
The system SHALL provide `POST /api/phone-assets`, validate all client-writable fields, and insert exactly one active `as_phone_asset` record when the phone number is not already active.
#### Scenario: Valid asset is created
- **WHEN** a client submits a phone number that normalizes to exactly 11 digits with optional valid asset fields
- **THEN** the system returns HTTP `200`, writes audit times, sets `delete_time` to `0`, and initializes each linked-account snapshot to JSON `[]`
#### Scenario: Input is invalid
- **WHEN** a client submits an invalid phone number, overlong text, invalid ICCID length, or negative device ID
- **THEN** the system returns HTTP `400` and inserts no record
#### Scenario: Active phone number is duplicated
- **WHEN** a client creates a phone asset whose phone number already belongs to an active record
- **THEN** the system returns HTTP `409` and preserves the existing record
### Requirement: Edit only client-writable fields
The system SHALL provide `PUT /api/phone-assets/{id}` and update only the documented client-writable fields of an active phone asset.
#### Scenario: Valid edit is submitted
- **WHEN** a client submits valid values for an existing active record
- **THEN** the system updates the editable fields and `update_time` while preserving all linked snapshots and `relation_synced_at`
#### Scenario: Edited phone number conflicts
- **WHEN** a client changes a phone number to another active record's number
- **THEN** the system returns HTTP `409` and does not partially update the target record
### Requirement: Soft-delete a phone asset
The system SHALL provide `DELETE /api/phone-assets/{id}` and mark only the target active record deleted without physically deleting its row.
#### Scenario: Active record is deleted
- **WHEN** a client deletes an existing active phone asset
- **THEN** the system writes a nonzero Unix-millisecond `delete_time` and a new `update_time`, returns success, and excludes the record from later reads
#### Scenario: Record is already unavailable
- **WHEN** a client deletes an absent or already-deleted phone asset
- **THEN** the system returns HTTP `404` and does not change another record
### Requirement: Keep relation snapshots server-managed
The system SHALL not accept linked-account snapshot fields or `relationSyncedAt` from create or update clients, and SHALL expose valid snapshots as read-only numeric ID arrays.
#### Scenario: Client attempts to write a relation field
- **WHEN** a client includes any linked-account snapshot or relation sync field in a save request
- **THEN** the system ignores that field and retains server-managed snapshot values
#### Scenario: New asset has no known relationships
- **WHEN** a phone asset is created before relation-sync modules exist
- **THEN** each response relation array is empty and the relation sync time is unset
### Requirement: Apply confirmed input and deletion rules
The system SHALL normalize a phone number by trimming whitespace and removing one leading `+86` before requiring exactly 11 digits. The system SHALL enforce the confirmed dropdown values, default a missing create disposal status to `正常使用`, accept null management type and device ID, and SHALL NOT verify that a provided device ID exists.
#### Scenario: Number includes a country code
- **WHEN** a client submits ` +8613812345678 `
- **THEN** the system stores `13812345678`
#### Scenario: Dropdown values are saved
- **WHEN** a client creates or edits using the confirmed dropdown values
- **THEN** card type and management type remain optional, while disposal status is required and defaults to `正常使用` on create
#### Scenario: Active associations block deletion
- **WHEN** a client deletes a phone asset with an active wecom account, wechat account, douyin account, domain account, or merchant reference
- **THEN** the system returns HTTP `409` with the blocking relation type and leaves the record active
\ No newline at end of file
## ADDED Requirements
### Requirement: Display a usable phone-asset workspace
The system SHALL provide a routed phone-asset workspace at `/phone-assets` and SHALL retain `/reference/phone` as a non-operational static reference route.
#### Scenario: User opens the main workspace
- **WHEN** a user selects “手机号资产” from the main navigation
- **THEN** the router opens `/phone-assets` and the page requests the first asset page
#### Scenario: User opens the legacy route
- **WHEN** a user opens `/reference/phone`
- **THEN** the system shows the existing static reference view without requesting the new phone-asset API
### Requirement: Filter and paginate assets
The workspace SHALL let users submit and reset supported filters, change pages, and change page size while displaying the returned total and current records.
#### Scenario: Filter is submitted
- **WHEN** a user enters supported filter values and selects search
- **THEN** the workspace requests page `1` with those values and renders the returned records
#### Scenario: Filter is reset
- **WHEN** a user selects reset
- **THEN** the workspace clears filter controls and reloads the default first page
### Requirement: Create and edit assets through one validated form
The workspace SHALL provide a single form for create and edit flows, require a phone number, and display server validation or conflict messages without closing the form.
#### Scenario: New asset is saved
- **WHEN** a user completes valid required fields and confirms creation
- **THEN** the workspace calls `POST /api/phone-assets`, closes the dialog after success, and refreshes the current list
#### Scenario: Server rejects save
- **WHEN** the API returns a validation or duplicate-phone error
- **THEN** the workspace keeps the dialog open and displays the returned message
### Requirement: Protect destructive actions and show read-only relations
The workspace SHALL require explicit confirmation before deletion and SHALL present relation snapshots and sync time as read-only details.
#### Scenario: Delete is confirmed
- **WHEN** a user confirms deletion of a table row
- **THEN** the workspace calls the delete API and refreshes the list only after success
#### Scenario: User opens details
- **WHEN** a user opens a record detail panel
- **THEN** the workspace displays relation array values and sync time without offering inputs that modify them
### Requirement: Use confirmed form controls
The workspace SHALL restrict the phone input to 11 digits after trimming whitespace and removing a leading `+86`. It SHALL show card type, management type, and disposal status as confirmed dropdowns; management type displays empty for null and disposal status is required with initial value `正常使用`.
#### Scenario: User enters a prefixed number
- **WHEN** a user enters ` +8613812345678 `
- **THEN** the form submits `13812345678`
#### Scenario: Delete is blocked by relations
- **WHEN** the delete API returns a blocking relation type
- **THEN** the workspace shows that reason and does not remove the table row
\ No newline at end of file
## 0. 实施前确认与审批门禁
- [ ] 0.1 确认 `xyw_data_test.as_phone_asset` 已由已完成建表动作提供,且本变更不包含 DDL、数据迁移、配置密钥或其他资产表写入。
- [ ] 0.2 审批本提案中的接口路径、三组下拉选项、手机号规范化、只读关联快照和关联阻止删除策略;未审批不得开始第 1 节代码修改。
- [ ] 0.3 开始每一组文件修改前检查工作区未提交改动,保护不属于本变更的用户修改。
## 1. 后端分页、DTO 与错误协议
- [ ] 1.1 新增 `MybatisPlusConfig` 并注册分页拦截器,确认 `Page` 查询以 MySQL 方言执行;为 `paginationInterceptor()` 添加新手注释:代码作用(白话)是启用分页 SQL,关联文件是 `PhoneAssetService`,关联逻辑是 Controller 列表请求 → Service Page → Mapper。
- [ ] 1.2 新增 `PhoneAssetPageQuery``PhoneAssetSaveRequest``PhoneAssetResponse``PhoneAssetPageResponse`,对页码、页大小、手机号、ICCID、文本长度和设备 ID 设置校验,并确保写入 DTO 不包含关联快照字段。
- [ ] 1.3 新增“未找到”和“手机号冲突”业务异常及统一异常处理;为 `handleValidation()``handleNotFound()``handleConflict()` 添加新手注释,分别说明校验/Service 异常 → `ApiResponse.error` → 前端错误提示的调用链。
- [ ] 1.4 为 DTO 校验、分页默认值和 400/404/409 响应编写后端测试,验证失败请求不写入 `as_phone_asset`
## 2. 后端手机号资产服务与接口
- [ ] 2.1 新增 `PhoneAssetService`,实现 `page()``getActiveById()``create()``update()``softDelete()`;每个方法添加新手注释,至少说明各自的白话作用、关联 Controller/Mapper/DTO 和“HTTP 请求 → Service → `PhoneAssetMapper``as_phone_asset`”数据流。
- [ ] 2.2 在 `PhoneAssetService` 中实现 `buildActiveQuery()``ensurePhoneNumberAvailable()``applyEditableFields()``toResponse()``parseRelationIds()`;每个方法添加新手注释,说明有效记录过滤、唯一性保护、可写字段边界、Entity→Response 转换和 JSON 快照解析各自的关联文件与数据流。
- [ ] 2.3 让创建显式写入五个关联快照 JSON `[]`、通用审计时间和 `delete_time=0`;让更新保留快照和 `relation_synced_at`;让删除写 Unix 毫秒 `delete_time``update_time`
- [ ] 2.4 新增 `PhoneAssetController``page()``getById()``create()``update()``delete()`;每个方法添加新手注释,说明 HTTP 路径、关联 Service/DTO、以及请求 → `ApiResponse` → 浏览器客户端的数据流。
- [ ] 2.5 为服务与 Controller 编写测试:默认分页、组合筛选、精确手机号、创建、重复手机号、编辑冲突、软删除后不可读、软删除后可重建、关联字段不可由请求覆盖。
## 3. 前端手机号资产工作页
- [ ] 3.1 新增 `phone-api-client.js``request()``listPhoneAssets()``getPhoneAsset()``createPhoneAsset()``updatePhoneAsset()``deletePhoneAsset()`;每个方法添加新手注释,说明浏览器请求、关联 `PhoneAssetController`、以及统一响应解析/异常消息的数据流。
- [ ] 3.2 新增 `PhoneAssetView.js``setup()``loadPage()``submitSearch()``resetSearch()``openCreate()``openEdit()``submitForm()``confirmDelete()``openDetails()`;每个方法或含业务逻辑回调添加新手注释,说明页面事件、关联 API 客户端/Element Plus、以及“用户操作 → API → 列表或弹窗状态”的消息链。
- [ ] 3.3 实现筛选栏、分页表格、新增/编辑共用表单、删除二次确认和只读关联快照详情;保存失败时保留弹窗与表单数据,成功后仅刷新当前筛选列表。
- [ ] 3.4 修改 `frontend/src/router/index.js` 注册 `/phone-assets` 并保留 `/reference/phone`;为 `createPlaceholderView()` 重新核对并补充其新手注释,说明占位页面与新路由的边界,避免旧入口误接新 API。
- [ ] 3.5 修改 `frontend/src/App.js` 的导航模板,将“手机号卡(参考)”改为“手机号资产”并指向 `/phone-assets`;补充组件模板相关注释,说明 RouterLink → RouterView 的导航链。
- [ ] 3.6 修改 `frontend/src/styles/app.css`,为筛选、表格、抽屉、弹窗和窄屏布局增加与现有后台一致的样式;不改变其他参考页面的可读性。
## 4. 验证与交付
- [ ] 4.1 新增 Playwright 用例,mock `/api/phone-assets`,覆盖页面初始加载、筛选重置、创建成功、服务端重复错误、删除确认和只读关系详情;所有测试辅助回调添加“代码作用(白话)/关联文件/关联逻辑”注释。
- [ ] 4.2 执行后端 `mvn test` 和前端 `npm run build`;若环境具备本地后端与数据库,再执行 Playwright 用例。任何失败先记录原因,再作最小范围修复。
- [ ] 4.3 使用本地 smoke 验证:创建一条测试手机号、按手机号查到、编辑、软删除、确认列表不再显示并可用同手机号重建;仅使用明确标识的测试记录。
- [ ] 4.4 交付时说明修改/新增文件、是否影响接口 DTO 数据库或配置、实际验证结果及未执行项原因;确认本期未修改数据库结构与旧参考路由。
## 5. 已确认规则的实现补充
- [ ] 5.1 在后端和前端实现手机号“去空格、去一个 `+86`、最终 11 位数字”的同一规则;`deviceId` 可为空且不查询设备表。
- [ ] 5.2 实现卡类型、管理类型和处置状态的固定下拉选项;管理类型空值显示为空并保存为 `null`,处置状态必填且创建默认“正常使用”。
- [ ] 5.3 在软删除前检查有效企微、微信、抖音、域名账号、商户关联;任一关联存在则返回关联类型并由前端提示不可删除。
- [ ] 5.4 为上述号码规范化、下拉值、空设备 ID、关联阻止删除编写后端与前端回归测试。
\ No newline at end of file
## Why
资产后台一期需要一套可独立演进的统一数据模型。当前后端仍直接访问旧的 `wx_phone``wx_data` 等表,且项目没有数据库迁移框架;在未锁定字段、审计列和索引规则前接入新后端,会造成建表不一致、软删除数据误读或误连数据库的风险。
本变更先只定义并交付一期 11 张空表的受控建表方案。用户最新约束优先:每张表均使用 `create_time``update_time``delete_time` 作为通用审计/软删除列,替代参考计划中的 `created_at``updated_at` 命名。
## What Changes
- 新增一期资产后台的 11 张空表定义:`company_profile``company_person``asset_device``phone_asset``wecom_account``wechat_account``douyin_account``domain_account``domain_asset``merchant``system_user`
- 为每张表统一增加 `id``create_time``update_time``delete_time`;软删除记录以 `delete_time = 0` 表示仍有效。
- 定义字段字典、主键、唯一索引和查询索引;不创建外键、`REFERENCES``CHECK`,关联存在性和业务枚举由后续 Service 校验。
- 新增只读预检、人工授权后建表、只读结构验收及执行记录的交付路径。脚本不得由应用自动运行,也不得包含旧表读取、迁移、删除或结构修改语句。
- 不修改现有旧后端接口、页面、DTO、Mapper 或旧表兼容逻辑;其启动异常和新后端接入留待后续梯队处理。
## Capabilities
### New Capabilities
- `asset-phase1-schema`: 定义并受控创建一期资产后台的 11 张空表、统一审计/软删除列、索引和结构验收规则。
### Modified Capabilities
- 无。
## Impact
- 新增规划范围:`sql/asset-phase1-preflight.sql``sql/asset-phase1-schema.sql``sql/asset-phase1-schema-verify.sql` 与执行后的 `docs/acceptance/asset-phase1-schema-YYYY-MM-DD.md`
- 数据库影响仅限获得单独书面授权后的测试库 `xyw_data_test`;本提案和后续脚本本身不连接、不执行 SQL。
- 现有 Spring Boot + MyBatis-Plus 工程没有 Flyway、Liquibase 或自动迁移依赖,因此本期采用人工审批的 SQL 交付方式,不新增依赖。
- 本期不影响公开 API、DTO、数据库已有数据、认证、前端页面或部署配置。
## ADDED Requirements
### Requirement: 一期资产表集合
系统 SHALL 在获得单独数据库授权后,仅为资产后台一期创建 `company_profile``company_person``asset_device``phone_asset``wecom_account``wechat_account``douyin_account``domain_account``domain_asset``merchant``system_user` 这 11 张空表。每张表 MUST 使用 InnoDB、`utf8mb4``utf8mb4_unicode_ci`,并包含表注释。
#### Scenario: 已授权的全新测试库建表
- **WHEN** 人工已确认目标库是 `xyw_data_test` 且 11 张目标表均不存在
- **THEN** 建表脚本仅创建这 11 张表及其定义的索引,不创建业务数据、外键、检查约束、触发器或存储过程
#### Scenario: 不完整或已存在的目标表集合
- **WHEN** 预检发现至少一张目标表已存在,或目标库中只存在部分目标表
- **THEN** 操作 MUST 停止,且不得使用 `IF NOT EXISTS`、自动补表、删除或重建来掩盖该异常状态
### Requirement: 通用审计与软删除字段
每张一期表 MUST 包含 `id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT` 主键、`create_time DATETIME NULL``update_time DATETIME NULL``delete_time BIGINT NOT NULL DEFAULT 0``delete_time = 0` MUST 表示有效记录;后续应用软删除时 MUST 写入 Unix 毫秒时间戳。
#### Scenario: 验证统一通用列
- **WHEN** 对任意一期表执行结构验收
- **THEN** 验收结果必须显示该表有 `id``create_time``update_time``delete_time`,且 `delete_time` 不可空并默认值为 `0`
#### Scenario: 业务唯一值在软删除后重建
- **WHEN** `company_profile.company_name``asset_device.device_name``phone_asset.phone_number``domain_account.account_identifier``merchant.merchant_number``system_user.username` 的旧记录已被软删除
- **THEN** 相同业务值可创建新的有效记录,并由“业务字段 + delete_time”复合唯一索引阻止两条有效记录并存
### Requirement: 一期字段与索引契约
系统 SHALL 按设计文档的 11 表字段字典创建业务列、主键、复合唯一索引和查询索引。逻辑关联字段 MUST 保存为 `*_id``BIGINT UNSIGNED` 列,但数据库 MUST NOT 定义 `FOREIGN KEY``REFERENCES``CHECK`
#### Scenario: 验证手机号资产关联快照
- **WHEN** 验收 `phone_asset` 的结构
- **THEN** 必须存在手机号、卡/实名/设备字段、五个 `JSON` 关联快照字段、`relation_synced_at`、手机号有效记录复合唯一索引,以及卡类型、ICCID、设备查询索引
#### Scenario: 验证账号表的重复策略
- **WHEN** 验收 `wecom_account``wechat_account``douyin_account`
- **THEN** 三张表必须具有设计规定的业务列和查询索引,且不得为账号标识创建一期业务唯一索引
### Requirement: 受控 SQL 交付与结构验收
系统 SHALL 将本期 SQL 交付为只读预检脚本、人工建表脚本和只读结构验收脚本。每个脚本 MUST 在开头以中文说明作用、关联文件、执行顺序、允许目标库、禁止行为和失败停止条件;建表后 MUST 产出执行记录。
#### Scenario: 非目标库预检
- **WHEN** 预检显示当前数据库不是 `xyw_data_test`
- **THEN** 执行人必须停止,且不得执行建表脚本
#### Scenario: 结构验收通过
- **WHEN** 建表脚本执行结束且结构验收核对了表、列、主键、唯一/查询索引与禁止约束
- **THEN** 只有全部匹配时才可记录 PASS 并进入后续新后端接入;否则必须记录 FAIL 与实际结构后停止
## Context
资产后台现有页面使用 Hash 路由和依赖 `window.*` 全局对象的旧模块。页面当前由 Python 临时静态服务托管,并直接读取 `node_modules`;后端本地端口为 `8888`。工作区已有未提交改动和未跟踪的 `assets/index.html`,迁移必须以它们为基线增量进行。
## Goals / Non-Goals
**Goals:**
- 以 Vite 提供稳定的本地开发、构建和预览流程。
- 固定开发入口为 `http://localhost:5173/assets/#/...`,预览入口为 `http://localhost:4173/assets/#/...`
- 使 `/api/...` 经 Vite 转发到本机 `8888`,不改变后端接口契约。
- 保持五个 Hash 路由与旧页面模块的加载、路由和初始化行为。
**Non-Goals:**
- 不部署生产环境,不调整域名、HTTPS、服务器根路径或生产代理。
- 不修改后端 Controller、DTO、数据库、业务规则或启动批处理脚本。
- 不删除或修改 `assets/index.html`,也不承诺旧 Python `8000` 前端入口可继续联调。
## Decisions
### 固定 Vite 地址与资源前缀
Vite 使用 `base: '/assets/'`,开发端口固定为 `5173`、预览端口固定为 `4173`,并启用严格端口。固定地址可让 README、验证和未来构建资源路径一致;不选择自动换端口,避免服务实际地址与预期不一致。
### 以相对 `/api` 访问本地后端
两个 API 客户端仅把根地址调整为 `/api/wechat-records``/api/wx-phones`。Vite 仅代理 `/api/``http://localhost:8888`。相对路径避免把本机主机地址写入前端构建产物,也避免浏览器跨域;不改动 HTTP 方法、请求体、响应解析或字段。
### 显式且顺序化地启动旧模块
`src/main.js` 先导入 Vue、Vue Router 与 Element Plus 并写入 `window`,再依既定顺序动态导入旧模块、检查每项预期全局对象,最后显式调用路由和页面初始化。旧模块不再依赖 `DOMContentLoaded` 自动启动;异步导入结束后才启动能避免漏启动。路由与页面初始化加入幂等保护,重复调用只执行一次。
### 保留旧入口参考与现有工作区改动
`index.html` 改为 Vite 入口,页面壳参考 `assets/index.html`,但后者保持原样。迁移不还原现有改动,也不把 `.idea/``start.bat` 纳入本次改动范围。
## Risks / Trade-offs
- [框架全局对象晚于旧模块加载而白屏] → 先写入框架全局对象,按顺序导入并逐项检查缺失对象。
- [异步加载造成路由或页面未启动] → 删除旧的自动启动依赖,全部模块就绪后显式初始化。
- [重复初始化造成重复请求或事件] → 路由与页面入口设置幂等保护,并通过刷新及路由切换验证。
- [端口占用造成联调误判] → 严格端口直接失败;README 说明释放占用后重试。
- [受控写入测试数据遗留] → 仅使用 `VITE-SMOKE-` 标识创建数据,记录返回 ID 并按同一 ID 清理。
## Migration Plan
1. 记录当前 Git 差异和未跟踪 `assets/` 基线。
2. 添加 Vite 依赖、命令、配置和构建产物忽略规则。
3. 迁移入口页面与所需公共图片,添加兼容启动入口。
4. 改造路由、页面初始化和两个 API 根地址。
5. 添加 README,并执行构建、预览、开发联调与受控手机号卡写入验证。
若某一组变更验证失败,仅恢复该组的增量改动并保留先前工作区基线;不使用 `git reset --hard`。生产部署与生产回滚不属于本次迁移,未来由独立提案处理。
## Open Questions
- 无。开发、预览、后端端口和 `/assets/` 前缀均已确认。
## ADDED Requirements
### Requirement: 固定本地 Vite 开发入口
系统 SHALL 通过 Vite 在端口 `5173` 提供资产后台,且页面入口 SHALL 使用 `/assets/` 前缀。端口被占用时,Vite MUST 启动失败而不得自动更换端口。
#### Scenario: 开发地址可访问
- **WHEN** 开发者执行 `npm run dev`
- **THEN** 页面可在 `http://localhost:5173/assets/#/phone-card` 访问
#### Scenario: 开发端口被占用
- **WHEN** 端口 `5173` 已被其他进程占用且开发者执行 `npm run dev`
- **THEN** Vite 启动失败并报告端口冲突
### Requirement: 相对 API 请求经本地代理转发
前端对企微与手机号卡的 API 请求 SHALL 使用相对 `/api/...` 路径,Vite SHALL 将该路径转发到本机 `http://localhost:8888`。迁移 MUST 不改变请求方法、请求体、响应解析、字段名或后端接口契约。
#### Scenario: 读取手机号卡数据
- **WHEN** 用户在 Vite 开发入口访问手机号卡页面并触发读取
- **THEN** 浏览器请求路径为 `/api/wx-phones`,且请求经 Vite 转发至本机后端并返回现有格式的数据
#### Scenario: 保存手机号卡数据
- **WHEN** 用户在 Vite 开发入口保存手机号卡
- **THEN** 请求使用相对 `/api/...` 路径并保持迁移前的 HTTP 方法、请求体和响应处理逻辑
### Requirement: 旧模块以完整依赖顺序启动一次
系统 SHALL 在加载旧模块前提供 Vue、Vue Router 和 Element Plus 的预期 `window` 全局对象,并 SHALL 按既定顺序导入旧模块。任一预期全局对象缺失时,系统 MUST 显示来源文件与缺失对象并停止启动。路由和页面初始化重复调用时 MUST 只完成一次真实初始化。
#### Scenario: 全部模块可用时启动
- **WHEN** 所有旧模块导入完成且其预期全局对象均存在
- **THEN** 系统依次初始化 Hash 路由和页面,且不出现重复挂载、重复点击或重复请求
#### Scenario: 某模块缺少预期对象
- **WHEN** 一个已导入模块未提供其要求的 `window` 全局对象
- **THEN** 系统停止页面启动并显示该模块来源文件和缺失对象名称
### Requirement: Hash 路由与资源在开发和预览中可用
系统 SHALL 支持 `overview``domain``wechat``phone-card``alerts` 五个 Hash 路由,并在刷新后保持当前路由。开发构建与预览 MUST 不请求 `node_modules/`、旧 Python 静态资源或源码脚本,且所需样式、图片和框架资源不得发生 404。
#### Scenario: 切换并刷新 Hash 路由
- **WHEN** 用户切换到任一受支持的 Hash 路由后刷新浏览器
- **THEN** 页面保持在同一路由并正常渲染对应页面
#### Scenario: 构建预览页面
- **WHEN** 开发者执行 `npm run build` 后执行 `npm run preview`
- **THEN** 页面可在 `http://localhost:4173/assets/#/phone-card` 访问,且 Network 中没有受管资源 404 或 `node_modules/` 请求
### Requirement: 受控手机号卡写入验证可清理
系统 SHALL 支持以 `VITE-SMOKE-` 开头的手机号卡测试记录完成新增、编辑和按返回 ID 删除。验证流程 MUST 只清理本轮创建并已记录 ID 的测试记录。
#### Scenario: 创建、编辑并清理测试记录
- **WHEN** 验证人员创建一条带有 `VITE-SMOKE-` 标识的手机号卡并记录返回 ID
- **THEN** 该记录可编辑并在列表刷新后可见,最后使用同一 ID 删除
# Vite 迁移闭环验证记录
## 自动验证证据
| 需求/任务 | 验证方式 | 实际证据 | 状态 |
|---|---|---|---|
| 单一模块渲染责任(3.3a、3.3b) | Playwright Chromium,直达与刷新 | 修复前 `#/wechat``#/phone-card` 的初始列表 GET 均为 2;修复后每个场景为 1 | 通过 |
| 路由切换生命周期(3.3c、5.2b) | `npm run test:e2e -- --reporter=line` | 2026-07-26:13/13 通过,覆盖两个目标页的直达、刷新、四个来源路由切换,以及替换 pageBody 后的行点击/抽屉关闭 | 通过 |
| 自动测试独立于本机后端(4.2) | Playwright route 拦截 | 两个列表 GET 均返回受控 `{ code: 200, message: "ok", data: [] }`,测试执行无后端代理错误 | 通过 |
| Vite 构建(4.3) | `npm run build` | 2026-07-26:退出码 0;存在第三方 `@vueuse/core` 注释和产物体积警告,未阻断构建 | 通过 |
| 五路由真实联调(5.1、5.2a) | 本机后端 8888 + Vite + Browser Network | 本地 Spring Boot 于 2026-07-26 启动后未监听 8888,未执行 | 阻塞 |
| `VITE-SMOKE-` 写入、编辑、按 ID 清理(5.3) | 本机后端 API | 因本地后端未监听 8888,未创建任何测试数据 | 阻塞 |
## 审查整改
- 2026-07-26:新增断言,要求被拦截的列表请求保持 GET;补齐 Playwright 业务回调的新手注释;新增替换 DOM 的事件回归。
- 变异检查:临时移除 `RouterView.mounted()``preparePageShell()` 调用后,替换 DOM 事件用例超时失败;随后已恢复源码。
- 依赖安全审计:`npm audit fix --package-lock-only` 将间接依赖 `postcss``8.5.16` 升至 `8.5.23``nanoid` 升至 `3.3.16`;随后 `npm ci``npm audit` 均报告 0 个漏洞。
## 执行环境
- Node:项目要求 `>=20.19.0 || >=22.12.0`
- 浏览器:Playwright Chromium(本机安装)
- 前端:Vite `5173`;后端真实联调端口:`8888`
## 后续人工验证与清理路径
1. 启动 `./start-backend-jdk17.bat``npm run dev`
2. 分别访问并刷新 `#/overview``#/domain``#/wechat``#/phone-card``#/alerts`,记录 Console 与 Network。
3. 仅创建名称或备注以 `VITE-SMOKE-` 开头的手机号卡;记录返回 ID。
4. 编辑并刷新确认可见后,仅调用该 ID 的删除操作;确认列表或按 ID 查询不再返回该记录。
5. 若删除失败,记录 ID、接口响应与人工清理路径;不得删除非本轮创建的数据。
\ No newline at end of file
## Why
手机号卡的“可变更 5 元套餐”不能表达真实的保号业务;账号信息也不应由普通手机号编辑流程改写。项目仍是预览环境,本次直接采用最终字段,不保留旧套餐字段、旧微信开通状态或旧数据兼容逻辑。
企业微信账号已有可复用来源:主数据源中的 `wx_data` 表。该表用 `phone_number` 关联手机号,用 `account` 保存企业微信账号。因此本次只新建抖音和微信账号来源表,不新建企业微信账号表。
## What Changes
- **BREAKING**`wx_phone.package_change_5yuan` 直接改为 `number_protection_package`;前端和 API 统一使用 `numberProtectionPackage`,不再接受或返回 `packageChange5yuan`
- **BREAKING** 删除 `wx_phone.wechat` 的“是否开通微信”布尔含义,改为可空文本字段,保存同步后的微信账号。
- `wx_phone.douyin_account``wx_phone.wechat``wx_phone.linked_wecom` 成为只读同步结果字段;普通手机号新增和编辑接口不得接收或写入这三个字段。
- 新建 `douyin_account``wechat_account` 两张账号来源表;每条账号一行,以 `phone_number` 关联手机号。
- 复用 `wx_data.phone_number``wx_data.account` 作为企业微信账号来源;不得创建 `linked_wecom_account` 表。
- 新增手动同步接口和列表按钮。同步分别读取三类来源,同一手机号的多个账号去空格、去重、按来源记录 `id` 升序用英文逗号拼接,再写入 `wx_phone`
- 来源记录不存在或账号值为空时保留 `wx_phone` 原值;单张手机号卡的三类结果全部处理完成后才写入,查询失败时该卡不更新。
- 支持勾选同步、筛选结果同步和全量同步:勾选时同步勾选记录;未勾选且有筛选时同步当前筛选结果;未勾选且无筛选时同步整个手机号列表。
## Capabilities
### New Capabilities
- `phone-protection-plan`: 手机号卡保号套餐字段和直接字段迁移。
- `phone-account-sync-fields`: 三类账号来源查询、只读结果字段与手动同步。
### Modified Capabilities
- None. 当前 `openspec/specs/` 中没有可修改的既有能力规格。
## Impact
- 前端:`phone-add-dialog.js``phone-list-runtime.js``phone-detail-drawer.js``record-adapters.js``phone-api-client.js``app-v2.js`
- 后端:手机号实体、普通保存 DTO/服务、同步 DTO/服务、`WxPhoneController`,以及现有 `WxDataMapper`
- 数据库:修改 `wx_phone` 三类字段和套餐列;创建 `douyin_account``wechat_account`;为三张来源表的 `phone_number` 建立或确认索引。复用 `wx_data`,不创建企业微信来源表。
- API:普通 `POST``PUT /api/wx-phones` 的字段缩减;新增 `POST /api/wx-phones/account-sync`
## Context
`wx_phone` 位于手机号独立数据源;现有 `wx_data` 位于主数据源。同步服务跨两个数据源读取企业微信、抖音和微信来源,最终只向 `wx_phone` 写入一次结果。企业微信来源不是手机号数据源中的表,不能在手机号来源 Mapper 中假定同库查询。
已确认 `wx_data.phone_number` 对应手机号,`wx_data.account` 对应企业微信账号。多个 `wx_data` 记录命中同一手机号时,所有非空 `account` 都参与合并。
## Goals / Non-Goals
**Goals:**
- 统一保号套餐的页面、API、实体和数据库语义。
-`wechat` 表示微信账号文本,而不是开通状态。
- 让三类账号只由专用同步入口更新。
- 复用 `wx_data` 作为企业微信来源,避免重复建表和重复维护数据。
- 支持单条、勾选、筛选结果和全量手机号同步。
**Non-Goals:**
- 不开发抖音、微信来源表的维护页面或 CRUD 接口。
- 不修改 `wx_data` 的现有企业微信资料维护流程。
- 不实现登录、权限、操作人审计、定时同步或外部平台调用。
- 不兼容旧套餐字段、旧微信开通状态或旧数据库数据。
## Decisions
### 1. 预览环境直接采用最终字段
`package_change_5yuan` 直接改为 `number_protection_package`;旧 `wechat` 布尔列删除后新建同名文本列;旧字段不再被 DTO、响应或前端适配器识别。
### 2. 企业微信复用 `wx_data`
企业微信来源固定为 `wx_data.account`,关联键固定为 `wx_data.phone_number`。同步服务通过 `WxDataMapper` 查询来源数据,不创建 `linked_wecom_account`
### 3. 一条账号一行,手机号卡只保存展示结果
`douyin_account``wechat_account``wx_data` 允许同一手机号有多行。同步按来源 `id` 升序读取,去掉首尾空格、忽略空值、去重,再用英文逗号拼接,写入 `wx_phone` 三个结果字段。
### 4. 同步以单张手机号卡为最小保护范围
同步服务先完成三类来源查询和合并;全部查询成功后才一次更新该手机号卡的三个结果字段。任一来源查询报错时,该卡标记失败且不写入;批量中的其他卡继续处理。这是“先查全、后一次写入”的保护方式,不要求两个数据源使用联合事务。
### 5. 空来源保留已有结果
某类来源没有该手机号,或查询到的账号均为空时,不清空 `wx_phone` 对应字段;只有查询到至少一个有效账号时才覆盖。
## Risks / Trade-offs
- [删除旧 `wechat` 布尔列] → 迁移前备份预览表结构与数据;回滚使用备份恢复,不能从新文本字段推回旧布尔值。
- [跨数据源读取] → 所有来源读取成功后再进行单次手机号卡写入;读取异常时该卡不写入。
- [批量同步范围过大] → 前端确认弹窗显示精确手机号卡数量;接口始终接收明确 `phoneIds`,不由后端猜测“全部”范围。
- [来源表数据增加后查询变慢] → 为 `douyin_account.phone_number``wechat_account.phone_number``wx_data.phone_number` 建立或确认索引。
## Migration Plan
1. 备份 `wx_phone` 的结构与数据,并保存 `SHOW CREATE TABLE wx_phone` 输出。
2. 确认 `wx_data` 数据;仅为缺失的 `wx_data.phone_number` 索引补建索引,不修改其账号数据。
3. 在预览库一次性执行迁移:改名套餐列,删除旧 `wechat` 布尔列,新增文本 `wechat`,调整两个已有账号结果列,并创建 `douyin_account``wechat_account`
4. 部署最终字段代码;启动时只检查最终表结构,不自动执行 `ALTER TABLE`
5. 验证普通保存、单条同步、筛选同步、全量同步和失败保护。
回滚使用迁移前 `wx_phone` 备份恢复结构和数据;由于旧微信布尔值已删除,不能只靠改列名回滚。
## Open Questions
- 本期不处理来源账号维护界面和权限;新增登录与权限管理时,需定义三张来源数据的维护权限和手动同步权限。
## Implementation Annotation Gate
实施时,所有新增或修改的方法必须有中文新手注释,写明:代码作用、关联文件、关联调用链或数据流。重点覆盖来源账号查询、账号合并、空来源保留、单卡失败保护、同步目标计算、确认提示、列表刷新和测试断言。
\ No newline at end of file
## ADDED Requirements
### Requirement: 三类账号字段必须从普通编辑流程移除
系统 SHALL 将 `douyin_account``wechat``linked_wecom` 视为同步结果字段。手机号新增/编辑弹窗不得展示这三个输入项,手机号列表不得允许行内编辑,普通保存接口不得接收或写入对应字段。
#### Scenario: 打开新增或编辑手机号卡弹窗
- **WHEN** 用户打开新增或编辑手机号卡弹窗
- **THEN** 页面 MUST 不显示抖音账号、微信账号、企业微信账号输入框或“开通微信”开关
#### Scenario: 普通保存请求试图修改账号
- **WHEN** 客户端向普通新增或编辑接口提交 `douyinAccount``wechat``linkedWecom`
- **THEN** 系统 MUST 返回 400,且不得修改三个同步结果字段
### Requirement: 企业微信来源必须复用现有 wx_data 表
系统 SHALL 使用 `wx_data.phone_number` 关联手机号,使用 `wx_data.account` 作为企业微信账号来源。系统 MUST 不创建 `linked_wecom_account` 表。
#### Scenario: 同一手机号存在多个企业微信来源记录
- **WHEN** `wx_data` 中存在多条相同 `phone_number``account` 非空的记录
- **THEN** 系统 MUST 按记录 `id` 升序读取、去空格和去重,并以英文逗号拼接后作为企业微信同步结果
### Requirement: 手动同步必须汇总三类账号来源
系统 SHALL 从 `douyin_account``wechat_account``wx_data` 分别读取抖音、微信、企业微信来源账号,并将结果写入 `wx_phone.douyin_account``wx_phone.wechat``wx_phone.linked_wecom`
#### Scenario: 同一手机号有多个抖音账号
- **WHEN** `douyin_account` 中同一 `phone_number` 对应多条非空账号记录
- **THEN** 系统 MUST 按记录 `id` 升序去空格和去重,并以英文逗号拼接后写入 `wx_phone.douyin_account`
#### Scenario: 来源不存在或账号为空
- **WHEN** 某类来源没有该手机号,或该手机号的账号值均为空
- **THEN** 系统 MUST 保留 `wx_phone` 中该类账号原值,不得清空
#### Scenario: 单张手机号卡的来源查询失败
- **WHEN** 同步某张手机号卡时任一来源查询失败
- **THEN** 系统 MUST 不更新该手机号卡的任一账号结果字段,并将该卡标记为失败
### Requirement: 手动同步范围必须由前端显式确定
系统 SHALL 只接受带有明确 `phoneIds` 的同步请求。前端 SHALL 根据勾选和筛选状态计算该数组,并在同步前向用户显示同步数量。
#### Scenario: 用户勾选手机号卡
- **WHEN** 用户勾选一条或多条手机号卡后点击同步
- **THEN** 前端 MUST 只提交勾选记录的 ID
#### Scenario: 用户未勾选但存在筛选条件
- **WHEN** 用户未勾选手机号卡、当前列表存在筛选条件并点击同步
- **THEN** 前端 MUST 提交当前筛选结果的全部 ID
#### Scenario: 用户未勾选且不存在筛选条件
- **WHEN** 用户未勾选手机号卡、当前列表不存在筛选条件并点击同步
- **THEN** 前端 MUST 提交当前手机号列表的全部 ID
\ No newline at end of file
## ADDED Requirements
### Requirement: 保号套餐字段必须成为唯一套餐字段
系统 SHALL 在手机号卡新增和编辑界面显示“保号套餐”开关,使用 `numberProtectionPackage` 作为前端、保存接口和实体字段,使用 `number_protection_package` 作为 `wx_phone` 数据库列。系统 SHALL 不再接受或返回 `packageChange5yuan`
#### Scenario: 新增手机号卡时办理保号套餐
- **WHEN** 用户在新增手机号卡弹窗中将“保号套餐”设为已办理并提交
- **THEN** 系统 MUST 将 `numberProtectionPackage=true` 保存到 `wx_phone.number_protection_package`
#### Scenario: 普通保存请求携带旧套餐字段
- **WHEN** 客户端向普通新增或编辑接口提交 `packageChange5yuan`
- **THEN** 系统 MUST 返回 400,且不得修改手机号卡记录
### Requirement: 微信字段必须表示微信账号文本
系统 SHALL 将 `wx_phone.wechat` 用作可空微信账号文本,不得再将其解释为“开通微信”的真假状态。
#### Scenario: 查看已有手机号卡
- **WHEN** 客户端查看手机号卡列表或详情
- **THEN** 系统 MUST 返回 `wechat` 的文本账号值或空值,页面 MUST 不显示“已开通 / 未开通”微信状态
### Requirement: 预览库结构必须由人工迁移并在启动时校验
系统 MUST 使用一次性人工迁移将套餐列改名、将旧 `wechat` 布尔列替换为文本列,并创建最终所需来源表。应用启动时 MUST 只校验最终结构,不得自动执行 `ALTER TABLE`
#### Scenario: 最终结构完整时启动
- **WHEN** `wx_phone` 最终字段和所需来源表均存在
- **THEN** 应用 MUST 正常启动且不得修改任何表结构
#### Scenario: 最终结构缺失时启动
- **WHEN** 任一必需列、`douyin_account` 表或 `wechat_account` 表缺失
- **THEN** 应用 MUST 停止启动并给出明确错误信息
\ No newline at end of file
## 1. 提交前准备
- [ ] 1.1 重新检查工作区未提交差异,确认本变更不会覆盖既有前端、企微维护或启动脚本改动。
- [ ] 1.2 备份预览库 `wx_phone` 的结构和数据,并保存 `SHOW CREATE TABLE wx_phone` 输出。
- [ ] 1.3 检查现有 `wx_data``phone_number` 索引;缺失时仅新增索引,不修改 `wx_data.account` 数据。
## 2. 预览库结构与后端契约
- [ ] 2.1 编写一次性预览库迁移 SQL:套餐列直接改名,旧 `wechat` 布尔列替换为可空文本,账号结果列调整为可空文本,创建 `douyin_account``wechat_account` 和手机号索引。
- [ ] 2.2 将 `PhoneMybatisConfig` 从自动改表调整为最终结构检查;缺列或缺表时明确阻止启动。
- [ ] 2.3 将 `WxPhoneEntity``WxPhoneSaveRequest``WxPhoneService` 改为最终套餐字段,移除普通保存的三类账号字段和旧微信开关字段。
- [ ] 2.4 让普通新增初始化三个账号结果为空,让普通编辑保留数据库已有账号结果;旧字段或账号字段进入普通保存接口时返回 400。
- [ ] 2.5 为本次修改和新增的每个方法补齐中文新手注释:代码作用、关联文件、调用链或数据流。
## 3. 三类账号查询与手动同步
- [ ] 3.1 新建抖音和微信来源查询 Mapper,按 `phone_number``id ASC` 返回账号列表。
- [ ] 3.2 扩展现有 `WxDataMapper`,按 `phone_number``id ASC` 返回 `account` 列表,作为企业微信来源;不得新建企业微信来源表。
- [ ] 3.3 新建同步请求、结果 DTO 和同步服务:三类查询全部成功后,再一次更新 `wx_phone`;无有效来源保留原值;单卡失败不写入,批量继续。
- [ ] 3.4 在 `WxPhoneController` 新增 `POST /api/wx-phones/account-sync`,仅接受明确 `phoneIds`
- [ ] 3.5 为来源查询、账号合并、单卡同步和批量汇总等每个新增或修改方法补齐中文新手注释。
## 4. 前端展示与同步交互
- [ ] 4.1 将字段适配器、详情抽屉和列表从微信布尔状态改为微信账号文本,并展示抖音、微信、企业微信三个结果字段。
- [ ] 4.2 从手机号新增/编辑弹窗移除三类账号输入和“开通微信”开关;保号套餐改为“已办理 / 未办理”。
- [ ] 4.3 移除列表对三类账号的行内编辑,保留只读显示。
- [ ] 4.4 新增同步按钮和确认弹窗:勾选优先;无勾选时按筛选状态发送筛选结果或全量 ID;目标为空时不调用接口。
- [ ] 4.5 显示同步汇总,成功后刷新列表;失败明细保留给用户查看。
- [ ] 4.6 为目标计算、确认、请求、结果提示和列表刷新等每个新增或修改方法补齐中文新手注释。
## 5. 自动化验证与验收
- [ ] 5.1 新增后端测试:多行来源合并、`wx_data.account` 企业微信复用、空来源保留、单卡失败不写入、批量失败隔离、普通保存拒绝旧字段和账号字段。
- [ ] 5.2 执行 `mvn test``mvn package``npm run build`,记录结果。
- [ ] 5.3 本地 smoke 验证单条同步、勾选同步、筛选结果同步、无筛选全量同步、空来源保留和列表回显。
- [ ] 5.4 记录迁移前备份位置、实际迁移结果、验证证据和未执行项原因。
\ No newline at end of file
## Context
旧 Vue 运行时代码、样式、Vite 配置与包文件散落在根目录;phone/wechat 前后端依赖旧表与独立数据源。用户要求彻底删除旧业务实现,但保留不可操作的界面参考,并将新资产持久层统一到 `.env` 主数据源。
## Goals / Non-Goals
**Goals:**
- 将可构建的 Vite 应用完整迁入 `frontend/`
- 保留 `/reference/phone``/reference/wechat` 静态页面,且不发送旧 API 请求。
- 删除旧 phone/wechat 后端模块和双数据源配置。
- 为全部 `as_*` 表新增 Entity 与 Mapper。
**Non-Goals:**
- 不创建 Service、Controller、DTO、鉴权或真实资产 API。
- 不读取 `.env`、连接数据库或操作表。
- 不迁移旧数据,也不维持旧接口兼容。
## Decisions
- `frontend/` 作为独立 npm/Vite 根目录;保留 Vite `base: '/assets/'` 与 5173/4173 端口。
- 参考界面以新的 Vue 静态组件重建,不导入旧 `app-v2.js`,按钮禁用且无 `fetch`
- 删除 `PhoneMybatisConfig`,主应用只扫描 `com.xyw.console.asset.mapper`;资产 Mapper 由 Spring 主数据源创建。
- Entity 显式映射所有列;JSON 列为 `String`,时间列为 `LocalDateTime`,不引入 TypeHandler。
## Risks / Trade-offs
- [旧接口消失] → 静态参考页替代旧业务页,并在按钮处提示不可操作。
- [前端搬迁路径失效] → 同步迁移 package、Vite、Playwright、启动脚本与 README,并运行构建和 E2E。
- [删除错误文件] → 仅删除清点到的旧 phone/wechat 源文件;不删除 `.env`、数据库或图片素材。
- [软删除规则未自动执行] → 本期 Mapper 不提供业务查询;后续 Service 显式处理 `delete_time = 0`
## Migration Plan
1. 先写 Playwright 失败测试,定义两条无 API 参考路由。
2. 创建新的 `frontend/` Vite 应用和静态参考页,通过测试后才删除旧前端运行时代码。
3. 删除旧后端 phone/wechat 目录和独立数据源,再新增 asset Entity/Mapper。
4. 执行前端构建、Playwright 与后端编译;失败时恢复到变更前的 Git 状态,不执行数据库操作。
## Open Questions
- 无;用户已确认旧接口、旧表和旧模块不需要兼容。
## Why
当前前端入口、样式与旧运行时代码散落在仓库根目录,旧 phone/wechat 模块还依赖已废弃的双数据源和旧表。资产表已切换为 `as_*` 命名,需要一个干净的前端边界与统一 `.env` 数据源下的持久层基础。
## What Changes
- **BREAKING** 删除旧 phone/wechat 后端模块、旧接口及独立 phone 数据源。
- **BREAKING** 删除旧 phone/wechat 前端业务运行时代码与 API 客户端;保留不调用 API 的静态参考界面。
- 将 Vite 应用迁移至 `frontend/`,保留 `/assets/` 基础路径和现有端口。
- 新增 11 张 `as_*` 表的 MyBatis-Plus Entity 与 Mapper,并仅使用 `.env` 主数据源。
## Capabilities
### New Capabilities
- `asset-persistence-foundation`: `as_*` 资产表的 Entity、Mapper 及统一主数据源扫描。
- `legacy-ui-reference`: phone/wechat 的静态、不可操作、无 API 请求参考界面。
- `frontend-workspace-layout`: 独立 `frontend/` 应用目录及对应构建、启动、测试入口。
### Modified Capabilities
- 无。
## Impact
- 删除旧 `/api/wx-phones``/api/wechat-records` 后端能力;旧运行时 UI 不再可用。
- 前端开发命令改为在 `frontend/` 目录运行。
- 不读取、展示或修改 `.env` 内容;数据库不执行 DDL/DML。
## ADDED Requirements
### Requirement: 资产表持久层
系统 MUST 为 11 张 `as_*` 表提供带精确 `@TableName` 和字段映射的 MyBatis-Plus Entity 与 `BaseMapper` Mapper。
#### Scenario: 编译资产持久层
- **WHEN** 后端执行 Maven 编译
- **THEN** 所有资产 Entity 和 Mapper 必须编译成功,且不依赖 phone/wechat 模块
### Requirement: 统一主数据源
系统 MUST 删除独立 phone 数据源配置,资产 Mapper MUST 由 `.env` 提供的 Spring 主数据源扫描。
#### Scenario: 应用扫描 Mapper
- **WHEN** Spring 创建 MyBatis Mapper
- **THEN**`com.xyw.console.asset.mapper` 被主应用扫描,且没有 phone 专用 SqlSessionTemplate
## ADDED Requirements
### Requirement: 独立前端工作区
系统 MUST 将 Vite 前端入口、包配置、源码、公开素材和测试置于 `frontend/`,根目录不再承载前端应用入口。
#### Scenario: 在前端目录构建
- **WHEN**`frontend/` 执行 `npm run build`
- **THEN** Vite 必须成功生成使用 `/assets/` 基础路径的构建产物
## ADDED Requirements
### Requirement: 静态旧界面参考
系统 MUST 提供 phone 和 wechat 两个静态参考路由,显示示例数据与不可操作提示。
#### Scenario: 打开参考路由
- **WHEN** 用户访问 `#/reference/phone``#/reference/wechat`
- **THEN** 页面显示对应参考界面、所有业务操作不可用,且不请求旧 API
## 1. Frontend reference and workspace
- [x] 1.1 Add failing Playwright tests for the phone/wechat reference routes, disabled controls, and zero old API requests.
- [x] 1.2 Create the `frontend/` Vite workspace, static shell, routes, reference views, styles, package configuration, and public images.
- [x] 1.3 Run the reference tests until they pass, then move front-end build and test configuration into `frontend/`.
- [x] 1.4 Update launch scripts and README for the new front-end directory.
## 2. Legacy removal
- [x] 2.1 Delete the old phone/wechat frontend runtime modules, API clients, root legacy entries, and obsolete root front-end configuration.
- [x] 2.2 Delete old phone/wechat backend modules and the independent phone data-source configuration.
## 3. Asset persistence foundation
- [x] 3.1 Configure the main application to scan only the new asset Mapper package.
- [x] 3.2 Add all `as_*` Entity classes with complete column mappings and beginner-readable class documentation.
- [x] 3.3 Add all `as_*` BaseMapper interfaces.
## 4. Verification and handoff
- [x] 4.1 Run front-end build and Playwright tests.
- [ ] 4.2 Run Maven compilation without database operations.
- [x] 4.3 Validate the OpenSpec change and record completed tasks.
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
import { defineConfig } from '@playwright/test';
/**
* 代码作用(白话):让浏览器回归测试自动启动本地 Vite,并固定使用 Chromium 与 tests 目录;关联文件:F:/Project/xyw_console/package.json、F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):npm run test:e2e -> Playwright 配置 -> Vite :5173 -> 生命周期测试。
*/
export default defineConfig({
testDir: './tests',
timeout: 30_000,
use: {
baseURL: 'http://127.0.0.1:5173',
browserName: 'chromium'
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: 'http://127.0.0.1:5173',
reuseExistingServer: false,
timeout: 30_000
}
});
import * as Vue from 'vue/dist/vue.esm-bundler.js';
import * as VueRouter from 'vue-router';
import * as ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import '../styles-v2.css';
/**
* 代码作用(白话):列出旧页面必须按顺序加载的模块与其加载后应提供的全局对象;关联文件:assets/index.html、src/router/index.js、app-v2.js;关联逻辑(调用链/消息链/数据流):bootstrap() -> loadLegacyModules() -> window 全局能力检查 -> startCompatibilityPage()。
*/
const legacyModules = [
{ source: './modules/domain/domain-add-dialog.js', expectedGlobal: 'DomainAddDialogAPI' },
{ source: './modules/domain/domain-list-runtime.js', expectedGlobal: 'DomainListRuntime' },
{ source: './modules/overview/overview-list-runtime.js', expectedGlobal: 'OverviewListRuntime' },
{ source: './modules/wechat/wechat-add-dialog.js', expectedGlobal: 'WechatAddDialogAPI' },
{ source: './modules/wechat/wechat-detail-drawer.js', expectedGlobal: 'WechatDetailDrawerAPI' },
{ source: './modules/wechat/wechat-list-runtime.js', expectedGlobal: 'WechatListRuntime' },
{ source: './modules/phone/phone-add-dialog.js', expectedGlobal: 'PhoneAddDialogAPI' },
{ source: './modules/phone/phone-detail-drawer.js', expectedGlobal: 'PhoneDetailDrawerAPI' },
{ source: './modules/phone/phone-image-cell.js', expectedGlobal: 'PhoneCardImageCell' },
{ source: './modules/phone/phone-list-runtime.js', expectedGlobal: 'PhoneListRuntime' },
{ source: './modules/shared/record-adapters.js', expectedGlobal: 'RecordAdapters' },
{ source: './modules/shared/wechat-api-client.js', expectedGlobal: 'WechatApiClient' },
{ source: './modules/shared/phone-api-client.js', expectedGlobal: 'PhoneApiClient' },
{ source: './router/index.js', expectedGlobal: 'initRouterApp' },
{ source: '../app-v2.js', expectedGlobal: 'initPage' }
];
/**
* 代码作用(白话):把 Vite 导入的框架对象放回旧脚本依赖的 window 位置;关联文件:src/router/index.js、src/modules/**、app-v2.js;关联逻辑(调用链/消息链/数据流):bootstrap() -> exposeFrameworkGlobals() -> 旧模块读取 window.Vue/window.VueRouter/window.ElementPlus。
*/
function exposeFrameworkGlobals() {
window.Vue = Vue;
window.VueRouter = VueRouter;
window.ElementPlus = ElementPlus;
}
/**
* 代码作用(白话):根据固定模块标识动态导入一个旧脚本,保留其既有的 window 全局模块模式;关联文件:src/main.js、src/modules/**、src/router/index.js、app-v2.js;关联逻辑(调用链/消息链/数据流):loadLegacyModules() -> importLegacyModule() -> 浏览器加载旧模块 -> 模块挂载 window 对象。
*/
async function importLegacyModule(source) {
switch (source) {
case './modules/domain/domain-add-dialog.js': return import('./modules/domain/domain-add-dialog.js');
case './modules/domain/domain-list-runtime.js': return import('./modules/domain/domain-list-runtime.js');
case './modules/overview/overview-list-runtime.js': return import('./modules/overview/overview-list-runtime.js');
case './modules/wechat/wechat-add-dialog.js': return import('./modules/wechat/wechat-add-dialog.js');
case './modules/wechat/wechat-detail-drawer.js': return import('./modules/wechat/wechat-detail-drawer.js');
case './modules/wechat/wechat-list-runtime.js': return import('./modules/wechat/wechat-list-runtime.js');
case './modules/phone/phone-add-dialog.js': return import('./modules/phone/phone-add-dialog.js');
case './modules/phone/phone-detail-drawer.js': return import('./modules/phone/phone-detail-drawer.js');
case './modules/phone/phone-image-cell.js': return import('./modules/phone/phone-image-cell.js');
case './modules/phone/phone-list-runtime.js': return import('./modules/phone/phone-list-runtime.js');
case './modules/shared/record-adapters.js': return import('./modules/shared/record-adapters.js');
case './modules/shared/wechat-api-client.js': return import('./modules/shared/wechat-api-client.js');
case './modules/shared/phone-api-client.js': return import('./modules/shared/phone-api-client.js');
case './router/index.js': return import('./router/index.js');
case '../app-v2.js': return import('../app-v2.js');
default: throw new Error(`未知旧模块:${source}`);
}
}
/**
* 代码作用(白话):在旧模块无法导入或没有提供约定全局对象时显示可定位的启动错误,避免半成品页面继续运行;关联文件:src/main.js、src/router/index.js、app-v2.js;关联逻辑(调用链/消息链/数据流):loadLegacyModules()/bootstrap() 失败 -> showStartupFailure() -> 页面展示来源文件和缺失对象或错误原因。
*/
function showStartupFailure(source, expectedGlobal, error) {
const message = expectedGlobal
? `无法启动资产后台:${source} 未提供 window.${expectedGlobal}`
: `无法启动资产后台:${source} 加载失败(${error.message})`;
console.error(message, error);
document.body.replaceChildren();
const errorPanel = document.createElement('pre');
errorPanel.textContent = message;
errorPanel.style.cssText = 'margin:24px;padding:16px;color:#991b1b;background:#fef2f2;border:1px solid #fecaca;white-space:pre-wrap;';
document.body.append(errorPanel);
}
/**
* 代码作用(白话):按页面原有依赖顺序加载全部旧模块,并确认每一步都已挂载预期全局对象;关联文件:src/modules/**、src/router/index.js、app-v2.js;关联逻辑(调用链/消息链/数据流):bootstrap() -> loadLegacyModules() -> importLegacyModule() -> window 对象检查 -> startCompatibilityPage()。
*/
async function loadLegacyModules() {
for (const legacyModule of legacyModules) {
try {
await importLegacyModule(legacyModule.source);
} catch (error) {
showStartupFailure(legacyModule.source, '', error);
return false;
}
if (!window[legacyModule.expectedGlobal]) {
showStartupFailure(legacyModule.source, legacyModule.expectedGlobal, new Error('缺少全局对象'));
return false;
}
}
return true;
}
/**
* 代码作用(白话):在全部旧模块就绪后先等待路由解析当前 Hash 地址,再启动页面逻辑,替代旧的 DOMContentLoaded 自动启动;关联文件:src/router/index.js、app-v2.js;关联逻辑(调用链/消息链/数据流):bootstrap() -> startCompatibilityPage() -> await window.initRouterApp() -> window.initPage()。
*/
async function startCompatibilityPage() {
await window.initRouterApp();
window.initPage();
}
/**
* 代码作用(白话):作为 Vite 页面唯一启动入口,确保框架、旧模块和初始化动作按安全顺序执行;关联文件:index.html、src/main.js、src/router/index.js、app-v2.js;关联逻辑(调用链/消息链/数据流):index.html module script -> bootstrap() -> 全局对象 -> 旧模块 -> 路由与页面启动。
*/
async function bootstrap() {
exposeFrameworkGlobals();
if (await loadLegacyModules()) {
await startCompatibilityPage();
}
}
void bootstrap();
\ No newline at end of file
(function initDomainAddDialog() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
const DomainAddDialog = {
setup() {
const visible = ref(false);
const formRef = ref(null);
const formData = reactive({
domain: '',
riskLevel: '',
expirationDate: '',
owner: '',
project: '',
registrar: '',
registerDate: '',
autoRenewal: 'manual',
dns: '',
remark: ''
});
const rules = {
domain: [{ required: true, message: '请输入域名', trigger: 'blur' }],
riskLevel: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
expirationDate: [{ required: true, message: '请选择到期时间', trigger: 'change' }],
owner: [{ required: true, message: '请输入或选择所有者', trigger: 'change' }],
project: [{ required: true, message: '请选择所属项目', trigger: 'change' }],
autoRenewal: [{ required: true, message: '请选择续费方式', trigger: 'change' }]
};
const open = () => {
visible.value = true;
// reset form on open
if (formRef.value) {
formRef.value.resetFields();
} else {
formData.domain = '';
formData.riskLevel = '';
formData.expirationDate = '';
formData.owner = '';
formData.project = '';
formData.registrar = '';
formData.registerDate = '';
formData.autoRenewal = 'manual';
formData.dns = '';
formData.remark = '';
}
};
const close = () => {
visible.value = false;
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate((valid) => {
if (valid) {
ElementPlus.ElMessage.success('提交成功');
close();
} else {
return false;
}
});
};
// expose to window so other scripts can call it
window.DomainAddDialogAPI = {
open
};
return {
visible,
formRef,
formData,
rules,
close,
handleSubmit
};
},
template: `
<el-dialog v-model="visible" width="640px" top="8vh">
<template #header>
<div style="text-align: center; font-size: 18px; font-weight: bold; color: #303133;">新增域名记录</div>
</template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="90px" label-position="right">
<el-form-item label="域名地址" prop="domain">
<el-input v-model="formData.domain" placeholder="请输入域名,例如 example.com"></el-input>
</el-form-item>
<div style="font-size: 15px; font-weight: 600; color: #303133; margin: 24px 0 16px 0; line-height: 1;">基本配置:</div>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="风险等级" prop="riskLevel">
<el-select v-model="formData.riskLevel" placeholder="请选择" style="width: 100%">
<el-option label="低风险" value="low"></el-option>
<el-option label="中风险" value="medium"></el-option>
<el-option label="高风险" value="high"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="到期时间" prop="expirationDate">
<el-date-picker v-model="formData.expirationDate" type="date" placeholder="请选择日期" style="width: 100%"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所有者" prop="owner">
<el-select v-model="formData.owner" placeholder="请选择/输入" filterable allow-create style="width: 100%">
<el-option label="张三" value="张三"></el-option>
<el-option label="李四" value="李四"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属项目" prop="project">
<el-select v-model="formData.project" placeholder="请选择项目" style="width: 100%">
<el-option label="主站项目" value="project1"></el-option>
<el-option label="活动推广" value="project2"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="注册商" prop="registrar">
<el-select v-model="formData.registrar" placeholder="请选择/输入" filterable allow-create style="width: 100%">
<el-option label="阿里云" value="阿里云"></el-option>
<el-option label="腾讯云" value="腾讯云"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="注册时间" prop="registerDate">
<el-date-picker v-model="formData.registerDate" type="date" placeholder="请选择日期" style="width: 100%"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="续费方式" prop="autoRenewal">
<el-radio-group v-model="formData.autoRenewal">
<el-radio label="manual">手动续费</el-radio>
<el-radio label="auto">自动续费</el-radio>
</el-radio-group>
</el-form-item>
<div style="font-size: 15px; font-weight: 600; color: #303133; margin: 24px 0 16px 0; line-height: 1;">网络与备注:</div>
<el-form-item label="DNS 服务器" prop="dns">
<el-input type="textarea" v-model="formData.dns" :rows="2" placeholder="请输入 DNS 服务器地址,多个用逗号或回车分隔"></el-input>
</el-form-item>
<el-form-item label="备注信息" prop="remark">
<el-input type="textarea" v-model="formData.remark" :rows="3" placeholder="请输入该域名的核心用途或备注说明..."></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="handleSubmit">确认提交</el-button>
</span>
</template>
</el-dialog>
`
};
const app = createApp(DomainAddDialog);
app.use(ElementPlus);
app.mount('#addDomainDialogRoot');
})();
(function initPhoneDetailDrawer() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
const PhoneDetailDrawer = {
setup() {
const visible = ref(false);
const recordData = ref(null);
const open = (record) => {
if (!record) return;
recordData.value = { ...record };
visible.value = true;
};
const close = () => {
visible.value = false;
};
const handleEdit = () => {
if (window.PhoneAddDialogAPI && recordData.value) {
window.PhoneAddDialogAPI.open('edit', recordData.value);
}
};
// Expose to global scope so app-v2.js can call it
window.PhoneDetailDrawerAPI = {
open,
close
};
return {
visible,
recordData,
close,
handleEdit
};
},
template: `
<el-drawer
v-model="visible"
:with-header="false"
size="480px"
direction="rtl"
custom-class="wechat-detail-drawer-vue premium-drawer"
>
<div v-if="recordData" class="wechat-drawer-inner" style="height: 100%; display: flex; flex-direction: column; background: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<!-- Hero Header -->
<div style="padding: 40px 32px 24px 32px; position: relative;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="display: flex; align-items: center; gap: 20px;">
<div style="width: 56px; height: 56px; border-radius: 14px; background: #f3f4f6; border: 1px solid #e5e7eb; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 600; color: #111827; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
📱
</div>
<div>
<h3 style="margin: 0 0 4px 0; font-size: 24px; font-weight: 700; color: #111827; letter-spacing: -0.5px; display: flex; align-items: center; gap: 10px;">
{{ recordData.phoneNumber || '未知号码' }}
<span :style="{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: '8px', height: '8px', borderRadius: '50%',
background: (recordData.cardStatus === '异常停机' || recordData.numberStatus === false) ? '#ef4444' : '#22c55e'
}"></span>
</h3>
<div style="font-size: 14px; color: #6b7280; font-weight: 400; letter-spacing: 0.2px;">
{{ recordData.realPerson || '未登记实名' }} &nbsp;&middot;&nbsp; {{ recordData.channelOperator || '未知运营商' }}
</div>
</div>
</div>
<button @click="close" style="background: none; border: none; cursor: pointer; color: #9ca3af; padding: 4px; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: background 0.2s;" onmouseover="this.style.background='#f3f4f6'; this.style.color='#4b5563'" onmouseout="this.style.background='none'; this.style.color='#9ca3af'">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div>
<!-- Body -->
<div style="flex: 1; overflow-y: auto; padding: 0 32px 40px 32px; display: flex; flex-direction: column; gap: 40px;">
<!-- Section 1: Basic Info Bento Grid -->
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px;">基础属性</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px 16px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">实名人</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.realPerson || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">归属地</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.city || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">使用地</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.cardUsageLocation || '—' }}</span>
</div>
</div>
</section>
<!-- Section 2: 状态与生态 -->
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px;">状态与生态</h4>
<div style="display: grid; grid-template-columns: 1fr; gap: 24px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">外呼</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.outboundCall ? '支持' : '不支持' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">微信</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.wechat ? '已开通' : '未开通' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">企微</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.wecom ? '已开通' : '未开通' }}</span>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">绑定企微号</span>
<span style="font-size: 15px; font-weight: 400; color: #374151;">{{ recordData.linkedWecom || '—' }}</span>
</div>
</div>
</section>
</div>
<!-- Footer -->
<div style="padding: 24px 32px; display: flex; gap: 16px; background: #ffffff; border-top: 1px solid #f3f4f6;">
<button @click="close" style="flex: 1; padding: 12px 0; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 14px; font-weight: 500; color: #374151; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='#ffffff'">
关闭面板
</button>
<button @click="handleEdit" style="flex: 1; padding: 12px 0; background: #111827; border: 1px solid #111827; border-radius: 8px; font-size: 14px; font-weight: 500; color: #ffffff; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);" onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 6px 8px -1px rgba(0,0,0,0.15)'" onmouseout="this.style.transform='none'; this.style.boxShadow='0 4px 6px -1px rgba(0,0,0,0.1)'">
编辑号卡
</button>
</div>
</div>
</el-drawer>
`
};
const app = createApp(PhoneDetailDrawer);
app.use(ElementPlus);
app.mount('#phoneDetailDrawerRoot');
})();
(function attachPhoneImageCell() {
const { ref } = window.Vue;
function handleImageError(event) {
const image = event?.target;
if (!image) { return; }
image.style.display = 'none';
const placeholder = image.nextElementSibling;
if (placeholder) { placeholder.style.display = 'flex'; }
}
const PhoneCardImageCell = {
name: 'PhoneCardImageCell',
props: {
images: { type: Array, default: () => [] },
recordId: { type: [String, Number], default: null },
clearImage: { type: Function, default: null },
uploadImage: { type: Function, default: null }
},
setup(props) {
const previewVisible = ref(false);
const previewIndex = ref(0);
const imageInputRef = ref(null);
const uploading = ref(false);
function openPreview(index) { previewIndex.value = index; previewVisible.value = true; }
function closePreview() { previewVisible.value = false; }
function handleDelete(index) { if (typeof props.clearImage === 'function') { props.clearImage(index); } }
function triggerUpload() {
if (uploading.value) { return; }
if (typeof props.uploadImage !== 'function') { return; }
imageInputRef.value?.click();
}
async function handleFileChange(event) {
const input = event?.target;
const files = Array.from(input?.files || []);
if (!files.length) { if (input) { input.value = ''; } return; }
uploading.value = true;
try { await props.uploadImage(files); } finally { uploading.value = false; if (input) { input.value = ''; } }
}
return { previewVisible, previewIndex, imageInputRef, openPreview, closePreview, handleDelete, handleImageError, triggerUpload, handleFileChange };
},
template: `
<div class="phone-image-cell" @click.stop @mousedown.stop @dblclick.stop>
<input ref="imageInputRef" type="file" accept="image/*" multiple class="phone-image-input" @change="handleFileChange" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;" />
<template v-if="images && images.length">
<div v-for="(image, index) in images" :key="(recordId ?? 'row') + '-img-' + index" class="phone-image-thumb" @click.stop @mousedown.stop @dblclick.stop>
<img :src="image" alt="thumbnail" class="phone-image-img" @error="handleImageError" />
<div class="phone-image-fallback">双击上传</div>
<div class="phone-image-actions" @click.stop @mousedown.stop @dblclick.stop>
<button type="button" class="phone-image-action phone-image-action--preview" title="预览" @click.stop="openPreview(index)" @mousedown.stop @dblclick.stop><svg viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M515.2 224c-307.2 0-492.8 313.6-492.8 313.6s214.4 304 492.8 304 492.8-304 492.8-304S822.4 224 515.2 224zM832 652.8c-102.4 86.4-211.2 140.8-320 140.8s-217.6-51.2-320-140.8c-35.2-32-70.4-64-99.2-99.2-6.4-6.4-9.6-12.8-16-19.2 3.2-6.4 9.6-12.8 12.8-19.2 25.6-35.2 57.6-70.4 92.8-102.4 99.2-89.6 208-144 329.6-144s230.4 54.4 329.6 144c35.2 32 64 67.2 92.8 102.4 3.2 6.4 9.6 12.8 12.8 19.2-3.2 6.4-9.6 12.8-16 19.2C902.4 585.6 870.4 620.8 832 652.8z"/><path fill="currentColor" d="M512 345.6c-96 0-169.6 76.8-169.6 169.6 0 96 76.8 169.6 169.6 169.6 96 0 169.6-76.8 169.6-169.6C681.6 422.4 604.8 345.6 512 345.6zM512 640c-67.2 0-121.6-54.4-121.6-121.6 0-67.2 54.4-121.6 121.6-121.6 67.2 0 121.6 54.4 121.6 121.6C633.6 582.4 579.2 640 512 640z"/></svg></button>
<button type="button" class="phone-image-action phone-image-action--delete" title="删除" @click.stop="handleDelete(index)" @mousedown.stop @dblclick.stop><svg viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M709.469091 209.454545H930.909091a34.909091 34.909091 0 0 1 0 69.818182h-81.454546v607.185455c0 56.366545-44.311273 102.632727-99.746909 102.632727H274.292364c-55.435636 0-99.746909-46.266182-99.746909-102.632727V279.272727H93.090909a34.909091 34.909091 0 0 1 0-69.818182h244.712727a186.181818 186.181818 0 0 1 371.665455 0z m-70.050909 0a116.363636 116.363636 0 0 0-231.563637 0h231.563637z m140.218182 69.818182h-535.272728v607.185455c0 18.455273 13.730909 32.814545 29.928728 32.814545h475.415272c16.174545 0 29.928727-14.359273 29.928728-32.814545V279.272727z m-418.909091 147.2a34.909091 34.909091 0 0 1 69.818182 0v338.897455a34.909091 34.909091 0 0 1-69.818182 0V426.472727z m232.727272 0a34.909091 34.909091 0 0 1 69.818182 0v338.897455a34.909091 34.909091 0 0 1-69.818182 0V426.472727z"/></svg></button>
</div>
</div>
</template>
<div v-else class="phone-image-thumb phone-image-thumb--empty" title="双击上传" @dblclick.stop="triggerUpload" @mousedown.stop @click.stop>
<span class="phone-image-fallback">双击上传</span>
</div>
<el-image-viewer v-if="previewVisible" :url-list="images" :initial-index="previewIndex" :z-index="3000" :hide-on-click-modal="true" teleported @close="closePreview" />
</div>
`
};
window.PhoneCardImageCell = PhoneCardImageCell;
})();
/**
* 代码作用(白话):把手机号接口能力挂载到页面共用对象,并使用相对 `/api` 经 Vite 代理访问本机后端;关联文件:F:/Project/xyw_console/vite.config.js、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):页面模块 -> window.PhoneApiClient -> /api/wx-phones -> Vite :5173 -> Spring Boot :8888。
*/
(function attachPhoneApiClient() {
const BASE_URL = '/api/wx-phones';
/**
* 代码作用(白话):统一处理手机号接口返回,遇到后端报错时直接抛出明确错误,避免列表和弹窗各自重复判断;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):listPhones()/savePhone()/deletePhone() -> parseJsonResponse() -> app-v2.js/手机号模块错误提示。
*/
async function parseJsonResponse(response) {
const json = await response.json();
if (!response.ok || json.code !== 200) {
throw new Error(json.message || '手机号接口请求失败');
}
return json;
}
/**
* 代码作用(白话):把浏览器在本地后端没启动时抛出的网络错误翻成稳定中文,避免页面直接展示原始 Failed to fetch;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):fetch() 失败 -> requestJson() -> 抛出中文错误 -> 外层 catch -> 页面提示。
*/
function isFetchUnavailableError(error) {
const message = String(error?.message || '').toLowerCase();
return error instanceof TypeError && message.includes('failed to fetch');
}
/**
* 代码作用(白话):先请求接口再统一解析 JSON,并在网络断开或本地后端未启动时返回稳定错误文案;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):listPhones()/savePhone()/deletePhone() -> requestJson() -> parseJsonResponse() -> 外层页面提示。
*/
async function requestJson(url, options, networkMessage) {
try {
const response = await fetch(url, options);
return await parseJsonResponse(response);
} catch (error) {
if (isFetchUnavailableError(error)) {
throw new Error(networkMessage);
}
throw error;
}
}
/**
* 代码作用(白话):从本地手机号接口拉取完整列表,返回后端 data 数组给外层适配;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):phone 页面挂载/刷新 -> listPhones() -> app-v2.js -> normalizePhoneRecord() -> 列表渲染。
*/
async function listPhones() {
const json = await requestJson(BASE_URL, undefined, '手机号服务暂时不可用,请确认本地后端已启动');
return Array.isArray(json.data) ? json.data : [];
}
/**
* 代码作用(白话):按新增或编辑模式把手机号表单提交给后端,统一 POST/PUT 分支;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):PhoneAddDialogActions.save() -> savePhone() -> 后端保存 -> afterSave() 刷新列表。
*/
async function savePhone(record) {
const payload = { ...record };
const isEdit = Boolean(payload.id);
const targetUrl = isEdit ? `${BASE_URL}/${payload.id}` : BASE_URL;
console.info('[phone-api] savePhone 请求:id=', payload.id,
'imageAttachment1 长度=', (payload.imageAttachment1 || '').length,
'imageAttachment2 长度=', (payload.imageAttachment2 || '').length);
const json = await requestJson(targetUrl, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}, '手机号保存失败,请确认本地后端已启动');
const data = json.data || json;
console.info('[phone-api] savePhone 响应:id=', payload.id,
'imageAttachment1 长度=', (data?.imageAttachment1 || data?.image_attachment_1 || '').length,
'imageAttachment2 长度=', (data?.imageAttachment2 || data?.image_attachment_2 || '').length);
return data;
}
/**
* 代码作用(白话):删除指定手机号记录,并把失败信息统一抛给外层处理;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):手机号列表删除按钮 -> deletePhoneRecord() -> deletePhone() -> 刷新列表或提示失败。
*/
async function deletePhone(id) {
const json = await requestJson(`${BASE_URL}/${id}`, {
method: 'DELETE'
}, '手机号删除失败,请确认本地后端已启动');
return json.data || json;
}
window.PhoneApiClient = {
listPhones,
savePhone,
deletePhone
};
})();
(function attachRecordAdapters() {
/**
* 代码作用(白话):把企微原始记录整理成列表、详情、编辑弹窗都能直接消费的统一字段;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):后端/本地记录 -> normalizeWechatRecord() -> app-v2.js 注入 -> 企微列表/详情/编辑弹窗。
*/
function normalizeWechatRecord(row) {
const source = row || {};
const departmentMap = {
Marketing: '市场部',
Operations: '运营部',
Sales: '销售部'
};
const ownerStatusRaw = source.ownerStatus ?? source.real_name_owner_status ?? source.realNameOwnerStatus;
const status = ownerStatusRaw === 'abnormal' || ownerStatusRaw === '异常' || ownerStatusRaw === 0 ? '异常' : '正常';
const remark = source.remark || source.project || '暂无备注';
const accountLocation = source.account_location || source.accountLocation || source['账号位置'] || '';
const phoneImage = source.phone_image || source.phoneImage || source['手机图片'] || '';
const departmentName = departmentMap[source.department] || source.department || source.department_name || source.departmentName || '';
const realName = source.realName || source.real_name || '';
const aliasName = source.alias_name || source.alias || source.aliasName || '';
const account = source.account || source.wxId || '';
const phoneNumber = source.phone_number || source.phone || source.phoneNumber || '';
const ownerName = source.real_name_owner || source.owner || source.realNameOwner || '';
return {
...source,
realName,
real_name: realName,
alias: aliasName,
alias_name: aliasName,
account,
department: departmentName || '未分配',
department_name: departmentName,
phone: phoneNumber,
phone_number: phoneNumber,
owner: ownerName,
real_name_owner: ownerName,
ownerStatus: status,
real_name_owner_status: status === '正常' ? 1 : 0,
status,
wxId: source.wxId || account,
createdAt: source.createdAt || source.created_at || '',
created_at: source.created_at || source.createdAt || '',
updatedAt: source.updatedAt || source.updated_at || '',
updated_at: source.updated_at || source.updatedAt || '',
remark,
project: source.project || remark,
gender: source.gender || '',
account_location: accountLocation,
accountLocation,
'账号位置': accountLocation,
phone_image: phoneImage,
phoneImage,
'手机图片': phoneImage
};
}
/**
* 代码作用(白话):把手机号真假值统一转成布尔值,避免不同接口口径导致列表和弹窗判断不一致;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):后端字段原值 -> toBooleanFlag() -> normalizePhoneRecord() -> 列表显示/弹窗回填。
*/
function toBooleanFlag(value, defaultValue) {
if (value === null || value === undefined || value === '') {
return defaultValue;
}
if (value === true || value === 1 || value === '1' || value === 'true' || value === 'enabled') {
return true;
}
if (value === false || value === 0 || value === '0' || value === 'false' || value === 'disabled') {
return false;
}
return Boolean(value);
}
/**
* 代码作用(白话):把两个 SQL 图片字段整理成前端统一图片集合,让列表和弹窗都按一个图片字段来理解;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):image_attachment_1/image_attachment_2 -> buildPhoneImages() -> normalizePhoneRecord() -> 图片列/图片上传区域。
*/
function buildPhoneImages(source) {
return [source.imageAttachment1, source.image_attachment_1, source.imageAttachment2, source.image_attachment_2]
.filter(Boolean)
.filter((value, index, array) => array.indexOf(value) === index)
.slice(0, 2);
}
/**
* 代码作用(白话):把手机号原始记录整理成列表、详情抽屉、编辑弹窗共用的统一字段,避免每个组件自己猜字段名;支持空值提交,使用 ?? 运算符区分 undefined/null 与空字符串;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):PhoneApiClient/DATA_SOURCES.phone -> normalizePhoneRecord() -> app-v2.js 注入 -> 手机号列表/详情/编辑弹窗。
*/
/**
* ????????????????????????????????????????????? status ??????????????F:/Project/xyw_console/src/modules/shared/record-adapters.js?F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js?F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js?????????/???/????????? -> normalizePhoneRecord() -> ??/??/?????
*/
function normalizePhoneRecord(row) {
const source = row || {};
const images = buildPhoneImages(source);
// cardStatus 支持空值:空字符串表示用户主动清空,应保持为空
const cardStatusRaw = source.cardStatus ?? source.card_status ?? source.status;
const cardStatus = cardStatusRaw === 0 || cardStatusRaw === '0' || cardStatusRaw === '异常'
? '异常停机'
: (cardStatusRaw ?? '正常');
const phoneNumber = source.phoneNumber ?? source.phone_number ?? source.phone ?? '';
// realPerson 支持空值:空字符串表示用户主动清空
const realPerson = source.realPerson ?? source.real_person ?? '';
// channelOperator 支持空值:空字符串表示用户主动清空
const channelOperator = source.channelOperator ?? source.channel_operator ?? source.carrier ?? '';
const updatedAt = source.updatedAt ?? source.updated_at ?? source.updateTime ?? '';
const usageLocation = source.cardUsageLocation ?? source.card_usage_location ?? source.usageLocation ?? '';
const wecomEnabled = toBooleanFlag(source.wecom, false);
const wechatEnabled = toBooleanFlag(source.wechat, false);
const outboundEnabled = toBooleanFlag(source.outboundCall ?? source.outbound_call ?? source.outbound, false);
const miniProgramFiled = toBooleanFlag(source.miniProgramFiling ?? source.mini_program_filing, false);
const packageChangeEnabled = toBooleanFlag(source.packageChange5yuan ?? source.package_change_5yuan, false);
const numberStatus = toBooleanFlag(source.numberStatus ?? source.mobile_status ?? source.number_status, true);
const wechatStatus = source.wechatStatus ?? (numberStatus ? '正常' : '异常停机');
const outbound = source.outbound ?? (outboundEnabled ? '可外呼' : '不可外呼');
// linkedWecom 支持空值:空字符串表示用户主动清空
const linkedWecom = source.linkedWecom ?? source.linked_wecom ?? '';
// douyinAccount 支持空值:空字符串表示用户主动清空
const douyinAccount = source.douyinAccount ?? source.douyin_account ?? '';
return {
...source,
id: source.id,
images,
primaryImage: images[0] ?? '',
imageAttachment1: images[0] ?? source.imageAttachment1 ?? source.image_attachment_1 ?? '',
image_attachment_1: images[0] ?? source.imageAttachment1 ?? source.image_attachment_1 ?? '',
imageAttachment2: images[1] ?? source.imageAttachment2 ?? source.image_attachment_2 ?? '',
image_attachment_2: images[1] ?? source.imageAttachment2 ?? source.image_attachment_2 ?? '',
phone: phoneNumber,
phoneNumber,
phone_number: phoneNumber,
realPerson,
real_person: realPerson,
// city 支持空值:空字符串表示用户主动清空
city: source.city ?? '',
usageLocation,
cardUsageLocation: usageLocation,
card_usage_location: usageLocation,
carrier: channelOperator,
channelOperator,
channel_operator: channelOperator,
status: cardStatus,
cardStatus,
card_status: cardStatus,
wechatStatus,
outbound,
outboundCall: outboundEnabled,
outbound_call: outboundEnabled,
wechat: wechatEnabled,
wecom: wecomEnabled,
updatedAt,
updated_at: source.updated_at ?? updatedAt,
updateTime: source.updateTime ?? updatedAt,
owner: source.owner ?? realPerson,
project: source.project ?? source.note ?? '暂无备注',
note: source.note ?? source.project ?? '',
// iccid 支持空值:空字符串表示用户主动清空
iccid: source.iccid ?? '',
douyinAccount,
douyin_account: douyinAccount,
miniProgramFiling: miniProgramFiled,
mini_program_filing: miniProgramFiled,
packageChange5yuan: packageChangeEnabled,
package_change_5yuan: packageChangeEnabled,
numberStatus,
mobileStatus: numberStatus,
mobile_status: numberStatus,
linkedWecom,
linked_wecom: linkedWecom,
};
}
window.RecordAdapters = {
normalizeWechatRecord,
normalizePhoneRecord
};
})();
/**
* 代码作用(白话):把企微接口能力挂载到页面共用对象,并使用相对 `/api` 经 Vite 代理访问本机后端;关联文件:F:/Project/xyw_console/vite.config.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):企微页面 -> window.WechatApiClient -> /api/wechat-records -> Vite :5173 -> Spring Boot :8888。
*/
(function attachWechatApiClient() {
// 当前企微资料接口对应测试数据库;后续切换到生产数据库(正式业务库)时,需要同步改成生产环境对应的后端配置。
const BASE_URL = '/api/wechat-records';
/**
* 代码作用(白话):统一解析企微接口返回,保证前端只认 code/message/data 这一套结构;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):listWechatRecords()/getWechatRecord()/saveWechatRecord() -> parseJsonResponse() -> 页面列表/弹窗提示。
*/
async function parseJsonResponse(response) {
const json = await response.json();
if (!response.ok || json.code !== 200) {
throw new Error(json.message || '企微接口请求失败');
}
return json;
}
/**
* 代码作用(白话):把浏览器在本地后端没启动时抛出的网络错误翻成稳定中文,避免页面直接展示原始 Failed to fetch;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):fetch() 失败 -> requestJson() -> 抛出中文错误 -> 外层 catch -> 页面提示。
*/
function isFetchUnavailableError(error) {
const message = String(error?.message || '').toLowerCase();
return error instanceof TypeError && message.includes('failed to fetch');
}
/**
* 代码作用(白话):先请求接口再统一解析 JSON,并在网络断开或本地后端未启动时返回稳定错误文案;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):listWechatRecords()/getWechatRecord()/saveWechatRecord() -> requestJson() -> parseJsonResponse() -> 外层页面提示。
*/
async function requestJson(url, options, networkMessage) {
try {
const response = await fetch(url, options);
return await parseJsonResponse(response);
} catch (error) {
if (isFetchUnavailableError(error)) {
throw new Error(networkMessage);
}
throw error;
}
}
/**
* 代码作用(白话):从后端拉取企微资料全量列表,交给页面继续做本地筛选和分页;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):wechat 页面挂载/刷新 -> listWechatRecords() -> app-v2.js -> normalizeWechatRecord() -> 列表渲染。
*/
async function listWechatRecords() {
const json = await requestJson(BASE_URL, undefined, '企微服务暂时不可用,请确认本地后端已启动');
return Array.isArray(json.data) ? json.data : [];
}
/**
* 代码作用(白话):按主键读取单条企微资料,让详情抽屉和编辑弹窗拿到数据库最新值;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):查看/编辑按钮 -> getWechatRecord() -> app-v2.js -> 详情抽屉或编辑弹窗打开。
*/
async function getWechatRecord(id) {
const json = await requestJson(`${BASE_URL}/${id}`, undefined, '企微详情读取失败,请确认本地后端已启动');
return json.data || null;
}
/**
* 代码作用(白话):按新增或编辑模式提交企微资料表单,统一收口 POST 和 PUT 两条保存分支;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):保存按钮 -> saveWechatRecord() -> 后端保存 -> afterSave() 刷新列表。
*/
async function saveWechatRecord(record) {
const payload = { ...record };
const isEdit = Boolean(payload.id);
const targetUrl = isEdit ? `${BASE_URL}/${payload.id}` : BASE_URL;
if (!isEdit) {
delete payload.id;
}
const json = await requestJson(targetUrl, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}, '企微保存失败,请确认本地后端已启动');
return json.data || json;
}
window.WechatApiClient = {
listWechatRecords,
getWechatRecord,
saveWechatRecord
};
})();
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