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) ```powershell
npm ci cd frontend
npm install
npm run dev npm run dev
``` ```
开发访问地址:`http://localhost:5173/assets/#/phone-card` 访问:`http://localhost:5173/#/reference/phone`
生产构建仍使用 `/assets/` 基础路径:
支持的 Hash 路由:`overview``domain``wechat``phone-card``alerts`。页面发出的 `/api/...` 请求由 Vite 转发到本机后端 `http://localhost:8888`
## 构建与预览
```powershell ```powershell
cd frontend
npm run build npm run build
npm run preview npm run test:e2e
``` ```
构建产物位于 `dist/`。预览地址固定为 `http://localhost:4173/assets/#/phone-card`;该预览只用于本机检查,不代表生产部署。 ## 后端编译
## 端口与排障 ```powershell
cd backend
- `5173`:Vite 开发服务。 mvn -q -DskipTests compile
- `4173`:Vite 构建预览。 ```
- `8888`:本地 Spring Boot 后端。
- 端口已启用严格模式:若被占用,服务会直接启动失败。请先释放占用端口,再重试,不要改用其他端口。
- `start.bat``start-frontend.bat` 保留以兼容既有脚本,但不再用于前端联调;旧 Python `8000` 入口无法代理相对 `/api` 请求。
## 验收测试数据
本地受控写入验证只允许创建名称或备注以 `VITE-SMOKE-` 开头的手机号卡。请记录创建接口返回的 ID,并在验证新增、编辑和列表刷新后,仅使用同一 ID 删除该记录。 运行 Maven 前需将 `JAVA_HOME` 配置为可用的 JDK 17 路径。
\ No newline at end of file \ No newline at end of file
const DATA_SOURCES = {
domain: [
{
id: 1,
domain: 'example.com',
platform: 'Aliyun',
expirationDate: '2025-05-28',
owner: '张三',
remark: '企业主站',
updatedAt: '2025-05-25 10:30'
},
{
id: 2,
domain: 'demo.ai',
platform: 'Tencent Cloud',
expirationDate: '2025-06-01',
owner: '李四',
remark: '活动备用域名',
updatedAt: '2025-05-23 14:30'
},
{
id: 3,
domain: 'shop.net',
platform: 'West CN',
expirationDate: '2025-06-10',
owner: '赵六',
remark: '商城二级站点',
updatedAt: '2025-05-24 09:42'
}
],
wechat: [
{
id: 101,
real_name: '张三',
alias_name: '阿三',
account: 'zhangsan_88',
department_name: 'Marketing',
phone_number: '13800008888',
real_name_owner: '张三',
real_name_owner_status: 1,
created_at: '2023-03-15 14:32',
updated_at: '2024-05-12 16:30',
remark: '华南区域负责人',
gender: '男',
account_location: '深圳南山区科技园'
},
{
id: 102,
real_name: '李四',
alias_name: '老李',
account: 'lisi_2023',
department_name: 'Operations',
phone_number: '13900001234',
real_name_owner: '李四',
real_name_owner_status: 0,
created_at: '2023-04-12 09:20',
updated_at: '2024-04-30 12:00',
remark: '实名信息待复核',
gender: '男',
account_location: '杭州滨江区'
}
],
phone: [
{
id: 201,
phoneNumber: '13800008888',
realPerson: '张三',
iccid: '89860117023012345678912',
city: '深圳',
cardStatus: '正常',
cardUsageLocation: '深圳南山',
wecom: true,
wechat: true,
outboundCall: true,
channelOperator: '中国移动',
numberStatus: true,
linkedWecom: '张三企业微信',
updateTime: '2024-03-12 10:25',
note: '用于客户联系'
}
],
alerts: [
{
id: 301,
title: 'demo.ai 需要续费跟进',
module: 'domain',
owner: '李四',
dueAt: '2025-06-01',
status: '待处理',
updatedAt: '2025-05-23 14:30',
remark: '付款仍待处理'
}
]
};
const MODULE_CONFIG = {
overview: {
title: '资产管理台',
description: '查看域名、企微、手机号卡和提醒概览。',
actions: [{ text: '导出数据', kind: 'secondary' }, { text: '新增记录', kind: 'primary' }]
},
domain: {
title: '域名资产',
description: '查看域名归属、续费和 SSL 状态。',
actions: [{ text: '同步域名', kind: 'secondary' }, { text: '新增域名', kind: 'primary' }]
},
wechat: {
title: '企微资产',
description: '管理企微资料和相关详情。',
actions: [{ text: '同步数据', kind: 'secondary' }, { text: '新增资料', kind: 'primary' }]
},
phone: {
title: '手机号卡',
description: '跟踪手机号、负责人和号卡状态。',
actions: [{ text: '导入号码', kind: 'secondary' }, { text: '新增号卡', kind: 'primary' }]
},
alerts: {
title: '提醒中心',
description: '查看需要人工跟进的提醒。',
actions: [{ text: '导出提醒', kind: 'secondary' }]
}
};
const state = {
activeModule: '',
selectedBySource: { domain: 1, wechat: 101, phone: 201, alerts: 301 }
};
const moduleMountState = {
wechat: { currentPage: 1, pageSize: 10, selectedIds: [] },
phone: { currentPage: 1, pageSize: 10, selectedIds: [] }
};
let appShell = document.getElementById('appShell');
let pageHeader = document.getElementById('pageHeader');
let pageBody = document.getElementById('pageBody');
let detailDrawer = document.getElementById('detailDrawer');
let detailShellTitle = document.getElementById('detailShellTitle');
let detailBody = document.getElementById('detailBody');
let sidebar = document.getElementById('sidebar');
let sidebarToggle = document.getElementById('sidebarToggle');
let sidebarToggleLabel = sidebarToggle?.querySelector('.nav-toggle-label');
let drawerClose = document.getElementById('drawerClose');
let wechatAppInstance = null;
let phoneAppInstance = null;
window.state = state;
/**
* 代码作用(白话):路由壳层重新挂载后,刷新页面上会被旧脚本复用的 DOM 引用,避免引用指向已经被替换的节点;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/assets/index.html;关联逻辑(调用链/消息链/数据流):RouterView mounted -> refreshDOMRefs() -> renderModule()/updateActiveNav()。
*/
function refreshDOMRefs() {
appShell = document.getElementById('appShell');
pageHeader = document.getElementById('pageHeader');
pageBody = document.getElementById('pageBody');
detailDrawer = document.getElementById('detailDrawer');
detailShellTitle = document.getElementById('detailShellTitle');
detailBody = document.getElementById('detailBody');
sidebar = document.getElementById('sidebar');
sidebarToggle = document.getElementById('sidebarToggle');
sidebarToggleLabel = sidebarToggle?.querySelector('.nav-toggle-label');
drawerClose = document.getElementById('drawerClose');
}
window.refreshDOMRefs = refreshDOMRefs;
window.updateDOMRefs = refreshDOMRefs;
/**
* 代码作用(白话):对字符串做 HTML 转义,防止记录内容直接写进 innerHTML 时破坏页面结构;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):记录对象 -> escapeHtml() -> renderHeader()/renderSimpleBody()/renderDetail() -> 页面 HTML。
*/
function escapeHtml(value) {
return String(value ?? '-')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/**
* 代码作用(白话):读取共享字段适配器,没有加载成功时退回原样数据,避免页面直接崩溃;关联文件:F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):app-v2.js -> getRecordAdapters() -> normalizeWechatRecord()/normalizePhoneRecord()。
*/
function getRecordAdapters() {
return window.RecordAdapters || {
normalizeWechatRecord: (row) => row,
normalizePhoneRecord: (row) => row
};
}
/**
* 代码作用(白话):读取手机号接口客户端,没有加载成功时抛出明确错误,避免列表静默失败;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):loadPhoneRows()/savePhoneRecord()/deletePhoneRecord() -> getPhoneApiClient() -> 接口请求。
*/
function getPhoneApiClient() {
if (!window.PhoneApiClient) {
throw new Error('手机号接口客户端未加载');
}
return window.PhoneApiClient;
}
/**
* 代码作用(白话):读取企微接口客户端,没有加载成功时抛出明确错误,避免企微页面静默回退到假数据;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):loadWechatRows()/getWechatRecordById()/saveWechatRecord() -> getWechatApiClient() -> 接口请求。
*/
function getWechatApiClient() {
if (!window.WechatApiClient) {
throw new Error('企微接口客户端未加载');
}
return window.WechatApiClient;
}
/**
* 代码作用(白话):统一显示失败消息,优先走 Element Plus 的消息提示;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):接口失败/列表失败 -> showError() -> 页面提示。
*/
function showError(message) {
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.error(message);
return;
}
console.error(message);
}
/**
* 代码作用(白话):给简单模块取对应数据源,overview 继续复用域名数据;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):renderModule() -> getModuleRecords() -> renderSimpleBody()/renderDetail()。
*/
function getModuleRecords(moduleKey) {
if (moduleKey === 'overview') {
return DATA_SOURCES.domain;
}
return DATA_SOURCES[moduleKey] || [];
}
/**
* 代码作用(白话):从当前模块记录里找到已选中对象,没有命中时回退到第一条,保证简单详情面板有稳定数据;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):state.selectedBySource -> getSelectedRecord() -> renderDetail()。
*/
function getSelectedRecord(moduleKey) {
const records = getModuleRecords(moduleKey);
if (!records.length) {
return null;
}
const sourceKey = moduleKey === 'overview' ? 'domain' : moduleKey;
const selectedId = state.selectedBySource[sourceKey] ?? records[0].id;
return records.find((record) => record.id === selectedId) || records[0];
}
/**
* 代码作用(白话):处理页面顶部壳层区域,但当前按最新界面要求不再渲染面包屑和顶部按钮,避免旧头部继续占住企微主工作区;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/styles.css;关联逻辑(调用链/消息链/数据流):renderModule() -> renderHeader() -> pageHeader.innerHTML 置空。
*/
function renderHeader(moduleKey) {
if (!pageHeader) {
return;
}
pageHeader.innerHTML = '';
}
/**
* 代码作用(白话):渲染简单列表模块,让 overview、domain、alerts 继续保留基础查看能力;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/styles-v2.css;关联逻辑(调用链/消息链/数据流):renderModule() -> renderSimpleBody() -> handlePageBodyClick() -> renderDetail()。
*/
function renderSimpleBody(moduleKey) {
if (!pageBody) {
return;
}
const records = getModuleRecords(moduleKey);
if (!records.length) {
pageBody.innerHTML = '<div class="note-box">暂无数据。</div>';
return;
}
const sourceKey = moduleKey === 'overview' ? 'domain' : moduleKey;
const rowsHtml = records.map((record) => {
const label = record.domain || record.title || record.realName || record.real_name || record.phone || `记录 ${record.id}`;
const sub = record.owner || record.module || record.platform || record.city || record.remark || '-';
const meta = record.expirationDate || record.updatedAt || record.dueAt || record.phone || '-';
return `
<button type="button" class="simple-list-row" data-record-id="${record.id}" data-source-key="${escapeHtml(sourceKey)}">
<span class="row-main">${escapeHtml(label)}</span>
<span class="row-sub">${escapeHtml(sub)}</span>
<span class="row-meta">${escapeHtml(meta)}</span>
</button>
`;
}).join('');
pageBody.innerHTML = `<div class="simple-list">${rowsHtml}</div>`;
}
/**
* 代码作用(白话):统一生成分页按钮显示列表,让企微模块也能复用一套页码截断规则;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):WechatListTable.setup() -> buildPageNumbers() -> getVisiblePageNumbers()。
*/
function getVisiblePageNumbers(totalPages, currentPage) {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const pages = [1];
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
if (start > 2) {
pages.push('ellipsis');
}
for (let page = start; page <= end; page += 1) {
pages.push(page);
}
if (end < totalPages - 1) {
pages.push('ellipsis');
}
pages.push(totalPages);
return pages;
}
/**
* 代码作用(白话):向企微接口拉取列表并做统一字段适配,把后端记录翻译成页面记录;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):wechat 页面挂载/刷新 -> loadWechatRows() -> listWechatRecords() -> normalizeWechatRecord() -> 列表渲染。
*/
async function loadWechatRows() {
const apiClient = getWechatApiClient();
const rows = await apiClient.listWechatRecords();
return rows.map((row) => getRecordAdapters().normalizeWechatRecord(row));
}
/**
* 代码作用(白话):按主键读取单条企微资料,并把结果适配成详情抽屉和编辑弹窗都能直接使用的格式;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):查看/编辑按钮 -> getWechatRecordById() -> getWechatRecord() -> normalizeWechatRecord() -> 抽屉/弹窗打开。
*/
async function getWechatRecordById(recordId) {
const apiClient = getWechatApiClient();
const row = await apiClient.getWechatRecord(recordId);
return row ? getRecordAdapters().normalizeWechatRecord(row) : null;
}
/**
* 代码作用(白话):把企微表单提交给统一接口客户端,并把错误继续抛给弹窗处理;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):WechatAddDialogActions.save() -> saveWechatRecord() -> 后端保存 -> 弹窗成功/失败提示。
*/
async function saveWechatRecord(payload) {
const apiClient = getWechatApiClient();
return apiClient.saveWechatRecord(payload);
}
/**
* 代码作用(白话):在列表里删除企微手机图片时,先取最新记录再清空图片字段并复用统一保存接口;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):列表删除图片按钮 -> removeWechatRecordImage() -> getWechatRecordById() -> saveWechatRecord() -> refreshWechatList()。
*/
async function removeWechatRecordImage(recordId) {
const record = await getWechatRecordById(recordId);
if (!record) {
throw new Error('未找到对应企微记录');
}
const payload = {
...record,
phone_image: '',
phoneImage: '',
'手机图片': ''
};
return saveWechatRecord(payload);
}
/**
* 代码作用(白话):在企微保存成功后刷新当前页面,保证列表、详情和弹窗看到的是同一份最新数据;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):WechatAddDialogActions.afterSave() -> refreshWechatList() -> renderModule() -> 重新挂载 wechat 列表。
*/
async function refreshWechatList() {
if (state.activeModule === 'wechat') {
renderModule();
}
}
/**
* 代码作用(白话):打开企微新增弹窗,让列表工具栏和后续其他入口都能复用同一个弹窗;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):新增按钮 -> openWechatAddDialog() -> WechatAddDialogAPI.open()。
*/
function openWechatAddDialog(mode, record) {
window.WechatAddDialogAPI?.open?.(mode, record);
}
/**
* 代码作用(白话):根据查看或编辑模式拉取企微最新记录,并打开对应的抽屉或弹窗;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):企微列表操作按钮 -> openWechatDrawer() -> getWechatRecordById() -> 详情抽屉或编辑弹窗。
*/
async function openWechatDrawer(mode, recordId) {
const record = await getWechatRecordById(recordId);
if (!record) {
throw new Error('未找到对应企微记录');
}
if (mode === 'edit') {
window.WechatAddDialogAPI?.open?.('edit', record);
return;
}
window.WechatDetailDrawerAPI?.open?.(record);
}
/**
* 代码作用(白话):把企微状态文字映射到统一状态圆点颜色,保持列表和详情视觉一致;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):row.status -> getWechatStatusDotClass() -> status-dot class。
*/
function getWechatStatusDotClass(status) {
return status === '正常' ? 'green' : 'orange';
}
window.WechatAddDialogActions = {
save: saveWechatRecord,
afterSave: refreshWechatList
};
/**
* 代码作用(白话):向手机号接口拉取列表并做统一字段适配,把原始接口记录翻译成页面记录;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):phone 页面挂载/刷新 -> loadPhoneRows() -> listPhones() -> normalizePhoneRecord() -> 列表渲染。
*/
async function loadPhoneRows() {
const apiClient = getPhoneApiClient();
const rows = await apiClient.listPhones();
return rows.map((row) => getRecordAdapters().normalizePhoneRecord(row));
}
/**
* 代码作用(白话):把手机号表单提交给统一接口客户端,并把错误继续抛给弹窗处理;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):PhoneAddDialogActions.save() -> savePhoneRecord() -> savePhone() -> 弹窗成功/失败提示。
*/
async function savePhoneRecord(payload) {
const apiClient = getPhoneApiClient();
return apiClient.savePhone(payload);
}
/**
* 代码作用(白话):删除指定手机号记录,让列表组件不用自己拼接口地址;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):手机号列表删除按钮 -> deletePhoneRecord() -> deletePhone() -> reload。
*/
async function deletePhoneRecord(recordId) {
const apiClient = getPhoneApiClient();
return apiClient.deletePhone(recordId);
}
/**
* 代码作用(白话):在手机号保存成功后刷新当前页面,保证列表、详情和弹窗看到的是同一份最新数据;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):PhoneAddDialogActions.afterSave() -> refreshPhoneList() -> renderModule() -> 重新挂载 phone 列表。
*/
async function refreshPhoneList() {
if (state.activeModule === 'phone') {
renderModule();
}
}
window.PhoneAddDialogActions = {
save: savePhoneRecord,
afterSave: refreshPhoneList
};
/**
* 代码作用(白话):卸载当前已经挂上的企微或手机号列表实例,避免重新渲染时留下重复事件和旧组件;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime() -> unmountRuntimeApps() -> 新实例挂载。
*/
function unmountRuntimeApps() {
try {
wechatAppInstance?.unmount?.();
} catch (error) {
console.warn(error);
}
try {
phoneAppInstance?.unmount?.();
} catch (error) {
console.warn(error);
}
wechatAppInstance = null;
phoneAppInstance = null;
}
/**
* 代码作用(白话):把 wechat 和 phone 复杂列表页交给各自运行时模块渲染,并由这里统一注入数据与动作;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):renderModule() -> renderExternalRuntime() -> mountXxxListComponent() -> 列表交互/抽屉/弹窗。
*/
function renderExternalRuntime(moduleKey) {
if (!pageBody) {
return;
}
unmountRuntimeApps();
pageBody.innerHTML = '<div id="wechatListRoot" style="display:none;"></div><div id="phoneListRoot" style="display:none;"></div>';
const wechatRoot = document.getElementById('wechatListRoot');
const phoneRoot = document.getElementById('phoneListRoot');
if (wechatRoot) {
wechatRoot.style.display = moduleKey === 'wechat' ? 'block' : 'none';
}
if (phoneRoot) {
phoneRoot.style.display = moduleKey === 'phone' ? 'block' : 'none';
}
if (detailDrawer) {
detailDrawer.classList.add('hidden');
}
if (moduleKey === 'wechat' && window.WechatListRuntime?.mountWechatListComponent) {
wechatAppInstance = window.WechatListRuntime.mountWechatListComponent({
wechatText: {
allRecords: '全部记录',
add: '新增资料',
sync: '同步数据',
reset: '重置',
realName: '姓名',
account: '账号',
alias: '别名',
department: '部门',
gender: '性别',
phone: '手机号',
owner: '实名人',
accountLocation: '账号位置',
ownerStatus: '实名状态',
phoneImage: '手机图片',
action: '操作',
view: '查看',
edit: '编辑',
replaceImage: '更换图片',
removeImage: '删除图片',
imageMissing: '暂无图片',
selected: '已选',
items: '项',
batchAction: '批量操作',
total: '共',
records: '条',
pageSuffix: ' 条/页',
searchPlaceholder: '搜索姓名、账号、手机号、部门或实名人',
loadFailed: '加载企微列表失败,请确认本地后端已启动',
syncSuccess: '企微列表已刷新',
syncFailed: '同步数据失败,请稍后重试',
openFailed: '打开企微详情失败,请稍后重试',
imageRemoved: '已删除手机图片',
imageRemoveFailed: '删除手机图片失败,请稍后重试'
},
wechatTablePageSizes: [10, 20, 50, 100],
mountState: moduleMountState.wechat,
globalState: state,
loadWechatRows,
openAddDialog: openWechatAddDialog,
openDrawer: openWechatDrawer,
removeRecordImage: removeWechatRecordImage,
getVisiblePageNumbers,
getWechatStatusDotClass,
notifyError: showError,
mountTarget: '#wechatListRoot'
});
}
if (moduleKey === 'phone' && window.PhoneListRuntime?.mountPhoneListComponent) {
phoneAppInstance = window.PhoneListRuntime.mountPhoneListComponent({
phoneText: {
add: '新增号卡',
reset: '重置',
searchPlaceholder: '搜索手机号、实名人或域名',
deleteConfirm: '确认删除这条手机号记录吗?',
deleteFailed: '删除失败,请稍后重试',
loadFailed: '加载手机号列表失败,请确认本地后端已启动'
},
phoneTableFilterLabels: ['全部', '正常', '异常停机', '保号中'],
phoneTablePageSizes: [10, 20, 50, 100],
mountState: moduleMountState.phone,
globalState: state,
loadPhoneRows,
deletePhoneRecord,
savePhoneRecord,
openAddDialog: (mode, record) => {
window.PhoneAddDialogAPI?.open?.(mode, record);
},
openDrawer: async (mode, id) => {
if (mode === 'edit') {
try {
const rows = await loadPhoneRows();
const record = rows.find((item) => item.id === id);
if (!record) {
throw new Error('未找到对应手机号记录');
}
window.PhoneAddDialogAPI?.open?.('edit', record);
} catch (error) {
showError(error.message || '打开编辑弹窗失败');
}
return;
}
try {
const rows = await loadPhoneRows();
const record = rows.find((item) => item.id === id);
if (!record) {
throw new Error('未找到对应手机号记录');
}
window.PhoneDetailDrawerAPI?.open?.(record);
} catch (error) {
showError(error.message || '打开详情失败');
}
},
getPhoneStatusDotClass: (status) => (status === '正常' || status === '可外呼' ? 'green' : 'orange'),
notifyError: showError,
mountTarget: '#phoneListRoot'
});
}
}
/**
* 代码作用(白话):渲染简单模块的详情面板,复杂列表模块仍走自己的抽屉,不复用旧详情壳;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):handlePageBodyClick() -> renderDetail() -> detailDrawer 内容更新。
*/
function renderDetail(moduleKey) {
if (!detailDrawer || !detailShellTitle || !detailBody) {
return;
}
if (moduleKey === 'wechat' || moduleKey === 'phone') {
detailDrawer.classList.add('hidden');
return;
}
const record = getSelectedRecord(moduleKey);
if (!record) {
detailDrawer.classList.add('hidden');
detailShellTitle.textContent = '详情';
detailBody.innerHTML = '<div class="note-box">暂无详情数据。</div>';
return;
}
const title = record.domain || record.title || record.realName || record.real_name || record.phone || `记录 ${record.id}`;
const fields = Object.entries(record)
.filter(([key, value]) => key !== 'log' && key !== 'notes' && typeof value !== 'object')
.map(([key, value]) => `<div class="detail-item"><label>${escapeHtml(key)}</label><span>${escapeHtml(value)}</span></div>`)
.join('');
detailShellTitle.textContent = title;
detailBody.innerHTML = `<div class="detail-grid">${fields}</div>`;
detailDrawer.classList.remove('hidden');
}
/**
* 代码作用(白话):根据当前激活模块高亮左侧导航按钮;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):renderModule()/Router mounted -> updateActiveNav() -> nav-link class 切换。
*/
function updateActiveNav() {
if (!sidebar) {
return;
}
sidebar.querySelectorAll('.nav-link[data-module]').forEach((button) => {
const target = button.dataset.module === 'phone' ? 'phone' : button.dataset.module;
button.classList.toggle('is-active', target === state.activeModule);
});
}
/**
* 代码作用(白话):按当前模块决定渲染简单列表还是复杂运行时模块,是页面切换后的总入口;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):Router mounted -> renderModule() -> renderHeader() -> renderSimpleBody()/renderExternalRuntime() -> updateActiveNav()。
*/
function renderModule() {
if (!state.activeModule || !pageHeader || !pageBody) {
return;
}
renderHeader(state.activeModule);
if (state.activeModule === 'wechat' || state.activeModule === 'phone') {
renderExternalRuntime(state.activeModule);
} else {
renderSimpleBody(state.activeModule);
renderDetail(state.activeModule);
}
updateActiveNav();
}
/**
* 代码作用(白话):处理简单模块列表项点击,把当前记录写进选中状态并刷新详情面板;关联文件:F:/Project/xyw_console/assets/index.html;关联逻辑(调用链/消息链/数据流):pageBody click -> handlePageBodyClick() -> state.selectedBySource -> renderDetail()。
*/
function handlePageBodyClick(event) {
const button = event.target.closest('[data-record-id][data-source-key]');
if (!button) {
return;
}
state.selectedBySource[button.dataset.sourceKey] = Number(button.dataset.recordId);
renderDetail(state.activeModule);
}
/**
* 代码作用(白话):关闭旧详情壳层的抽屉,只影响简单模块详情显示;关联文件:F:/Project/xyw_console/assets/index.html;关联逻辑(调用链/消息链/数据流):drawerClose click -> handleDrawerCloseClick() -> detailDrawer.hidden。
*/
function handleDrawerCloseClick() {
detailDrawer?.classList.add('hidden');
}
/**
* 代码作用(白话):根据侧边栏是否折叠,同步按钮文案和可访问性属性;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/styles-v2.css;关联逻辑(调用链/消息链/数据流):handleSidebarToggleClick() -> updateSidebarToggleState() -> appShell/sidebar class + aria/text。
*/
function updateSidebarToggleState() {
if (!sidebarToggle || !appShell) {
return;
}
const isCollapsed = appShell.classList.contains('sidebar-collapsed');
sidebarToggle.setAttribute('aria-expanded', String(!isCollapsed));
sidebarToggle.setAttribute('aria-label', isCollapsed ? '展开侧边导航' : '收起侧边导航');
if (sidebarToggleLabel) {
sidebarToggleLabel.textContent = isCollapsed ? '展开' : '收起';
}
}
/**
* 代码作用(白话):切换左侧边栏折叠状态,并同步主区域布局;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/styles-v2.css;关联逻辑(调用链/消息链/数据流):sidebarToggle click -> handleSidebarToggleClick() -> sidebar/appShell class 切换 -> updateSidebarToggleState()。
*/
function handleSidebarToggleClick() {
sidebar?.classList.toggle('is-collapsed');
appShell?.classList.toggle('sidebar-collapsed');
updateSidebarToggleState();
}
/**
* 代码作用(白话):给页面壳层重新绑定点击事件,避免 Router 重新挂载后旧事件丢失;关联文件:F:/Project/xyw_console/assets/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):initPage() -> bindLayoutEvents() -> 点击事件重新生效。
*/
function bindLayoutEvents() {
pageBody?.removeEventListener('click', handlePageBodyClick);
pageBody?.addEventListener('click', handlePageBodyClick);
drawerClose?.removeEventListener('click', handleDrawerCloseClick);
drawerClose?.addEventListener('click', handleDrawerCloseClick);
sidebarToggle?.removeEventListener('click', handleSidebarToggleClick);
sidebarToggle?.addEventListener('click', handleSidebarToggleClick);
}
let pageInitialized = false;
/**
* 代码作用(白话):在路由已挂载后只执行一次 DOM 引用刷新与布局事件绑定,并恢复当前页面渲染;关联文件:src/main.js、src/router/index.js、index.html;关联逻辑(调用链/消息链/数据流):src/main.js -> window.initPage() -> refreshDOMRefs()/bindLayoutEvents() -> renderModule()。
*/
function initPage() {
if (pageInitialized) {
return;
}
refreshDOMRefs();
bindLayoutEvents();
updateSidebarToggleState();
if (state.activeModule) {
renderModule();
}
pageInitialized = true;
}
window.updateActiveNav = updateActiveNav;
window.renderModule = renderModule;
window.initPage = initPage;
\ No newline at end of file
const DATA_SOURCES = {
domain: [
{ id: 1, platform: "阿里云", platformCode: "ALI", domain: "example.com", expirationDate: "2025-05-28", expirationHint: "3天后", autoRenewal: "已开启", sslExpiry: "2025-06-15", sslHint: "21天后", accountId: "aliyun_admin", renewalResult: "续费成功", dns: "阿里云 DNS", owner: "张三", remark: "企业官网主域名", detailStatus: "正常", log: [["2025-05-25 10:30", "续费成功(1年)"], ["2024-05-28 09:15", "续费成功(1年)"]] },
{ id: 2, platform: "腾讯云", platformCode: "TEN", domain: "demo.ai", expirationDate: "2025-06-01", expirationHint: "7天后", autoRenewal: "已开启", sslExpiry: "2025-06-20", sslHint: "26天后", accountId: "tencent_admin", renewalResult: "待处理", dns: "腾讯云 DNSPod", owner: "李四", remark: "投放活动备用域名", detailStatus: "正常", log: [["2025-05-23 14:30", "自动续费待支付"], ["2024-06-01 10:20", "续费成功(1年)"]] },
{ id: 3, platform: "西部数码", platformCode: "WEST", domain: "shop.net", expirationDate: "2025-06-10", expirationHint: "16天后", autoRenewal: "已开启", sslExpiry: "2025-06-25", sslHint: "31天后", accountId: "west_123", renewalResult: "续费失败", dns: "西部数码 DNS", owner: "赵六", remark: "商城二级主站", detailStatus: "正常", log: [["2025-05-24 09:42", "续费失败:余额不足"], ["2024-06-10 12:00", "续费成功(1年)"]] }
],
wechat: [
{ id: 101, realName: "张三", alias: "阿三", account: "zhangsan_88", department: "市场部", phone: "138****8888", owner: "张三", ownerStatus: "正常", wxId: "wxc_3123abcd4567", createdAt: "2023-03-15 14:32", role: "市场专员", remark: "负责华南区域市场推广", notes: [["2024-05-10 10:30", "由管理员添加"], ["2024-05-12 16:30", "客户对接主负责人"]] },
{ id: 102, realName: "李四", alias: "老李", account: "lisi_2023", department: "运营部", phone: "139****1234", owner: "李四", ownerStatus: "异常", wxId: "wxid_lisi00932", createdAt: "2023-04-12 09:20", role: "运营专员", remark: "资料待核对实名一致性", notes: [["2024-04-30 12:00", "实名状态待复核"]] }
],
phone: [
{ id: 201, phone: "138****8888", realPerson: "张三", city: "广东 深圳", usageLocation: "广东 深圳 南山区", carrier: "中国移动", cardStatus: "正常", wechat: "已开通", outbound: "可外呼", iccid: "8986 0117 0230 1234 5678 912", note: "主要用于私域客户联系与外呼。", log: [["2024-03-12 10:23", "号码激活"], ["2024-03-12 10:25", "业务开通完成"]] },
{ id: 202, phone: "176****1234", realPerson: "王五", city: "浙江 杭州", usageLocation: "浙江 杭州", carrier: "中国电信", cardStatus: "异常停机", wechat: "未开通", outbound: "不可外呼", iccid: "8986 0117 8844 2134 9087 122", note: "号码停机,需要核查是否保留。", log: [["2024-05-01 09:20", "检测到停机"], ["2024-05-01 11:00", "已进入人工复核"]] }
]
};
const MODULE_CONFIG = {
overview: { title: "账号与域名资产管理台", searchPlaceholder: "搜索域名、账号、备注等", searchShortcut: "/", pills: [{ text: "系统正常", type: "status" }, { text: "数据更新: <strong>5 分钟前</strong> ↻", type: "sync" }], actions: [{ text: "导出数据", kind: "secondary" }, { text: "新建记录", kind: "primary" }], stats: [{ title: "即将到期域名", value: "18", sub: "7 天内到期 12 个", tone: "blue", icon: "◎", link: "查看详情" }, { title: "实名异常微信", value: "7", sub: "需要处理 7 个", tone: "orange", icon: "◉", link: "查看详情" }, { title: "异常手机号卡", value: "9", sub: "需关注 9 个", tone: "red", icon: "▣", link: "查看详情" }] },
domain: { title: "域名资料管理", searchPlaceholder: "搜索域名、网站平台或绑定账号 ID", searchShortcut: "⌘K", pills: [{ text: "数据更新: <strong>5 分钟前</strong> ↻", type: "sync" }], actions: [{ text: "导出数据", kind: "secondary" }, { text: "同步域名", kind: "secondary" }, { text: "添加域名", kind: "primary" }], stats: [{ title: "即将到期", value: "18", sub: "占全部 12.5%", tone: "blue", icon: "◎", link: "查看列表" }, { title: "自动续费已开启", value: "156", sub: "占全部 75.0%", tone: "green", icon: "✓", link: "查看列表" }, { title: "续费失败", value: "6", sub: "占全部 2.9%", tone: "red", icon: "✕", link: "查看列表" }] },
wechat: { title: "企微资料管理", searchPlaceholder: "搜索姓名、微信号、手机号、部门…", searchShortcut: "", pills: [], actions: [{ text: "同步数据", kind: "secondary" }, { text: "新增资料", kind: "primary" }], stats: [{ title: "资料总数", value: "1,248", sub: "较昨日 ↑ 223", tone: "blue", icon: "◉", link: "查看详情" }, { title: "实名异常", value: "56", sub: "占比 4.49%", tone: "orange", icon: "!", link: "查看详情" }] },
phone: { title: "手机号卡管理", searchPlaceholder: "搜索手机号、联系人、城市、ICCID、运营商等", searchShortcut: "", pills: [], actions: [{ text: "导入号码", kind: "secondary" }, { text: "添加号卡", kind: "primary" }], stats: [{ title: "可用号码", value: "128", sub: "占比 64.0%", tone: "blue", icon: "◔", link: "查看详情" }, { title: "异常号码", value: "16", sub: "占比 8.0%", tone: "orange", icon: "⌂", link: "查看详情" }] },
alerts: { title: "提醒中心", searchPlaceholder: "搜索提醒标题、负责人、模块", searchShortcut: "", pills: [], actions: [{ text: "导出提醒", kind: "secondary" }], stats: [] }
};
const PAGE_SIZE_OPTIONS = [10, 20, 40, 100];
const state = {
activeModule: "overview",
selectedBySource: { domain: 1, wechat: 101, phone: 201 },
paginationBySource: { domain: { page: 1, pageSize: 10 }, wechat: { page: 1, pageSize: 10 }, phone: { page: 1, pageSize: 10 } }
};
const appShell = document.getElementById("appShell");
const pageHeader = document.getElementById("pageHeader");
const pageBody = document.getElementById("pageBody");
const detailDrawer = document.getElementById("detailDrawer");
const detailShellTitle = document.getElementById("detailShellTitle");
const detailBody = document.getElementById("detailBody");
const sidebar = document.getElementById("sidebar");
const sidebarToggle = document.getElementById("sidebarToggle");
const sidebarToggleLabel = sidebarToggle?.querySelector(".nav-toggle-label");
const drawerClose = document.getElementById("drawerClose");
/** 代码作用(白话):转义动态文本,避免字符串里的标签或特殊符号把页面结构顶坏;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:所有 innerHTML 输出前都先走这里,数据流是“数据源 -> escapeHtml() -> 页面片段”。 */
function escapeHtml(value) {
return String(value ?? "—")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/** 代码作用(白话):把模块名转成真实数据源名,让 overview 复用域名数据、alerts 走空态;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderBody()、renderTable()、renderDetail() 都先通过它判断当前该读哪组数据。 */
function getDataSourceKey(moduleKey) {
if (moduleKey === "overview") return "domain";
if (moduleKey === "alerts") return "";
return moduleKey;
}
/** 代码作用(白话):读取当前模块对应的数据数组;关联文件:F:/Project/xyw_console/app.js;关联逻辑:表格、详情、分页都通过它拿基础数据,调用链是“renderModule() -> renderBody()/renderDetail() -> getModuleRecords()”。 */
function getModuleRecords(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
return sourceKey ? (DATA_SOURCES[sourceKey] || []) : [];
}
/** 代码作用(白话):找到当前模块里被选中的那条记录,没有选中时自动兜底第一条;关联文件:F:/Project/xyw_console/app.js;关联逻辑:表格高亮、最近动态、详情抽屉都依赖它,数据流是“state.selectedBySource -> getSelectedRecord() -> 多个渲染方法”。 */
function getSelectedRecord(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
const records = getModuleRecords(moduleKey);
if (!sourceKey || !records.length) return null;
const selectedId = state.selectedBySource[sourceKey] ?? records[0].id;
return records.find((record) => record.id === selectedId) || records[0];
}
/** 代码作用(白话):规范化当前模块的分页状态,保证页码和每页条数都落在合法范围;关联文件:F:/Project/xyw_console/app.js、styles.css;关联逻辑:renderPagination()、getPagedRecords()、翻页事件都会先经过这里,避免分页 UI 和数据切片不同步。 */
function getPaginationState(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
const records = getModuleRecords(moduleKey);
if (!sourceKey || !records.length) return { page: 1, pageSize: PAGE_SIZE_OPTIONS[0], totalPages: 1, totalRecords: records.length };
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
if (!PAGE_SIZE_OPTIONS.includes(paginationState.pageSize)) paginationState.pageSize = PAGE_SIZE_OPTIONS[0];
const totalPages = Math.max(1, Math.ceil(records.length / paginationState.pageSize));
paginationState.page = Math.min(Math.max(1, paginationState.page), totalPages);
return { page: paginationState.page, pageSize: paginationState.pageSize, totalPages, totalRecords: records.length };
}
/** 代码作用(白话):按当前分页状态切出表格真正要显示的记录;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 只渲染它返回的这一页数据,数据流是“完整 records -> getPaginationState() -> getPagedRecords() -> tbody”。 */
function getPagedRecords(moduleKey) {
const records = getModuleRecords(moduleKey);
const pagination = getPaginationState(moduleKey);
const startIndex = (pagination.page - 1) * pagination.pageSize;
return records.slice(startIndex, startIndex + pagination.pageSize);
}
/** 代码作用(白话):根据总页数和当前页,算出分页条应该显示哪些页码和省略号;关联文件:F:/Project/xyw_console/app.js、F:/Project/xyw_console/styles.css;关联逻辑:renderPagination() 先调用它,再把数字渲染成页码按钮。 */
function getVisiblePageNumbers(totalPages, currentPage) {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1);
if (currentPage <= 4) return [1, 2, 3, 4, 5, "ellipsis", totalPages];
if (currentPage >= totalPages - 3) return [1, "ellipsis", totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, "ellipsis", currentPage - 1, currentPage, currentPage + 1, "ellipsis", totalPages];
}
/** 代码作用(白话):把用户输入的目标页码收口到合法范围,再写回分页状态并刷新页面;关联文件:F:/Project/xyw_console/app.js;关联逻辑:handlePaginationChange() 和 handlePaginationKeydown() 都把输入值交给它,消息链是“输入框 -> applyPaginationJump() -> renderModule()”。 */
function applyPaginationJump(rawValue) {
const sourceKey = getDataSourceKey(state.activeModule);
if (!sourceKey) return false;
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
const pagination = getPaginationState(state.activeModule);
const nextPage = Math.min(Math.max(1, Number(rawValue) || pagination.page), pagination.totalPages);
if (nextPage === pagination.page) return false;
paginationState.page = nextPage;
renderModule();
return true;
}
/** 代码作用(白话):让“当前选中记录”始终留在当前页里,避免翻页后右侧详情还指向不可见数据;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderModule() 每次刷新前先调用它,数据流是“分页变化 -> syncSelectedRecordWithPage() -> 详情同步回当前页首条”。 */
function syncSelectedRecordWithPage(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
const pagedRecords = getPagedRecords(moduleKey);
if (!sourceKey || !pagedRecords.length) return;
const selectedRecord = getSelectedRecord(moduleKey);
if (!selectedRecord || !pagedRecords.some((record) => record.id === selectedRecord.id)) state.selectedBySource[sourceKey] = pagedRecords[0].id;
}
/** 代码作用(白话):把业务状态词映射成成功、警告、危险这几种视觉色;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:formatStatusChip() 会用它统一生成表格、动态、详情里的状态颜色。 */
function getToneByValue(value) {
if (["续费失败", "异常", "异常停机", "未开通", "未绑定", "不可外呼", "已关闭", "已释放"].includes(value)) return "danger";
if (["待处理", "实名异常", "未实名", "保留中", "未备案"].includes(value)) return "warning";
if (["续费成功", "正常", "已开启", "已开通", "已绑定", "可外呼", "已备案"].includes(value)) return "success";
return "neutral";
}
/** 代码作用(白话):输出统一的状态标签 HTML,让不同模块共用同一套状态外观;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTableCell()、renderPreviewGrid()、renderDetail() 都会直接调用它。 */
function formatStatusChip(label) {
return `<span class="status-chip ${getToneByValue(label)}">${escapeHtml(label)}</span>`;
}
/** 代码作用(白话):给每个模块准备右侧头部的文案说明和小标记,补齐效果图里的信息层级;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderHeader() 读取它,消息链是“当前模块 -> getHeaderMeta() -> 头部标题区”。 */
function getHeaderMeta(moduleKey) {
const metaMap = {
overview: { badge: "总览模式", description: "聚合域名、企微、手机号卡等核心资产,先看风险,再落到单条记录处理。" },
domain: { badge: "域名资产", description: "统一查看平台、到期时间、自动续费和 SSL 状态,优先处理临近到期记录。" },
wechat: { badge: "企微资料", description: "按人员、部门和实名状态巡检资料完整度,减少资料错配和责任不清。" },
phone: { badge: "号码资产", description: "聚焦号码可用性、归属地、实名信息和外呼状态,快速定位异常号卡。" },
alerts: { badge: "提醒中心", description: "集中承接待处理提醒,后续可继续补入真实告警和跟进记录。" }
};
return metaMap[moduleKey] || metaMap.overview;
}
/** 代码作用(白话):给主表格准备分区标题和辅助说明,让数据列表更接近设计稿里的“主工作台”气质;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 读取它,再把结果写进表格头部和工具栏。 */
function getTableMeta(moduleKey) {
const metaMap = {
overview: { kicker: "核心清单", note: "默认展示域名资产列表,点击任意行可同步右侧详情。", emphasis: "高风险优先" },
domain: { kicker: "域名清单", note: "聚焦续费结果、到期时间和平台归属,适合日常巡检。", emphasis: "到期风险监控" },
wechat: { kicker: "资料清单", note: "聚焦人员账号、部门、手机号和实名状态,便于排查异常资料。", emphasis: "实名异常优先" },
phone: { kicker: "号码清单", note: "聚焦归属地、运营商和外呼能力,适合号码资产盘点。", emphasis: "停机号码优先" },
alerts: { kicker: "提醒清单", note: "当前暂无数据,后续可以把真实提醒流接进来。", emphasis: "等待数据" }
};
return metaMap[moduleKey] || metaMap.overview;
}
/** 代码作用(白话):把头部状态胶囊渲染成可直接插入页面的 HTML;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderHeader() 调用它,把 MODULE_CONFIG 里的 pills 变成顶部状态条。 */
function renderPills(pills) {
return pills.map((pill) => `<span class="status-pill ${escapeHtml(pill.type || "")}">${pill.text}</span>`).join("");
}
/** 代码作用(白话):把头部操作按钮渲染成主按钮和次按钮,保留原有操作入口;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderHeader() 调用它,把 config.actions 输出到头部操作区。 */
function renderActions(actions) {
return actions.map((action) => {
const className = action.kind === "primary" ? "header-button-primary" : "header-button";
const suffix = action.suffix ? `<span class="header-button-suffix">${escapeHtml(action.suffix)}</span>` : "";
return `<button class="${className}" type="button">${escapeHtml(action.text)}${suffix}</button>`;
}).join("");
}
/** 代码作用(白话):渲染右侧头部,让标题、说明、搜索框、状态胶囊和主操作按钮按效果图节奏排开;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 先刷新它,再继续刷新表格和详情。 */
function renderHeader() {
const config = MODULE_CONFIG[state.activeModule];
const meta = getHeaderMeta(state.activeModule);
const shortcut = config.searchShortcut ? `<span class="header-shortcut">${escapeHtml(config.searchShortcut)}</span>` : "";
const pills = config.pills.length ? `<div class="hero-pills">${renderPills(config.pills)}</div>` : "";
const actions = config.actions.length ? renderActions(config.actions) : "";
pageHeader.innerHTML = `
<div class="workbench-hero">
<div class="hero-copy">
<div class="hero-copy-top">
<p class="hero-kicker">工作台</p>
<span class="hero-badge">${escapeHtml(meta.badge)}</span>
</div>
<h2 class="page-title">${escapeHtml(config.title)}</h2>
<p class="hero-summary">${escapeHtml(meta.description)}</p>
${pills}
</div>
<div class="header-tools">
<label class="header-search">
<input type="search" placeholder="${escapeHtml(config.searchPlaceholder)}" />
${shortcut}
</label>
<div class="header-actions">${actions}</div>
</div>
</div>
`;
}
/** 代码作用(白话):把顶部统计卡渲染成更接近效果图的三段式卡片,包括图标、数字和底部去向;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 会先输出它,再继续输出表格和最近动态。 */
function renderMetricCards(stats, moduleKey) {
if (!stats.length) return "";
const gridClass = moduleKey === "overview" ? "stats-grid overview" : "stats-grid";
return `
<section class="${gridClass}">
${stats.map((item) => `
<article class="metric-card metric-card-premium" data-tone="${escapeHtml(item.tone || "blue")}">
<div class="metric-card-top">
<span class="metric-icon-shell"><span class="metric-icon">${escapeHtml(item.icon || "•")}</span></span>
<span class="metric-tag">重点巡检</span>
</div>
<div class="metric-copy">
<p class="metric-title">${escapeHtml(item.title)}</p>
<div class="metric-value-row">
<h3 class="metric-value">${escapeHtml(item.value)}</h3>
<span class="metric-unit">项</span>
</div>
<p class="metric-sub">${escapeHtml(item.sub || "")}</p>
</div>
<div class="metric-card-footer">
<span class="metric-foot-note">风险台账</span>
<span class="metric-foot-link">${escapeHtml(item.link || "查看详情")}</span>
</div>
</article>
`).join("")}
</section>
`;
}
/** 代码作用(白话):返回表格列顺序,让不同模块仍旧沿用各自的字段布局;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 先拿列配置,再去渲染表头和每一行单元格。 */
function getTableColumns(moduleKey) {
if (moduleKey === "wechat") return ["realName", "account", "department", "phone", "wxId", "ownerStatus"];
if (moduleKey === "phone") return ["phone", "realPerson", "city", "carrier", "cardStatus", "wechat", "outbound"];
return ["domain", "platform", "expirationDate", "autoRenewal", "sslExpiry", "accountId", "renewalResult"];
}
/** 代码作用(白话):把字段 key 转成给用户看的中文列名;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 渲染表头时逐列调用它。 */
function getTableLabel(columnKey) {
const map = {
domain: "域名",
platform: "平台",
expirationDate: "到期时间",
autoRenewal: "自动续费",
sslExpiry: "SSL 到期",
accountId: "账号 ID",
renewalResult: "续费结果",
realName: "姓名",
account: "账号",
department: "部门",
phone: "手机号",
wxId: "微信 ID",
ownerStatus: "状态",
realPerson: "实名人",
city: "归属地",
carrier: "运营商",
cardStatus: "卡状态",
wechat: "微信",
outbound: "外呼"
};
return map[columnKey] || columnKey;
}
/** 代码作用(白话):把不同字段渲染成表格里的最终内容块,比如双行文字、状态标签、时间信息;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 渲染 tbody 时对每一个单元格都走这里。 */
function renderTableCell(columnKey, record) {
if (columnKey === "domain") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.domain)}</span><span class="row-secondary">${escapeHtml(record.remark || "—")}</span></div>`;
if (columnKey === "platform") return `<div class="platform-copy"><span class="platform-name">${escapeHtml(record.platform)}</span><span class="row-secondary">${escapeHtml(record.platformCode || "—")}</span></div>`;
if (columnKey === "expirationDate") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.expirationDate)}</span><span class="warn">${escapeHtml(record.expirationHint || "—")}</span></div>`;
if (columnKey === "sslExpiry") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.sslExpiry || "—")}</span><span class="row-secondary">${escapeHtml(record.sslHint || "—")}</span></div>`;
if (["renewalResult", "ownerStatus", "cardStatus", "wechat", "outbound", "autoRenewal"].includes(columnKey)) return formatStatusChip(record[columnKey] || "正常");
if (columnKey === "realName") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realName)}</span><span class="row-secondary">${escapeHtml(record.alias || "—")}</span></div>`;
if (columnKey === "account") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.account)}</span><span class="row-secondary">${escapeHtml(record.owner || "—")}</span></div>`;
if (columnKey === "department") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.department)}</span><span class="row-secondary">${escapeHtml(record.role || "—")}</span></div>`;
if (columnKey === "phone") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.phone)}</span><span class="row-secondary">${escapeHtml(record.city || record.owner || "—")}</span></div>`;
if (columnKey === "wxId") return `<span class="link-text">${escapeHtml(record.wxId)}</span>`;
if (columnKey === "realPerson") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realPerson)}</span><span class="row-secondary">${escapeHtml(record.iccid || "—")}</span></div>`;
if (columnKey === "city") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.city)}</span><span class="row-secondary">${escapeHtml(record.usageLocation || "—")}</span></div>`;
return escapeHtml(record[columnKey] || "—");
}
/** 代码作用(白话):渲染表格底部的分页器,包括页码、上一页下一页、前往输入框和每页条数;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 在表格底部调用它,点击和输入再交给分页事件处理函数。 */
function renderPagination(moduleKey) {
const pagination = getPaginationState(moduleKey);
const pageNumbers = getVisiblePageNumbers(pagination.totalPages, pagination.page).map((item) => {
if (item === "ellipsis") return `<span class="pagination-ellipsis">···</span>`;
return `<button class="pagination-page-button${item === pagination.page ? " is-active" : ""}" type="button" data-page-number="${item}" aria-current="${item === pagination.page ? "page" : "false"}">${item}</button>`;
}).join("");
const pageSizeOptions = PAGE_SIZE_OPTIONS.map((size) => `<option value="${size}"${size === pagination.pageSize ? " selected" : ""}>每页 ${size}</option>`).join("");
return `
<div class="pagination-shell">
<div class="pagination-main">
<button class="pagination-arrow" type="button" data-page-action="prev"${pagination.page === 1 ? " disabled" : ""} aria-label="上一页">‹</button>
<div class="pagination-pages">${pageNumbers}</div>
<button class="pagination-arrow" type="button" data-page-action="next"${pagination.page === pagination.totalPages ? " disabled" : ""} aria-label="下一页">›</button>
</div>
<label class="pagination-jump">
<span class="pagination-jump-label">前往</span>
<input class="pagination-jump-input" type="number" min="1" max="${pagination.totalPages}" value="${pagination.page}" data-page-jump-input />
</label>
<label class="pagination-size-select-wrap">
<select class="pagination-size-select" data-page-size-select aria-label="每页条数">${pageSizeOptions}</select>
<span class="pagination-size-arrow" aria-hidden="true">⌄</span>
</label>
</div>
`;
}
/** 代码作用(白话):渲染主表格卡片,把标题、工具栏、数据表、分页和说明组合成右侧主工作区;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 调用它输出列表主体,表格点击再通过 handleTableClick() 反向刷新详情。 */
function renderTable(moduleKey) {
const records = getModuleRecords(moduleKey);
const pagedRecords = getPagedRecords(moduleKey);
const sourceKey = getDataSourceKey(moduleKey);
const selectedRecord = getSelectedRecord(moduleKey);
const columns = getTableColumns(moduleKey);
const pagination = getPaginationState(moduleKey);
const tableMeta = getTableMeta(moduleKey);
const selectedLabel = selectedRecord ? escapeHtml(selectedRecord.domain || selectedRecord.realName || selectedRecord.phone) : "未选中记录";
const headHtml = columns.map((columnKey) => `<th>${escapeHtml(getTableLabel(columnKey))}</th>`).join("");
const bodyHtml = pagedRecords.map((record) => `
<tr class="${selectedRecord && record.id === selectedRecord.id ? "is-selected" : ""}" data-record-id="${record.id}" data-source-key="${escapeHtml(sourceKey)}">
${columns.map((columnKey) => `<td>${renderTableCell(columnKey, record)}</td>`).join("")}
</tr>
`).join("");
return `
<section class="table-card table-card-shell">
<div class="table-head table-head-v2">
<div class="table-head-copy">
<p class="section-kicker">${escapeHtml(tableMeta.kicker)}</p>
<h3 class="section-title">${escapeHtml(MODULE_CONFIG[moduleKey].title)}</h3>
<p class="table-head-note">${escapeHtml(tableMeta.note)}</p>
</div>
<div class="table-selection-chip">
<span class="table-selection-label">当前聚焦</span>
<strong>${selectedLabel}</strong>
</div>
</div>
<div class="toolbar-row toolbar-row-v2">
<div class="toolbar-left toolbar-left-v2">
<span class="toolbar-pill">${escapeHtml(tableMeta.emphasis)}</span>
<span class="toolbar-note">第 ${pagination.page} / ${pagination.totalPages} 页</span>
</div>
<div class="toolbar-right toolbar-right-fixed">
<button class="toolbar-button" type="button">筛选条件</button>
<button class="toolbar-button" type="button">视图切换</button>
<button class="toolbar-button-primary" type="button">刷新数据</button>
</div>
</div>
<div class="table-scroll-shell">
<table class="data-table">
<thead><tr>${headHtml}</tr></thead>
<tbody>${bodyHtml}</tbody>
</table>
</div>
<div class="table-footer table-footer-v2">
<div class="footer-left">
<span class="footer-total">共 ${escapeHtml(records.length)} 条记录</span>
<span class="footer-hint">点击表格行后,右侧详情会同步切换</span>
</div>
${renderPagination(moduleKey)}
</div>
</section>
`;
}
/** 代码作用(白话):渲染底部“最近动态”整行卡片,把当前选中记录的处理记录压成更接近效果图的时间线列表;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 在表格下方调用它,数据流是“selectedRecord.log/notes -> renderPreviewGrid() -> 最近动态卡”。 */
function renderPreviewGrid(moduleKey) {
const record = getSelectedRecord(moduleKey);
if (!record) return "";
const focusName = record.domain || record.realName || record.phone || "当前记录";
const statusValue = record.renewalResult || record.ownerStatus || record.cardStatus || record.detailStatus || "正常";
const logRows = (record.log || record.notes || []).slice(0, 3).map((item, index) => {
const time = Array.isArray(item) ? item[0] : "";
const text = Array.isArray(item) ? item[1] : item;
return `
<div class="preview-row preview-row-v2">
<div class="preview-row-marker">${index + 1}</div>
<div class="preview-copy">
<span class="preview-name">${escapeHtml(text)}</span>
<span class="preview-sub">${escapeHtml(time || "最近更新")}</span>
</div>
${formatStatusChip(statusValue)}
</div>
`;
}).join("") || `
<div class="preview-row preview-row-v2">
<div class="preview-row-marker">1</div>
<div class="preview-copy">
<span class="preview-name">暂无动态</span>
<span class="preview-sub">等待后续记录</span>
</div>
${formatStatusChip(statusValue)}
</div>
`;
return `
<section class="preview-grid preview-grid-full">
<article class="preview-card preview-card-wide preview-card-shell">
<div class="preview-head preview-head-v2">
<div>
<p class="preview-kicker">最近动态</p>
<h3 class="preview-title">跟进记录</h3>
<p class="preview-summary">当前聚焦:${escapeHtml(focusName)},最近 3 条处理记录会优先展示在这里。</p>
</div>
<div class="preview-status-wrap">${formatStatusChip(statusValue)}</div>
</div>
<div class="preview-list preview-list-v2">${logRows}</div>
</article>
</section>
`;
}
/** 代码作用(白话):输出空态卡片,让没有数据的模块也保持和新右侧视觉一致;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 在 alerts 这类空模块时直接返回它。 */
function renderEmptyState(moduleKey) {
return `
<section class="empty-card empty-card-shell">
<div>
<h3>${escapeHtml(MODULE_CONFIG[moduleKey].title)}</h3>
<p>当前模块暂无可渲染数据,后续可以继续补充真实记录。</p>
</div>
</section>
`;
}
/** 代码作用(白话):渲染右侧主内容区,把统计卡、主表格和最近动态按效果图顺序串起来;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 调用它输出主工作区,数据流是“模块状态 -> renderBody() -> pageBody.innerHTML”。 */
function renderBody() {
const moduleKey = state.activeModule;
const records = getModuleRecords(moduleKey);
pageBody.innerHTML = records.length
? `${renderMetricCards(MODULE_CONFIG[moduleKey].stats, moduleKey)}${renderTable(moduleKey)}${renderPreviewGrid(moduleKey)}`
: renderEmptyState(moduleKey);
}
/** 代码作用(白话):按模块挑选详情抽屉需要展示的字段,并给每个字段补上小标题;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderDetail() 循环渲染它返回的字段数组,数据流是“record -> getDetailFields() -> 详情网格”。 */
function getDetailFields(moduleKey, record) {
if (!record) return [];
if (moduleKey === "wechat") {
return [
["姓名", record.realName],
["账号", record.account],
["部门", record.department],
["手机号", record.phone],
["微信 ID", record.wxId],
["负责人", record.owner],
["创建时间", record.createdAt],
["备注", record.remark, true]
];
}
if (moduleKey === "phone") {
return [
["手机号", record.phone],
["实名人", record.realPerson],
["归属地", record.city],
["使用地", record.usageLocation],
["卡状态", record.cardStatus],
["运营商", record.carrier],
["ICCID", record.iccid],
["备注", record.note, true]
];
}
return [
["域名", record.domain],
["平台", record.platform],
["平台账号", record.accountId],
["到期时间", record.expirationDate],
["SSL 到期", record.sslExpiry],
["自动续费", record.autoRenewal],
["DNS", record.dns],
["备注", record.remark, true]
];
}
/** 代码作用(白话):渲染右侧详情抽屉,把概要、字段、时间线和底部操作做成和效果图一致的辅助工作区;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 最后刷新这里,表格点选后会通过 renderDetail() 同步显示新记录。 */
function renderDetail() {
const moduleKey = state.activeModule;
const record = getSelectedRecord(moduleKey);
detailShellTitle.innerHTML = `
<div class="detail-shell-title-wrap">
<p class="detail-shell-kicker">实时详情</p>
<h3 class="detail-title">资产详情</h3>
</div>
`;
if (!record) {
detailBody.innerHTML = `<div class="note-box">当前模块没有详情数据。</div>`;
return;
}
const title = moduleKey === "domain" || moduleKey === "overview" ? record.domain : (record.realName || record.phone);
const statusValue = record.renewalResult || record.ownerStatus || record.cardStatus || record.detailStatus || "正常";
const fieldHtml = getDetailFields(moduleKey, record).map(([label, value, full]) => `
<div class="detail-item${full ? " full" : ""}">
<label>${escapeHtml(label)}</label>
<span>${escapeHtml(value)}</span>
</div>
`).join("");
const logs = (record.log || record.notes || []).map((item) => {
const time = Array.isArray(item) ? item[0] : "";
const text = Array.isArray(item) ? item[1] : item;
return `
<div class="timeline-row">
<span class="timeline-dot"></span>
<span class="timeline-copy">
<span class="preview-name">${escapeHtml(text)}</span>
<span class="timeline-time">${escapeHtml(time || "最近更新")}</span>
</span>
</div>
`;
}).join("") || `<div class="note-box">暂无更多跟进记录。</div>`;
detailBody.innerHTML = `
<section class="detail-group detail-group-shell">
<div class="detail-top-card detail-top-card-v2">
<div class="detail-main-avatar">${escapeHtml(String(title).slice(0, 1))}</div>
<div class="detail-title-wrap">
<div class="detail-primary-line">
<span class="detail-primary-name">${escapeHtml(title)}</span>
${formatStatusChip(statusValue)}
</div>
<div class="detail-meta-line">模块:${escapeHtml(moduleKey)} · 更新时间:2026-06-25</div>
<div class="detail-summary-badges">
<span class="info-chip blue">已同步到详情视图</span>
<span class="info-chip orange">支持继续处理</span>
</div>
</div>
</div>
<div class="detail-section-shell">
<div class="detail-section-head">
<h4 class="detail-section-title">关键信息</h4>
<span class="detail-note">用于快速确认当前记录归属和状态</span>
</div>
<div class="detail-grid">${fieldHtml}</div>
</div>
<div class="detail-section-shell">
<div class="detail-section-head">
<h4 class="detail-section-title">处理时间线</h4>
<span class="detail-note">最近处理记录会按时间倒序展示</span>
</div>
<div class="log-list">${logs}</div>
</div>
<div class="detail-footer">
<button class="footer-button" type="button">导出详情</button>
<button class="footer-button-primary" type="button">继续处理</button>
</div>
</section>
`;
}
/** 代码作用(白话):同步左侧导航高亮,让当前模块在菜单栏里保持选中状态;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 每次切模块都调用,但不会改菜单栏样式规则本身。 */
function updateActiveNav() {
sidebar.querySelectorAll(".nav-link[data-module]").forEach((button) => button.classList.toggle("is-active", button.dataset.module === state.activeModule));
}
/** 代码作用(白话):统一刷新右侧页面的头部、主体和详情抽屉,是整个界面联动的总入口;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:导航点击、表格点击、分页和首屏初始化最终都会走到 renderModule()。 */
function renderModule() {
updateActiveNav();
syncSelectedRecordWithPage(state.activeModule);
renderHeader();
renderBody();
renderDetail();
}
/** 代码作用(白话):处理左侧菜单点击,把 activeModule 切到对应模块后刷新右侧;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:bindTabEvents() 把 sidebar click 事件委托给它,再流向 renderModule()。 */
function handleNavClick(event) {
const button = event.target.closest(".nav-link[data-module]");
if (!button) return;
state.activeModule = button.dataset.module;
renderModule();
}
/** 代码作用(白话):给左侧菜单注册点击事件;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:页面启动时 initPage() 调用它,后续菜单点击都统一进入 handleNavClick()。 */
function bindTabEvents() {
sidebar.addEventListener("click", handleNavClick);
}
/** 代码作用(白话):处理表格行点击,把当前记录设成选中项后刷新右侧详情;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:handlePageBodyClick() 在不是分页点击时会落到这里,消息链是“tbody 行点击 -> handleTableClick() -> renderModule()”。 */
function handleTableClick(event) {
const row = event.target.closest("tr[data-record-id][data-source-key]");
if (!row) return;
state.selectedBySource[row.dataset.sourceKey] = Number(row.dataset.recordId);
renderModule();
}
/** 代码作用(白话):响应分页条里的页码按钮和左右箭头,只处理翻页动作;关联文件:F:/Project/xyw_console/app.js、F:/Project/xyw_console/styles.css;关联逻辑:handlePageBodyClick() 先把点击交给它,命中后直接更新分页状态并重渲染。 */
function handlePaginationClick(event) {
const sourceKey = getDataSourceKey(state.activeModule);
if (!sourceKey) return false;
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
const pageNumberButton = event.target.closest("[data-page-number]");
if (pageNumberButton) {
paginationState.page = Number(pageNumberButton.dataset.pageNumber);
renderModule();
return true;
}
const pageActionButton = event.target.closest("[data-page-action]");
if (!pageActionButton || pageActionButton.disabled) return false;
const pagination = getPaginationState(state.activeModule);
if (pageActionButton.dataset.pageAction === "prev") paginationState.page = Math.max(1, pagination.page - 1);
if (pageActionButton.dataset.pageAction === "next") paginationState.page = Math.min(pagination.totalPages, pagination.page + 1);
renderModule();
return true;
}
/** 代码作用(白话):统一接收主内容区点击,优先把分页点击和表格点击分流到各自处理函数;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:bindTableEvents() 把 pageBody click 绑定到这里,消息链是“pageBody click -> handlePaginationClick() 或 handleTableClick() -> renderModule()”。 */
function handlePageBodyClick(event) {
if (handlePaginationClick(event)) return;
handleTableClick(event);
}
/** 代码作用(白话):处理分页区里的下拉选择和输入框失焦变更,让每页条数和目标页码真正生效;关联文件:F:/Project/xyw_console/app.js、F:/Project/xyw_console/styles.css;关联逻辑:bindTableEvents() 把 pageBody change 交给它,再分别流向 pageSize 或 applyPaginationJump()。 */
function handlePaginationChange(event) {
const sourceKey = getDataSourceKey(state.activeModule);
if (!sourceKey) return;
const pageSizeSelect = event.target.closest("[data-page-size-select]");
if (pageSizeSelect) {
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
paginationState.pageSize = Number(pageSizeSelect.value);
paginationState.page = 1;
renderModule();
return;
}
const pageJumpInput = event.target.closest("[data-page-jump-input]");
if (pageJumpInput) {
const didJump = applyPaginationJump(pageJumpInput.value);
if (!didJump) {
const pagination = getPaginationState(state.activeModule);
pageJumpInput.value = String(pagination.page);
}
}
}
/** 代码作用(白话):处理分页输入框里的回车键,让用户输入目标页后按回车就能跳转;关联文件:F:/Project/xyw_console/app.js;关联逻辑:bindTableEvents() 把 pageBody keydown 交给它,再通过 applyPaginationJump() 更新当前页。 */
function handlePaginationKeydown(event) {
const pageJumpInput = event.target.closest("[data-page-jump-input]");
if (!pageJumpInput || event.key !== "Enter") return;
event.preventDefault();
const didJump = applyPaginationJump(pageJumpInput.value);
if (!didJump) {
const pagination = getPaginationState(state.activeModule);
pageJumpInput.value = String(pagination.page);
}
}
/** 代码作用(白话):给主内容区注册点击、变更和键盘事件,让表格、分页、跳页输入框都走统一入口;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:initPage() 调用它后,pageBody 的 click/change/keydown 都会被分流到对应分页或表格处理函数。 */
function bindTableEvents() {
pageBody.addEventListener("click", handlePageBodyClick);
pageBody.addEventListener("change", handlePaginationChange);
pageBody.addEventListener("keydown", handlePaginationKeydown);
}
/** 代码作用(白话):同步侧边栏收起按钮的 aria 状态,保证菜单栏辅助信息还正确;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:首屏和点击收起时都会调用,但不会改菜单栏视觉方案。 */
function updateSidebarToggleState() {
const isCollapsed = appShell.classList.contains("sidebar-collapsed");
sidebarToggle.setAttribute("aria-expanded", String(!isCollapsed));
sidebarToggle.setAttribute("aria-label", isCollapsed ? "展开侧边导航" : "收起侧边导航");
if (sidebarToggleLabel) sidebarToggleLabel.textContent = "收起";
}
/** 代码作用(白话):处理侧边栏收起按钮,让左侧菜单保持原有收起能力;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:bindLayoutEvents() 把 sidebarToggle click 绑定到这里,再同步 aria。 */
function handleSidebarToggleClick() {
sidebar.classList.toggle("is-collapsed");
appShell.classList.toggle("sidebar-collapsed");
updateSidebarToggleState();
}
/** 代码作用(白话):处理详情抽屉关闭按钮,只控制抽屉的隐藏和显示;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:bindLayoutEvents() 把 drawerClose click 绑定到这里。 */
function handleDrawerCloseClick() {
detailDrawer.classList.toggle("hidden");
}
/** 代码作用(白话):注册布局相关事件,让左侧收起按钮和右侧详情关闭按钮继续可用;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:initPage() 调用它后,布局层交互就接通了。 */
function bindLayoutEvents() {
sidebarToggle.addEventListener("click", handleSidebarToggleClick);
drawerClose.addEventListener("click", handleDrawerCloseClick);
updateSidebarToggleState();
}
/** 代码作用(白话):页面启动入口,先把事件接上,再渲染第一屏;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:文件加载完成后立即执行 initPage(),消息链是“initPage() -> 绑定事件 -> renderModule()”。 */
function initPage() {
bindTabEvents();
bindTableEvents();
bindLayoutEvents();
renderModule();
}
initPage();
/* Professional admin dashboard override: fake data + right workspace redesign */
DATA_SOURCES.domain = [
{ id: 1, domain: "example.com", status: "正常", riskLevel: "低", expirationDate: "2025-07-18", expirationHint: "剩余 45 天", project: "官网项目组", owner: "张三", renewalResult: "待续费", registrar: "阿里云", updatedAt: "2025-06-30 10:23", registrationDate: "2020-07-18", renewalMethod: "手动续费", dnsProvider: "dns1.hichina.com, dns2.hichina.com", remark: "公司官网主域名。", relatedAssets: ["企业官网", "SEO 监控"], logs: [{ time: "2025-06-30 10:23", tag: "续费提醒", text: "张三 更新了 example.com 的续费方式。" }, { time: "2025-06-20 14:11", tag: "信息更新", text: "张三 更新了域名备注与备案信息。" }] },
{ id: 2, domain: "datahub.cn", status: "风险", riskLevel: "中", expirationDate: "2025-06-15", expirationHint: "剩余 12 天", project: "数据中台", owner: "李四", renewalResult: "续费中", registrar: "腾讯云", updatedAt: "2025-06-02 15:42", registrationDate: "2021-06-15", renewalMethod: "自动续费", dnsProvider: "dnspod.tencentcloudapi.com", remark: "数据中台统一域名。", relatedAssets: ["BI 报表"], logs: [{ time: "2025-06-02 15:42", tag: "续费操作", text: "李四 提交了 datahub.cn 的续费申请。" }] },
{ id: 3, domain: "ai-platform.net", status: "风险", riskLevel: "高", expirationDate: "2025-06-05", expirationHint: "剩余 2 天", project: "AI 平台", owner: "王五", renewalResult: "未续费", registrar: "西部数码", updatedAt: "2025-06-01 09:18", registrationDate: "2022-06-05", renewalMethod: "手动续费", dnsProvider: "dns.west.cn", remark: "AI 平台主域名。", relatedAssets: ["AI 控制台", "API 文档"], logs: [{ time: "2025-06-01 09:18", tag: "风险提示", text: "系统检测到续费未处理。" }] },
{ id: 4, domain: "metrics.io", status: "过期", riskLevel: "高", expirationDate: "2025-05-28", expirationHint: "已过期 6 天", project: "监控平台", owner: "赵六", renewalResult: "未续费", registrar: "阿里云", updatedAt: "2025-05-28 11:07", registrationDate: "2021-05-28", renewalMethod: "余额续费", dnsProvider: "alidns.aliyuncs.com", remark: "监控平台展示域名。", relatedAssets: ["告警页"], logs: [{ time: "2025-05-28 11:07", tag: "到期提醒", text: "metrics.io 已过期 6 天。" }] },
{ id: 5, domain: "docs.example.com", status: "正常", riskLevel: "低", expirationDate: "2025-09-30", expirationHint: "剩余 119 天", project: "文档中心", owner: "张三", renewalResult: "自动续费", registrar: "阿里云", updatedAt: "2025-06-03 08:55", registrationDate: "2023-09-30", renewalMethod: "自动续费", dnsProvider: "alidns.aliyuncs.com", remark: "开发者文档域名。", relatedAssets: ["帮助中心"], logs: [{ time: "2025-06-03 08:55", tag: "状态同步", text: "证书与到期信息同步完成。" }] }
];
DATA_SOURCES.wechat = [
{ id: 101, realName: "张三", alias: "阿三", department: "市场部", phone: "138****8888", wxId: "wxc_3123abcd4567", owner: "张三", status: "正常", createdAt: "2023-03-15", updatedAt: "2025-06-02 10:20", role: "市场专员", project: "华南投放", remark: "负责华南线索承接。", logs: [{ time: "2025-06-02 10:20", tag: "资料同步", text: "实名信息与手机号一致。" }] },
{ id: 102, realName: "李四", alias: "老李", department: "运营部", phone: "139****1234", wxId: "wxid_lisi00932", owner: "李四", status: "待复核", createdAt: "2023-04-12", updatedAt: "2025-06-01 15:35", role: "运营专员", project: "增长实验", remark: "实名信息待确认。", logs: [{ time: "2025-06-01 15:35", tag: "待复核", text: "系统标记需要二次复核。" }] },
{ id: 103, realName: "王五", alias: "Wendy", department: "销售部", phone: "137****5521", wxId: "wxid_sales_5521", owner: "王五", status: "异常", createdAt: "2022-12-08", updatedAt: "2025-05-30 11:48", role: "销售顾问", project: "KA 客户", remark: "手机号与实名信息不一致。", logs: [{ time: "2025-05-30 11:48", tag: "异常提醒", text: "资料进入异常队列。" }] }
];
DATA_SOURCES.phone = [
{ id: 201, phone: "138****8888", realPerson: "张三", city: "广东 深圳", carrier: "中国移动", status: "正常", wechatStatus: "已开通", outbound: "可外呼", updatedAt: "2025-06-02 09:18", usageLocation: "广东 深圳 南山区", iccid: "8986 0117 0230 1234 5678 912", owner: "张三", project: "私域运营", remark: "主要用于私域外呼。", logs: [{ time: "2025-06-02 09:18", tag: "状态同步", text: "号卡状态正常。" }] },
{ id: 202, phone: "176****1234", realPerson: "王五", city: "浙江 杭州", carrier: "中国电信", status: "异常停机", wechatStatus: "未开通", outbound: "不可外呼", updatedAt: "2025-06-01 11:00", usageLocation: "浙江 杭州", iccid: "8986 0117 8844 2134 9087 122", owner: "王五", project: "测试号池", remark: "号码停机,需要复核。", logs: [{ time: "2025-06-01 11:00", tag: "异常提醒", text: "系统检测到该号码停机。" }] },
{ id: 203, phone: "153****7654", realPerson: "李四", city: "上海 浦东", carrier: "中国联通", status: "保留中", wechatStatus: "已开通", outbound: "不可外呼", updatedAt: "2025-05-31 17:26", usageLocation: "上海 浦东", iccid: "8986 0302 1945 0012 7766 320", owner: "李四", project: "品牌活动", remark: "活动结束后保留号码。", logs: [{ time: "2025-05-31 17:26", tag: "保留中", text: "号码进入保留流程。" }] }
];
DATA_SOURCES.alerts = [
{ id: 301, title: "ai-platform.net 即将到期,请尽快续费", module: "域名资料", level: "高", owner: "王五", dueAt: "2025-06-05", status: "待处理", updatedAt: "2025-06-01 09:18", remark: "AI 平台主域名进入高风险窗口。", logs: [{ time: "2025-06-01 09:18", tag: "风险提示", text: "已提醒负责人尽快处理。" }] },
{ id: 302, title: "176****1234 号卡停机,等待人工复核", module: "手机号卡", level: "中", owner: "王五", dueAt: "2025-06-03", status: "待处理", updatedAt: "2025-06-01 11:00", remark: "号码停机可能影响测试外呼。", logs: [{ time: "2025-06-01 11:00", tag: "异常提醒", text: "号码停机告警已同步到提醒中心。" }] }
];
Object.assign(MODULE_CONFIG.overview, { title: "资料总览", searchPlaceholder: "搜索域名、所有者、备注等", pills: [{ text: "全部 <strong>1,256</strong>", type: "status blue" }, { text: "正常 <strong>982</strong>", type: "status green" }, { text: "风险 <strong>168</strong>", type: "status orange" }, { text: "过期 <strong>106</strong>", type: "status red" }], actions: [{ text: "导出数据", kind: "secondary" }, { text: "新建记录", kind: "primary" }], stats: [{ title: "正常域名", value: "982", sub: "占比 78.21%", tone: "blue", icon: "✓", link: "稳定" }, { title: "风险域名", value: "168", sub: "占比 13.38%", tone: "orange", icon: "!", link: "需跟进" }, { title: "过期域名", value: "106", sub: "占比 8.41%", tone: "red", icon: "×", link: "高优先" }] });
Object.assign(MODULE_CONFIG.domain, MODULE_CONFIG.overview, { title: "域名资料" });
Object.assign(MODULE_CONFIG.wechat, { title: "企微资料", searchPlaceholder: "搜索姓名、微信 ID、手机号、部门等", pills: [{ text: "全部 <strong>648</strong>", type: "status blue" }, { text: "正常 <strong>532</strong>", type: "status green" }, { text: "待复核 <strong>73</strong>", type: "status orange" }, { text: "异常 <strong>43</strong>", type: "status red" }], actions: [{ text: "同步资料", kind: "secondary" }, { text: "新增资料", kind: "primary" }], stats: [{ title: "正常资料", value: "532", sub: "资料同步完成", tone: "blue", icon: "✓", link: "稳定" }, { title: "待复核", value: "73", sub: "需要管理员复核", tone: "orange", icon: "?", link: "处理中" }, { title: "异常资料", value: "43", sub: "实名信息异常", tone: "red", icon: "!", link: "待修复" }] });
Object.assign(MODULE_CONFIG.phone, { title: "手机号卡", searchPlaceholder: "搜索手机号、实名人、城市、项目等", pills: [{ text: "全部 <strong>324</strong>", type: "status blue" }, { text: "正常 <strong>241</strong>", type: "status green" }, { text: "保留中 <strong>46</strong>", type: "status orange" }, { text: "停机 <strong>37</strong>", type: "status red" }], actions: [{ text: "导入号卡", kind: "secondary" }, { text: "新增号卡", kind: "primary" }], stats: [{ title: "正常号卡", value: "241", sub: "可正常使用", tone: "blue", icon: "✓", link: "稳定" }, { title: "保留中", value: "46", sub: "已暂停外呼", tone: "orange", icon: "•", link: "待决策" }, { title: "异常停机", value: "37", sub: "需要人工复核", tone: "red", icon: "!", link: "高优先" }] });
Object.assign(MODULE_CONFIG.alerts, { title: "提醒中心", searchPlaceholder: "搜索提醒标题、负责人、模块", pills: [{ text: "全部 <strong>37</strong>", type: "status blue" }, { text: "待处理 <strong>19</strong>", type: "status orange" }, { text: "今日到期 <strong>6</strong>", type: "status red" }, { text: "已完成 <strong>12</strong>", type: "status green" }], actions: [{ text: "导出提醒", kind: "secondary" }, { text: "新建提醒", kind: "primary" }], stats: [{ title: "待处理提醒", value: "19", sub: "需要负责人跟进", tone: "orange", icon: "!", link: "处理中" }, { title: "今日到期", value: "6", sub: "建议优先处理", tone: "red", icon: "×", link: "高优先" }, { title: "已完成", value: "12", sub: "最近 24 小时", tone: "blue", icon: "✓", link: "已关闭" }] });
state.selectedBySource.alerts = 301;
state.paginationBySource.alerts = { page: 1, pageSize: 10 };
/** 代码作用(白话):让提醒中心也能走真实数据源;关联文件:F:/Project/xyw_console/app.js;关联逻辑:表格、详情、分页都会先通过它判断当前数据源。 */
function getDataSourceKey(moduleKey) { return moduleKey === "overview" ? "domain" : moduleKey; }
/** 代码作用(白话):统一返回记录主名称;关联文件:F:/Project/xyw_console/app.js;关联逻辑:当前聚焦、详情标题、最近跟进都会共用它。 */
function getRecordDisplayName(moduleKey, record) { if (!record) return "未选中记录"; if (["overview", "domain"].includes(moduleKey)) return record.domain; if (moduleKey === "wechat") return record.realName; if (moduleKey === "phone") return record.phone; return record.title; }
/** 代码作用(白话):把状态词映射成新的后台视觉色;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:状态标签、时间线标签都会共用它。 */
function getToneByValue(value) { if (["过期", "异常", "异常停机", "未开通", "不可外呼", "未续费", "高"].includes(value)) return "danger"; if (["风险", "待处理", "待复核", "保留中", "待确认", "续费中", "中"].includes(value)) return "warning"; if (["正常", "自动续费", "已开通", "可外呼", "低", "待续费"].includes(value)) return "success"; return "neutral"; }
/** 代码作用(白话):补充风险等级标签;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:域名表格和提醒中心会直接调用它。 */
function formatRiskChip(level) { const tone = level === "高" ? "danger" : level === "中" ? "warning" : "success"; return `<span class="tag-chip ${tone}">${escapeHtml(level)}</span>`; }
/** 代码作用(白话):准备右侧头部说明文案;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderHeader() 会读取它生成标题说明区。 */
function getHeaderMeta(moduleKey) { const metaMap = { overview: { badge: "资料管理后台", description: "把域名、企微、号卡与提醒统一放进一个高密度但可读的工作区里。" }, domain: { badge: "域名资产", description: "统一管理域名状态、风险等级、到期时间与续费进度。" }, wechat: { badge: "企微资料", description: "统一巡检人员资料、实名状态与项目归属。" }, phone: { badge: "号码资料", description: "聚焦号卡状态、开通情况与外呼能力。" }, alerts: { badge: "提醒流", description: "集中承接待处理提醒,按优先级排队处理。" } }; return metaMap[moduleKey] || metaMap.overview; }
/** 代码作用(白话):准备表格头部说明;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 会读取它渲染表格头部和焦点说明。 */
function getTableMeta(moduleKey) { const metaMap = { overview: { kicker: "全部域名", note: "点击任意一行后,右侧详情与最近跟进会同步切换。", emphasis: "资料总览模式" }, domain: { kicker: "全部域名", note: "状态、风险等级、到期时间和续费结果都放在同一张表里。", emphasis: "域名资料清单" }, wechat: { kicker: "全部资料", note: "按人员资料、部门归属、手机号和微信 ID 统一巡检。", emphasis: "企微资料清单" }, phone: { kicker: "全部号卡", note: "聚焦号卡状态、开通情况与外呼能力。", emphasis: "号卡资料清单" }, alerts: { kicker: "全部提醒", note: "把高优先级提醒集中排队处理。", emphasis: "提醒处理视图" } }; return metaMap[moduleKey] || metaMap.overview; }
/** 代码作用(白话):准备筛选条文案;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 通过它生成表格上方筛选入口。 */
function getFilterOptions(moduleKey) { const filterMap = { overview: [{ label: "状态", value: "全部" }, { label: "风险等级", value: "全部" }, { label: "到期时间", value: "全部" }, { label: "所属项目", value: "全部" }], domain: [{ label: "状态", value: "全部" }, { label: "风险等级", value: "全部" }, { label: "到期时间", value: "全部" }, { label: "所属项目", value: "全部" }], wechat: [{ label: "状态", value: "全部" }, { label: "部门", value: "全部" }, { label: "项目", value: "全部" }, { label: "更新时间", value: "全部" }], phone: [{ label: "状态", value: "全部" }, { label: "运营商", value: "全部" }, { label: "城市", value: "全部" }, { label: "更新时间", value: "全部" }], alerts: [{ label: "状态", value: "全部" }, { label: "优先级", value: "全部" }, { label: "模块", value: "全部" }, { label: "到期时间", value: "全部" }] }; return filterMap[moduleKey] || filterMap.overview; }
/** 代码作用(白话):重做右侧头部结构;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 先刷新它,再继续刷新表格和详情。 */
function renderHeader() { const config = MODULE_CONFIG[state.activeModule]; const meta = getHeaderMeta(state.activeModule); const pills = config.pills.length ? `<div class="hero-pills admin-summary-pills">${renderPills(config.pills)}</div>` : ""; const actions = config.actions.length ? renderActions(config.actions) : ""; pageHeader.innerHTML = `<div class="workspace-heading"><div class="heading-copy"><div class="heading-caption-row"><p class="heading-caption">专业资料管理后台</p><span class="heading-badge">${escapeHtml(meta.badge)}</span></div><h2 class="page-title">${escapeHtml(config.title)}</h2><p class="page-summary">${escapeHtml(meta.description)}</p>${pills}</div><div class="header-tools"><label class="header-search"><span class="header-search-icon" aria-hidden="true">⌕</span><input type="search" placeholder="${escapeHtml(config.searchPlaceholder)}" /></label><div class="header-actions">${actions}</div></div></div>`; }
/** 代码作用(白话):返回新表格列顺序;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 先拿它再渲染表头和每一行。 */
function getTableColumns(moduleKey) { if (moduleKey === "wechat") return ["selector", "realName", "department", "phone", "wxId", "status", "createdAt", "owner", "updatedAt", "actions"]; if (moduleKey === "phone") return ["selector", "phone", "realPerson", "city", "carrier", "status", "wechatStatus", "outbound", "updatedAt", "actions"]; if (moduleKey === "alerts") return ["selector", "title", "module", "level", "owner", "dueAt", "status", "updatedAt", "actions"]; return ["selector", "domain", "status", "riskLevel", "expirationDate", "project", "owner", "renewalResult", "registrar", "updatedAt", "actions"]; }
/** 代码作用(白话):把字段 key 转成新表格的中文列名;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 渲染表头时逐列调用它。 */
function getTableLabel(columnKey) { const map = { selector: "", domain: "域名", status: "状态", riskLevel: "风险等级", expirationDate: "到期时间", project: "所属项目", owner: "所有者", renewalResult: "续费结果", registrar: "注册商", updatedAt: "更新时间", actions: "操作", realName: "姓名", department: "部门", phone: "手机号", wxId: "微信 ID", createdAt: "创建时间", realPerson: "实名人", city: "归属地", carrier: "运营商", wechatStatus: "微信开通", outbound: "外呼能力", title: "提醒标题", module: "来源模块", level: "优先级", dueAt: "到期时间" }; return map[columnKey] || columnKey; }
/** 代码作用(白话):把字段渲染成新的专业后台表格单元格;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 渲染 tbody 时每个单元格都走这里。 */
function renderTableCell(columnKey, record) { const selectedRecord = getSelectedRecord(state.activeModule); const isSelected = selectedRecord && record.id === selectedRecord.id; if (columnKey === "selector") return `<span class="table-check${isSelected ? " is-active" : ""}" aria-hidden="true"></span>`; if (columnKey === "domain") return `<div class="name-copy"><span class="row-link">${escapeHtml(record.domain)}</span><span class="row-secondary">${escapeHtml(record.remark || "—")}</span></div>`; if (columnKey === "status") return formatStatusChip(record.status || "正常"); if (columnKey === "riskLevel" || columnKey === "level") return formatRiskChip(record[columnKey] || "低"); if (columnKey === "expirationDate") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.expirationDate)}</span><span class="row-secondary">${escapeHtml(record.expirationHint || "—")}</span></div>`; if (["renewalResult", "wechatStatus", "outbound"].includes(columnKey)) return formatStatusChip(record[columnKey] || "正常"); if (columnKey === "realName") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realName)}</span><span class="row-secondary">${escapeHtml(record.alias || record.role || "—")}</span></div>`; if (columnKey === "department") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.department)}</span><span class="row-secondary">${escapeHtml(record.project || "—")}</span></div>`; if (columnKey === "phone" && state.activeModule === "wechat") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.phone)}</span><span class="row-secondary">${escapeHtml(record.owner || "—")}</span></div>`; if (columnKey === "wxId") return `<span class="row-link">${escapeHtml(record.wxId)}</span>`; if (columnKey === "phone" && state.activeModule === "phone") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.phone)}</span><span class="row-secondary">${escapeHtml(record.project || "—")}</span></div>`; if (columnKey === "realPerson") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realPerson)}</span><span class="row-secondary">${escapeHtml(record.owner || "—")}</span></div>`; if (columnKey === "city") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.city)}</span><span class="row-secondary">${escapeHtml(record.usageLocation || "—")}</span></div>`; if (columnKey === "title") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.title)}</span><span class="row-secondary">${escapeHtml(record.remark || "—")}</span></div>`; if (columnKey === "dueAt") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.dueAt)}</span><span class="row-secondary">建议优先处理</span></div>`; if (columnKey === "actions") return `<div class="action-links"><button type="button">查看</button><button type="button">编辑</button></div>`; return `<span class="row-primary">${escapeHtml(record[columnKey] || "—")}</span>`; }
/** 代码作用(白话):渲染表格上方筛选条;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 在表格头部和表格内容之间调用它。 */
function renderFilterToolbar(moduleKey) { const filters = getFilterOptions(moduleKey).map((item) => `<button class="filter-chip" type="button"><span>${escapeHtml(item.label)}${escapeHtml(item.value)}</span><span class="filter-caret" aria-hidden="true">⌄</span></button>`).join(""); return `<div class="filter-toolbar"><div class="filter-group">${filters}</div><div class="filter-actions"><button class="toolbar-button" type="button">更多筛选</button><button class="toolbar-button" type="button">重置</button></div></div>`; }
/** 代码作用(白话):重做主表格卡片;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 输出列表主体,表格点击再反向刷新详情。 */
function renderTable(moduleKey) { const records = getModuleRecords(moduleKey); const pagedRecords = getPagedRecords(moduleKey); const sourceKey = getDataSourceKey(moduleKey); const selectedRecord = getSelectedRecord(moduleKey); const columns = getTableColumns(moduleKey); const tableMeta = getTableMeta(moduleKey); const headHtml = columns.map((columnKey) => `<th>${escapeHtml(getTableLabel(columnKey))}</th>`).join(""); const bodyHtml = pagedRecords.map((record) => `<tr class="${selectedRecord && record.id === selectedRecord.id ? "is-selected" : ""}" data-record-id="${record.id}" data-source-key="${escapeHtml(sourceKey)}">${columns.map((columnKey) => `<td>${renderTableCell(columnKey, record)}</td>`).join("")}</tr>`).join(""); return `<section class="table-card table-card-shell"><div class="table-head table-head-v3"><div class="table-head-copy"><p class="section-kicker">${escapeHtml(tableMeta.kicker)} (${escapeHtml(String(records.length))})</p><h3 class="section-title">${escapeHtml(MODULE_CONFIG[moduleKey].title)}</h3><p class="table-head-note">${escapeHtml(tableMeta.note)}</p></div><div class="table-selection-chip"><span class="table-selection-label">当前聚焦</span><strong>${escapeHtml(getRecordDisplayName(moduleKey, selectedRecord))}</strong></div></div>${renderFilterToolbar(moduleKey)}<div class="table-scroll-shell"><table class="data-table"><thead><tr>${headHtml}</tr></thead><tbody>${bodyHtml}</tbody></table></div><div class="table-footer table-footer-v3"><div class="footer-left"><span class="footer-total">共 ${escapeHtml(String(records.length))} 条假数据记录</span><span class="footer-hint">${escapeHtml(tableMeta.emphasis)} · 点击表格行后,右侧详情会同步切换</span></div>${renderPagination(moduleKey)}</div></section>`; }
/** 代码作用(白话):重做底部最近跟进模块;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 在表格下方调用它。 */
function renderPreviewGrid(moduleKey) { const record = getSelectedRecord(moduleKey); if (!record) return ""; const logs = (record.logs || []).slice(0, 5).map((item, index) => `<div class="preview-row preview-row-v3"><div class="preview-row-marker">${index + 1}</div><div class="preview-copy"><div class="preview-copy-top"><span class="preview-name">${escapeHtml(item.text)}</span>${item.tag ? `<span class="preview-tag ${getToneByValue(item.tag)}">${escapeHtml(item.tag)}</span>` : ""}</div><span class="preview-sub">${escapeHtml(item.time || "最近更新")}</span></div><button class="preview-link" type="button">查看详情</button></div>`).join(""); return `<section class="preview-grid preview-grid-full"><article class="preview-card preview-card-shell"><div class="preview-head preview-head-v3"><div><p class="preview-kicker">最近跟进</p><h3 class="preview-title">${escapeHtml(getRecordDisplayName(moduleKey, record))}</h3><p class="preview-summary">这里集中展示当前记录最近的处理动作。</p></div><button class="toolbar-button" type="button">查看全部</button></div><div class="preview-list preview-list-v3">${logs || `<div class="note-box">暂无更多跟进记录。</div>`}</div></article></section>`; }
/** 代码作用(白话):按模块挑选详情抽屉要展示的字段;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderDetail() 循环渲染它返回的字段数组。 */
function getDetailFields(moduleKey, record) { if (!record) return []; if (moduleKey === "wechat") return [["姓名", record.realName], ["微信 ID", record.wxId], ["部门", record.department], ["项目归属", record.project], ["手机号", record.phone], ["负责人", record.owner], ["创建时间", record.createdAt], ["更新时间", record.updatedAt], ["备注", record.remark, true]]; if (moduleKey === "phone") return [["手机号", record.phone], ["实名人", record.realPerson], ["归属地", record.city], ["使用地", record.usageLocation], ["运营商", record.carrier], ["微信开通", record.wechatStatus], ["外呼能力", record.outbound], ["ICCID", record.iccid], ["备注", record.remark, true]]; if (moduleKey === "alerts") return [["提醒标题", record.title], ["来源模块", record.module], ["优先级", record.level], ["负责人", record.owner], ["到期时间", record.dueAt], ["状态", record.status], ["更新时间", record.updatedAt], ["备注", record.remark, true]]; return [["域名", record.domain], ["状态", record.status], ["风险等级", record.riskLevel], ["到期时间", `${record.expirationDate}${record.expirationHint})`], ["所有者", record.owner], ["所属项目", record.project], ["注册商", record.registrar], ["注册时间", record.registrationDate], ["续费结果", record.renewalResult], ["续费方式", record.renewalMethod], ["DNS 服务器", record.dnsProvider], ["备注", record.remark, true]]; }
/** 代码作用(白话):重做右侧详情抽屉;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:表格点选后会通过 renderDetail() 同步显示新记录。 */
function renderDetail() { const moduleKey = state.activeModule; const record = getSelectedRecord(moduleKey); const title = getRecordDisplayName(moduleKey, record); const statusValue = record?.status || record?.renewalResult || "正常"; detailShellTitle.innerHTML = `<div class="detail-shell-title-wrap"><h3 class="detail-title">${escapeHtml(title)}</h3>${record ? formatStatusChip(statusValue) : ""}</div>`; if (!record) { detailBody.innerHTML = `<div class="note-box">当前模块没有详情数据。</div>`; return; } const fieldHtml = getDetailFields(moduleKey, record).map(([label, value, full]) => `<div class="detail-item${full ? " full" : ""}"><label>${escapeHtml(label)}</label><span>${escapeHtml(value)}</span></div>`).join(""); const relatedAssets = (record.relatedAssets || []).map((asset) => `<span class="info-chip blue">${escapeHtml(asset)}</span>`).join("") || `<span class="info-chip neutral">暂无关联资产</span>`; const logs = (record.logs || []).map((item) => `<div class="timeline-row"><span class="timeline-dot"></span><span class="timeline-copy"><span class="preview-name">${escapeHtml(item.text)}</span><span class="timeline-time">${escapeHtml(item.time || "最近更新")}</span></span></div>`).join("") || `<div class="note-box">暂无更多跟进记录。</div>`; detailBody.innerHTML = `<section class="detail-group detail-group-shell"><div class="detail-tab-bar"><button class="detail-tab is-active" type="button">基本信息</button><button class="detail-tab" type="button">更多信息</button><button class="detail-tab" type="button">关联资产</button></div><div class="detail-top-card detail-top-card-v3"><div class="detail-main-avatar">${escapeHtml(String(title).slice(0, 1))}</div><div class="detail-title-wrap"><div class="detail-primary-line"><span class="detail-primary-name">${escapeHtml(title)}</span>${formatStatusChip(statusValue)}</div><div class="detail-meta-line">模块:${escapeHtml(MODULE_CONFIG[moduleKey].title)} · 更新时间:${escapeHtml(record.updatedAt || record.createdAt || "2025-06-25")}</div><div class="detail-summary-badges">${relatedAssets}</div></div></div><div class="detail-section-shell"><div class="detail-section-head"><h4 class="detail-section-title">关键信息</h4><span class="detail-note">用于快速确认当前记录归属、状态和处理方式</span></div><div class="detail-grid">${fieldHtml}</div></div><div class="detail-section-shell"><div class="detail-section-head"><h4 class="detail-section-title">最近动态</h4><span class="detail-note">最近处理记录会按时间倒序展示</span></div><div class="log-list">${logs}</div></div><div class="detail-footer"><button class="footer-button" type="button">关闭</button><button class="footer-button-primary" type="button">继续处理</button></div></section>`; }
/** 代码作用(白话):重做右侧主内容区的内容串联;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 调用它输出统计卡、表格和最近跟进。 */
function renderBody() { const moduleKey = state.activeModule; const records = getModuleRecords(moduleKey); pageBody.innerHTML = records.length ? `${renderMetricCards(MODULE_CONFIG[moduleKey].stats, moduleKey)}${renderTable(moduleKey)}${renderPreviewGrid(moduleKey)}` : renderEmptyState(moduleKey); }
renderModule();
/** 代码作用(白话):修正侧边栏收起按钮的文字状态;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:点击收起按钮后会重新走这里同步 aria 与按钮文案。 */
function updateSidebarToggleState() {
const isCollapsed = appShell.classList.contains("sidebar-collapsed");
sidebarToggle.setAttribute("aria-expanded", String(!isCollapsed));
sidebarToggle.setAttribute("aria-label", isCollapsed ? "展开侧边导航" : "收起侧边导航");
if (sidebarToggleLabel) sidebarToggleLabel.textContent = isCollapsed ? "展开" : "收起";
}
updateSidebarToggleState();
<!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; ...@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication @SpringBootApplication
@MapperScan("com.xyw.console.wechat.mapper") @MapperScan("com.xyw.console.asset.mapper")
public class XywConsoleBackendApplication { 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.phone.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.xyw.console.phone.dto.WxPhoneSaveRequest;
import com.xyw.console.phone.entity.WxPhoneEntity;
import com.xyw.console.phone.mapper.WxPhoneMapper;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class WxPhoneService {
private final WxPhoneMapper wxPhoneMapper;
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭妸鎵嬫満鍙峰垪琛ㄦ寜 id 鍊掑簭璇诲嚭鏉ワ紝缁欏墠绔垪琛ㄩ〉鐩存帴娓叉煋銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java銆丗:/Project/xyw_console/src/modules/shared/phone-api-client.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛欸ET /api/wx-phones -> listRecords() -> Mapper.selectList() -> 鍓嶇 normalizePhoneRecord() -> 鍒楄〃灞曠ず銆? */
public List<WxPhoneEntity> listRecords() {
return wxPhoneMapper.selectList(new QueryWrapper<WxPhoneEntity>().orderByDesc("id"))
.stream()
.map(this::normalizeEntity)
.toList();
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭寜涓婚敭璇讳竴鏉℃墜鏈哄彿鍗★紝缁欒鎯呮娊灞夊拰缂栬緫寮圭獥鐢ㄣ€? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java銆丗:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛欸ET /api/wx-phones/{id} -> getRecordById() -> Mapper.selectById() -> 璇︽儏/缂栬緫鎵撳紑銆? */
public WxPhoneEntity getRecordById(Long id) {
return normalizeEntity(wxPhoneMapper.selectById(id));
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭妸鍓嶇鏂板琛ㄥ崟鍐欏叆 wx_phone 琛紝骞惰繑鍥炲甫涓婚敭鐨勬柊璁板綍銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java銆丗:/Project/xyw_console/src/modules/phone/phone-add-dialog.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歅OST /api/wx-phones -> createRecord() -> copyRequestToEntity() -> Mapper.insert() -> 鍓嶇鍒锋柊鍒楄〃銆? */
public WxPhoneEntity createRecord(WxPhoneSaveRequest request) {
WxPhoneEntity entity = new WxPhoneEntity();
copyRequestToEntity(request, entity);
wxPhoneMapper.insert(entity);
return normalizeEntity(wxPhoneMapper.selectById(entity.getId()));
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭洿鏂版寚瀹氭墜鏈哄彿鍗★紝骞舵妸鏁版嵁搴撻噷鐨勬渶鏂拌褰曡繑鍥炵粰鍓嶇銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java銆丗:/Project/xyw_console/src/modules/phone/phone-add-dialog.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歅UT /api/wx-phones/{id} -> updateRecord() -> Mapper.updateById() -> 鍓嶇鍒锋柊鍒楄〃銆? */
public WxPhoneEntity updateRecord(Long id, WxPhoneSaveRequest request) {
log.info("更新请求:id={}, request.imageAttachment1={}, request.imageAttachment2={}",
id,
request.getImageAttachment1() == null ? "null" : "长度=" + request.getImageAttachment1().length(),
request.getImageAttachment2() == null ? "null" : "长度=" + request.getImageAttachment2().length());
WxPhoneEntity entity = wxPhoneMapper.selectById(id);
if (entity == null) {
log.warn("更新手机号卡失败:未找到 id={}", id);
return null;
}
String oldImage1 = entity.getImageAttachment1();
String oldImage2 = entity.getImageAttachment2();
copyRequestToEntity(request, entity);
String newImage1 = entity.getImageAttachment1();
String newImage2 = entity.getImageAttachment2();
log.info("复制后:id={}, entity.imageAttachment1={}, entity.imageAttachment2={}",
id,
newImage1 == null ? "null" : "长度=" + newImage1.length(),
newImage2 == null ? "null" : "长度=" + newImage2.length());
if (oldImage1 != null && newImage1 == null) {
log.info("删除图片:id={}, 字段=image_attachment_1, 旧值长度={}", id, oldImage1.length());
}
if (oldImage2 != null && newImage2 == null) {
log.info("删除图片:id={}, 字段=image_attachment_2, 旧值长度={}", id, oldImage2.length());
}
int updatedRows = wxPhoneMapper.updateById(entity);
log.info("updateById 返回行数={}, id={}", updatedRows, id);
// 显式更新图片字段,绕过 FieldStrategy.ALWAYS 可能不生效的问题,确保 null 值写入 DB
int imageRows = wxPhoneMapper.updateImages(id, newImage1, newImage2);
log.info("updateImages 返回行数={}, id={}, image1={}, image2={}",
imageRows, id,
newImage1 == null ? "null" : "长度=" + newImage1.length(),
newImage2 == null ? "null" : "长度=" + newImage2.length());
WxPhoneEntity updated = wxPhoneMapper.selectById(id);
log.info("更新完成:id={}, image_attachment_1={}, image_attachment_2={}",
id,
updated.getImageAttachment1() != null ? "存在(长度=" + updated.getImageAttachment1().length() + ")" : "null",
updated.getImageAttachment2() != null ? "存在(长度=" + updated.getImageAttachment2().length() + ")" : "null");
return normalizeEntity(updated);
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氬垹闄ゆ寚瀹氭墜鏈哄彿鍗★紝缁欐帶鍒跺櫒鍒ゆ柇鏄惁闇€瑕佽繑鍥?404銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java銆丗:/Project/xyw_console/src/modules/phone/phone-list-runtime.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛欴ELETE /api/wx-phones/{id} -> deleteRecord() -> Mapper.deleteById() -> 鍒楄〃閲嶆柊鍔犺浇銆? */
public boolean deleteRecord(Long id) {
return wxPhoneMapper.deleteById(id) > 0;
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭妸鎺ュ彛鍏ュ弬瀹夊叏鍦版嫹璐濆埌鏁版嵁搴撳疄浣撻噷锛岄『鎵嬪仛绌虹櫧娓呮礂鍜岄粯璁ゅ€煎鐞嗐€? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java銆丗:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/entity/WxPhoneEntity.java銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歝reateRecord()/updateRecord() -> copyRequestToEntity() -> Entity -> Mapper 鎸佷箙鍖栥€? */
public void copyRequestToEntity(WxPhoneSaveRequest request, WxPhoneEntity entity) {
entity.setPhoneNumber(normalizeText(request.getPhoneNumber()));
entity.setRealPerson(normalizeText(request.getRealPerson()));
entity.setIccid(normalizeText(request.getIccid()));
entity.setCity(normalizeText(request.getCity()));
entity.setImageAttachment1(normalizeText(request.getImageAttachment1()));
entity.setImageAttachment2(normalizeText(request.getImageAttachment2()));
entity.setCardStatus(normalizeCardStatus(request.getCardStatus()));
entity.setCardUsageLocation(normalizeText(request.getCardUsageLocation()));
entity.setWecom(normalizeBoolean(request.getWecom(), Boolean.FALSE));
entity.setWechat(normalizeBoolean(request.getWechat(), Boolean.FALSE));
entity.setOutboundCall(normalizeBoolean(request.getOutboundCall(), Boolean.FALSE));
entity.setDouyinAccount(normalizeText(request.getDouyinAccount()));
entity.setMiniProgramFiling(normalizeBoolean(request.getMiniProgramFiling(), Boolean.FALSE));
entity.setPackageChange5yuan(normalizeBoolean(request.getPackageChange5yuan(), Boolean.FALSE));
entity.setChannelOperator(normalizeText(request.getChannelOperator()));
entity.setNumberStatus(normalizeBoolean(request.getNumberStatus(), Boolean.TRUE));
entity.setLinkedWecom(normalizeText(request.getLinkedWecom()));
entity.setWechatStatus(Boolean.TRUE.equals(entity.getWechat()) ? "正常" : "异常");
entity.setOutbound(Boolean.TRUE.equals(entity.getOutboundCall()) ? "可外呼" : "不可外呼");
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭妸鏁版嵁搴撻噷鍙栧洖鏉ョ殑鎵嬫満鍙峰崱缁熶竴鏁寸悊鎴愬墠绔兘鐩存帴鍚冪殑鐘舵€佹枃鏈拰绌哄€兼牸寮忋€? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/src/modules/shared/record-adapters.js銆丗:/Project/xyw_console/src/modules/phone/phone-list-runtime.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歁apper.selectList()/selectById() -> normalizeEntity() -> controller response -> 鍓嶇 normalizePhoneRecord()銆? */
public WxPhoneEntity normalizeEntity(WxPhoneEntity entity) {
if (entity == null) {
return null;
}
entity.setPhoneNumber(normalizeText(entity.getPhoneNumber()));
entity.setRealPerson(normalizeText(entity.getRealPerson()));
entity.setIccid(normalizeText(entity.getIccid()));
entity.setCity(normalizeText(entity.getCity()));
entity.setImageAttachment1(normalizeText(entity.getImageAttachment1()));
entity.setImageAttachment2(normalizeText(entity.getImageAttachment2()));
entity.setCardStatus(normalizeCardStatus(entity.getCardStatus()));
entity.setCardUsageLocation(normalizeText(entity.getCardUsageLocation()));
entity.setDouyinAccount(normalizeText(entity.getDouyinAccount()));
entity.setChannelOperator(normalizeText(entity.getChannelOperator()));
entity.setLinkedWecom(normalizeText(entity.getLinkedWecom()));
entity.setWechatStatus(Boolean.TRUE.equals(entity.getWechat()) ? "正常" : "异常");
entity.setOutbound(Boolean.TRUE.equals(entity.getOutboundCall()) ? "可外呼" : "不可外呼");
return entity;
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭妸鍓嶇鍙兘浼犳潵鐨?0/1銆佹甯?寮傚父绛夊崱鐘舵€侊紝缁熶竴鎴愰〉闈㈣兘绋冲畾鏄剧ず鐨勬枃鏈€? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/src/modules/phone/phone-add-dialog.js銆丗:/Project/xyw_console/src/modules/phone/phone-list-runtime.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歳equest.cardStatus/DB.card_status -> normalizeCardStatus() -> response.cardStatus -> 鍒楄〃鍜岃鎯呮樉绀恒€? */
public String normalizeCardStatus(String value) {
String normalized = normalizeText(value);
if (normalized == null) {
return "正常";
}
return switch (normalized) {
case "1", "正常", "正常在用" -> "正常";
case "0", "异常", "异常停机", "停机" -> "异常停机";
default -> normalized;
};
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭妸瀛楃涓查噷鐨勯灏剧┖鏍煎幓鎺夛紝绌虹櫧鍐呭鐩存帴褰撴垚娌″~銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java銆丗:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/entity/WxPhoneEntity.java銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歳equest/entity 瀛楁 -> normalizeText() -> 鍏ュ簱/鍑哄簱鍓嶇殑缁熶竴娓呮礂銆? */
public String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmedValue = value.trim();
// 支持空值提交:空字符串表示用户主动清空,应保存为空字符串而非 null
return trimmedValue;
}
/**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氱粰鍙┖甯冨皵瀛楁琛ラ粯璁ゅ€硷紝閬垮厤鏁版嵁搴撳拰椤甸潰鍑虹幇涓€鍫?null銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java銆丗:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/entity/WxPhoneEntity.java銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歳equest.Boolean -> normalizeBoolean() -> Entity.Boolean -> MyBatis-Plus 淇濆瓨銆? */
public Boolean normalizeBoolean(Boolean value, Boolean defaultValue) {
return value == null ? defaultValue : value;
}
}
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>域名资料 - Vue Element Plus重构</title>
<!-- 引入 Element Plus CSS -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
.table-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
/* 强化的列宽对齐控制,避免Element Plus默认挤压 */
.el-table {
table-layout: fixed;
}
.domain-cell {
display: flex;
flex-direction: column;
}
.domain-name {
font-weight: 500;
color: #303133;
font-size: 14px;
}
.domain-remark {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.date-cell {
display: flex;
flex-direction: column;
}
.date-primary {
font-size: 14px;
color: #303133;
}
.date-hint {
font-size: 12px;
margin-top: 4px;
}
.date-hint.danger {
color: #f56c6c;
}
.date-hint.warning {
color: #e6a23c;
}
.date-hint.success {
color: #67c23a;
}
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.footer-pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
</head>
<body>
<div id="app">
<div class="table-container">
<div class="header-toolbar">
<h2>域名资料清单</h2>
<div>
<el-input v-model="searchQuery" placeholder="搜索域名、所有者、备注等" style="width: 250px; margin-right: 15px;">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary">新建记录</el-button>
<el-button>导出数据</el-button>
</div>
</div>
<el-table
:data="filteredData"
style="width: 100%"
border
stripe
>
<el-table-column type="selection" width="50" align="center"></el-table-column>
<el-table-column label="域名" min-width="180">
<template #default="scope">
<div class="domain-cell">
<span class="domain-name">{{ scope.row.domain }}</span>
<span class="domain-remark">{{ scope.row.remark || '—' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)" effect="light">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="风险等级" width="100" align="center">
<template #default="scope">
<el-tag :type="getRiskType(scope.row.riskLevel)" effect="dark" size="small">
{{ scope.row.riskLevel }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" min-width="140">
<template #default="scope">
<div class="date-cell">
<span class="date-primary">{{ scope.row.expirationDate }}</span>
<span :class="['date-hint', getHintClass(scope.row.expirationHint)]">
{{ scope.row.expirationHint }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="project" label="所属项目" min-width="120"></el-table-column>
<el-table-column prop="owner" label="所有者" width="100"></el-table-column>
<el-table-column label="续费结果" width="100" align="center">
<template #default="scope">
<el-tag :type="getRenewalResultType(scope.row.renewalResult)" effect="plain" size="small">
{{ scope.row.renewalResult }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="registrar" label="注册商" min-width="120"></el-table-column>
<el-table-column prop="updatedAt" label="更新时间" min-width="140"></el-table-column>
<!-- 修正最右侧操作列对齐问题,设置fixed="right"并固定合适宽度 -->
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="handleView(scope.row)">查看</el-button>
<el-button link type="primary" size="small">编辑</el-button>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="footer-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="tableData.length"
/>
</div>
</div>
</div>
<!-- 引入 Vue 3 和 Element Plus 及其图标 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script>
const { createApp, ref, computed } = Vue;
const { Search } = ElementPlusIconsVue;
const app = createApp({
setup() {
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(10);
// 从 app-v2.js 提取的真实域名数据列表
const tableData = ref([
{ id: 1, domain: "example.com", status: "正常", riskLevel: "低", expirationDate: "2025-07-18", expirationHint: "剩余 45 天", project: "官网项目组", owner: "张三", renewalResult: "待续费", registrar: "阿里云", updatedAt: "2025-06-30 10:23", registrationDate: "2020-07-18", renewalMethod: "手动续费", dnsProvider: "dns1.hichina.com, dns2.hichina.com", remark: "公司官网主域名。", relatedAssets: ["企业官网", "SEO 监控"], logs: [{ time: "2025-06-30 10:23", tag: "续费提醒", text: "张三 更新了 example.com 的续费方式。" }, { time: "2025-06-20 14:11", tag: "信息更新", text: "张三 更新了域名备注与备案信息。" }] },
{ id: 2, domain: "datahub.cn", status: "风险", riskLevel: "中", expirationDate: "2025-06-15", expirationHint: "剩余 12 天", project: "数据中台", owner: "李四", renewalResult: "续费中", registrar: "腾讯云", updatedAt: "2025-06-02 15:42", registrationDate: "2021-06-15", renewalMethod: "自动续费", dnsProvider: "dnspod.tencentcloudapi.com", remark: "数据中台统一域名。", relatedAssets: ["BI 报表"], logs: [{ time: "2025-06-02 15:42", tag: "续费操作", text: "李四 提交了 datahub.cn 的续费申请。" }] },
{ id: 3, domain: "ai-platform.net", status: "风险", riskLevel: "高", expirationDate: "2025-06-05", expirationHint: "剩余 2 天", project: "AI 平台", owner: "王五", renewalResult: "未续费", registrar: "西部数码", updatedAt: "2025-06-01 09:18", registrationDate: "2022-06-05", renewalMethod: "手动续费", dnsProvider: "dns.west.cn", remark: "AI 平台主域名。", relatedAssets: ["AI 控制台", "API 文档"], logs: [{ time: "2025-06-01 09:18", tag: "风险提示", text: "系统检测到续费未处理。" }] },
{ id: 4, domain: "metrics.io", status: "过期", riskLevel: "高", expirationDate: "2025-05-28", expirationHint: "已过期 6 天", project: "监控平台", owner: "赵六", renewalResult: "未续费", registrar: "阿里云", updatedAt: "2025-05-28 11:07", registrationDate: "2021-05-28", renewalMethod: "余额续费", dnsProvider: "alidns.aliyuncs.com", remark: "监控平台展示域名。", relatedAssets: ["告警页"], logs: [{ time: "2025-05-28 11:07", tag: "到期提醒", text: "metrics.io 已过期 6 天。" }] },
{ id: 5, domain: "docs.example.com", status: "正常", riskLevel: "低", expirationDate: "2025-09-30", expirationHint: "剩余 119 天", project: "文档中心", owner: "张三", renewalResult: "自动续费", registrar: "阿里云", updatedAt: "2025-06-03 08:55", registrationDate: "2023-09-30", renewalMethod: "自动续费", dnsProvider: "alidns.aliyuncs.com", remark: "开发者文档域名。", relatedAssets: ["帮助中心"], logs: [{ time: "2025-06-03 08:55", tag: "状态同步", text: "证书与到期信息同步完成。" }] }
]);
const filteredData = computed(() => {
let data = tableData.value;
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
data = data.filter(item =>
item.domain.toLowerCase().includes(query) ||
item.owner.toLowerCase().includes(query) ||
(item.remark && item.remark.toLowerCase().includes(query))
);
}
// 分页逻辑
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return data.slice(start, end);
});
const getStatusType = (status) => {
const map = { '正常': 'success', '风险': 'warning', '过期': 'danger' };
return map[status] || 'info';
};
const getRiskType = (level) => {
const map = { '低': 'success', '中': 'warning', '高': 'danger' };
return map[level] || 'info';
};
const getRenewalResultType = (result) => {
const map = {
'自动续费': 'success',
'待续费': 'warning',
'续费中': 'primary',
'未续费': 'danger'
};
return map[result] || 'info';
};
const getHintClass = (hint) => {
if (!hint) return '';
if (hint.includes('过期') || hint.includes('2 天')) return 'danger';
if (hint.includes('12 天')) return 'warning';
return 'success';
};
const handleView = (row) => {
console.log('查看详情', row);
};
return {
searchQuery,
currentPage,
pageSize,
tableData,
filteredData,
getStatusType,
getRiskType,
getRenewalResultType,
getHintClass,
handleView
};
}
});
app.use(ElementPlus);
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.mount('#app');
</script>
</body>
</html>
<!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", "name": "xyw-console-frontend",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "xyw-console-frontend",
"dependencies": { "dependencies": {
"element-plus": "^2.14.2", "element-plus": "^2.14.2",
"jsdom": "^29.1.1",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"vite": "^7.3.6" "vite": "^7.3.6"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": ">=20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
...@@ -107,152 +64,6 @@ ...@@ -107,152 +64,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
"integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
"integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@ctrl/tinycolor": { "node_modules/@ctrl/tinycolor": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
...@@ -713,46 +524,29 @@ ...@@ -713,46 +524,29 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.5", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.11" "@floating-ui/utils": "^0.2.12"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.7.6", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.5", "@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.11" "@floating-ui/utils": "^0.2.12"
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.11", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
...@@ -761,6 +555,22 @@ ...@@ -761,6 +555,22 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es", "name": "@sxzz/popperjs-es",
"version": "2.11.8", "version": "2.11.8",
...@@ -773,9 +583,9 @@ ...@@ -773,9 +583,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
"integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
...@@ -787,9 +597,9 @@ ...@@ -787,9 +597,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
"integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
...@@ -801,9 +611,9 @@ ...@@ -801,9 +611,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
"integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
...@@ -815,9 +625,9 @@ ...@@ -815,9 +625,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
"integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
...@@ -829,9 +639,9 @@ ...@@ -829,9 +639,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
"integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
...@@ -843,9 +653,9 @@ ...@@ -843,9 +653,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
"integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
...@@ -857,9 +667,9 @@ ...@@ -857,9 +667,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
"integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
...@@ -871,9 +681,9 @@ ...@@ -871,9 +681,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
"integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
...@@ -885,9 +695,9 @@ ...@@ -885,9 +695,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
"integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
...@@ -899,9 +709,9 @@ ...@@ -899,9 +709,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
"integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
...@@ -913,9 +723,9 @@ ...@@ -913,9 +723,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
"integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
...@@ -927,9 +737,9 @@ ...@@ -927,9 +737,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
"integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
...@@ -941,9 +751,9 @@ ...@@ -941,9 +751,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
"integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
...@@ -955,9 +765,9 @@ ...@@ -955,9 +765,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
"integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
...@@ -969,9 +779,9 @@ ...@@ -969,9 +779,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
"integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
...@@ -983,9 +793,9 @@ ...@@ -983,9 +793,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
"integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
...@@ -997,9 +807,9 @@ ...@@ -997,9 +807,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
"integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
...@@ -1011,9 +821,9 @@ ...@@ -1011,9 +821,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
"integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
...@@ -1025,9 +835,9 @@ ...@@ -1025,9 +835,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
"integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
...@@ -1039,9 +849,9 @@ ...@@ -1039,9 +849,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
"integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
...@@ -1053,9 +863,9 @@ ...@@ -1053,9 +863,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
"integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
...@@ -1067,9 +877,9 @@ ...@@ -1067,9 +877,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
"integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
...@@ -1081,9 +891,9 @@ ...@@ -1081,9 +891,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
"integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
...@@ -1095,9 +905,9 @@ ...@@ -1095,9 +905,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
"integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
...@@ -1109,9 +919,9 @@ ...@@ -1109,9 +919,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
"integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
...@@ -1151,65 +961,53 @@ ...@@ -1151,65 +961,53 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.7", "@babel/parser": "^7.29.7",
"@vue/shared": "3.5.39", "@vue/shared": "3.5.40",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-core/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz",
"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.39", "@vue/compiler-core": "3.5.40",
"@vue/shared": "3.5.39" "@vue/shared": "3.5.40"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz",
"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.7", "@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.39", "@vue/compiler-core": "3.5.40",
"@vue/compiler-dom": "3.5.39", "@vue/compiler-dom": "3.5.40",
"@vue/compiler-ssr": "3.5.39", "@vue/compiler-ssr": "3.5.40",
"@vue/shared": "3.5.39", "@vue/shared": "3.5.40",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.15", "postcss": "^8.5.19",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz",
"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.39", "@vue/compiler-dom": "3.5.40",
"@vue/shared": "3.5.39" "@vue/shared": "3.5.40"
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
...@@ -1219,53 +1017,51 @@ ...@@ -1219,53 +1017,51 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz",
"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.39" "@vue/shared": "3.5.40"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz",
"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.39", "@vue/reactivity": "3.5.40",
"@vue/shared": "3.5.39" "@vue/shared": "3.5.40"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz",
"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.39", "@vue/reactivity": "3.5.40",
"@vue/runtime-core": "3.5.39", "@vue/runtime-core": "3.5.40",
"@vue/shared": "3.5.39", "@vue/shared": "3.5.40",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz",
"integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.39", "@vue/compiler-ssr": "3.5.40",
"@vue/shared": "3.5.39" "@vue/runtime-dom": "3.5.40",
}, "@vue/shared": "3.5.40"
"peerDependencies": {
"vue": "3.5.39"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
...@@ -1312,63 +1108,22 @@ ...@@ -1312,63 +1108,22 @@
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.21", "version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/element-plus": { "node_modules/element-plus": {
"version": "2.14.2", "version": "2.14.3",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
"integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==", "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ctrl/tinycolor": "^4.2.0", "@ctrl/tinycolor": "^4.2.0",
...@@ -1385,19 +1140,19 @@ ...@@ -1385,19 +1140,19 @@
"lodash-unified": "^1.0.3", "lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0", "normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.3" "vue-component-type-helpers": "^3.3.5"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "^3.3.7" "vue": "^3.3.7"
} }
}, },
"node_modules/entities": { "node_modules/entities": {
"version": "8.0.0", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=0.12"
}, },
"funding": { "funding": {
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
...@@ -1470,9 +1225,9 @@ ...@@ -1470,9 +1225,9 @@
} }
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
...@@ -1484,64 +1239,6 @@ ...@@ -1484,64 +1239,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
...@@ -1565,15 +1262,6 @@ ...@@ -1565,15 +1262,6 @@
"lodash-es": "*" "lodash-es": "*"
} }
}, },
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
...@@ -1583,12 +1271,6 @@ ...@@ -1583,12 +1271,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
...@@ -1596,9 +1278,9 @@ ...@@ -1596,9 +1278,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
...@@ -1619,18 +1301,6 @@ ...@@ -1619,18 +1301,6 @@
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
...@@ -1650,10 +1320,42 @@ ...@@ -1650,10 +1320,42 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
...@@ -1670,7 +1372,7 @@ ...@@ -1670,7 +1372,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
...@@ -1678,28 +1380,10 @@ ...@@ -1678,28 +1380,10 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.62.2", "version": "4.62.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
...@@ -1713,46 +1397,34 @@ ...@@ -1713,46 +1397,34 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm-eabi": "4.62.3",
"@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-android-arm64": "4.62.3",
"@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.3",
"@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.3",
"@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.3",
"@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
"@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.3",
"@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.3",
"@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.3",
"@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.3",
"@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.3",
"@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.3",
"@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.3",
"@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.3",
"@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.3",
"@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.3",
"@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.3",
"@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.3",
"@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.3",
"@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.3",
"@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.3",
"@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.3",
"@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.3",
"@rollup/rollup-win32-x64-msvc": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.3",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
...@@ -1762,12 +1434,6 @@ ...@@ -1762,12 +1434,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
...@@ -1785,57 +1451,6 @@ ...@@ -1785,57 +1451,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tldts": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz",
"integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.5"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz",
"integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==",
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.6", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
...@@ -1911,17 +1526,32 @@ ...@@ -1911,17 +1526,32 @@
} }
} }
}, },
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.39", "version": "3.5.40",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz",
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.39", "@vue/compiler-dom": "3.5.40",
"@vue/compiler-sfc": "3.5.39", "@vue/compiler-sfc": "3.5.40",
"@vue/runtime-dom": "3.5.39", "@vue/runtime-dom": "3.5.40",
"@vue/server-renderer": "3.5.39", "@vue/server-renderer": "3.5.40",
"@vue/shared": "3.5.39" "@vue/shared": "3.5.40"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"
...@@ -1933,9 +1563,9 @@ ...@@ -1933,9 +1563,9 @@
} }
}, },
"node_modules/vue-component-type-helpers": { "node_modules/vue-component-type-helpers": {
"version": "3.3.5", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz",
"integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", "integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue-router": { "node_modules/vue-router": {
...@@ -1952,65 +1582,6 @@ ...@@ -1952,65 +1582,6 @@
"peerDependencies": { "peerDependencies": {
"vue": "^3.5.0" "vue": "^3.5.0"
} }
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
} }
} }
} }
{ {
"name": "xyw-console-frontend",
"private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "playwright test"
}, },
"engines": { "engines": {
"node": ">=20.19.0 || >=22.12.0" "node": ">=20.19.0 || >=22.12.0"
}, },
"dependencies": { "dependencies": {
"element-plus": "^2.14.2", "element-plus": "^2.14.2",
"jsdom": "^29.1.1",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"vite": "^7.3.6" "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>
## Context
`xyw_data_test.as_phone_asset``PhoneAssetEntity``PhoneAssetMapper` 已存在,但没有 Controller、Service、DTO、分页配置或可操作的前端页面。当前前端只在 `/reference/phone` 保留静态参考;根导航中的“手机号卡(参考)”不连接任何新表接口。
本变更是前后端联动:以已有 `as_phone_asset` 为唯一持久化来源,补齐手机号资产独立维护闭环。它不假设其他资产表的业务 Service 已完成,因此不能把手机号的关联快照当成用户可编辑的关系来源。
### 范围文件用途注释表
| 文件 | 文件用途 | 核心职责 | 关联文件与数据流 |
|---|---|---|---|
| `backend/.../config/MybatisPlusConfig.java`(新增) | MyBatis-Plus 配置 | 启用分页查询插件 | Controller → Service → Page 查询 → `PhoneAssetMapper` |
| `backend/.../asset/controller/PhoneAssetController.java`(新增) | HTTP 入口 | 接收 `/api/phone-assets` 请求并返回 `ApiResponse` | 前端 API 客户端 → Controller → Service |
| `backend/.../asset/service/PhoneAssetService.java`(新增) | 业务规则 | 过滤有效记录、校验唯一性、审计时间和软删除 | Controller → Service → Entity/Mapper → `as_phone_asset` |
| `backend/.../asset/dto/PhoneAssetSaveRequest.java`(新增) | 写入 DTO | 限定可由用户写入的字段并做字段校验 | 前端表单 → Controller 校验 → Service |
| `backend/.../asset/dto/PhoneAssetPageQuery.java`(新增) | 查询 DTO | 约束页码、页大小和筛选项 | 列表筛选 → Controller → Service |
| `backend/.../asset/dto/PhoneAssetResponse.java`(新增) | 读取 DTO | 向前端提供资产字段和只读关联快照 | Entity → Service 映射 → 前端表格/详情 |
| `backend/.../asset/dto/PhoneAssetPageResponse.java`(新增) | 分页返回 DTO | 统一返回 records、total、page、size | Service 分页结果 → API 客户端 |
| `backend/.../asset/exception/*.java`(新增) | 业务异常与统一返回 | 将不存在、重复、请求校验失败稳定映射为 404/409/400 | Service/Validator → Exception handler → `ApiResponse.error` |
| `backend/src/test/.../asset/**/*.java`(新增) | 后端自动化测试 | 覆盖分页、重复、软删除、输入校验和错误返回 | Service/Controller → MockMapper/MockMvc |
| `frontend/src/modules/phone/phone-api-client.js`(新增) | 浏览器 API 客户端 | 组装请求、解析统一返回体、抛出可显示错误 | 视图事件 → fetch → 后端 API |
| `frontend/src/modules/phone/PhoneAssetView.js`(新增) | 手机号资产工作页面 | 筛选、表格、分页、编辑弹窗、删除确认和详情 | 路由 → View → API client → 页面状态 |
| `frontend/src/router/index.js`(修改) | 前端路由 | 注册 `/phone-assets`;保留 `/reference/phone` 静态回退 | URL → PhoneAssetView |
| `frontend/src/App.js`(修改) | 导航壳 | 将主导航指向可操作手机号资产页 | 导航点击 → RouterLink → 路由 |
| `frontend/src/styles/app.css`(修改) | 共享样式 | 为工作页筛选区、表格、详情和窄屏布局提供样式 | View DOM → CSS |
| `frontend/tests/phone-asset.spec.js`(新增) | 前端端到端测试 | 以 mock API 验证页面加载、保存、删除和错误展示 | Playwright → View → mock `/api/phone-assets` |
## Goals / Non-Goals
**Goals:**
- 实现手机号资产的分页查询、单条查询、新增、编辑与软删除,所有有效记录读取均固定 `delete_time = 0`
- 保存前去除手机号的首尾空格和 `+86` 前缀,并校验最终值为 11 位数字;手机号重复必须反馈为冲突,不覆盖原记录。
- 使用固定下拉选项:卡类型为中国移动/中国电信/中国联通/中国广电/虚拟号码/空串,管理类型为自有/租用/代运营/空值,处置状态为正常使用/闲置/停机/已注销且必填、默认正常使用。
- 新建记录将五个 JSON 关联快照显式初始化为 `[]`,并只读返回数组形式给前端。
- 提供与现有 Vue 3 + Element Plus 代码风格一致的操作页面,保留旧参考路由,避免旧链接失效。
- 为关键服务规则、HTTP 错误和前端主流程提供自动化回归测试。
**Non-Goals:**
- 不改 `as_phone_asset` 表、Entity、Mapper、已有索引、数据库配置或任何其他资产表。
- 不新增登录、权限、导入导出、批量操作、关联数据同步、关联字段编辑、设备存在性校验或外键。
- 不删除旧参考页面、旧路由或其他模块占位页面。
## Decisions
### 1. API 采用单资源 REST 路径与稳定返回壳
使用 `GET/POST /api/phone-assets``GET/PUT/DELETE /api/phone-assets/{id}`;成功响应沿用 `ApiResponse<T>``code/message/data` 结构。列表 `data` 固定为 `{ records, total, page, size }`,默认第 1 页、每页 20 条,页大小上限 100。
查询条件采用 `phoneNumber`(完整 11 位精确匹配)、`iccid``cardType``managementType``disposalStatus``deviceId`,并以 `id DESC` 稳定排序。精确手机号查询可利用已有 `(phone_number, delete_time)` 唯一索引,避免无索引模糊搜索成为首期性能隐患。
备选:沿用旧模块接口或先只做前端本地数据。前者耦合已淘汰表,后者不能验证新表;均不采用。
### 2. Service 显式负责软删除、审计列与并发结果
本期不在 Entity 上使用 `@TableLogic``delete_time` 需记录删除时刻而非布尔值。Service 的每个查询条件都加入 `delete_time = 0`;创建和更新显式写入 `create_time`/`update_time`;删除只更新 `delete_time`(Unix 毫秒)和 `update_time`。更新或删除影响行数为零时返回“记录不存在或已删除”,不恢复记录。
创建与改手机号均先检查有效手机号是否存在,并仍捕获数据库唯一索引冲突作为并发保护;冲突一律映射 HTTP 409。备选为仅依赖预查询或仅依赖数据库异常;两者单独使用都会在并发或错误提示方面不足。
### 3. 关联快照由服务端托管且本期只读
写入 DTO 仅包含 `phoneNumber``cardType``iccid``realNameOwner``managementType``disposalStatus``deviceId`。五个 `linked*` 字段不接受前端输入:创建时写入字符串 `[]`,编辑保持数据库现值;响应 DTO 解析为 `List<Long>`,前端仅在详情区以数量和 ID 列表展示。`relationSyncedAt` 也只读。
这样不会把尚未建成的 `wecom_account` 等关系同步逻辑伪装成可用功能。备选为让用户手工填写 JSON;会绕过关联表校验并制造未来同步覆盖风险,因此不采用。
### 4. 前端采用模块化 JS 视图与 Element Plus 原生组件
新增 `PhoneAssetView.js``phone-api-client.js`,延续当前非 SFC 的 Vue JavaScript 写法。页面由筛选栏、分页表格、右侧详情抽屉、同一新增/编辑弹窗和删除二次确认组成;所有 API 错误用 `ElMessage` 展示,保存成功后刷新当前筛选条件。路由新增 `/phone-assets`,导航文案从“手机号卡(参考)”调整为“手机号资产”;`/reference/phone` 继续保留。
备选为直接替换 `LegacyReferenceView`。该组件同时承载静态回退语义,直接替换会损失安全参考入口,故不采用。
### 5. 错误协议与方法注释作为跨端契约
后端使用统一异常处理:校验失败 400、无有效记录 404、手机号重复 409;前端 API 客户端只读取 `ApiResponse` 后再把 message 交给页面。所有本变更新增或含业务逻辑的方法、Vue 回调和测试辅助方法必须添加中文注释,至少写明“代码作用(白话)/关联文件/关联逻辑(调用链、消息链或数据流)”。
这使前端不依赖 HTTP 文本或数据库异常细节,也满足项目注释门禁。备选为在各 Controller 分支手写错误 JSON;会造成状态码和文案漂移,不采用。
### 6. 已确认的输入与删除规则
手机号输入在前端限制为数字和最多 11 位;前端与后端保存前都删除首尾空格和一个 `+86` 前缀,结果必须恰好为 11 位数字。`deviceId` 可为空,且本期不查询设备表验证存在性。
卡类型固定为中国移动、中国电信、中国联通、中国广电、虚拟号码、空串;管理类型固定为自有、租用、代运营、空值,空值在页面显示为空、后端存 `null`;处置状态固定为正常使用、闲置、停机、已注销,必填且创建默认“正常使用”。
删除前,Service 必须查询有效企微、微信、抖音、域名账号和商户中是否存在指向该手机号的关联;任一存在即返回冲突与关联类型,不执行软删除。关联快照仍仅初始化为 `[]` 并只读展示,自动同步留给后续独立功能。
## Risks / Trade-offs
- 关联快照当前均为空 → 只读展示空数组,后续关联模块完成后再单独计划同步与回填,不能由本期伪造数据。
- 无固定状态枚举 → 保留兼容性,但会允许不同拼写;待业务确认字典后用独立提案统一收敛。
- 数据库无外键,且设备模块未有 Service → `deviceId` 只做非负数校验,不判断存在性;后续设备模块上线时补逻辑关联校验。
- 数据库唯一索引的重复异常驱动依赖 MySQL → 先做业务预检并将异常集中转换,测试覆盖两条路径。
- `relationSyncedAt` 由未来同步维护 → 本期不在创建/编辑时伪造同步时间,避免“显示已同步”但实际没有同步。
## Migration Plan
1. 部署前确认 `xyw_data_test.as_phone_asset` 已含已审批的列和索引;本变更不执行 DDL。
2. 先部署后端,再部署前端;旧 `/reference/phone` 路由继续可用,因此前端切换失败可暂时把导航链路恢复到参考页。
3. 上线后以一条新建记录验证创建、筛选、编辑、删除;删除后同手机号可重新创建且列表不显示旧记录。
4. 回滚仅回滚新增应用代码与导航链接,不删除或改写已创建的 `as_phone_asset` 数据;已软删除记录保持审计状态。
## Open Questions
- 无阻塞业务问题;后续自动同步关联快照、登录与权限属于独立功能范围。
## 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
## Context
一期资产后台的参考计划已定义 11 张空表及业务关联方向,但当前 Spring Boot + MyBatis-Plus 工程仍使用旧表,也没有 Flyway、Liquibase 或其他自动迁移机制。现有 `sql/phone-account-sync-preview-reset.sql` 是历史人工脚本,包含备份和删除操作,不能复用到本期。
本设计只准备可审阅的数据库结构交付物,目标库只在获得单独书面授权后由人工连接的 `xyw_data_test` 执行。用户提供的 `mch_ai_reply_rule` 是本期审计字段命名的参考:所有新表均带 `create_time DATETIME NULL``update_time DATETIME NULL``delete_time BIGINT NOT NULL DEFAULT 0`
### 范围文件用途注释表
| 文件 | 文件用途 | 核心职责 | 上下游关联文件 | 主要关联逻辑 | 风险点 |
|---|---|---|---|---|---|
| `sql/asset-phase1-preflight.sql`(新增) | 只读预检脚本 | 显示当前库、11 表的存在状态及非目标库风险 | 建表脚本、验收记录 | 人工先读预检结果,再决定是否执行建表 | 误连库或发现部分表存在仍继续 |
| `sql/asset-phase1-schema.sql`(新增) | 一期建表脚本 | 创建 11 张空表、索引与表注释 | 字段字典、后续 Entity/Mapper | 授权后按固定顺序运行,仅含 `CREATE TABLE` | 字段或索引遗漏;DDL 失败后形成部分表 |
| `sql/asset-phase1-schema-verify.sql`(新增) | 只读结构验收脚本 | 核对表、列、主键、索引和禁止的约束 | 建表脚本、验收记录 | 建表完成后读取 `information_schema` 并人工判定 | 只检查表存在而漏掉关键列或索引 |
| `docs/acceptance/asset-phase1-schema-YYYY-MM-DD.md`(执行后新增) | 建表验收记录 | 记录授权、实际库名、执行时间、结果与异常 | 三份 SQL 脚本、授权记录 | 结构验收通过后归档证据 | 缺少可追溯证据,无法判断是否在授权库执行 |
## Goals / Non-Goals
**Goals:**
- 锁定 11 表的字段、主键、唯一规则和查询索引,供后续新后端的 Entity、Mapper、DTO 和 API 使用。
- 对所有表统一采用 `id``create_time``update_time``delete_time`;有效记录的 `delete_time` 固定为 `0`
- 将建表拆为“只读预检 → 获权后人工建表 → 只读结构验收 → 留存记录”,并明确每一步的停止条件。
- 让同一业务主键可在软删除后重建:需要唯一的业务字段使用 `(业务字段, delete_time)` 复合唯一索引。
**Non-Goals:**
- 不连接数据库、不执行 SQL、不创建或删除任何实际表。
- 不修改旧表、旧接口、旧后端启动异常、前端页面、权限、认证或 API 文档。
- 不导入、迁移、回填或兼容历史数据;不创建外键、`REFERENCES``CHECK`、触发器或存储过程。
- 不定义 API 字段、DTO、Service 方法或 Entity;这些在后续新后端梯队再进行完整字段追踪。
## Decisions
### 1. 统一审计列与软删除列
每张表必须定义:
| 列 | 类型与默认值 | 规则 |
|---|---|---|
| `id` | `BIGINT UNSIGNED NOT NULL AUTO_INCREMENT` | 主键 |
| `create_time` | `DATETIME NULL` | 后续应用创建记录时写入;本期空表不依赖数据库默认值 |
| `update_time` | `DATETIME NULL` | 后续应用更新记录时写入;本期空表不依赖数据库自动更新行为 |
| `delete_time` | `BIGINT NOT NULL DEFAULT 0` | `0` 表示有效;软删除时由后续 Service 写入 Unix 毫秒时间戳 |
选择参考表的列名和空值策略,而不使用参考计划里的 `created_at` / `updated_at``ON UPDATE CURRENT_TIMESTAMP`。这样既满足最新命名约束,也避免不同 MySQL 版本对自动更新时间行为的差异。后续唯一查询必须带 `delete_time = 0`
### 2. 11 张表与字段字典
所有表均使用 `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,并在每项下附加上一节的 4 个通用列。
| 表 | 业务字段(除通用列) | 主键/唯一规则 | 查询索引 |
|---|---|---|---|
| `company_profile` | `company_name VARCHAR(128) NOT NULL``short_name VARCHAR(128)``unified_social_credit_code VARCHAR(64)``address VARCHAR(255)``contact_name VARCHAR(64)``contact_value VARCHAR(128)` | `id``uk_company_profile_name_delete_time (company_name, delete_time)` | 无额外索引 |
| `company_person` | `company_profile_id BIGINT UNSIGNED``person_name VARCHAR(64) NOT NULL``employment_status VARCHAR(32)``resigned_at DATETIME` | `id` | `idx_company_person_company_id_id (company_profile_id, id)``idx_company_person_employment_status (employment_status)` |
| `asset_device` | `device_name VARCHAR(128) NOT NULL``image_attachment_1 LONGTEXT``image_attachment_2 LONGTEXT``user_person_id BIGINT UNSIGNED``user_usage_status VARCHAR(32)``asset_relation_status VARCHAR(32)` | `id``uk_asset_device_name_delete_time (device_name, delete_time)` | `idx_asset_device_user_person_id``idx_asset_device_user_usage_status``idx_asset_device_relation_status` |
| `phone_asset` | `phone_number CHAR(11) NOT NULL``card_type VARCHAR(32)``iccid VARCHAR(32)``real_name_owner VARCHAR(64)``management_type VARCHAR(32)``disposal_status VARCHAR(32)``device_id BIGINT UNSIGNED``linked_wecom_accounts JSON``linked_wechat_accounts JSON``linked_douyin_accounts JSON``linked_domain_accounts JSON``linked_merchants JSON``relation_synced_at DATETIME` | `id``uk_phone_asset_phone_number_delete_time (phone_number, delete_time)` | `idx_phone_asset_card_type``idx_phone_asset_iccid``idx_phone_asset_device_id` |
| `wecom_account` | `wecom_name VARCHAR(128)``wecom_alias VARCHAR(128)``company_profile_id BIGINT UNSIGNED``wecom_account VARCHAR(128)``phone_asset_id BIGINT UNSIGNED``real_name_owner VARCHAR(64)``real_name_owner_status VARCHAR(32)``gender VARCHAR(16)``device_id BIGINT UNSIGNED``operator_person_id BIGINT UNSIGNED` | `id`;账号允许重复 | `idx_wecom_account_company_id``idx_wecom_account_phone_id_id (phone_asset_id, id)``idx_wecom_account_device_id``idx_wecom_account_operator_person_id``idx_wecom_account_name` |
| `wechat_account` | `wechat_id VARCHAR(128)``real_name_owner VARCHAR(64)``phone_asset_id BIGINT UNSIGNED``device_id BIGINT UNSIGNED``operator_person_id BIGINT UNSIGNED` | `id`;微信号允许重复 | `idx_wechat_account_wechat_id``idx_wechat_account_phone_id_id (phone_asset_id, id)``idx_wechat_account_device_id``idx_wechat_account_operator_person_id` |
| `douyin_account` | `douyin_id VARCHAR(128)``real_name_owner VARCHAR(64)``company_profile_id BIGINT UNSIGNED``phone_asset_id BIGINT UNSIGNED``device_id BIGINT UNSIGNED``operator_person_id BIGINT UNSIGNED` | `id`;抖音号允许重复 | `idx_douyin_account_douyin_id``idx_douyin_account_company_id``idx_douyin_account_phone_id_id (phone_asset_id, id)``idx_douyin_account_device_id``idx_douyin_account_operator_person_id` |
| `domain_account` | `account_identifier VARCHAR(128) NOT NULL``phone_asset_id BIGINT UNSIGNED` | `id``uk_domain_account_identifier_delete_time (account_identifier, delete_time)` | `idx_domain_account_phone_id_id (phone_asset_id, id)` |
| `domain_asset` | `domain_name VARCHAR(255) NOT NULL``domain_account_id BIGINT UNSIGNED``company_profile_id BIGINT UNSIGNED``expires_at DATETIME` | `id`;完整域名一期允许重复 | `idx_domain_asset_account_id_id (domain_account_id, id)``idx_domain_asset_company_id``idx_domain_asset_name``idx_domain_asset_expires_at` |
| `merchant` | `merchant_number VARCHAR(128) NOT NULL``company_profile_id BIGINT UNSIGNED``phone_asset_id BIGINT UNSIGNED` | `id``uk_merchant_number_delete_time (merchant_number, delete_time)` | `idx_merchant_company_id``idx_merchant_phone_id_id (phone_asset_id, id)` |
| `system_user` | `username VARCHAR(64) NOT NULL``password_hash VARCHAR(255) NOT NULL``role_code VARCHAR(32) NOT NULL``status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE'` | `id``uk_system_user_username_delete_time (username, delete_time)` | `idx_system_user_status``idx_system_user_role_code_status (role_code, status)` |
`JSON` 快照列不设置数据库默认值,以兼容 MySQL 的 JSON 默认值限制;后续 Service 创建 `phone_asset` 时必须显式传入 `[]`,同步失败时不得覆盖已有来源快照。图片字段保存地址或内容文本,不保存二进制数据。
### 3. 关系、约束与删除策略
字段名以 `*_id` 表示逻辑关联,但本期不建立数据库外键。这样能在后续多批次导入、独立 Service 校验和清理测试数据时避免 DDL 级阻塞;代价是后续新后端必须校验关联记录存在且 `delete_time = 0`,并在删除前阻止或处理仍被引用的记录。
不使用 `ENUM``CHECK` 限死状态取值。`employment_status``user_usage_status``asset_relation_status``card_type``management_type``disposal_status``real_name_owner_status``role_code``status` 均保存文本,由后续 Service 统一枚举校验。
### 4. 受控人工执行而非应用迁移
预检脚本只读取 `DATABASE()``information_schema`;当数据库名不是 `xyw_data_test`、任一目标表已存在或已存在部分目标表时,人工必须停止,不可继续。建表脚本不使用 `IF NOT EXISTS`,以避免把异常现状伪装为成功;每条 DDL 失败后立即停止,并运行验收脚本记录实际状态。
三份 SQL 文件的开头均需用中文说明:代码作用、关联文件、执行顺序、允许目标库、禁止行为与失败停止条件。它们不包含业务方法;因此本期没有 Java/JavaScript 方法注释需求。后续实现 Entity/Mapper/Service 时,改动范围内每个方法再补“代码作用(白话)/关联文件/关联逻辑(调用链、消息链或数据流)”注释。
### 5. 备选方案与取舍
| 方案 | 结论 | 原因 |
|---|---|---|
| 引入 Flyway/Liquibase 自动迁移 | 不采用 | 当前仅建空表、尚未授权连接库;引入依赖和自动执行会扩大风险与范围 |
| 复用历史重建脚本 | 不采用 | 该脚本包含备份、`DROP` 与旧表,违反本期禁止删除和旧表读取的边界 |
| 数据库外键/检查约束 | 不采用 | 参考计划已指定由后续 Service 做关联、枚举与容量校验 |
| 使用 `created_at`/`updated_at` | 不采用 | 用户最新通用字段约束明确为 `create_time`/`update_time` |
| 为 JSON 快照设数据库默认 `[]` | 不采用 | 存在 MySQL 版本差异;由后续 Service 显式写入空数组更可控 |
## Risks / Trade-offs
| 风险 | 缓解措施 |
|---|---|
| 误连非测试库 | 预检先显示 `DATABASE()`;发现非 `xyw_data_test` 立即停止,验收记录保留实际库名 |
| 任一 DDL 失败形成部分表 | 预检要求 11 表全不存在;发生错误即停止并运行只读验收,不自动补表或删除 |
| 软删除查询漏加过滤条件 | 后续 Entity/Mapper/Service 统一以 `delete_time = 0` 查询有效数据,并以 API 回归测试覆盖 |
| JSON 空值或无效内容 | 表结构允许 `NULL` 以保证数据库兼容;新后端写入必须显式 `[]` 并校验 JSON 结构 |
| 无外键导致孤儿关联 | 后续 Service 在创建、更新、删除时校验逻辑关联和引用关系;一期结构验收仅确认无外键 |
| 复合唯一索引中的删除时间冲突 | 软删除操作统一写 Unix 毫秒时间戳;若实际删除实现无法保证该粒度,后续必须改用单调值后再上线 |
### 工程术语注释表
| 术语 | 白话解释 | 使用位置 | 不这样做的风险 | 示例 |
|---|---|---|---|---|
| 软删除(不直接删行,而是标记已删除) | 保留旧记录,把 `delete_time``0` 改为删除时刻 | 所有 11 表 | 直接删除会丢失追溯线索,也无法安全恢复 | 有效手机号 `delete_time=0`;删除后写入毫秒时间戳 |
| 复合唯一索引(两个字段合起来不能重复) | 数据库把业务键和删除标记一起判断是否重复 | 需去重的业务表 | 仅按业务键唯一会阻止软删除后重新创建;不唯一会产生多条有效重复数据 | `phone_number + delete_time` |
| 预检(先只读查看再操作) | 先确认连的是哪一个库、表是否为空 | `asset-phase1-preflight.sql` | 误库执行或在半成品结构上继续会扩大事故 | `SELECT DATABASE()` 后检查 11 表 |
| 回滚(失败后恢复到安全状态) | 本期不自动删除表,而是停止后记录现状,等待新的授权修订方案 | DDL 失败处理 | 自动 `DROP` 可能误删不在范围的数据 | 验收脚本显示已创建表,后续走单独审批 |
| 幂等(重复执行结果仍可预测) | 本期建表脚本故意不追求静默重复执行,重复时必须报错暴露异常现状 | 建表脚本 | `IF NOT EXISTS` 会掩盖表结构不一致 | 已存在 `phone_asset``CREATE TABLE` 失败并停止 |
## Migration Plan
1. 审批本变更的字段字典和 SQL 范围;在此之前不生成或执行实际 SQL。
2. 获得对 `xyw_data_test` 的单独书面建表授权,并记录可恢复备份位置和执行人。
3. 人工连接目标库,执行只读预检;任一停止条件命中即结束。
4. 仅在预检全部通过后,按 `company_profile → company_person → asset_device → phone_asset → wecom_account / wechat_account / douyin_account / domain_account / merchant → domain_asset → system_user` 执行建表脚本。
5. 立即执行只读结构验收,检查 11 表、通用列、业务列、主键、唯一/查询索引以及“无外键、无检查约束”。
6. 在验收记录中写明实际库名、授权、时间、脚本版本、PASS/FAIL 和异常;仅验收通过才进入后续新后端梯队。
本期没有自动回滚 SQL。若失败:停止后续 DDL/开发,保留现状和验收输出;任何修订、删除或重建动作均须由新的明确计划和单独数据库授权覆盖。
## Open Questions
- 参考计划已给出一期最小字段,本提案据此锁定。若业务需要人员手机号/岗位/备注,或变更 `management_type``disposal_status` 的正式枚举,应在建表前作为新的字段变更审批,不能在执行时临时添加。
- `system_user` 仅建结构,不创建管理员账号或密码摘要;初始账户策略属于后续认证梯队。
## 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 与实际结构后停止
## 0. 任务卡片与审批门禁
| 项目 | 内容 |
|---|---|
| 目标 | 为资产后台一期准备 11 张空表的字段字典、预检 SQL、建表 SQL、结构验收 SQL 和执行记录模板 |
| 数据库范围 | 仅限获得单独书面授权后的 `xyw_data_test` |
| 本变更不做 | 连接数据库、执行 SQL、删除或修改旧表、旧数据迁移、旧后端修复、API/DTO/前端/认证/发布 |
| 计划审批点 | 审批字段字典、SQL 范围和目标库授权后,才允许开始第 2 节;审批本变更本身不等于授权执行数据库 DDL |
- [ ] 0.1 由负责人确认 11 表字段字典、`create_time` / `update_time` / `delete_time` 命名及“无外键、无检查约束”边界。
- [ ] 0.2 获得 `xyw_data_test` 的单独书面建表授权,记录执行人、授权时间和可恢复备份位置;未获授权不得进入任何数据库。
## 1. 范围文件与实现准备
| 修改点 | 输入 | 输出 | 完成判定 | 依赖前置 | 风险解析与缓解 | 回滚动作 | 新手注释要求 |
|---|---|---|---|---|---|---|---|
| SQL-01 | 已审批字段字典 | `asset-phase1-preflight.sql` | 仅含只读核对语句 | 0.1 | 误连库;先显示库名并遇异常停止 | 不执行建表 | 文件头说明作用、关联文件、顺序、目标库、禁止行为、停止条件 |
| SQL-02 | SQL-01 通过结果与授权 | `asset-phase1-schema.sql` | 仅含 11 个 `CREATE TABLE` 和索引 | 0.2 | DDL 失败出现部分表;立即停止并验收 | 不自动删除,走新审批 | 文件头同上;逐表中文注释说明表用途与关联 |
| SQL-03 | SQL-02 执行结果 | `asset-phase1-schema-verify.sql` | 能核对表、列、主键、索引、禁止约束 | SQL-02 | 漏检结构;逐项读取 `information_schema` | 记录 FAIL,停止后续工作 | 文件头同上;查询分组说明验证项目 |
| DOC-01 | SQL-03 输出 | 验收记录 | 含库名、授权、时间、版本、PASS/FAIL | SQL-03 | 证据不足;记录实际输出 | 保留原始记录,新增修订记录 | 使用中文模板解释每项证据 |
- [ ] 1.1 在开始写 SQL 前再次确认工作区现有改动不被覆盖;只新增本变更定义的 SQL 和验收记录文件。
- [ ] 1.2 为三份 SQL 文件预置统一中文文件头,明确它们不会被 Spring Boot 应用自动读取或执行。
- [ ] 1.3 不新增 Java、JavaScript、DTO、Entity 或 Service 方法;本期 SQL 没有业务方法。后续若扩展到代码,范围内每个方法必须附“代码作用(白话)/关联文件/关联逻辑(调用链、消息链或数据流)”注释。
## 2. 只读预检脚本
- [ ] 2.1 创建 `sql/asset-phase1-preflight.sql`,只查询 `DATABASE()``information_schema.tables``information_schema.columns``information_schema.statistics`,列出 11 张目标表的存在状态。
- [ ] 2.2 在预检输出中明确三类停止条件:当前库不是 `xyw_data_test`、任一目标表存在、目标表集合部分存在;不得用 DDL/DML 自动处理这些状态。
- [ ] 2.3 审阅预检脚本,确认不含 `CREATE``ALTER``DROP``DELETE``TRUNCATE`、旧表读取、外键或存储过程语句。
## 3. 一期建表脚本
- [ ] 3.1 创建 `sql/asset-phase1-schema.sql`,按 `company_profile → company_person → asset_device → phone_asset → wecom_account / wechat_account / douyin_account / domain_account / merchant → domain_asset → system_user` 的顺序定义表。
- [ ] 3.2 为全部 11 表定义 `id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT``create_time DATETIME NULL``update_time DATETIME NULL``delete_time BIGINT NOT NULL DEFAULT 0`、主键、表注释、InnoDB、`utf8mb4``utf8mb4_unicode_ci`
- [ ] 3.3 按设计文档落实 `company_profile``company_person``asset_device``phone_asset` 的业务字段、五个 JSON 快照字段及所有唯一/查询索引。
- [ ] 3.4 按设计文档落实 `wecom_account``wechat_account``douyin_account``domain_account``domain_asset``merchant``system_user` 的业务字段、重复策略及所有唯一/查询索引。
- [ ] 3.5 对需要有效记录去重的 `company_name``device_name``phone_number``account_identifier``merchant_number``username` 使用“业务字段 + `delete_time`”复合唯一索引;账号表和域名资产不得增加未审批的业务唯一限制。
- [ ] 3.6 审阅建表脚本,确认不使用 `IF NOT EXISTS`、外键、`REFERENCES``CHECK``ENUM`、触发器、存储过程、旧表读取、迁移、导入或任何删除性语句。
## 4. 结构验收脚本与记录
- [ ] 4.1 创建 `sql/asset-phase1-schema-verify.sql`,以只读查询确认当前库名和 11 张表全部存在。
- [ ] 4.2 为每张表验证 4 个通用列、业务列数据类型、可空性、`delete_time` 默认值、主键、唯一索引和查询索引。
- [ ] 4.3 验证目标表没有 `FOREIGN KEY``REFERENCES``CHECK` 约束,并输出可人工审阅的 PASS/FAIL 明细。
- [ ] 4.4 建立 `docs/acceptance/asset-phase1-schema-YYYY-MM-DD.md` 模板,记录授权标识、实际数据库名、脚本校验和、执行时间、执行人、预检/建表/验收结果及异常。
## 5. 数据库实施与失败处置
- [ ] 5.1 在授权会话中运行预检并人工确认全部停止条件均未命中;把实际 `DATABASE()` 输出写入验收记录。
- [ ] 5.2 仅在 5.1 通过后运行建表脚本;任何一条语句失败即停止,不重试、不自动补表、不自动删除。
- [ ] 5.3 运行只读结构验收;仅全部匹配时将记录标记为 PASS,否则记录 FAIL、当前结构和失败 SQL 后停止。
- [ ] 5.4 出现部分表、字段/索引缺失或目标库不符时,提出新的修订变更与单独数据库授权;本变更不提供自动回滚或清理 SQL。
## 6. 字段追踪与后续接口边界
本期没有新增页面、接口、DTO、Controller、Service、Prompt 或聊天链路,字段追踪止于数据库层;任何后续新后端改动必须补全完整链路。
| 字段组 | 当前层级 | 文件 | 使用位置 | 作用 | 输入/输出 | 是否必填 | 默认/兜底 | 风险点 | 覆盖测试 |
|---|---|---|---|---|---|---|---|---|---|
| `id/create_time/update_time/delete_time` | DB | 三份 SQL | 全部 11 表 | 主键、审计和软删除 | SQL 定义 / 后续 Entity | `id``delete_time` 必填 | `delete_time=0` | 查询漏过滤软删除行 | 结构验收逐表检查 |
| 11 表业务字段与索引 | DB | 建表/验收 SQL | 对应表 | 后续资产数据模型 | SQL 定义 / 后续 Entity | 依设计的 `NOT NULL` | 其他字段允许空 | 类型或索引遗漏 | `information_schema` 验收 |
| `phone_asset` 五个 JSON 快照 | DB | 建表/验收 SQL | `phone_asset` | 关联摘要存储 | 后续 Service 写入 / 后续 API 读取 | 表列可空 | 后续新建显式写 `[]` | 版本兼容、失效快照 | 类型与列存在性验收 |
- [ ] 6.1 在后续新后端变更前,补齐“页面 → 前端 API → DTO → Controller → Service → Entity/Mapper → DB → 测试 → OpenAPI”的逐字段追踪,未进入接口层的字段必须明确标为“不落库以外层”。
- [ ] 6.2 后续查询有效数据时统一加入 `delete_time = 0`;后续删除实现写 Unix 毫秒时间戳并覆盖同毫秒冲突策略。
## 7. 接口、权限与 API 文档
- [ ] 7.1 保持本期“无 API 变更、无 DTO 变更、无 Apifox/OpenAPI 变更、无权限变更”的边界;不得为建表临时暴露接口。
- [ ] 7.2 后续认证梯队才定义 `system_user` 初始管理员、密码摘要生成和登录接口;本期不得写入任何账户数据。
- [ ] 7.3 后续 API 梯队新增接口时,再在 Apifox/OpenAPI 中给出分组、最小请求、成功/失败响应、错误码、前端调用点和最小回归清单。
## 8. 验证与自动化
- [ ] 8.1 对三份 SQL 文件执行静态审阅:预检和验收仅含只读查询;建表仅含 `CREATE TABLE`/索引;任一文件不含 `DROP``DELETE``TRUNCATE``ALTER`、旧表读取或外键定义。
- [ ] 8.2 在已授权测试库中按“预检 → 建表 → 验收”执行人工 smoke 验证,并将 SQL 客户端原始输出摘录到验收记录。
- [ ] 8.3 验收至少覆盖:目标库名、11 表存在、所有通用列、关键业务列、主键、复合唯一索引、查询索引、无外键/检查约束、无业务数据写入。
- [ ] 8.4 本期不跑后端构建、前端 E2E 或 Playwright,因为不修改业务代码;后续 Entity/Mapper 接入时再增加数据库集成测试并以非零退出码报告失败。
## 9. 工程术语与新手注释验收
- [ ] 9.1 保留设计文档的工程术语注释表:软删除、复合唯一索引、预检、回滚和幂等均需说明白话含义、使用位置、不这样做的风险和示例。
- [ ] 9.2 验收 SQL 文件头和逐表注释能让新手理解:该文件/表做什么、依赖什么、从预检到验收的执行顺序以及异常时为何停止。
- [ ] 9.3 若实施范围扩展到 Java 或 JavaScript,先更新本计划的范围文件用途表与方法清单,再为每个改动方法补齐新手注释后实施。
## 10. 数据安全与兼容性
- [ ] 10.1 确认 SQL、验收记录和日志不写入 `.env`、数据库密码、token、私钥或连接字符串。
- [ ] 10.2 确认本期不读取、修改、迁移或兼容旧 `wx_phone``wx_data` 和历史账号表;旧后端启动问题不作为本次验收条件。
- [ ] 10.3 确认所有逻辑关联保持无外键,后续 Service 需补足引用存在性、有效性和删除保护校验。
## 11. 风险观察与降级
- [ ] 11.1 把“数据库名不匹配、目标表部分存在、DDL 执行错误、验收缺列/缺索引”列为阻断信号,任何一个命中即停止。
- [ ] 11.2 发生阻断时的降级(先保持系统安全可用的做法)为:不接入新后端、不写数据、不修改旧逻辑,仅保留只读诊断与验收记录。
- [ ] 11.3 回滚(失败后恢复安全状态)只允许“停止并走新审批”;严禁把自动 `DROP` 当作本期回滚手段。
## 12. 完成定义(DoD)
- [ ] 12.1 11 张表、字段、通用审计/软删除列、主键、唯一和查询索引均与设计和规格一致。
- [ ] 12.2 预检、建表、结构验收与验收记录均存在;预检/验收只读,建表脚本无删除、迁移、旧表读取、外键或检查约束。
- [ ] 12.3 已获得 `xyw_data_test` 单独授权,且执行记录证明预检、建表、验收均在该库完成。
- [ ] 12.4 验收记录明确 PASS,或在失败时已标记 FAIL 并停止;未产生任何旧表兼容、数据导入、业务接口或发布改动。
## 13. 实施后新手流程图
```mermaid
flowchart TD
A[负责人审批字段字典和 SQL 范围] --> B{已获 xyw_data_test 单独授权?}
B -- 否 --> C[停止:只保留 OpenSpec 计划]
B -- 是 --> D[人工连接测试库并执行只读预检]
D --> E{库名正确且 11 表均不存在?}
E -- 否 --> F[记录异常并停止:不自动补表或删除]
E -- 是 --> G[人工执行仅含 CREATE TABLE 的建表脚本]
G --> H{任一 DDL 失败?}
H -- 是 --> I[停止并执行只读验收,记录部分结构]
H -- 否 --> J[执行只读结构验收]
J --> K{表、列和索引全匹配?}
K -- 否 --> L[记录 FAIL,等待新的修订计划和授权]
K -- 是 --> M[记录 PASS,后续再接入新后端]
```
## 14. 假设与待确认项
- [ ] 14.1 确认 `xyw_data_test` 是可建空表的授权测试库,生产环境永不属于本任务范围。
- [ ] 14.2 确认一期采用设计文档的最小字段;人员附加资料、状态正式枚举、设备容量、管理员初始密码和账号恢复均留待后续变更。
- [ ] 14.3 确认应用创建/更新时间由后续代码写入,而非在数据库列上添加未审批的自动时间戳行为。
## 15. 交接与后续梯队
- [ ] 15.1 交接提案、设计、规格、任务清单、三份 SQL 和验收记录给新后端梯队。
- [ ] 15.2 新后端梯队按字段追踪表新增 Entity、Mapper、Service、Controller、DTO、测试和 API 文档,并保证旧逻辑不被本期建表直接替换。
- [ ] 15.3 未完成任一审批、授权、静态审阅或结构验收门槛前,不得标记本变更为已实施。
## 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 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 attachDomainListRuntime() {
const DOMAIN_TABLE_COLUMN_WIDTHS = [
'48px',
'calc((100% - 48px) * 0.16)',
'calc((100% - 48px) * 0.08)',
'calc((100% - 48px) * 0.08)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.08)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.14)'
];
/** 代码作用(白话):定义“全部域名列表”组件,让表格结构从大脚本里拆出来,但仍复用项目原字段;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/styles-v2.css、F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:mountDomainListComponent() 会把 app-v2.js 传来的字段数据、分页方法、抽屉方法交给这个组件,再由组件模板渲染整张域名列表。 */
const DomainListTable = {
name: 'DomainListTable',
props: {
domainText: { type: Object, required: true },
domainTableTotal: { type: Number, required: true },
domainTableFilterLabels: { type: Array, required: true },
domainTablePageSizes: { type: Array, required: true },
mountState: { type: Object, required: true },
globalState: { type: Object, required: true },
detailDrawer: { type: Object, required: true },
getDomainRowsByPage: { type: Function, required: true },
getVisiblePageNumbers: { type: Function, required: true },
getDomainStatusDotClass: { type: Function, required: true },
getDomainRiskBadgeClass: { type: Function, required: true },
renderDomainDetailDrawer: { type: Function, required: true },
getDomainRecordById: { type: Function, required: true },
openDrawer: { type: Function, required: true }
},
/** 代码作用(白话):初始化域名列表组件自己的页码、勾选和选中行状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:mountDomainListComponent() 挂载组件后先进入 setup(),再把状态和操作方法交给模板、抽屉联动和分页按钮。 */
setup(props) {
const { computed, ref } = window.Vue;
const currentPage = ref(props.mountState.currentPage);
const pageSize = ref(props.mountState.pageSize);
const selectedRecordId = ref(props.globalState.selectedBySource.domain || 1);
const checkedIds = ref([].concat(props.mountState.selectedIds || []));
const filterDate = ref('');
const searchQuery = ref('');
const filterStatus = ref('all');
const filterRisk = ref('all');
const filterOwner = ref('all');
const allRecordsCache = ref(null);
function getAllRecords() {
if (!allRecordsCache.value) {
allRecordsCache.value = props.getDomainRowsByPage(1, props.domainTableTotal);
}
return allRecordsCache.value;
}
const filteredRecords = computed(() => {
let result = getAllRecords();
if (filterStatus.value !== 'all') {
result = result.filter(r => r.status === filterStatus.value);
}
if (filterRisk.value !== 'all') {
result = result.filter(r => r.riskLevel === filterRisk.value);
}
if (filterOwner.value !== 'all') {
result = result.filter(r => r.owner === filterOwner.value);
}
if (filterDate.value && filterDate.value.length === 2) {
const start = new Date(filterDate.value[0]).getTime();
const end = new Date(filterDate.value[1]).getTime() + 86400000;
result = result.filter(r => {
if (!r.expirationDate) return false;
const t = new Date(r.expirationDate).getTime();
return t >= start && t < end;
});
}
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase();
result = result.filter(r =>
(r.domain && r.domain.toLowerCase().includes(q)) ||
(r.project && r.project.toLowerCase().includes(q)) ||
(r.owner && r.owner.toLowerCase().includes(q))
);
}
return result;
});
const totalFiltered = computed(() => filteredRecords.value.length);
window.Vue.watch([filterStatus, filterRisk, filterOwner, filterDate, searchQuery], () => {
currentPage.value = 1;
});
/** 代码作用(白话):把组件内部状态同步回外层共享状态,保证切换模块后页码和勾选还能保留;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:分页、勾选、行点击都会在动作完成后调用它,再把结果写回 domainListMountState。 */
function syncMountState() {
props.mountState.currentPage = currentPage.value;
props.mountState.pageSize = pageSize.value;
props.mountState.selectedIds = [].concat(checkedIds.value);
}
/** 代码作用(白话):计算域名列表总页数;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:分页按钮和页码条都会通过 computed(totalPages) 读取它的结果,不这样算,翻页边界会错。 */
function buildTotalPages() {
return Math.max(1, Math.ceil(totalFiltered.value / pageSize.value));
}
/** 代码作用(白话):按当前页码和每页条数取出当前页记录;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:computed(visibleRows) 调用它,再把 app-v2.js 里保留的原字段记录渲染到表格 tbody。 */
function buildVisibleRows() {
const start = (currentPage.value - 1) * pageSize.value;
return filteredRecords.value.slice(start, start + pageSize.value);
}
/** 代码作用(白话):生成分页栏要显示的页码列表;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:computed(pageNumbers) 调用它,再复用原项目的页码显示规则。 */
function buildPageNumbers() {
return props.getVisiblePageNumbers(totalPages.value, currentPage.value);
}
/** 代码作用(白话):判断当前页是否已经全选;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:表头复选框会通过 computed(allVisibleChecked) 读取这里的结果,不这样判断,全选状态会失真。 */
function buildAllVisibleChecked() {
if (!visibleRows.value.length) return false;
for (let index = 0; index < visibleRows.value.length; index += 1) {
if (!checkedIds.value.includes(visibleRows.value[index].id)) return false;
}
return true;
}
const totalPages = computed(buildTotalPages);
const visibleRows = computed(buildVisibleRows);
const pageNumbers = computed(buildPageNumbers);
const allVisibleChecked = computed(buildAllVisibleChecked);
/** 代码作用(白话):判断某一行是否被勾选;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:表格每一行的复选框都会调它决定 checked 状态。 */
function isRowChecked(recordId) {
return checkedIds.value.includes(recordId);
}
/** 代码作用(白话):判断某一行是否是当前聚焦行;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:表格高亮 class 通过它判断当前行是否需要加 selected。 */
function isRowSelected(recordId) {
return selectedRecordId.value === recordId;
}
/** 代码作用(白话):把状态字段转换成原项目表格使用的颜色类名;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:表格状态圆点会先调它,再复用 app-v2.js 里现有状态映射。 */
function getStatusClass(status) {
return props.getDomainStatusDotClass(status);
}
/** 代码作用(白话):把风险等级字段转换成原项目表格使用的徽标类名;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:表格风险等级标签会调它,再复用 app-v2.js 里现有风险映射。 */
function getRiskClass(riskLevel) {
return props.getDomainRiskBadgeClass(riskLevel);
}
/** 代码作用(白话):聚焦某一行域名记录,并在抽屉已打开时同步刷新详情;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:点击行、点击域名、点击操作列前都会先进这里,再更新 selectedBySource.domain 和抽屉内容。 */
function focusRow(recordId) {
selectedRecordId.value = recordId;
props.globalState.selectedBySource.domain = recordId;
syncMountState();
if (!props.detailDrawer.classList.contains('hidden')) {
props.renderDomainDetailDrawer(props.getDomainRecordById(recordId));
}
}
/** 代码作用(白话):切换单行勾选状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:行内复选框 change 事件会调用它,再同步 footer 已选数量和外层共享状态。 */
function toggleRowChecked(recordId, checked) {
const nextIds = new Set(checkedIds.value);
if (checked) nextIds.add(recordId);
else nextIds.delete(recordId);
checkedIds.value = Array.from(nextIds);
syncMountState();
}
/** 代码作用(白话):切换当前页全部行的勾选状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:表头复选框会调用它,再逐行同步当前页记录的勾选结果。 */
function toggleAllVisible(checked) {
const nextIds = new Set(checkedIds.value);
for (let index = 0; index < visibleRows.value.length; index += 1) {
const row = visibleRows.value[index];
if (checked) nextIds.add(row.id);
else nextIds.delete(row.id);
}
checkedIds.value = Array.from(nextIds);
syncMountState();
}
/** 代码作用(白话):跳到指定页码,并把结果限制在合法范围内;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:页码按钮、上一页、下一页和页大小变更都会最终流向这里。 */
function goToPage(pageNumber) {
currentPage.value = Math.min(Math.max(1, pageNumber), totalPages.value);
syncMountState();
}
/** 代码作用(白话):处理上一页和下一页按钮;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:分页箭头按钮通过它把动作翻译成具体页码,再流向 goToPage()。 */
function changePage(direction) {
if (direction === 'prev') goToPage(currentPage.value - 1);
if (direction === 'next') goToPage(currentPage.value + 1);
}
/** 代码作用(白话):处理每页条数变化,并把页码重置到第一页;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:页大小下拉框变化后先更新 pageSize,再通过这里收口到第一页,避免落在空页。 */
function handlePageSizeChange() {
props.mountState.pageSize = pageSize.value;
goToPage(1);
}
/** 代码作用(白话):把域名列表恢复到默认分页和默认勾选状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:工具栏“重置”按钮会调用它,同时恢复页码、页大小和已选项。 */
function resetTableState() {
currentPage.value = 1;
pageSize.value = props.domainTablePageSizes[0];
checkedIds.value = [selectedRecordId.value];
syncMountState();
}
/** 代码作用(白话):处理“查看/编辑”操作按钮,先聚焦当前行,再走原项目抽屉打开链路;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:操作列点击后会先流向这里,再交给 window.openDrawer() 打开详情抽屉。 */
function handleRowAction(mode, recordId) {
focusRow(recordId);
props.openDrawer(mode, recordId);
}
syncMountState();
return {
DOMAIN_TABLE_COLUMN_WIDTHS,
currentPage,
pageSize,
visibleRows,
pageNumbers,
checkedIds,
allVisibleChecked,
isRowChecked,
isRowSelected,
getStatusClass,
getRiskClass,
focusRow,
toggleRowChecked,
toggleAllVisible,
goToPage,
changePage,
handlePageSizeChange,
resetTableState,
handleRowAction,
filterDate,
searchQuery,
filterStatus,
filterRisk,
filterOwner,
totalFiltered
};
},
template: `
<div class="table-area table-area-refined">
<div class="table-toolbar table-toolbar-refined">
<div class="toolbar-left">
<div class="toolbar-title-wrap">
<div class="toolbar-title">{{ domainText.allDomains }} ({{ totalFiltered.toLocaleString('zh-CN') }})</div>
</div>
<div class="filter-selects filter-selects-refined" style="display: flex; align-items: center; gap: 8px;">
<el-select v-model="filterStatus" class="custom-fselect" style="width: 120px; height: 32px;">
<el-option label="状态: 全部" value="all"></el-option>
<el-option label="正常" value="正常"></el-option>
<el-option label="风险" value="风险"></el-option>
<el-option label="过期" value="过期"></el-option>
</el-select>
<el-select v-model="filterRisk" class="custom-fselect" style="width: 130px; height: 32px;">
<el-option label="风险等级: 全部" value="all"></el-option>
<el-option label="低" value="低"></el-option>
<el-option label="中" value="中"></el-option>
<el-option label="高" value="高"></el-option>
</el-select>
<el-date-picker
v-model="filterDate"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px; height: 32px;"
></el-date-picker>
<el-select v-model="filterOwner" class="custom-fselect" style="width: 120px; height: 32px;">
<el-option label="所有者: 全部" value="all"></el-option>
<el-option label="张三" value="张三"></el-option>
<el-option label="李四" value="李四"></el-option>
<el-option label="王五" value="王五"></el-option>
<el-option label="赵六" value="赵六"></el-option>
<el-option label="周七" value="周七"></el-option>
</el-select>
<div class="fselect">{{ domainTableFilterLabels[4] }}</div>
<el-input
v-model="searchQuery"
placeholder="搜索域名、所属项目、所有者"
clearable
style="width: 260px; height: 32px;"
>
<template #prefix>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</template>
</el-input>
</div>
</div>
<div class="toolbar-right">
<button class="btn-reset btn-reset-refined" type="button" @click="resetTableState">{{ domainText.reset }}</button>
<button class="btn-settings btn-settings-refined" type="button" :aria-label="domainText.rowSetting"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.56-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.49-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></button>
</div>
</div>
<div class="table-wrap table-wrap-refined">
<table class="xtable domain-table domain-table-refined">
<colgroup>
<col v-for="width in DOMAIN_TABLE_COLUMN_WIDTHS" :key="width" :style="{ width }" />
</colgroup>
<thead>
<tr>
<th class="th-selector"><input type="checkbox" :checked="allVisibleChecked" @change="toggleAllVisible($event.target.checked)" /></th>
<th>{{ domainText.domain }}</th>
<th>{{ domainText.status }}</th>
<th>{{ domainText.riskLevel }}</th>
<th>{{ domainText.expirationDate }}</th>
<th>{{ domainText.project }}</th>
<th>{{ domainText.owner }}</th>
<th>{{ domainText.renewalResult }}</th>
<th>{{ domainText.registrar }}</th>
<th>{{ domainText.updatedAt }} <svg viewBox="0 0 24 24" width="12" height="12"><path d="M7 10l5 5 5-5z"/></svg></th>
<th class="th-action">{{ domainText.action }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ selected: isRowSelected(row.id) }" @click="focusRow(row.id)">
<td class="td-selector"><input type="checkbox" :checked="isRowChecked(row.id)" @click.stop @change="toggleRowChecked(row.id, $event.target.checked)" /></td>
<td class="td-domain"><a href="#" class="domain-link" @click.prevent="focusRow(row.id)">{{ row.domain }}</a><div class="domain-inline-meta">{{ row.project }}</div></td>
<td><span class="status-chip-wrap"><span class="status-dot" :class="getStatusClass(row.status)"></span><span>{{ row.status }}</span></span></td>
<td><span class="risk-badge" :class="getRiskClass(row.riskLevel)">{{ row.riskLevel }}</span></td>
<td><div class="date-stack"><span>{{ row.expirationDate }}</span><div class="cell-sub" :class="{ green: row.status === domainText.normal && row.riskLevel === domainText.low }">{{ row.expirationHint }}</div></div></td>
<td>{{ row.project }}</td>
<td>{{ row.owner }}</td>
<td>{{ row.renewalResult }}</td>
<td>{{ row.registrar }}</td>
<td>{{ row.updatedAt }}</td>
<td class="td-action"><div class="action-cluster"><button class="action-pill action-pill-primary" type="button" @click.stop="handleRowAction('view', row.id)">{{ domainText.view }}</button><button class="action-pill" type="button" @click.stop="handleRowAction('edit', row.id)">{{ domainText.edit }}</button><button class="action-icon" type="button" aria-label="更多操作" @click.stop><svg t="1782455023712" class="icon action-dots" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2600" width="16" height="16"><path d="M170.666667 512m-85.333334 0a85.333333 85.333333 0 1 0 170.666667 0 85.333333 85.333333 0 1 0-170.666667 0Z" fill="#BBBBBB" p-id="2601"></path><path d="M512 512m-85.333333 0a85.333333 85.333333 0 1 0 170.666666 0 85.333333 85.333333 0 1 0-170.666666 0Z" fill="#BBBBBB" p-id="2602"></path><path d="M853.333333 512m-85.333333 0a85.333333 85.333333 0 1 0 170.666667 0 85.333333 85.333333 0 1 0-170.666667 0Z" fill="#BBBBBB" p-id="2603"></path></svg></button></div></td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer table-footer-refined">
<div class="tfoot-left tfoot-left-refined">
<span>{{ domainText.selected }} {{ checkedIds.length }} {{ domainText.items }}</span>
<button class="btn-batch btn-batch-refined" type="button">{{ domainText.batchAction }} &#9662;</button>
</div>
<div class="tfoot-right tfoot-right-refined">
<span>{{ domainText.total }} {{ totalFiltered.toLocaleString('zh-CN') }} {{ domainText.records }}</span>
<button class="page-btn outline" type="button" @click="changePage('prev')" :disabled="currentPage === 1">&#8249;</button>
<div class="pagination">
<template v-for="item in pageNumbers" :key="String(item)">
<button v-if="item !== 'ellipsis'" class="page-btn" :class="{ active: currentPage === item }" type="button" @click="goToPage(item)">{{ item }}</button>
<span v-else>...</span>
</template>
</div>
<button class="page-btn outline" type="button" @click="changePage('next')" :disabled="currentPage === Math.ceil(totalFiltered / pageSize)">&#8250;</button>
<select class="page-size" v-model.number="pageSize" @change="handlePageSizeChange">
<option v-for="size in domainTablePageSizes" :key="size" :value="size">{{ size }}{{ domainText.pageSuffix }}</option>
</select>
</div>
</div>
</div>
`
};
/** 代码作用(白话):把域名列表组件挂到指定 DOM 容器上;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:app-v2.js 里的 mountDomainListApp() 会把当前项目原字段和原逻辑方法打包后交给这里,再由这里创建 Vue 实例。 */
function mountDomainListComponent(options) {
const { createApp } = window.Vue;
const app = createApp(DomainListTable, options);
if (window.ElementPlus) {
app.use(window.ElementPlus);
}
app.mount(options.mountTarget);
return app;
}
window.DomainListRuntime = {
mountDomainListComponent
};
})();
window.OverviewListRuntime = (function () {
const { createApp, ref, computed, onMounted } = Vue;
function mountOverviewListComponent(config) {
if (!document.querySelector(config.mountTarget)) {
return null;
}
const app = createApp({
setup() {
const stats = ref(config.stats || []);
const records = ref(config.getRows() || []);
// Flatten logs from records for Recent Activities
const recentLogs = computed(() => {
let allLogs = [];
records.value.forEach(record => {
if (record.logs && record.logs.length > 0) {
record.logs.forEach(log => {
allLogs.push({
...log,
domainId: record.id,
domainName: record.domain
});
});
}
});
// Sort by time descending (simple string compare works for YYYY-MM-DD HH:mm)
allLogs.sort((a, b) => b.time.localeCompare(a.time));
return allLogs.slice(0, 10); // Take top 10 recent
});
const getToneColor = (tone) => {
const colors = {
blue: '#3b82f6',
green: '#10b981',
orange: '#f59e0b',
red: '#ef4444'
};
return colors[tone] || '#64748b';
};
const getToneBg = (tone) => {
const colors = {
blue: '#eff6ff',
green: '#ecfdf5',
orange: '#fffbeb',
red: '#fef2f2'
};
return colors[tone] || '#f8fafc';
};
const getStatusTagType = (status) => {
if (status.includes('正常')) return 'success';
if (status.includes('风险')) return 'warning';
if (status.includes('异常') || status.includes('过期')) return 'danger';
return 'info';
};
return {
stats,
records,
recentLogs,
getToneColor,
getToneBg,
getStatusTagType
};
},
template: `
<div class="overview-container" style="padding: 24px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 32px;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2 style="margin: 0; font-size: 24px; font-weight: 600; color: #111827;">资料总览</h2>
<p style="margin: 4px 0 0 0; font-size: 14px; color: #6b7280;">账号与域名资产管理,全局概况数据</p>
</div>
<el-button type="primary" size="large" style="border-radius: 8px;">导出数据</el-button>
</div>
<!-- 数据大屏 (Data Dashboard) - Bento Grid -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px;">
<div v-for="(stat, index) in stats" :key="index"
style="background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px -4px rgba(0,0,0,0.05); border: 1px solid #f1f5f9; display: flex; flex-direction: column; gap: 16px; transition: transform 0.2s ease, box-shadow 0.2s ease;"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 30px -4px rgba(0,0,0,0.08)';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 20px -4px rgba(0,0,0,0.05)';">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 15px; font-weight: 500; color: #64748b;">{{ stat.title }}</span>
<div :style="{ width: '32px', height: '32px', borderRadius: '8px', background: getToneBg(stat.tone), color: getToneColor(stat.tone), display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 'bold' }">
{{ stat.icon }}
</div>
</div>
<div style="font-size: 36px; font-weight: 700; color: #0f172a; line-height: 1;">
{{ stat.value }}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: auto; padding-top: 16px; border-top: 1px dashed #e2e8f0;">
<span style="font-size: 13px; color: #64748b;">{{ stat.sub }}</span>
<span :style="{ fontSize: '13px', fontWeight: '500', color: getToneColor(stat.tone) }">{{ stat.link }}</span>
</div>
</div>
</div>
<!-- 下方两列布局:数据表 + 最近动态 -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 24px;">
<!-- 首页数据表 (Data Table) -->
<div style="background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px -4px rgba(0,0,0,0.05); border: 1px solid #f1f5f9;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #111827;">重点关注资产</h3>
<el-button link type="primary">查看全部</el-button>
</div>
<el-table :data="records.slice(0, 5)" style="width: 100%; border-radius: 8px; overflow: hidden;" :header-cell-style="{ background: '#f8fafc', color: '#475569', fontWeight: 500, borderBottom: 'none' }" :cell-style="{ borderBottom: '1px solid #f1f5f9' }">
<el-table-column prop="domain" label="域名/资产" min-width="160">
<template #default="scope">
<div style="font-weight: 500; color: #0f172a;">{{ scope.row.domain }}</div>
<div style="font-size: 12px; color: #64748b;">{{ scope.row.project }}</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row.status)" size="small" effect="light" style="border-radius: 6px;">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="expirationDate" label="到期时间" min-width="140">
<template #default="scope">
<div style="color: #334155;">{{ scope.row.expirationDate }}</div>
<div :style="{ fontSize: '12px', color: scope.row.status.includes('过期') || scope.row.status.includes('风险') ? '#ef4444' : '#10b981' }">
{{ scope.row.expirationHint }}
</div>
</template>
</el-table-column>
<el-table-column prop="owner" label="责任人" width="100" />
</el-table>
</div>
<!-- 最近动态 (Recent Activities) -->
<div style="background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px -4px rgba(0,0,0,0.05); border: 1px solid #f1f5f9;">
<h3 style="margin: 0 0 24px 0; font-size: 16px; font-weight: 600; color: #111827;">最近动态</h3>
<div v-if="recentLogs.length > 0" style="display: flex; flex-direction: column; gap: 20px;">
<div v-for="(log, idx) in recentLogs" :key="idx" style="display: flex; gap: 16px;">
<div style="display: flex; flex-direction: column; align-items: center; margin-top: 4px;">
<div style="width: 8px; height: 8px; border-radius: 50%; background: #3b82f6;"></div>
<div v-if="idx !== recentLogs.length - 1" style="width: 1px; flex: 1; background: #e2e8f0; margin-top: 4px; min-height: 24px;"></div>
</div>
<div style="flex: 1; padding-bottom: idx === recentLogs.length - 1 ? 0 : 8px;">
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px;">
<span style="font-size: 13px; font-weight: 500; color: #111827;">{{ log.tag }}</span>
<span style="font-size: 12px; color: #94a3b8;">{{ log.time }}</span>
</div>
<div style="font-size: 13px; color: #475569; line-height: 1.5;">
{{ log.text }}
</div>
</div>
</div>
</div>
<div v-else style="padding: 40px 0; text-align: center; color: #94a3b8; font-size: 14px;">
暂无近期动态
</div>
</div>
</div>
</div>
`
});
app.use(ElementPlus);
app.mount(config.mountTarget);
return app;
}
return {
mountOverviewListComponent
};
})();
(function initPhoneAddDialog() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
/**
* 代码作用(白话):给手机号卡新增/编辑弹窗准备一份干净的默认表单对象,避免多次打开时残留上一次输入;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):open('add') -> buildEmptyFormData() -> reactive formData -> 弹窗输入框和图片区域渲染。
*/
function buildEmptyFormData() {
return {
id: null,
phoneNumber: '',
realPerson: '',
iccid: '',
city: '',
cardStatus: '正常',
cardUsageLocation: '',
wecom: false,
wechat: false,
outboundCall: false,
douyinAccount: '',
miniProgramFiling: false,
packageChange5yuan: false,
channelOperator: '',
numberStatus: true,
linkedWecom: '',
imageAttachment1: '',
imageAttachment2: ''
};
}
/**
* 代码作用(白话):把本地图片文件转成当前接口可直接保存的文本内容,避免新增文件上传接口;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java;关联逻辑(调用链/消息链/数据流):本地文件 -> readFileAsDataUrl() -> imageItems -> submitAfterValidate() -> savePhone()。
*/
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('读取图片失败,请重新选择'));
reader.readAsDataURL(file);
});
}
/**
* 代码作用(白话):把已有记录里的两个 SQL 图片字段整理成弹窗统一图片预览集合,保证编辑时能按一个图片区域回显;关联文件:F:/Project/xyw_console/src/modules/shared/record-adapters.js、F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):record.images/imageAttachment1/2 -> buildImageItemsFromRecord() -> populateFormData() -> 图片上传区域预览。
*/
function buildImageItemsFromRecord(record) {
const rawImages = Array.isArray(record?.images)
? record.images
: [record?.imageAttachment1, record?.image_attachment_1, record?.imageAttachment2, record?.image_attachment_2].filter(Boolean);
return rawImages
.filter(Boolean)
.filter((value, index, array) => array.indexOf(value) === index)
.slice(0, 2)
.map((url, index) => ({
id: `existing-${index}`,
url,
name: `已保存图片${index + 1}`
}));
}
/**
* 代码作用(白话):读取外层注入的手机号保存动作,没有配置时直接给出明确错误,避免弹窗假保存;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):保存按钮 -> getPhoneAddDialogActions() -> save()/afterSave() -> 刷新列表。
*/
function getPhoneAddDialogActions() {
if (!window.PhoneAddDialogActions?.save) {
throw new Error('手机号保存动作尚未初始化');
}
return window.PhoneAddDialogActions;
}
const PhoneAddDialog = {
/**
* 代码作用(白话):初始化手机号卡弹窗的表单状态、图片多选上传和统一提交流程,让列表与详情都能共用一个弹窗;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js;关联逻辑(调用链/消息链/数据流):新增/编辑入口 -> PhoneAddDialogAPI.open() -> setup() 状态变化 -> 表单与图片区域渲染。
*/
setup() {
const visible = ref(false);
const formRef = ref(null);
const imageInputRef = ref(null);
const isEdit = ref(false);
const imageItems = ref([]);
const formData = reactive(buildEmptyFormData());
const rules = {
phoneNumber: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
channelOperator: [{ required: true, message: '请选择运营商', trigger: 'change' }]
};
/**
* 代码作用(白话):把图片预览集合回写到两个 SQL 对应的表单字段,保证一个图片区域里的两张图最终能落回后端两个字段;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java;关联逻辑(调用链/消息链/数据流):imageItems -> syncImagesToFormData() -> formData.imageAttachment1/2 -> submitAfterValidate()。
*/
function syncImagesToFormData() {
formData.imageAttachment1 = imageItems.value[0]?.url || '';
formData.imageAttachment2 = imageItems.value[1]?.url || '';
}
/**
* 代码作用(白话):把手机号表单恢复成新增状态,避免上一次编辑数据残留到下一次打开弹窗;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):open('add') -> resetFormData() -> formData 默认值和图片区域清空 -> 表单渲染。
*/
function resetFormData() {
Object.assign(formData, buildEmptyFormData());
imageItems.value = [];
syncImagesToFormData();
}
/**
* 代码作用(白话):把外层传入的手机号记录回填到表单里,兼容列表记录和详情记录两种字段名,并把两张旧图按一个图片区域回显;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):编辑按钮/详情抽屉编辑 -> PhoneAddDialogAPI.open('edit', record) -> populateFormData() -> 表单与图片预览回填。
*/
/**
* ???????????????????????????????? `card_status` / `mobile_status` / `channel_operator`?????????????????????F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js?F:/Project/xyw_console/src/modules/shared/record-adapters.js?????????/???/???????/???? -> populateFormData() -> formData -> ?????
*/
function populateFormData(record) {
formData.id = record.id || null;
formData.phoneNumber = record.phoneNumber || record.phone || '';
formData.realPerson = record.realPerson || '';
formData.iccid = record.iccid || '';
formData.city = record.city || '';
formData.cardStatus = record.cardStatus || record.card_status || record.status || '正常';
formData.cardUsageLocation = record.cardUsageLocation || record.card_usage_location || record.usageLocation || '';
formData.wecom = record.wecom === true;
formData.wechat = record.wechat === true;
formData.outboundCall = record.outboundCall === true || record.outbound_call === true || record.outbound === '可外呼';
formData.douyinAccount = record.douyinAccount || '';
formData.miniProgramFiling = record.miniProgramFiling === true;
formData.packageChange5yuan = record.packageChange5yuan === true;
formData.channelOperator = record.channelOperator || record.channel_operator || record.carrier || '';
formData.numberStatus = record.numberStatus ?? record.mobile_status ?? record.number_status ?? true;
formData.linkedWecom = record.linkedWecom || record.linked_wecom || '';
imageItems.value = buildImageItemsFromRecord(record);
syncImagesToFormData();
}
function open(mode = 'add', record = null) {
visible.value = true;
isEdit.value = mode === 'edit';
resetFormData();
if (mode === 'edit' && record) {
populateFormData(record);
}
window.requestAnimationFrame(() => {
formRef.value?.clearValidate();
});
}
/**
* 代码作用(白话):关闭手机号编辑弹窗,只负责隐藏当前弹窗;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):取消按钮/右上角关闭 -> close() -> visible=false。
*/
function close() {
visible.value = false;
}
/**
* 代码作用(白话):触发系统文件选择框,让图片上传区域保持一个按钮入口而不是暴露两个底层字段;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):点击“选择图片” -> triggerImageSelect() -> hidden file input -> handleImageFileChange()。
*/
function triggerImageSelect() {
imageInputRef.value?.click();
}
/**
* 代码作用(白话):读取用户一次多选的图片文件,限制最多 2 张,并生成可预览可保存的图片集合;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java;关联逻辑(调用链/消息链/数据流):file input change -> handleImageFileChange() -> readFileAsDataUrl() -> imageItems/syncImagesToFormData() -> submitAfterValidate()。
*/
async function handleImageFileChange(event) {
const files = Array.from(event?.target?.files || []);
if (!files.length) {
return;
}
if (imageItems.value.length + files.length > 2) {
ElementPlus.ElMessage.warning('最多上传 2 张图片');
event.target.value = '';
return;
}
try {
const nextItems = [];
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const url = await readFileAsDataUrl(file);
nextItems.push({
id: `${Date.now()}-${index}`,
url,
name: file.name || `图片${imageItems.value.length + index + 1}`
});
}
imageItems.value = imageItems.value.concat(nextItems).slice(0, 2);
syncImagesToFormData();
} catch (error) {
ElementPlus.ElMessage.error(error.message || '读取图片失败,请稍后重试');
} finally {
event.target.value = '';
}
}
/**
* 代码作用(白话):删除当前图片区域中的单张图片,并把剩余图片重新同步回两个 SQL 字段;关联文件:F:/Project/xyw_console/src/modules/shared/phone-api-client.js、F:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/dto/WxPhoneSaveRequest.java;关联逻辑(调用链/消息链/数据流):点击删除图片 -> removeImage() -> imageItems -> syncImagesToFormData() -> 保存请求。
*/
function removeImage(index, event) {
event?.stopPropagation();
event?.preventDefault();
imageItems.value = imageItems.value.filter((_, itemIndex) => itemIndex !== index);
syncImagesToFormData();
}
/**
* 代码作用(白话):双击空图片槽位时触发文件选择,让用户通过双击占位框上传;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):双击占位框 -> handleImageDblClick() -> triggerImageSelect() -> file input -> handleImageFileChange()。
*/
function handleImageDblClick(event) {
event?.stopPropagation();
event?.preventDefault();
triggerImageSelect();
}
/**
* 代码作用(白话):处理图片拖拽上传,支持拖拽图片文件到上传区域;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):拖拽图片 -> handleImageDrop() -> readFileAsDataUrl() -> imageItems/syncImagesToFormData()。
*/
async function handleImageDrop(event, slotIndex) {
event?.preventDefault();
event?.stopPropagation();
const files = Array.from(event?.dataTransfer?.files || []);
if (!files.length) {
return;
}
// 只处理图片文件
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (!imageFiles.length) {
ElementPlus.ElMessage.warning('请上传图片文件');
return;
}
if (imageItems.value.length + imageFiles.length > 2) {
ElementPlus.ElMessage.warning('最多上传 2 张图片');
return;
}
try {
const nextItems = [];
for (let index = 0; index < imageFiles.length; index += 1) {
const file = imageFiles[index];
const url = await readFileAsDataUrl(file);
nextItems.push({
id: `${Date.now()}-${index}`,
url,
name: file.name || `图片${imageItems.value.length + index + 1}`
});
}
imageItems.value = imageItems.value.concat(nextItems).slice(0, 2);
syncImagesToFormData();
} catch (error) {
ElementPlus.ElMessage.error(error.message || '读取图片失败,请稍后重试');
}
}
/**
* 代码作用(白话):预览已有图片,使用 Element Plus 的 ElImageViewer 全屏查看;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):点击预览按钮 -> previewImage() -> ElImageViewer 临时组件。
*/
function previewImage(url, event) {
event?.stopPropagation();
event?.preventDefault();
const tempDiv = document.createElement('div');
document.body.appendChild(tempDiv);
const viewerApp = createApp({
components: { 'el-image-viewer': ElementPlus.ElImageViewer },
template: '<el-image-viewer :url-list="urls" :initial-index="0" @close="close" teleported />',
setup() {
const urls = ref([url]);
function close() {
viewerApp.unmount();
tempDiv.remove();
}
return { urls, close };
}
});
viewerApp.use(ElementPlus);
viewerApp.mount(tempDiv);
}
/**
* 代码作用(白话):在表单校验通过后调用外层统一保存动作,并在成功后刷新列表;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):保存按钮 -> handleSubmit() -> submitAfterValidate() -> PhoneAddDialogActions.save() -> afterSave() -> phone 列表刷新。
*/
async function submitAfterValidate(valid) {
if (!valid) {
return false;
}
try {
const actions = getPhoneAddDialogActions();
syncImagesToFormData();
await actions.save({ ...formData });
ElementPlus.ElMessage.success(formData.id ? '修改成功' : '新增成功');
close();
if (typeof actions.afterSave === 'function') {
await actions.afterSave();
}
return true;
} catch (error) {
console.error(error);
ElementPlus.ElMessage.error(error.message || '保存失败,请稍后重试');
return false;
}
}
/**
* 代码作用(白话):触发表单校验并把保存动作收口到统一入口,是弹窗保存按钮唯一出口;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):保存按钮 -> handleSubmit() -> ElForm.validate() -> submitAfterValidate()。
*/
function handleSubmit() {
if (!formRef.value) {
return;
}
formRef.value.validate(submitAfterValidate);
}
window.PhoneAddDialogAPI = {
open
};
return {
visible,
isEdit,
formRef,
imageInputRef,
imageItems,
formData,
rules,
close,
triggerImageSelect,
handleImageFileChange,
removeImage,
handleImageDblClick,
handleImageDrop,
previewImage,
handleSubmit
};
},
template: `
<el-dialog v-model="visible" width="860px" align-center modal-class="phone-form-dialog-modal">
<template #header>
<div class="phone-form-dialog__title">
<span class="phone-form-dialog__title-text">{{ isEdit ? '编辑手机号卡' : '新增手机号卡' }}</span>
<span class="phone-form-dialog__title-hint">基础信息为必填,其余可按需补充</span>
</div>
</template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="104px" label-position="right" class="phone-form-dialog__form">
<!-- 01 图片附件区域 -->
<div class="phone-form-dialog__section phone-form-dialog__section--image">
<div class="phone-form-dialog__section-title">
<span class="phone-form-dialog__section-no">01</span>
<span class="phone-form-dialog__section-name">图片附件</span>
</div>
<input ref="imageInputRef" type="file" accept="image/*" multiple style="display: none;" @change="handleImageFileChange" />
<div class="phone-form-dialog__image-slots">
<div
v-for="slotIndex in 2"
:key="slotIndex"
class="phone-form-dialog__image-slot"
@click="triggerImageSelect"
@dragover.prevent
@drop="handleImageDrop($event, slotIndex - 1)">
<div v-if="imageItems[slotIndex - 1]" class="phone-form-dialog__image-item">
<img :src="imageItems[slotIndex - 1].url" :alt="imageItems[slotIndex - 1].name" class="phone-form-dialog__image-img" />
<div class="phone-form-dialog__image-overlay">
<button type="button" class="phone-form-dialog__image-preview-btn" @click.stop="previewImage(imageItems[slotIndex - 1].url, $event)">预览</button>
<button type="button" class="phone-form-dialog__image-remove" @click.stop="removeImage(slotIndex - 1, $event)">×</button>
</div>
</div>
<div v-else class="phone-form-dialog__image-empty">
<span class="phone-form-dialog__upload-icon" aria-hidden="true">⇧</span>
<span class="phone-form-dialog__image-empty-text">拖拽上传 / 点击选择</span>
</div>
</div>
</div>
<div class="phone-form-dialog__image-hint">支持 JPG / PNG,最多上传 2 张</div>
</div>
<!-- 分隔线 -->
<div class="phone-form-dialog__divider"></div>
<!-- 02 基础信息区域 -->
<div class="phone-form-dialog__section phone-form-dialog__section--primary">
<div class="phone-form-dialog__section-title">
<span class="phone-form-dialog__section-no">02</span>
<span class="phone-form-dialog__section-name">基础信息</span>
<span class="phone-form-dialog__section-tag">必填</span>
</div>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="手机号" prop="phoneNumber" required>
<el-input v-model="formData.phoneNumber" placeholder="请输入手机号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="实名联系人" prop="realPerson">
<el-input v-model="formData.realPerson" placeholder="请输入实名联系人"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="ICCID 卡号" prop="iccid">
<el-input v-model="formData.iccid" placeholder="请输入 ICCID"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="渠道运营商" prop="channelOperator" required>
<el-select v-model="formData.channelOperator" placeholder="请选择运营商" style="width: 100%;">
<el-option label="中国移动" value="中国移动"></el-option>
<el-option label="中国联通" value="中国联通"></el-option>
<el-option label="中国电信" value="中国电信"></el-option>
<el-option label="中国广电" value="中国广电"></el-option>
<el-option label="亮哥渠道(虚拟)" value="亮哥渠道(虚拟)"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="所在城市" prop="city">
<el-input v-model="formData.city" placeholder="请输入城市"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="卡使用地点" prop="cardUsageLocation">
<el-input v-model="formData.cardUsageLocation" placeholder="请输入使用地点"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="抖音账号" prop="douyinAccount">
<el-input v-model="formData.douyinAccount" placeholder="请输入关联的抖音号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="微信账号" prop="linkedWecom">
<el-input v-model="formData.linkedWecom" placeholder="请输入关联的微信号"></el-input>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 分隔线 -->
<div class="phone-form-dialog__divider"></div>
<!-- 03 辅助配置区域 -->
<div class="phone-form-dialog__section phone-form-dialog__section--auxiliary">
<div class="phone-form-dialog__section-title">
<span class="phone-form-dialog__section-no">03</span>
<span class="phone-form-dialog__section-name">辅助配置</span>
</div>
<div class="phone-form-dialog__settings-list">
<div class="phone-form-dialog__setting-row"><span class="phone-form-dialog__setting-name">号卡状态</span><div class="phone-form-dialog__switch"><span class="phone-form-dialog__switch-status" :class="{ 'is-on': formData.numberStatus }">{{ formData.numberStatus ? '正常' : '注销' }}</span><el-switch v-model="formData.numberStatus"></el-switch></div></div>
<div class="phone-form-dialog__setting-row"><span class="phone-form-dialog__setting-name">可变更 5 元套餐</span><div class="phone-form-dialog__switch"><span class="phone-form-dialog__switch-status" :class="{ 'is-on': formData.packageChange5yuan }">{{ formData.packageChange5yuan ? '可变更' : '不可变更' }}</span><el-switch v-model="formData.packageChange5yuan"></el-switch></div></div>
<div class="phone-form-dialog__setting-row"><span class="phone-form-dialog__setting-name">开通微信</span><div class="phone-form-dialog__switch"><span class="phone-form-dialog__switch-status" :class="{ 'is-on': formData.wechat }">{{ formData.wechat ? '已开通' : '未开通' }}</span><el-switch v-model="formData.wechat"></el-switch></div></div>
<div class="phone-form-dialog__setting-row"><span class="phone-form-dialog__setting-name">开通企业微信</span><div class="phone-form-dialog__switch"><span class="phone-form-dialog__switch-status" :class="{ 'is-on': formData.wecom }">{{ formData.wecom ? '已开通' : '未开通' }}</span><el-switch v-model="formData.wecom"></el-switch></div></div>
<div class="phone-form-dialog__setting-row"><span class="phone-form-dialog__setting-name">可外呼</span><div class="phone-form-dialog__switch"><span class="phone-form-dialog__switch-status" :class="{ 'is-on': formData.outboundCall }">{{ formData.outboundCall ? '支持' : '不支持' }}</span><el-switch v-model="formData.outboundCall"></el-switch></div></div>
<div class="phone-form-dialog__setting-row"><span class="phone-form-dialog__setting-name">小程序备案</span><div class="phone-form-dialog__switch"><span class="phone-form-dialog__switch-status" :class="{ 'is-on': formData.miniProgramFiling }">{{ formData.miniProgramFiling ? '已备案' : '未备案' }}</span><el-switch v-model="formData.miniProgramFiling"></el-switch></div></div>
</div>
</div>
</el-form> <template #footer>
<div class="phone-form-dialog__footer">
<el-button @click="close">取消</el-button>
<el-button type="primary" class="phone-form-dialog__submit-btn" @click="handleSubmit">提交</el-button>
</div>
</template>
</el-dialog>
`
};
const app = createApp(PhoneAddDialog);
app.use(ElementPlus);
app.mount('#phoneDialogRoot');
})();
(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;
})();
(function attachPhoneListRuntime() {
/**
* 代码作用(白话):把各种日期输入统一转成可比较的时间戳,方便手机号卡页面按更新时间筛选;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):表格记录.updatedAt -> toTimestamp() -> matchesDateRange() -> filteredRecords。
*/
function toTimestamp(value) {
if (!value) {
return NaN;
}
if (value instanceof Date) {
return value.getTime();
}
const normalizedValue = String(value).replace(/-/g, '/');
return new Date(normalizedValue).getTime();
}
/**
* 代码作用(白话):判断一条手机号记录是否落在当前日期筛选范围内,没有选择日期时直接放行;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):filterDate -> matchesDateRange() -> filteredRecords() -> visibleRows。
*/
function matchesDateRange(record, rangeValue) {
if (!Array.isArray(rangeValue) || rangeValue.length !== 2) {
return true;
}
const recordTime = toTimestamp(record.updatedAt);
const startTime = toTimestamp(rangeValue[0]);
const endTime = toTimestamp(rangeValue[1]);
if (Number.isNaN(recordTime) || Number.isNaN(startTime) || Number.isNaN(endTime)) {
return true;
}
return recordTime >= startTime && recordTime <= endTime + 24 * 60 * 60 * 1000 - 1;
}
/**
* 代码作用(白话):把列表里的长文本压成 20 个字符内的单行展示,鼠标悬浮时还能看到完整内容;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):表格单元格原始值 -> formatCellText() -> 列表展示/tooltip 提示。
*/
function formatCellText(value) {
const text = String(value ?? '-');
if (text.length <= 20) {
return text;
}
return `${text.slice(0, 20)}...`;
}
const PhoneListTable = {
name: 'PhoneListTable',
props: {
phoneText: { type: Object, required: true },
phoneTableFilterLabels: { type: Array, required: true },
phoneTablePageSizes: { type: Array, required: true },
mountState: { type: Object, required: true },
globalState: { type: Object, required: true },
loadPhoneRows: { type: Function, required: true },
deletePhoneRecord: { type: Function, required: true },
savePhoneRecord: { type: Function, required: true },
openAddDialog: { type: Function, required: true },
openDrawer: { type: Function, required: true },
getPhoneStatusDotClass: { type: Function, required: true },
notifyError: { type: Function, required: true }
},
/**
* 代码作用(白话):初始化手机号卡页面的分页、筛选、全字段渲染和图片列辅助能力,让壳层传入的数据与动作变成真正可交互的表格;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime('phone') -> mountPhoneListComponent() -> PhoneListTable.setup() -> 列表渲染/弹窗/抽屉/删除。
*/
setup(props) {
const { computed, ref, onBeforeUnmount } = window.Vue;
const currentPage = ref(props.mountState.currentPage || 1);
const pageSize = ref(props.mountState.pageSize || props.phoneTablePageSizes[0]);
const selectedRecordId = ref(props.globalState.selectedBySource.phone || null);
const checkedIds = ref([].concat(props.mountState.selectedIds || []));
const filterDate = ref('');
const searchQuery = ref('');
const filterStatus = ref('all');
const filterCarrier = ref('all');
const filterOutbound = ref('all');
const phonePrefix = ref('');
const phoneLogic = ref('and');
const phoneSuffix = ref('');
const showBasicFilter = ref(false);
const showPhoneSearch = ref(false);
const allRecordsCache = ref([]);
const loading = ref(false);
const multipleSelection = ref([]);
const multipleTableRef = ref(null);
/**
* 代码作用(白话):内联编辑状态,记录当前正在编辑的单元格 { recordId, field, value },同一时刻只允许一个单元格处于编辑态;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):双击单元格 -> handleCellDblclick() -> editingCell -> 渲染输入控件 -> 失焦/回车 -> handleInlineSave() -> buildSavePayload() -> props.savePhoneRecord() -> loadAllRecords()。
*/
const editingCell = ref(null);
/**
* 代码作用(白话):防止重复保存的标志位,避免 mousedown 和 blur 同时触发导致多次提交;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):handleInlineSave() -> inlineSaving -> 防止并发保存。
*/
let inlineSaving = false;
/**
* 代码作用(白话):定义哪些字段支持双击内联编辑以及对应的控件类型,避免模板里写死判断逻辑;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):handleCellDblclick() -> isFieldEditable() -> 渲染 input/select/switch。
*/
const editableFields = {
realPerson: { type: 'input', placeholder: '请输入实名人' },
iccid: { type: 'input', placeholder: '请输入 ICCID' },
city: { type: 'input', placeholder: '请输入城市' },
cardStatus: { type: 'select', options: ['正常', '异常停机', '保号中'] },
carrier: { type: 'select', options: ['中国移动', '中国联通', '中国电信', '中国广电', '亮哥渠道(虚拟)'] },
outbound: { type: 'select', options: ['可外呼', '不可外呼'] },
douyinAccount: { type: 'input', placeholder: '请输入抖音账号' },
linkedWecom: { type: 'input', placeholder: '请输入绑定企业微信' },
channelOperator: { type: 'input', placeholder: '请输入渠道运营商' }
};
/**
* 代码作用(白话):判断某个字段是否允许双击内联编辑;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):模板 @dblclick -> isFieldEditable() -> 决定是否进入编辑态。
*/
function isFieldEditable(field) {
return Object.prototype.hasOwnProperty.call(editableFields, field);
}
/**
* 代码作用(白话):双击单元格时进入编辑态,把当前值暂存到 editingCell,阻止事件冒泡避免触发行选中;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):@dblclick.stop -> handleCellDblclick() -> editingCell = { recordId, field, value }。
*/
function handleCellDblclick(record, field) {
if (!isFieldEditable(field)) {
return;
}
let value;
if (field === 'outbound') {
value = record.outboundCall ? '可外呼' : '不可外呼';
} else {
value = record[field] ?? '';
}
editingCell.value = {
recordId: record.id,
field,
value: typeof value === 'boolean' ? value : String(value)
};
}
/**
* 代码作用(白话):内联编辑确认后构造完整保存载荷并调用保存接口,成功后刷新列表并退出编辑态;支持空值提交,允许用户清空字段内容后保存空字符串;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):回车/失焦/点击外部 -> handleInlineSave() -> buildSavePayload() -> props.savePhoneRecord() -> loadAllRecords() -> editingCell = null。
*/
async function handleInlineSave() {
if (!editingCell.value) {
return;
}
// 防止重复保存(blur 和 mousedown 可能同时触发)
if (inlineSaving) {
return;
}
inlineSaving = true;
try {
const { recordId, field, value } = editingCell.value;
const record = allRecordsCache.value.find((item) => item.id === recordId);
if (!record) {
editingCell.value = null;
return;
}
const updatedRecord = { ...record };
if (field === 'outbound') {
updatedRecord.outboundCall = value === '可外呼';
} else {
// 支持空值提交:允许保存空字符串
updatedRecord[field] = value ?? '';
}
const images = getDisplayImages(record);
const payload = buildSavePayload(updatedRecord, images);
try {
await props.savePhoneRecord(payload);
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.success('修改成功');
}
await loadAllRecords();
} catch (error) {
props.notifyError(error.message || '保存失败,请稍后重试');
} finally {
editingCell.value = null;
}
} finally {
inlineSaving = false;
}
}
/**
* 代码作用(白话):处理 document 级 mousedown 事件,点击非内联编辑区域时自动提交保存;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):点击页面任意位置 -> handleDocumentMousedown() -> 判断是否点击编辑区域外 -> handleInlineSave()。
*/
function handleDocumentMousedown(event) {
if (!editingCell.value) {
return;
}
// 检查点击目标是否在编辑单元格内
const target = event.target;
const editingElement = document.querySelector('.phone-inline-edit-cell');
if (editingElement && editingElement.contains(target)) {
return; // 点击在编辑区域内,不处理
}
// 点击在编辑区域外,触发保存
handleInlineSave();
}
/**
* 代码作用(白话):取消内联编辑,直接退出编辑态不保存;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):Esc/点击外部 -> handleInlineCancel() -> editingCell = null。
*/
function handleInlineCancel() {
editingCell.value = null;
}
/**
* 代码作用(白话):从当前缓存里提取运营商选项,避免筛选下拉框写死,后端换数据时前端还能跟着显示;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):loadAllRecords() -> allRecordsCache -> getCarrierOptions() -> 运营商筛选下拉。
*/
function getCarrierOptions() {
const values = allRecordsCache.value
.map((record) => record.carrier)
.filter(Boolean);
return Array.from(new Set(values));
}
/**
* 代码作用(白话):从当前缓存里提取外呼能力选项,保证筛选项和真实记录一致;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):loadAllRecords() -> allRecordsCache -> getOutboundOptions() -> 外呼能力筛选下拉。
*/
function getOutboundOptions() {
const values = allRecordsCache.value
.map((record) => record.outbound)
.filter(Boolean);
return Array.from(new Set(values));
}
const carrierOptions = computed(getCarrierOptions);
const outboundOptions = computed(getOutboundOptions);
/**
* 代码作用(白话):向后端拉手机号卡数据并刷新本地缓存,失败时把错误交给壳层提示;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):PhoneListTable mounted/删除后刷新 -> loadAllRecords() -> props.loadPhoneRows() -> allRecordsCache -> filteredRecords()/visibleRows()。
*/
async function loadAllRecords() {
loading.value = true;
try {
const rows = await props.loadPhoneRows();
allRecordsCache.value = Array.isArray(rows) ? rows : [];
} catch (error) {
allRecordsCache.value = [];
props.notifyError(error.message || props.phoneText.loadFailed);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):按状态、运营商、外呼能力、日期和关键词筛出当前应显示的手机号记录;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):筛选控件 -> getFilteredRecords() -> totalFiltered()/visibleRows() -> 表格。
*/
/**
* ????????????????????????????????????????????????????????? `card_status` ??????????F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js?F:/Project/xyw_console/src/modules/shared/record-adapters.js?????????/???/????????? -> getFilteredRecords() -> visibleRows -> ?????
*/
function getFilteredRecords() {
let result = allRecordsCache.value.slice();
if (filterStatus.value !== 'all') {
result = result.filter((record) => {
const cardStatus = record.cardStatus || record.card_status || record.status;
return cardStatus === filterStatus.value;
});
}
if (filterCarrier.value !== 'all') {
result = result.filter((record) => record.carrier === filterCarrier.value);
}
if (filterOutbound.value !== 'all') {
result = result.filter((record) => record.outbound === filterOutbound.value);
}
result = result.filter((record) => matchesDateRange(record, filterDate.value));
const prefix = phonePrefix.value.trim();
const suffix = phoneSuffix.value.trim();
if (prefix || suffix) {
result = result.filter((record) => {
const phone = String(record.phone || '');
const matchPrefix = prefix ? phone.startsWith(prefix) : false;
const matchSuffix = suffix ? phone.endsWith(suffix) : false;
if (prefix && suffix) {
return phoneLogic.value === 'and'
? (matchPrefix && matchSuffix)
: (matchPrefix || matchSuffix);
}
if (prefix) {
return matchPrefix;
}
return matchSuffix;
});
}
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return result;
}
return result.filter((record) => {
return [
record.phone,
record.realPerson,
record.city,
record.carrier,
record.usageLocation,
record.iccid,
record.douyinAccount,
record.linkedWecom
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
});
}
const filteredRecords = computed(getFilteredRecords);
/**
* 代码作用(白话):返回筛选后的总条数,给标题统计和分页器共用;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):getFilteredRecords() -> getTotalFiltered() -> 工具栏标题 / 分页 total。
*/
function getTotalFiltered() {
return filteredRecords.value.length;
}
/**
* 代码作用(白话):根据当前页大小算出总页数,防止分页器落到 0 页;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):filteredRecords/pageSize -> getTotalPages() -> handleFilterStateChange()/分页器。
*/
function getTotalPages() {
return Math.max(1, Math.ceil(totalFiltered.value / pageSize.value));
}
/**
* 代码作用(白话):切出当前页应显示的手机号记录,让表格只渲染这一页数据;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):filteredRecords/currentPage/pageSize -> getVisibleRows() -> el-table。
*/
function getVisibleRows() {
const start = (currentPage.value - 1) * pageSize.value;
return filteredRecords.value.slice(start, start + pageSize.value);
}
const totalFiltered = computed(getTotalFiltered);
const totalPages = computed(getTotalPages);
const visibleRows = computed(getVisibleRows);
/**
* 代码作用(白话):筛选条件变化后把页码拉回第一页,并把当前组件状态同步回壳层缓存;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):筛选控件变化 -> handleFilterStateChange() -> currentPage/syncMountState() -> 再次渲染。
*/
function handleFilterStateChange() {
currentPage.value = 1;
syncMountState();
}
window.Vue.watch([filterStatus, filterCarrier, filterOutbound, filterDate, searchQuery, phonePrefix, phoneSuffix, phoneLogic], handleFilterStateChange);
window.Vue.watch(phonePrefix, (value) => {
const cleaned = String(value).replace(/[^0-9]/g, '').slice(0, 3);
if (cleaned !== value) {
phonePrefix.value = cleaned;
}
});
window.Vue.watch(phoneSuffix, (value) => {
const cleaned = String(value).replace(/[^0-9]/g, '').slice(0, 4);
if (cleaned !== value) {
phoneSuffix.value = cleaned;
}
});
/**
* 代码作用(白话):把当前页码、分页大小和批量勾选状态存回壳层 mountState,保证切走再切回来还能恢复现场;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):列表交互 -> syncMountState() -> moduleMountState.phone -> renderExternalRuntime('phone') 恢复。
*/
function syncMountState() {
props.mountState.currentPage = currentPage.value;
props.mountState.pageSize = pageSize.value;
props.mountState.selectedIds = checkedIds.value.slice();
}
/**
* 代码作用(白话):给表格行判断是否是当前选中的手机号记录,用于高亮或兼容旧壳层选中态;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js;关联逻辑(调用链/消息链/数据流):focusRow()/openDrawer() -> selectedRecordId -> isRowSelected() -> 表格选中表现。
*/
function isRowSelected(rowId) {
return selectedRecordId.value === rowId;
}
/**
* 代码作用(白话):把不同状态映射成状态点颜色,让正常、异常、外呼能力等字段在列表里更直观;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):status/wechatStatus/outbound -> getStatusClass() -> props.getPhoneStatusDotClass() -> status-dot class。
*/
/**
* ?????????????????????????? `card_status` ??????????????????F:/Project/xyw_console/app-v2.js?F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js?????????/???/?????cardStatus -> getStatusClass() -> props.getPhoneStatusDotClass() -> status-dot class?
*/
function getStatusClass(cardStatus) {
return props.getPhoneStatusDotClass(cardStatus);
}
/**
* 代码作用(白话):把真假值字段翻译成人能看懂的文案,避免列表里直接出现 true/false;关联文件:F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):normalizePhoneRecord() -> formatBooleanText() -> 手机号卡全字段列表列渲染。
*/
function formatBooleanText(value, trueText = '是', falseText = '否') {
return value ? trueText : falseText;
}
/**
* 代码作用(白话):把手机号状态和号码保留状态统一翻译成业务文案,方便列表按库表字段直观看;关联文件:F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):normalizePhoneRecord() -> formatStatusText()/formatRetentionText() -> 状态列渲染。
*/
function formatStatusText(value) {
if (value === false || value === 0 || value === '0' || value === 'false' || value === 'disabled' || value === '异常') {
return '异常';
}
if (value === true || value === 1 || value === '1' || value === 'true' || value === 'enabled' || value === '正常') {
return '正常';
}
return value ? '正常' : '异常';
}
function formatRetentionText(value) {
return value ? '保留' : '注销';
}
/**
* 代码作用(白话):从统一图片集合里挑出当前记录应展示的最多两张缩略图,让两个 SQL 图片字段以前端一个图片列表现;关联文件:F:/Project/xyw_console/src/modules/shared/record-adapters.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):normalizePhoneRecord() -> getDisplayImages() -> 列表图片列双缩略图渲染。
*/
function getDisplayImages(record) {
return Array.isArray(record.images) ? record.images.slice(0, 2) : [];
}
/**
* 代码作用(白话):给没有图片或图片打不开的情况显示统一占位块,避免列表出现空洞或破图;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):getDisplayImages() -> handleImageError() -> 图片列占位图回退。
*/
function handleImageError(event) {
const image = event?.target;
if (!image) {
return;
}
image.style.display = 'none';
const placeholder = image.nextElementSibling;
if (placeholder) {
placeholder.style.display = 'flex';
}
}
/**
* 代码作用(白话):按后端 WxPhoneSaveRequest 字段把列表记录拼成完整保存载荷,保证删除图片走 PUT 全量更新时不丢字段;支持空值提交,使用 ?? 或显式判断区分 undefined/null 与空字符串;关联文件: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;关联逻辑(调用链/消息链/数据流):handleInlineSave()/handleClearImage() -> buildSavePayload() -> props.savePhoneRecord() -> 后端全量更新。
*/
function buildSavePayload(record, images) {
return {
id: record.id,
phoneNumber: record.phoneNumber ?? record.phone ?? '',
realPerson: record.realPerson ?? '',
iccid: record.iccid ?? '',
city: record.city ?? '',
imageAttachment1: images[0] ?? '',
imageAttachment2: images[1] ?? '',
cardStatus: record.cardStatus ?? record.status ?? '正常',
cardUsageLocation: record.cardUsageLocation ?? record.usageLocation ?? '',
wecom: record.wecom === true,
wechat: record.wechat === true,
outboundCall: record.outboundCall === true,
douyinAccount: record.douyinAccount ?? '',
miniProgramFiling: record.miniProgramFiling === true,
packageChange5yuan: record.packageChange5yuan === true,
channelOperator: record.channelOperator ?? record.carrier ?? '',
numberStatus: record.numberStatus ?? true,
linkedWecom: record.linkedWecom ?? ''
};
}
/**
* 代码作用(白话):删除列表图片列中指定位置的单张图片,复用现有保存逻辑把对应 imageAttachment1/2 置空并刷新列表;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-image-cell.js;关联逻辑(调用链/消息链/数据流):图片单元删除 icon -> handleClearImage() -> buildSavePayload() -> props.savePhoneRecord() -> loadAllRecords()。
*/
async function handleClearImage(record, imageIndex) {
if (!window.confirm('确认删除这张图片吗?')) {
return;
}
// 优先从缓存取最新记录,避免 scope.row 是旧引用导致图片数据不一致
const latestRecord = allRecordsCache.value.find((item) => item.id === record.id) || record;
const currentImages = getDisplayImages(latestRecord);
const updatedImages = currentImages.slice();
updatedImages[imageIndex] = '';
const payload = buildSavePayload(latestRecord, updatedImages);
const expectedCount = Math.max(0, currentImages.filter(Boolean).length - 1);
console.info('[phone] 删除图片请求:id=', latestRecord.id, '索引=', imageIndex,
'当前图片数=', currentImages.filter(Boolean).length,
'payload.imageAttachment1 长度=', (payload.imageAttachment1 || '').length,
'payload.imageAttachment2 长度=', (payload.imageAttachment2 || '').length);
try {
const saved = await props.savePhoneRecord(payload);
const savedImages = [
saved?.imageAttachment1 || saved?.image_attachment_1,
saved?.imageAttachment2 || saved?.image_attachment_2
].filter(Boolean);
if (savedImages.length > expectedCount) {
console.error('[phone] 图片删除验证失败:id=', latestRecord.id, '预期剩余', expectedCount, '张,实际', savedImages.length, '张',
'saved.imageAttachment1 长度=', (saved?.imageAttachment1 || saved?.image_attachment_1 || '').length,
'saved.imageAttachment2 长度=', (saved?.imageAttachment2 || saved?.image_attachment_2 || '').length);
props.notifyError('图片删除失败:服务端未清除图片数据,请稍后重试');
await loadAllRecords();
return;
}
console.info('[phone] 图片删除成功:id=', latestRecord.id, '索引=', imageIndex, '剩余', savedImages.length, '张');
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.success('图片已删除');
}
await loadAllRecords();
} catch (error) {
console.error('[phone] 删除图片失败:', error);
props.notifyError(error.message || '删除图片失败,请稍后重试');
}
}
/**
* 代码作用(白话):把本地图片文件读成 base64 DataURL,供列表内联上传直接复用现有保存接口;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):File -> readFileAsDataUrl() -> handleInlineImageUpload() -> buildSavePayload() -> props.savePhoneRecord()。
*/
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('读取图片失败,请重新选择'));
reader.readAsDataURL(file);
});
}
/**
* 代码作用(白话):列表空占位框双击后接收用户选择的图片文件,最多保留 2 张,复用全量保存接口写入 imageAttachment1/2,并从后端刷新缓存以保证后续删除能拿到真实 URL;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-image-cell.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):phone-image-cell handleFileChange() -> handleInlineImageUpload() -> readFileAsDataUrl() -> buildSavePayload() -> props.savePhoneRecord() -> loadAllRecords()。
*/
async function handleInlineImageUpload(record, files) {
if (!record || !Array.isArray(files) || !files.length) {
return;
}
// 优先从缓存取最新记录,避免 scope.row 是旧引用导致已有图片丢失
const latestRecord = allRecordsCache.value.find((item) => item.id === record.id) || record;
const existing = getDisplayImages(latestRecord).slice();
const remainSlots = Math.max(0, 2 - existing.length);
if (remainSlots <= 0) {
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.warning('最多上传 2 张图片');
}
return;
}
const picked = files.slice(0, remainSlots);
try {
const nextImages = existing.slice();
for (let index = 0; index < picked.length; index += 1) {
const url = await readFileAsDataUrl(picked[index]);
if (url) {
nextImages.push(url);
}
}
if (!nextImages.length) {
return;
}
const payload = buildSavePayload(latestRecord, nextImages.slice(0, 2));
await props.savePhoneRecord(payload);
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.success('图片已上传');
}
await loadAllRecords();
} catch (error) {
props.notifyError(error.message || '上传图片失败,请稍后重试');
}
}
/**
* 代码作用(白话):把某一行设为当前选中手机号,并同步到壳层全局状态,方便详情抽屉和编辑弹窗拿到当前对象;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js;关联逻辑(调用链/消息链/数据流):点击表格手机号/操作按钮 -> focusRow() -> globalState.selectedBySource.phone -> openDrawer()/详情联动。
*/
function focusRow(recordId) {
selectedRecordId.value = recordId;
props.globalState.selectedBySource.phone = recordId;
}
/**
* 代码作用(白话):重置手机号页所有筛选条件和分页位置,回到默认浏览视图;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):点击“重置” -> resetTableState() -> 筛选 ref 清空 -> filteredRecords()/visibleRows() 重算。
*/
function resetTableState() {
filterDate.value = '';
searchQuery.value = '';
filterStatus.value = 'all';
filterCarrier.value = 'all';
filterOutbound.value = 'all';
phonePrefix.value = '';
phoneSuffix.value = '';
phoneLogic.value = 'and';
showBasicFilter.value = false;
showPhoneSearch.value = false;
currentPage.value = 1;
syncMountState();
}
/**
* 代码作用(白话):同步表格勾选结果,给底部已选数量和壳层缓存共用;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):el-table selection-change -> handleSelectionChange() -> checkedIds/mountState -> 底部统计。
*/
function handleSelectionChange(rows) {
multipleSelection.value = rows.slice();
checkedIds.value = rows.map((row) => row.id);
syncMountState();
}
/**
* 代码作用(白话):切页时更新当前页码并回写壳层状态;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):el-pagination current-change -> handleCurrentChange() -> currentPage -> visibleRows()。
*/
function handleCurrentChange(page) {
currentPage.value = Math.min(Math.max(page, 1), totalPages.value);
syncMountState();
}
/**
* 代码作用(白话):切换每页条数时重置到第一页,避免分页落在不存在的页码上;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):el-pagination size-change -> handleSizeChange() -> pageSize/currentPage -> visibleRows()。
*/
function handleSizeChange(size) {
pageSize.value = size;
currentPage.value = 1;
syncMountState();
}
/**
* 代码作用(白话):处理查看、编辑、删除三类行操作,并在删除后主动刷新列表;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):操作按钮 -> handleRowAction() -> openDrawer()/deletePhoneRecord() -> reload。
*/
async function handleRowAction(mode, recordId) {
focusRow(recordId);
const record = allRecordsCache.value.find((item) => item.id === recordId);
if (!record && mode !== 'delete') {
props.notifyError('未找到对应手机号记录');
return;
}
if (mode === 'delete') {
if (!confirm(props.phoneText.deleteConfirm)) {
return;
}
try {
await props.deletePhoneRecord(recordId);
await loadAllRecords();
} catch (error) {
props.notifyError(error.message || props.phoneText.deleteFailed);
}
return;
}
try {
await props.openDrawer(mode, recordId);
} catch (error) {
props.notifyError(error.message || '打开详情失败');
}
}
/**
* 代码作用(白话):打开新增号卡弹窗,让手机号页通过壳层继续复用已有新增表单;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):点击“新增号卡” -> handleAdd() -> openAddDialog() -> PhoneAddDialogAPI.open('add')。
*/
function handleAdd() {
props.openAddDialog('add');
}
window.Vue.onMounted(() => {
loadAllRecords();
syncMountState();
// 添加 document 级 mousedown 监听,点击非编辑区域时自动保存
document.addEventListener('mousedown', handleDocumentMousedown, true);
});
window.Vue.onBeforeUnmount(() => {
// 清理事件监听
document.removeEventListener('mousedown', handleDocumentMousedown, true);
});
return {
formatCellText,
carrierOptions,
outboundOptions,
currentPage,
pageSize,
visibleRows,
checkedIds,
isRowSelected,
getStatusClass,
formatBooleanText,
formatStatusText,
formatRetentionText,
getDisplayImages,
handleImageError,
handleClearImage,
handleInlineImageUpload,
editingCell,
editableFields,
isFieldEditable,
handleCellDblclick,
handleInlineSave,
handleInlineCancel,
focusRow,
resetTableState,
handleRowAction,
filterDate,
searchQuery,
filterStatus,
filterCarrier,
filterOutbound,
phonePrefix,
phoneLogic,
phoneSuffix,
showBasicFilter,
showPhoneSearch,
totalFiltered,
totalPages,
loading,
handleAdd,
multipleSelection,
multipleTableRef,
handleSelectionChange,
handleCurrentChange,
handleSizeChange
};
},
template: `
<div class="table-area table-area-refined">
<div class="table-toolbar table-toolbar-refined phone-toolbar-main">
<div class="toolbar-left">
<div class="toolbar-title-wrap">
<div class="toolbar-title">全部号卡 ({{ totalFiltered.toLocaleString('zh-CN') }})</div>
</div>
</div>
<div class="toolbar-right phone-toolbar-actions">
<button type="button" class="filter-toggle-btn" :class="{ 'is-active': showBasicFilter }" @click="showBasicFilter = !showBasicFilter">基础筛选</button>
<button type="button" class="filter-toggle-btn" :class="{ 'is-active': showPhoneSearch }" @click="showPhoneSearch = !showPhoneSearch">号码搜索</button>
<el-input
v-model="searchQuery"
:placeholder="phoneText.searchPlaceholder"
clearable
class="phone-search-input"
style="width: 220px; height: 32px;"
>
<template #prefix>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</template>
</el-input>
<button class="btn btn-primary" type="button" @click="handleAdd" style="height: 32px; padding: 0 16px;">{{ phoneText.add }}</button>
<button class="btn-reset btn-reset-refined" type="button" @click="resetTableState">{{ phoneText.reset }}</button>
</div>
</div>
<div v-if="showBasicFilter" class="phone-filter-row">
<el-select v-model="filterStatus" class="custom-fselect" style="width: 120px;">
<el-option label="状态:全部" value="all"></el-option>
<el-option v-for="label in phoneTableFilterLabels.filter((item) => item !== '全部')" :key="label" :label="label" :value="label"></el-option>
</el-select>
<el-select v-model="filterCarrier" class="custom-fselect" style="width: 140px;">
<el-option label="运营商:全部" value="all"></el-option>
<el-option v-for="carrier in carrierOptions" :key="carrier" :label="carrier" :value="carrier"></el-option>
</el-select>
<el-select v-model="filterOutbound" class="custom-fselect" style="width: 150px;">
<el-option label="外呼:全部" value="all"></el-option>
<el-option v-for="outbound in outboundOptions" :key="outbound" :label="outbound" :value="outbound"></el-option>
</el-select>
<el-date-picker
v-model="filterDate"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="phone-date-range"
></el-date-picker>
</div>
<div v-if="showPhoneSearch" class="phone-filter-row">
<el-input
v-model="phonePrefix"
placeholder="前3位"
maxlength="3"
clearable
style="width: 90px;"
></el-input>
<el-select v-model="phoneLogic" style="width: 72px;">
<el-option label="与" value="and"></el-option>
<el-option label="或" value="or"></el-option>
</el-select>
<el-input
v-model="phoneSuffix"
placeholder="后4位"
maxlength="4"
clearable
style="width: 100px;"
></el-input>
</div>
<div class="table-wrap table-wrap-refined" v-loading="loading">
<el-table
ref="multipleTableRef"
:data="visibleRows"
row-key="id"
style="width: 100%"
class="domain-table domain-table-refined phone-table-header-nowrap"
@selection-change="handleSelectionChange"
:header-cell-style="{ background: 'linear-gradient(180deg, #f9fafb 0%, #f3f4f6 100%)', color: '#374151', fontWeight: 600, padding: '10px 12px', borderBottom: '1px solid #e5e7eb', whiteSpace: 'nowrap' }"
:cell-style="{ padding: '10px 12px', borderBottom: '1px solid #f3f4f6', color: '#111827', whiteSpace: 'nowrap' }"
>
<el-table-column type="selection" width="50" fixed="left" />
<el-table-column label="图片" min-width="160">
<template #default="scope">
<phone-card-image-cell
:images="getDisplayImages(scope.row)"
:record-id="scope.row.id"
:clear-image="(imageIndex) => handleClearImage(scope.row, imageIndex)"
:upload-image="(files) => handleInlineImageUpload(scope.row, files)"
/>
</template>
</el-table-column>
<el-table-column label="手机号" min-width="150">
<template #default="scope">
<a href="#" class="domain-link" @click.prevent="focusRow(scope.row.id)">{{ scope.row.phone || '-' }}</a>
</template>
</el-table-column>
<el-table-column label="实名人" min-width="150">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'realPerson'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.realPerson.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.realPerson || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'realPerson')">{{ scope.row.realPerson || '双击编辑' }}</span>
</template>
</el-table-column>
<el-table-column label="ICCID" min-width="220">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'iccid'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.iccid.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.iccid || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'iccid')">{{ scope.row.iccid || '双击编辑' }}</span>
</template>
</el-table-column>
<el-table-column label="所在城市" min-width="140">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'city'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.city.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.city || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'city')">{{ scope.row.city || '双击编辑' }}</span>
</template>
</el-table-column>
<el-table-column label="号卡状态" min-width="120">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'cardStatus'">
<el-select v-model="editingCell.value" size="small" class="phone-inline-edit-cell" @change="handleInlineSave" @click.stop>
<el-option v-for="opt in editableFields.cardStatus.options" :key="opt" :label="opt" :value="opt"></el-option>
</el-select>
</template>
<span v-else class="status-chip-wrap" style="cursor: pointer;" @dblclick.stop="handleCellDblclick(scope.row, 'cardStatus')">
<span class="status-dot" :class="getStatusClass(scope.row.cardStatus || scope.row.card_status || scope.row.status)"></span>
<span>{{ scope.row.cardStatus || scope.row.card_status || scope.row.status }}</span>
</span>
</template>
</el-table-column> <el-table-column label="企业微信" min-width="110">
<template #default="scope">{{ formatBooleanText(scope.row.wecom, '已开通', '未开通') }}</template>
</el-table-column>
<el-table-column label="微信" min-width="90">
<template #default="scope">{{ formatBooleanText(scope.row.wechat, '已开通', '未开通') }}</template>
</el-table-column>
<el-table-column label="可外呼" min-width="100">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'outbound'">
<el-select v-model="editingCell.value" size="small" class="phone-inline-edit-cell" @change="handleInlineSave" @click.stop>
<el-option v-for="opt in editableFields.outbound.options" :key="opt" :label="opt" :value="opt"></el-option>
</el-select>
</template>
<span v-else style="cursor: pointer;" @dblclick.stop="handleCellDblclick(scope.row, 'outbound')">{{ formatBooleanText(scope.row.outboundCall, '可外呼', '不可外呼') }}</span>
</template>
</el-table-column>
<el-table-column label="抖音账号" min-width="180">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'douyinAccount'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.douyinAccount.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.douyinAccount || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'douyinAccount')">{{ scope.row.douyinAccount || '双击编辑' }}</span>
</template>
</el-table-column>
<el-table-column label="小程序备案" min-width="120">
<template #default="scope">{{ formatBooleanText(scope.row.miniProgramFiling, '已备案', '未备案') }}</template>
</el-table-column>
<el-table-column label="渠道运营商" min-width="170">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'channelOperator'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.channelOperator.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.channelOperator || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'channelOperator')">{{ scope.row.channelOperator || '双击编辑' }}</span>
</template>
</el-table-column>
<el-table-column label="号码状态" min-width="120">
<template #default="scope">{{ formatStatusText(scope.row.numberStatus ?? scope.row.mobile_status ?? scope.row.number_status) }}</template>
</el-table-column>
<el-table-column label="绑定企业微信" min-width="260">
<template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'linkedWecom'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.linkedWecom.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.linkedWecom || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'linkedWecom')">{{ scope.row.linkedWecom || '双击编辑' }}</span>
</template>
</el-table-column> <el-table-column label="操作" width="100" fixed="right">
<template #default="scope">
<div class="action-cluster" style="display: flex; flex-direction: column; align-items: flex-start; gap: 8px;">
<button class="action-pill action-pill-primary" type="button" @click.stop="handleRowAction('view', scope.row.id)" style="border: none; background: transparent; background-image: linear-gradient(90deg, #2563eb, #60a5fa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; cursor: pointer; padding: 0; font-weight: 600; line-height: 1;">查看</button>
<button class="action-pill" type="button" @click.stop="handleRowAction('edit', scope.row.id)" style="border: none; background: transparent; color: #4b5563; cursor: pointer; padding: 0; font-weight: 500; line-height: 1;">编辑</button>
<button class="action-pill" type="button" @click.stop="handleRowAction('delete', scope.row.id)" style="border: none; background: transparent; color: #ef4444; cursor: pointer; padding: 0; font-weight: 500; line-height: 1;">删除</button>
</div>
</template>
</el-table-column>
<template #empty>
<div style="padding: 24px; text-align: center; color: #9ca3af;">暂无手机号卡资料</div>
</template>
</el-table>
</div>
<div class="table-footer table-footer-refined">
<div class="tfoot-left tfoot-left-refined">
<span>已选 {{ checkedIds.length }} 项</span>
</div>
<div class="tfoot-right tfoot-right-refined">
<span>共 {{ totalFiltered.toLocaleString('zh-CN') }} 条</span>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="phoneTablePageSizes"
layout="sizes, prev, pager, next, jumper"
:total="totalFiltered"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
`
};
/**
* 代码作用(白话):把手机号卡列表组件挂到壳层指定 DOM,并注入 Element Plus 组件能力;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime('phone') -> mountPhoneListComponent() -> createApp().mount() -> PhoneListTable.setup()。
*/
function mountPhoneListComponent(options) {
const { createApp } = window.Vue;
const app = createApp(PhoneListTable, options);
if (window.ElementPlus) {
app.use(window.ElementPlus);
}
if (window.PhoneCardImageCell) {
app.component('PhoneCardImageCell', window.PhoneCardImageCell);
}
app.mount(options.mountTarget);
return app;
}
window.PhoneListRuntime = {
mountPhoneListComponent
};
})();
/**
* 代码作用(白话):把手机号接口能力挂载到页面共用对象,并使用相对 `/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
};
})();
(function initWechatAddDialog() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
/**
* 代码作用(白话):给企微新增/编辑弹窗准备一份干净的默认表单对象,避免多次打开时残留上一次输入;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):open('add') -> buildEmptyFormData() -> reactive formData -> 弹窗输入框渲染。
*/
function buildEmptyFormData() {
return {
real_name: '',
account: '',
alias_name: '',
department_name: '',
gender: '男',
phone_number: '',
real_name_owner: '',
account_location: '',
phone_image: '',
real_name_owner_status: 1
};
}
/**
* 代码作用(白话):读取外层注入的企微保存动作,没有配置时直接给出明确错误,避免弹窗假保存;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):保存按钮 -> getWechatAddDialogActions() -> save()/afterSave() -> 刷新列表。
*/
function getWechatAddDialogActions() {
if (!window.WechatAddDialogActions?.save) {
throw new Error('企微保存动作未加载');
}
return window.WechatAddDialogActions;
}
const WechatAddDialog = {
/**
* 代码作用(白话):初始化企微新增/编辑弹窗的显示状态、表单数据和提交流程,让列表和详情都能共用一个弹窗;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):列表按钮/详情按钮 -> WechatAddDialogAPI.open() -> setup() 状态变化 -> 弹窗模板渲染。
*/
setup() {
const visible = ref(false);
const formRef = ref(null);
const isEditMode = ref(false);
const currentId = ref(null);
const submitting = ref(false);
const formData = reactive(buildEmptyFormData());
const rules = {
real_name: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
account: [{ required: true, message: '请输入企微账号', trigger: 'blur' }],
department_name: [{ required: true, message: '请输入部门', trigger: 'blur' }],
phone_number: [{ required: true, message: '请输入绑定手机号', trigger: 'blur' }],
real_name_owner: [{ required: true, message: '请输入实名人', trigger: 'blur' }]
};
/**
* 代码作用(白话):把当前表单恢复成新增默认值,避免编辑态数据串到下一次打开的新增弹窗;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):open('add')/close 后再次打开 -> resetFormData() -> formData 默认值。
*/
function resetFormData() {
currentId.value = null;
Object.assign(formData, buildEmptyFormData());
}
/**
* 代码作用(白话):根据已有记录推导实名状态下拉框的默认值,兼容详情态和列表态两种字段命名;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):record -> buildOwnerStatus() -> formData.real_name_owner_status -> 下拉框回填。
*/
function buildOwnerStatus(record) {
if (record.ownerStatus === '异常' || record.real_name_owner_status === 0 || record.realNameOwnerStatus === 0) {
return 0;
}
return 1;
}
/**
* 代码作用(白话):把外部记录回填到编辑表单里,统一兼容前端适配后的字段和后端原始字段;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):open('edit', record) -> populateFormData() -> formData -> 弹窗输入框显示旧值。
*/
function populateFormData(record) {
currentId.value = record.id || null;
formData.real_name = record.realName || record.real_name || '';
formData.account = record.account || '';
formData.alias_name = record.alias || record.alias_name || '';
formData.department_name = record.department || record.department_name || record.departmentName || '';
formData.gender = record.gender || '男';
formData.phone_number = record.phone || record.phone_number || record.phoneNumber || '';
formData.real_name_owner = record.owner || record.real_name_owner || record.realNameOwner || '';
formData.account_location = record.account_location || record.accountLocation || record['账号位置'] || '';
formData.phone_image = record.phone_image || record.phoneImage || record['手机图片'] || '';
formData.real_name_owner_status = buildOwnerStatus(record);
}
/**
* 代码作用(白话):按新增或编辑模式打开企微弹窗,并在打开时决定是重置表单还是回填旧记录;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):新增/编辑入口 -> open() -> visible/formData 更新 -> 弹窗渲染。
*/
function open(mode = 'add', record = null) {
isEditMode.value = mode === 'edit';
visible.value = true;
if (mode === 'edit' && record) {
populateFormData(record);
} else {
resetFormData();
}
window.setTimeout(() => {
formRef.value?.clearValidate?.();
}, 0);
}
/**
* 代码作用(白话):关闭当前企微弹窗,只负责收起弹窗显示状态;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):取消按钮/右上角关闭 -> close() -> visible=false。
*/
function close() {
visible.value = false;
}
/**
* 代码作用(白话):在表单校验通过后调用外层保存动作,并在成功后刷新企微列表;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):handleSubmit() -> submitAfterValidate() -> WechatAddDialogActions.save() -> afterSave() -> renderModule()。
*/
async function submitAfterValidate() {
const actions = getWechatAddDialogActions();
submitting.value = true;
try {
const payload = {
...formData,
id: currentId.value || undefined
};
await actions.save(payload);
ElementPlus.ElMessage.success(isEditMode.value ? '编辑企微资料成功' : '新增企微资料成功');
close();
if (typeof actions.afterSave === 'function') {
await actions.afterSave();
}
} catch (error) {
ElementPlus.ElMessage.error(error.message || '保存企微资料失败');
} finally {
submitting.value = false;
}
}
/**
* 代码作用(白话):触发表单校验并把保存动作收口到统一入口,是弹窗保存按钮唯一出口;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):保存按钮 -> handleSubmit() -> ElForm.validate() -> submitAfterValidate()。
*/
async function handleSubmit() {
if (!formRef.value || submitting.value) {
return;
}
try {
await formRef.value.validate();
await submitAfterValidate();
} catch (error) {
if (error instanceof Error) {
ElementPlus.ElMessage.error(error.message);
}
}
}
window.WechatAddDialogAPI = {
open
};
return {
visible,
isEditMode,
formRef,
formData,
rules,
submitting,
close,
handleSubmit
};
},
template: `
<el-dialog
v-model="visible"
width="640px"
top="6vh"
:show-close="false"
custom-class="premium-dialog"
style="border-radius: 16px; overflow: hidden; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"
>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 32px 40px 16px 40px;">
<h2 style="margin: 0; font-size: 24px; font-weight: 700; color: #111827; letter-spacing: 0;">
{{ isEditMode ? '编辑企微资料' : '新增企微记录' }}
</h2>
<button @click="close" style="background: #f3f4f6; border: none; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #6b7280; transition: background 0.2s;" onmouseover="this.style.background='#e5e7eb'" onmouseout="this.style.background='#f3f4f6'">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</template>
<div style="padding: 0 40px 20px 40px;">
<el-form :model="formData" :rules="rules" ref="formRef" label-position="top">
<div style="background: #f9fafb; border-radius: 12px; padding: 24px; margin-bottom: 24px; border: 1px solid #f3f4f6;">
<h4 style="margin: 0 0 20px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0;">基础信息</h4>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="真实姓名" prop="real_name" style="margin-bottom: 16px;">
<el-input v-model="formData.real_name" placeholder="请输入真实姓名" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="别名" prop="alias_name" style="margin-bottom: 16px;">
<el-input v-model="formData.alias_name" placeholder="请输入内部别名" size="large"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="部门" prop="department_name" style="margin-bottom: 0;">
<el-input v-model="formData.department_name" placeholder="例如:市场部" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender" style="margin-bottom: 0;">
<el-radio-group v-model="formData.gender" size="large" style="width: 100%;">
<el-radio-button label="男" style="width: 50%; text-align: center;"></el-radio-button>
<el-radio-button label="女" style="width: 50%; text-align: center;"></el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</div>
<div style="background: #f9fafb; border-radius: 12px; padding: 24px; margin-bottom: 24px; border: 1px solid #f3f4f6;">
<h4 style="margin: 0 0 20px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0;">账号与实名</h4>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="企微账号 / 微信 ID" prop="account" style="margin-bottom: 16px;">
<el-input v-model="formData.account" placeholder="请输入企微账号" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="绑定手机号" prop="phone_number" style="margin-bottom: 16px;">
<el-input v-model="formData.phone_number" placeholder="请输入绑定手机号" size="large"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="实名人" prop="real_name_owner" style="margin-bottom: 0;">
<el-input v-model="formData.real_name_owner" placeholder="请输入实名人姓名" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账号状态" prop="real_name_owner_status" style="margin-bottom: 0;">
<el-select v-model="formData.real_name_owner_status" size="large" style="width: 100%;">
<el-option label="正常" :value="1"></el-option>
<el-option label="异常 / 封号" :value="0"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<div style="background: #f9fafb; border-radius: 12px; padding: 24px; margin-bottom: 0; border: 1px solid #f3f4f6;">
<h4 style="margin: 0 0 20px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0;">设备与归属</h4>
<el-form-item label="账号位置" prop="account_location" style="margin-bottom: 16px;">
<el-input v-model="formData.account_location" placeholder="例如:深圳南山区科技园" size="large"></el-input>
</el-form-item>
<el-form-item label="手机图片链接" prop="phone_image" style="margin-bottom: 0;">
<el-input v-model="formData.phone_image" placeholder="请输入 HTTPS 图片链接" size="large"></el-input>
<div v-if="formData.phone_image" style="margin-top: 12px; border-radius: 8px; overflow: hidden; border: 1px solid #e5e7eb; width: fit-content; max-height: 120px;">
<img :src="formData.phone_image" style="height: 120px; object-fit: cover; display: block;" alt="手机图片预览" onerror="this.style.display='none'" />
</div>
</el-form-item>
</div>
</el-form>
</div>
<template #footer>
<div style="padding: 0 40px 32px 40px; display: flex; gap: 16px;">
<button @click="close" style="flex: 1; padding: 12px 0; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 15px; font-weight: 600; color: #374151; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='#ffffff'">
取消
</button>
<button @click="handleSubmit" :disabled="submitting" style="flex: 2; padding: 12px 0; background: #111827; border: 1px solid #111827; border-radius: 10px; font-size: 15px; font-weight: 600; 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)'">
{{ submitting ? '保存中...' : '保存记录' }}
</button>
</div>
</template>
</el-dialog>
`
};
const app = createApp(WechatAddDialog);
app.use(ElementPlus);
app.mount('#wechatDialogRoot');
})();
(function initWechatDetailDrawer() {
const { createApp, ref } = window.Vue;
const ElementPlus = window.ElementPlus;
const WechatDetailDrawer = {
/**
* 代码作用(白话):初始化企微详情抽屉的开关状态和当前记录,让列表点击“查看”后能在右侧展示完整资料;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):查看按钮 -> WechatDetailDrawerAPI.open() -> setup() 状态更新 -> 抽屉模板渲染。
*/
setup() {
const visible = ref(false);
const recordData = ref(null);
/**
* 代码作用(白话):接收外层选中的企微记录并打开详情抽屉,是右侧详情面板的统一入口;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):openDrawer('view') -> WechatDetailDrawerAPI.open() -> open() -> visible/recordData 更新。
*/
function open(record) {
if (!record) {
return;
}
recordData.value = { ...record };
visible.value = true;
}
/**
* 代码作用(白话):关闭当前详情抽屉,只负责收起右侧面板;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):关闭按钮 -> close() -> visible=false。
*/
function close() {
visible.value = false;
}
/**
* 代码作用(白话):从详情抽屉直接跳到编辑弹窗,复用当前记录的编辑能力;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):详情抽屉编辑按钮 -> handleEdit() -> WechatAddDialogAPI.open('edit', recordData)。
*/
function handleEdit() {
if (window.WechatAddDialogAPI && recordData.value) {
window.WechatAddDialogAPI.open('edit', recordData.value);
}
}
window.WechatDetailDrawerAPI = {
open
};
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;">
<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;">
{{ (recordData.realName || recordData.real_name || 'U').charAt(0).toUpperCase() }}
</div>
<div>
<h3 style="margin: 0 0 4px 0; font-size: 24px; font-weight: 700; color: #111827; letter-spacing: 0; display: flex; align-items: center; gap: 10px;">
{{ recordData.realName || recordData.real_name || '未知姓名' }}
<span :style="{ width: '8px', height: '8px', borderRadius: '50%', background: (recordData.ownerStatus === '异常' || recordData.real_name_owner_status === 0) ? '#ef4444' : '#22c55e' }"></span>
</h3>
<div style="font-size: 14px; color: #6b7280;">
{{ recordData.account || '-' }} &nbsp;&middot;&nbsp; {{ recordData.department || recordData.department_name || '未分配部门' }}
</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>
<div style="flex: 1; overflow-y: auto; padding: 0 32px 40px 32px; display: flex; flex-direction: column; gap: 40px;">
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0;">基础信息</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.alias || recordData.alias_name || '-' }}</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.gender || '-' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">微信 ID</span>
<span style="font-size: 15px; font-weight: 500; color: #111827; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;">{{ recordData.wxId || recordData.account || '-' }}</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.ownerStatus || recordData.status || '正常' }}</span>
</div>
</div>
</section>
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0;">实名与设备</h4>
<div style="display: grid; grid-template-columns: 1fr; gap: 24px;">
<div style="display: grid; grid-template-columns: 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.phone || recordData.phone_number || '-' }}</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.owner || recordData.real_name_owner || '-' }}</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.account_location || recordData.accountLocation || recordData['账号位置'] || '-' }}</span>
</div>
<div v-if="recordData.phone_image || recordData.phoneImage || recordData['手机图片']" style="display: flex; flex-direction: column; gap: 12px; margin-top: 8px;">
<span style="font-size: 13px; color: #6b7280;">手机图片</span>
<div style="border-radius: 12px; overflow: hidden; border: 1px solid #e5e7eb;">
<el-image
:src="recordData.phone_image || recordData.phoneImage || recordData['手机图片']"
:preview-src-list="[recordData.phone_image || recordData.phoneImage || recordData['手机图片']]"
fit="cover"
style="width: 100%; display: block;"
></el-image>
</div>
</div>
</div>
</section>
</div>
<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(WechatDetailDrawer);
app.use(ElementPlus);
app.mount('#wechatDetailDrawerRoot');
})();
(function attachWechatListRuntime() {
const WECHAT_TABLE_COLUMN_WIDTHS = [
'48px',
'140px',
'140px',
'140px',
'140px',
'92px',
'132px',
'132px',
'180px',
'120px',
'156px',
'216px'
];
const WECHAT_TABLE_MIN_WIDTH = '1636px';
const WechatListTable = {
name: 'WechatListTable',
props: {
wechatText: { type: Object, required: true },
wechatTablePageSizes: { type: Array, required: true },
mountState: { type: Object, required: true },
globalState: { type: Object, required: true },
loadWechatRows: { type: Function, required: true },
openAddDialog: { type: Function, required: true },
openDrawer: { type: Function, required: true },
removeRecordImage: { type: Function, required: true },
getVisiblePageNumbers: { type: Function, required: true },
getWechatStatusDotClass: { type: Function, required: true },
notifyError: { type: Function, required: true }
},
/**
* 代码作用(白话):初始化企微列表页的分页、筛选、选中态和动作入口,让后端真实数据能变成当前页面的可交互表格;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime('wechat') -> mountWechatListComponent() -> WechatListTable.setup() -> 列表渲染与按钮交互。
*/
setup(props) {
const { computed, ref } = window.Vue;
const currentPage = ref(props.mountState.currentPage || 1);
const pageSize = ref(props.mountState.pageSize || props.wechatTablePageSizes[0]);
const selectedRecordId = ref(props.globalState.selectedBySource.wechat || null);
const checkedIds = ref([].concat(props.mountState.selectedIds || []));
const filterDate = ref('');
const searchQuery = ref('');
const filterStatus = ref('all');
const filterDepartment = ref('all');
const filterProject = ref('all');
const allRecordsCache = ref([]);
const loading = ref(false);
/**
* 代码作用(白话):把列表里可能为空的字段统一转换成稳定显示文案,避免扩列后出现大片空白;关联文件:F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):record 字段 -> displayValue() -> 列表单元格文本。
*/
function displayValue(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
}
/**
* 代码作用(白话):从一条企微记录里读出手机图片链接,给缩略图、放大预览和删除动作共用;关联文件:F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):record -> getPhoneImageUrl() -> 图片单元格/删除图片动作。
*/
function getPhoneImageUrl(record) {
return record.phone_image || record.phoneImage || record['手机图片'] || '';
}
/**
* 代码作用(白话):把分页和勾选状态同步回外层挂载状态,保证路由切走再切回来时还能恢复现场;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):列表交互 -> syncMountState() -> mountState -> 下次重新挂载复用。
*/
function syncMountState() {
props.mountState.currentPage = currentPage.value;
props.mountState.pageSize = pageSize.value;
props.mountState.selectedIds = [].concat(checkedIds.value);
}
/**
* 代码作用(白话):从后端拉取企微资料列表并缓存到当前组件,给筛选、分页和详情入口复用;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):页面挂载/保存成功刷新 -> loadAllRecords() -> loadWechatRows() -> allRecordsCache -> 列表渲染。
*/
async function loadAllRecords() {
loading.value = true;
try {
const rows = await props.loadWechatRows();
allRecordsCache.value = Array.isArray(rows) ? rows : [];
if (allRecordsCache.value.length && !allRecordsCache.value.some((item) => item.id === selectedRecordId.value)) {
selectedRecordId.value = allRecordsCache.value[0].id;
props.globalState.selectedBySource.wechat = selectedRecordId.value;
}
} catch (error) {
allRecordsCache.value = [];
props.notifyError(error.message || props.wechatText.loadFailed);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):按状态、部门、项目、日期和搜索词过滤企微列表,让页面仍保留现有的本地筛选体验;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/shared/record-adapters.js;关联逻辑(调用链/消息链/数据流):filter v-model -> buildFilteredRecords() -> visibleRows -> 表格模板显示。
*/
function buildFilteredRecords() {
let result = allRecordsCache.value;
if (filterStatus.value !== 'all') {
result = result.filter((record) => record.status === filterStatus.value);
}
if (filterDepartment.value !== 'all') {
result = result.filter((record) => record.department === filterDepartment.value);
}
if (filterProject.value !== 'all') {
result = result.filter((record) => (record.project || record.remark || '暂无备注') === filterProject.value);
}
if (filterDate.value && filterDate.value.length === 2) {
const start = new Date(filterDate.value[0]).getTime();
const end = new Date(filterDate.value[1]).getTime() + 86400000;
result = result.filter((record) => {
if (!record.updatedAt) {
return false;
}
const updatedAt = new Date(record.updatedAt).getTime();
return updatedAt >= start && updatedAt < end;
});
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter((record) =>
(record.realName && record.realName.toLowerCase().includes(query)) ||
(record.account && record.account.toLowerCase().includes(query)) ||
(record.alias_name && record.alias_name.toLowerCase().includes(query)) ||
(record.phone && record.phone.toLowerCase().includes(query)) ||
(record.wxId && record.wxId.toLowerCase().includes(query)) ||
(record.department && record.department.toLowerCase().includes(query)) ||
(record.real_name_owner && record.real_name_owner.toLowerCase().includes(query))
);
}
return result;
}
/**
* 代码作用(白话):从当前全量列表中提取部门选项,避免写死部门值导致后端真实数据筛不出来;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):allRecordsCache -> buildDepartmentOptions() -> 部门下拉框 option 渲染。
*/
function buildDepartmentOptions() {
return Array.from(new Set(allRecordsCache.value.map((record) => record.department).filter(Boolean)));
}
/**
* 代码作用(白话):从当前全量列表中提取项目选项,让后端真实备注字段也能参与筛选;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):allRecordsCache -> buildProjectOptions() -> 项目下拉框 option 渲染。
*/
function buildProjectOptions() {
return Array.from(new Set(allRecordsCache.value.map((record) => record.project || record.remark || '暂无备注').filter(Boolean)));
}
/**
* 代码作用(白话):在任一筛选条件变化时把当前页重置回第一页,避免用户停留在旧页码看到空表格;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):筛选控件 v-model 变化 -> handleFilterChange() -> currentPage 重置 -> visibleRows 重新计算。
*/
function handleFilterChange() {
currentPage.value = 1;
syncMountState();
}
/**
* 代码作用(白话):根据筛选后的记录数和每页条数计算总页数,给分页按钮提供边界;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):buildFilteredRecords() -> buildTotalPages() -> getVisiblePageNumbers()。
*/
function buildTotalPages() {
return Math.max(1, Math.ceil(totalFiltered.value / pageSize.value));
}
/**
* 代码作用(白话):把筛选后的记录裁成当前页应该显示的那一段,避免模板自己处理分页切片;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):filteredRecords -> buildVisibleRows() -> tbody 渲染。
*/
function buildVisibleRows() {
const start = (currentPage.value - 1) * pageSize.value;
return filteredRecords.value.slice(start, start + pageSize.value);
}
/**
* 代码作用(白话):向外层通用分页算法要当前应显示的页码按钮,保持企微页和其他模块分页风格一致;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):totalPages/currentPage -> buildPageNumbers() -> 分页按钮模板。
*/
function buildPageNumbers() {
return props.getVisiblePageNumbers(totalPages.value, currentPage.value);
}
/**
* 代码作用(白话):判断当前页记录是不是已经全部勾选,用来控制表头全选框状态;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):visibleRows/checkedIds -> buildAllVisibleChecked() -> 表头 checkbox checked。
*/
function buildAllVisibleChecked() {
if (!visibleRows.value.length) {
return false;
}
return visibleRows.value.every((record) => checkedIds.value.includes(record.id));
}
const filteredRecords = computed(buildFilteredRecords);
const totalFiltered = computed(() => filteredRecords.value.length);
const totalPages = computed(buildTotalPages);
const visibleRows = computed(buildVisibleRows);
const pageNumbers = computed(buildPageNumbers);
const allVisibleChecked = computed(buildAllVisibleChecked);
const departmentOptions = computed(buildDepartmentOptions);
const projectOptions = computed(buildProjectOptions);
window.Vue.watch(
[filterStatus, filterDepartment, filterProject, filterDate, searchQuery],
handleFilterChange
);
/**
* 代码作用(白话):判断某一行是否已被勾选,给行内复选框提供选中状态;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):checkedIds -> isRowChecked() -> 行 checkbox checked。
*/
function isRowChecked(recordId) {
return checkedIds.value.includes(recordId);
}
/**
* 代码作用(白话):判断某一行是不是当前选中的记录,用来高亮当前正在查看的企微资料;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):selectedRecordId -> isRowSelected() -> 表格行高亮。
*/
function isRowSelected(recordId) {
return selectedRecordId.value === recordId;
}
/**
* 代码作用(白话):把状态文字映射到外层约定的圆点颜色 class,保持企微状态展示统一;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):row.status -> getStatusClass() -> status-dot class。
*/
function getStatusClass(status) {
return props.getWechatStatusDotClass(status);
}
/**
* 代码作用(白话):记录当前聚焦的企微行,并同步回外层全局状态,让查看详情和返回列表时仍能记住选中项;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):行点击/操作按钮 -> focusRow() -> globalState.selectedBySource.wechat -> 详情或下次挂载复用。
*/
function focusRow(recordId) {
selectedRecordId.value = recordId;
props.globalState.selectedBySource.wechat = recordId;
syncMountState();
}
/**
* 代码作用(白话):切换单行复选框时,把当前记录加入或移出批量勾选集合;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):行 checkbox change -> toggleRowChecked() -> checkedIds/syncMountState()。
*/
function toggleRowChecked(recordId, checked) {
const nextIds = new Set(checkedIds.value);
if (checked) {
nextIds.add(recordId);
} else {
nextIds.delete(recordId);
}
checkedIds.value = Array.from(nextIds);
syncMountState();
}
/**
* 代码作用(白话):一次性勾选或取消勾选当前页所有记录,给后续批量操作保留统一入口;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):表头 checkbox change -> toggleAllVisible() -> checkedIds/syncMountState()。
*/
function toggleAllVisible(checked) {
const nextIds = new Set(checkedIds.value);
visibleRows.value.forEach((record) => {
if (checked) {
nextIds.add(record.id);
} else {
nextIds.delete(record.id);
}
});
checkedIds.value = Array.from(nextIds);
syncMountState();
}
/**
* 代码作用(白话):切换到指定页码,并把页码限制在合法范围内,避免翻页翻出空页;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):分页按钮 -> goToPage() -> currentPage/syncMountState() -> visibleRows 更新。
*/
function goToPage(pageNumber) {
currentPage.value = Math.min(Math.max(1, pageNumber), totalPages.value);
syncMountState();
}
/**
* 代码作用(白话):处理上一页和下一页按钮,只负责把方向翻译成具体页码;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):上一页/下一页按钮 -> changePage() -> goToPage()。
*/
function changePage(direction) {
if (direction === 'prev') {
goToPage(currentPage.value - 1);
}
if (direction === 'next') {
goToPage(currentPage.value + 1);
}
}
/**
* 代码作用(白话):在用户修改每页条数后回到第一页,避免停留在旧页码导致列表显示为空;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):page size change -> handlePageSizeChange() -> goToPage(1)。
*/
function handlePageSizeChange() {
props.mountState.pageSize = pageSize.value;
goToPage(1);
}
/**
* 代码作用(白话):把分页、筛选和勾选状态恢复成初始值,方便用户重新开始筛选;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑(调用链/消息链/数据流):重置按钮 -> resetTableState() -> 各状态归零 -> syncMountState()。
*/
function resetTableState() {
currentPage.value = 1;
pageSize.value = props.wechatTablePageSizes[0];
checkedIds.value = selectedRecordId.value ? [selectedRecordId.value] : [];
filterStatus.value = 'all';
filterDepartment.value = 'all';
filterProject.value = 'all';
filterDate.value = '';
searchQuery.value = '';
syncMountState();
}
/**
* 代码作用(白话):打开企微新增弹窗,让新增入口也走外层统一装配点;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):新增按钮 -> handleAdd() -> openAddDialog() -> WechatAddDialogAPI.open('add')。
*/
function handleAdd() {
props.openAddDialog('add');
}
/**
* 代码作用(白话):处理“同步数据”按钮,按当前兼容方案重新拉取企微列表,避免在未知后端同步接口上误触发不确定副作用;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):同步数据按钮 -> handleSync() -> loadAllRecords() -> allRecordsCache/visibleRows 刷新。
*/
async function handleSync() {
loading.value = true;
try {
await loadAllRecords();
window.ElementPlus?.ElMessage?.success?.(props.wechatText.syncSuccess);
} catch (error) {
props.notifyError(error.message || props.wechatText.syncFailed);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):统一处理企微列表的查看和编辑动作,把副作用交给外层注入的动作入口;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):操作按钮 -> handleRowAction() -> openDrawer() -> 详情抽屉或编辑弹窗打开。
*/
async function handleRowAction(mode, recordId) {
focusRow(recordId);
loading.value = true;
try {
await props.openDrawer(mode, recordId);
} catch (error) {
props.notifyError(error.message || props.wechatText.openFailed);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):从列表里直接打开当前记录的编辑弹窗,让用户在原有保存流程里更换图片链接;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑(调用链/消息链/数据流):更换图片按钮 -> handleReplaceImage() -> openDrawer('edit') -> 编辑弹窗保存。
*/
async function handleReplaceImage(recordId) {
await handleRowAction('edit', recordId);
}
/**
* 代码作用(白话):从列表里删除手机图片链接并刷新全量记录,保证列表、详情和编辑弹窗看到同一份最新状态;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):删除图片按钮 -> handleRemoveImage() -> removeRecordImage() -> loadAllRecords。
*/
async function handleRemoveImage(recordId) {
focusRow(recordId);
loading.value = true;
try {
await props.removeRecordImage(recordId);
await loadAllRecords();
window.ElementPlus?.ElMessage?.success?.(props.wechatText.imageRemoved);
} catch (error) {
props.notifyError(error.message || props.wechatText.imageRemoveFailed);
} finally {
loading.value = false;
}
}
window.Vue.onMounted(loadAllRecords);
syncMountState();
return {
WECHAT_TABLE_COLUMN_WIDTHS,
WECHAT_TABLE_MIN_WIDTH,
currentPage,
pageSize,
visibleRows,
pageNumbers,
checkedIds,
allVisibleChecked,
departmentOptions,
projectOptions,
isRowChecked,
isRowSelected,
getStatusClass,
focusRow,
toggleRowChecked,
toggleAllVisible,
goToPage,
changePage,
handlePageSizeChange,
resetTableState,
handleAdd,
handleSync,
handleRowAction,
handleReplaceImage,
handleRemoveImage,
displayValue,
getPhoneImageUrl,
filterDate,
searchQuery,
filterStatus,
filterDepartment,
filterProject,
totalFiltered,
totalPages,
loading
};
},
template: `
<div class="table-area table-area-refined">
<div class="table-toolbar table-toolbar-refined">
<div class="toolbar-left">
<div class="toolbar-title-wrap">
<div class="toolbar-title">{{ wechatText.allRecords }} ({{ totalFiltered.toLocaleString('zh-CN') }})</div>
</div>
<div class="filter-selects filter-selects-refined" style="display: flex; align-items: center; gap: 8px;">
<el-select v-model="filterStatus" class="custom-fselect" style="width: 120px; height: 32px;">
<el-option label="状态:全部" value="all"></el-option>
<el-option label="正常" value="正常"></el-option>
<el-option label="异常" value="异常"></el-option>
</el-select>
<el-select v-model="filterDepartment" class="custom-fselect" style="width: 140px; height: 32px;">
<el-option label="部门:全部" value="all"></el-option>
<el-option v-for="department in departmentOptions" :key="department" :label="department" :value="department"></el-option>
</el-select>
<el-select v-model="filterProject" class="custom-fselect" style="width: 160px; height: 32px;">
<el-option label="项目:全部" value="all"></el-option>
<el-option v-for="project in projectOptions" :key="project" :label="project" :value="project"></el-option>
</el-select>
<el-date-picker
v-model="filterDate"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px; height: 32px;"
></el-date-picker>
<el-input
v-model="searchQuery"
:placeholder="wechatText.searchPlaceholder"
clearable
style="width: 260px; height: 32px;"
>
<template #prefix>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</template>
</el-input>
</div>
</div>
<div class="toolbar-right">
<div class="toolbar-primary-actions">
<button class="btn btn-primary" type="button" @click="handleAdd" style="height: 32px; padding: 0 16px;">{{ wechatText.add }}</button>
<button class="btn btn-primary" type="button" @click="handleSync" style="height: 32px; padding: 0 16px;">{{ wechatText.sync }}</button>
</div>
<button class="btn-reset btn-reset-refined" type="button" @click="resetTableState">{{ wechatText.reset }}</button>
</div>
</div>
<div class="table-wrap table-wrap-refined" v-loading="loading">
<table class="xtable domain-table domain-table-refined wechat-list-table" :style="{ minWidth: WECHAT_TABLE_MIN_WIDTH }">
<colgroup>
<col v-for="width in WECHAT_TABLE_COLUMN_WIDTHS" :key="width" :style="{ width }" />
</colgroup>
<thead>
<tr>
<th class="th-selector"><input type="checkbox" :checked="allVisibleChecked" @change="toggleAllVisible($event.target.checked)" /></th>
<th>{{ wechatText.realName }}</th>
<th>{{ wechatText.account }}</th>
<th>{{ wechatText.alias }}</th>
<th>{{ wechatText.department }}</th>
<th>{{ wechatText.gender }}</th>
<th>{{ wechatText.phone }}</th>
<th>{{ wechatText.owner }}</th>
<th>{{ wechatText.accountLocation }}</th>
<th>{{ wechatText.ownerStatus }}</th>
<th>{{ wechatText.phoneImage }}</th>
<th class="th-action">{{ wechatText.action }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ selected: isRowSelected(row.id) }" @click="focusRow(row.id)">
<td class="td-selector"><input type="checkbox" :checked="isRowChecked(row.id)" @click.stop @change="toggleRowChecked(row.id, $event.target.checked)" /></td>
<td class="td-domain">
<a href="#" class="domain-link" @click.prevent="focusRow(row.id)">{{ displayValue(row.realName || row.real_name) }}</a>
</td>
<td>{{ displayValue(row.account || row.wxId) }}</td>
<td>{{ displayValue(row.alias_name || row.alias) }}</td>
<td>
<div class="date-stack">
<span>{{ displayValue(row.department || row.department_name) }}</span>
<div class="cell-sub">{{ displayValue(row.project || row.remark || '暂无备注') }}</div>
</div>
</td>
<td>{{ displayValue(row.gender) }}</td>
<td>{{ displayValue(row.phone || row.phone_number) }}</td>
<td>{{ displayValue(row.owner || row.real_name_owner) }}</td>
<td class="wechat-location-cell">{{ displayValue(row.account_location || row.accountLocation || row['账号位置']) }}</td>
<td>
<span class="status-chip-wrap">
<span class="status-dot" :class="getStatusClass(row.ownerStatus || row.status)"></span>
<span>{{ displayValue(row.ownerStatus || row.status) }}</span>
</span>
</td>
<td>
<div v-if="getPhoneImageUrl(row)" class="wechat-image-cell">
<el-image
class="wechat-image-thumb"
:src="getPhoneImageUrl(row)"
:preview-src-list="[getPhoneImageUrl(row)]"
fit="cover"
preview-teleported
@click.stop
></el-image>
</div>
<span v-else class="wechat-image-empty">{{ wechatText.imageMissing }}</span>
</td>
<td class="td-action">
<div class="action-cluster wechat-action-cluster">
<button class="action-pill action-pill-primary" type="button" @click.stop="handleRowAction('view', row.id)">{{ wechatText.view }}</button>
<button class="action-pill" type="button" @click.stop="handleRowAction('edit', row.id)">{{ wechatText.edit }}</button>
<button class="action-pill" type="button" @click.stop="handleReplaceImage(row.id)">{{ wechatText.replaceImage }}</button>
<button class="action-pill action-pill-danger" type="button" :disabled="!getPhoneImageUrl(row)" @click.stop="handleRemoveImage(row.id)">{{ wechatText.removeImage }}</button>
</div>
</td>
</tr>
<tr v-if="!visibleRows.length">
<td colspan="12" style="padding: 24px; text-align: center; color: #9ca3af;">暂无企微资料</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer table-footer-refined">
<div class="tfoot-left tfoot-left-refined">
<span>{{ wechatText.selected }} {{ checkedIds.length }} {{ wechatText.items }}</span>
<button class="btn-batch btn-batch-refined" type="button">{{ wechatText.batchAction }} &#9662;</button>
</div>
<div class="tfoot-right tfoot-right-refined">
<span>{{ wechatText.total }} {{ totalFiltered.toLocaleString('zh-CN') }} {{ wechatText.records }}</span>
<button class="page-btn outline" type="button" @click="changePage('prev')" :disabled="currentPage === 1">&#8249;</button>
<div class="pagination">
<template v-for="item in pageNumbers" :key="String(item)">
<button v-if="item !== 'ellipsis'" class="page-btn" :class="{ active: currentPage === item }" type="button" @click="goToPage(item)">{{ item }}</button>
<span v-else>...</span>
</template>
</div>
<button class="page-btn outline" type="button" @click="changePage('next')" :disabled="currentPage === totalPages">&#8250;</button>
<select class="page-size" v-model.number="pageSize" @change="handlePageSizeChange">
<option v-for="size in wechatTablePageSizes" :key="size" :value="size">{{ size }}{{ wechatText.pageSuffix }}</option>
</select>
</div>
</div>
</div>
`
};
/**
* 代码作用(白话):把企微列表组件挂载到指定 DOM 根节点,并注入 Element Plus 能力;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime('wechat') -> mountWechatListComponent() -> createApp().mount()。
*/
function mountWechatListComponent(options) {
const { createApp } = window.Vue;
const app = createApp(WechatListTable, options);
if (window.ElementPlus) {
app.use(window.ElementPlus);
}
app.mount(options.mountTarget);
return app;
}
window.WechatListRuntime = {
mountWechatListComponent
};
})();
// Reusable wrapper template that provides the DOM structure required by app-v2.js
const LegacyShellTemplate = `
<div class="legacy-wrapper">
<div id="pageHeader"></div>
<div class="shell-body" id="pageBody">
<!-- Vue runtime roots for other modules will be moved or recreated here -->
<div id="overviewListRoot" style="display:none;"></div>
<div id="wechatListRoot" style="display:none;"></div>
<div id="phoneListRoot" style="display:none;"></div>
</div>
<div id="detailDrawer" class="detail-drawer hidden">
<div class="detail-drawer-head" id="detailShellTitle"></div>
<div class="detail-drawer-body" id="detailBody"></div>
<button class="drawer-close" id="drawerClose">×</button>
</div>
</div>
`;
function createLegacyWrapper(moduleName) {
return {
template: LegacyShellTemplate,
/** 代码作用(白话):路由页面挂载后,把旧版 app-v2 的状态和渲染入口重新接到当前 DOM 上,这样切页后还能沿用原来的页面渲染;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/assets/index.html;关联逻辑:调用链是“RouterView mounted -> updateDOMRefs()/refreshDOMRefs() -> state.activeModule 切换 -> renderModule() -> updateActiveNav()”。 */
mounted() {
const domRefUpdater = window.updateDOMRefs || window.refreshDOMRefs;
if (typeof domRefUpdater === 'function') {
domRefUpdater();
}
if (!window.state) {
return;
}
window.state.activeModule = moduleName;
if (window.renderModule) {
window.renderModule();
}
if (window.updateActiveNav) {
window.updateActiveNav();
}
}
};
}
const OverviewView = createLegacyWrapper('overview');
const DomainView = createLegacyWrapper('domain');
const WechatView = createLegacyWrapper('wechat');
const PhoneView = createLegacyWrapper('phone');
const AlertsView = createLegacyWrapper('alerts');
const routes = [
{ path: '/', redirect: '/overview' },
{ path: '/overview', component: OverviewView },
{ path: '/domain', component: DomainView },
{ path: '/wechat', component: WechatView },
{ path: '/phone-card', component: PhoneView },
{ path: '/alerts', component: AlertsView }
];
const router = window.VueRouter.createRouter({
history: window.VueRouter.createWebHashHistory(),
routes
});
const app = window.Vue.createApp({});
app.use(router);
let routerAppInitialized = false;
/** 代码作用(白话):拦截左侧导航点击,把旧按钮的 `data-module` 映射成 Vue Router 路由地址,避免旧脚本和新路由重复处理一次点击;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/assets/index.html;关联逻辑:消息链是“sidebar click -> handleSidebarRouteClick() -> router.push() -> createLegacyWrapper().mounted()”。 */
function handleSidebarRouteClick(event) {
const button = event.target.closest('.nav-link[data-module]');
if (!button) return;
const mod = button.dataset.module;
let path = `/${mod}`;
if (mod === 'phone') {
path = '/phone-card';
}
router.push(path);
event.preventDefault();
event.stopPropagation();
}
/**
* 代码作用(白话):在所有旧模块加载完成后只绑定一次导航事件并挂载一次 Vue Router,并等待当前 Hash 路由就绪,避免重复挂载或页面初始化过早;关联文件:src/main.js、index.html、app-v2.js;关联逻辑(调用链/消息链/数据流):src/main.js -> window.initRouterApp() -> app.mount() -> router.isReady() -> window.initPage()。
*/
async function initRouterApp() {
if (routerAppInitialized) {
return;
}
const sidebar = document.getElementById('sidebar');
const appRoot = document.getElementById('vue-router-app');
if (!sidebar || !appRoot) {
throw new Error('路由壳层缺失:未找到 sidebar 或 vue-router-app');
}
sidebar.addEventListener('click', handleSidebarRouteClick, true);
app.mount(appRoot);
routerAppInitialized = true;
await router.isReady();
}
window.initRouterApp = initRouterApp;
\ No newline at end of file
@echo off
chcp 65001 >nul
set PORT=8000
set URL=http://localhost:%PORT%
echo ===================================================
echo [Starting Frontend]
echo Frontend URL: %URL%
echo ===================================================
echo.
echo (Press Ctrl+C or close this window to stop frontend)
python -m http.server %PORT%
\ No newline at end of file
@echo off
chcp 65001 >nul
set FRONTEND_PORT=8000
set FRONTEND_URL=http://localhost:%FRONTEND_PORT%
REM Backend API requests use port 8888, matching backend/src/main/resources/application.yml.
set BACKEND_URL=http://localhost:8888
set FRONTEND_SCRIPT=%~dp0start-frontend.bat
REM Use the JDK 17 launcher so one-click startup does not call the legacy JDK path.
set BACKEND_SCRIPT=%~dp0start-backend-jdk17.bat
echo ===================================================
echo [Starting Project]
echo Frontend URL: %FRONTEND_URL%
echo Backend URL: %BACKEND_URL%
echo ===================================================
echo | set /p dummy="%FRONTEND_URL%" | clip
echo [Success] Frontend URL copied to clipboard!
echo.
if exist "%BACKEND_SCRIPT%" (
echo [1/2] Starting backend in a separate window...
start "XYW Backend" "%BACKEND_SCRIPT%"
) else (
echo [Warning] Missing backend launcher: %BACKEND_SCRIPT%
)
echo.
if exist "%FRONTEND_SCRIPT%" (
echo [2/2] Starting frontend in a separate window...
start "XYW Frontend" "%FRONTEND_SCRIPT%"
start "" "%FRONTEND_URL%/assets/#/phone-card"
) else (
echo [Warning] Missing frontend launcher: %FRONTEND_SCRIPT%
)
echo.
echo [Done] Frontend and backend launchers were triggered.
pause
\ No newline at end of file
@echo off
setlocal EnableExtensions EnableDelayedExpansion
chcp 65001 >nul
set "FRONTEND_PORT=8000"
REM Keep this port aligned with backend/src/main/resources/application.yml and start-backend-jdk17.bat.
set "BACKEND_PORT=8888"
echo ===================================================
echo [Stopping Project]
echo Frontend Port: %FRONTEND_PORT%
echo Backend Port: %BACKEND_PORT%
echo ===================================================
echo.
call :stopByPort %FRONTEND_PORT% Frontend
call :stopByPort %BACKEND_PORT% Backend
echo.
pause
exit /b
:stopByPort
REM Purpose: collect every listening PID on the target port and stop them one by one.
REM Related files: start-frontend.bat, start-backend-jdk17.bat.
REM Related flow: stop.bat -> stopByPort -> killProcess -> release port 8000 or 8888.
set "TARGET_PORT=%~1"
set "TARGET_NAME=%~2"
set "FOUND_ANY=0"
for /f "tokens=5" %%a in ('netstat -aon ^| findstr "LISTENING" ^| findstr ":%TARGET_PORT%"') do (
if not defined SEEN_%%a (
set "SEEN_%%a=1"
set "FOUND_ANY=1"
call :killProcess %%a %TARGET_PORT% %TARGET_NAME%
)
)
if "!FOUND_ANY!"=="0" (
echo [Info] %TARGET_NAME% service on port %TARGET_PORT% is not running.
)
exit /b
:killProcess
REM Purpose: stop one PID and print whether stop succeeded or failed.
REM Related file: stop.bat.
REM Related flow: stopByPort -> killProcess -> taskkill -> print result.
set "TARGET_PID=%~1"
set "TARGET_PORT=%~2"
set "TARGET_NAME=%~3"
echo [Found] %TARGET_NAME% service on port %TARGET_PORT% with PID %TARGET_PID%.
taskkill /F /PID %TARGET_PID% >nul 2>&1
if errorlevel 1 (
echo [Error] Failed to stop %TARGET_NAME% service on port %TARGET_PORT% with PID %TARGET_PID%.
) else (
echo [Stopped] %TARGET_NAME% service on port %TARGET_PORT% with PID %TARGET_PID%.
)
exit /b
\ No newline at end of file
/* Import original styles first */
@import url('styles.css');
/* Overrides for right side replication */
.domain-mode .domain-mode .domain-mode .shell-main {
background: #f7f8fa;
padding: 16px 24px;
display: flex;
flex-direction: column;
gap: 16px;
height: 100vh;
overflow-y: auto;
}
.domain-mode .domain-mode .domain-mode .shell-header {
background: transparent;
padding: 0;
border: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title {
font-size: 20px;
font-weight: bold;
color: #111827;
margin: 0;
}
.header-tags {
display: flex;
gap: 8px;
}
.htag {
font-size: 13px;
padding: 4px 10px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.htag-all {
background: #eef2ff;
color: #3b82f6;
}
.htag-normal {
background: #f0fdf4;
color: #22c55e;
}
.htag-risk {
background: #fff7ed;
color: #f97316;
}
.htag-expired {
background: #fef2f2;
color: #ef4444;
}
.htag .num {
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.search-box {
display: flex;
align-items: center;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 6px 12px;
width: 240px;
}
.search-box svg {
width: 16px;
height: 16px;
fill: #9ca3af;
margin-right: 8px;
}
.search-box input {
border: none;
outline: none;
font-size: 13px;
width: 100%;
color: #374151;
}
.btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn-default {
background: #fff;
border-color: #e5e7eb;
color: #374151;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-icon {
padding: 6px 8px;
border-left: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0 6px 6px 0;
}
.btn-primary.main {
border-radius: 6px 0 0 6px;
}
.btn-group {
display: flex;
}
.btn-close-top {
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
padding: 4px;
display: flex;
}
/* Cards */
.cards-row {
display: flex;
gap: 16px;
}
.kcard {
flex: 1;
background: #fff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.kcard-left {
display: flex;
align-items: center;
gap: 16px;
}
.kcard-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.kcard-icon.blue {
background: #eff6ff;
color: #2563eb;
}
.kcard-icon.orange {
background: #fff7ed;
color: #ea580c;
}
.kcard-icon.red {
background: #fef2f2;
color: #dc2626;
}
.kcard-icon svg {
width: 24px;
height: 24px;
fill: currentColor;
}
.kcard-info {
display: flex;
flex-direction: column;
}
.kcard-title {
font-size: 13px;
color: #4b5563;
display: flex;
align-items: center;
gap: 4px;
}
.kcard-title svg {
width: 14px;
height: 14px;
fill: #9ca3af;
}
.kcard-num {
font-size: 28px;
font-weight: bold;
color: #111827;
margin: 4px 0;
}
.kcard-sub {
font-size: 12px;
color: #6b7280;
}
.kcard-chart svg {
width: 80px;
height: 30px;
}
.kcard-chart path.blue {
stroke: #3b82f6;
fill: none;
stroke-width: 2;
}
.kcard-chart path.orange {
stroke: #f97316;
fill: none;
stroke-width: 2;
}
.kcard-chart path.red {
stroke: #ef4444;
fill: none;
stroke-width: 2;
}
/* Table Area */
.table-area {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f3f4f6;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.toolbar-title {
font-weight: 600;
font-size: 14px;
color: #111827;
}
.filter-selects {
display: flex;
gap: 8px;
}
.fselect {
border: 1px solid #e5e7eb;
background: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
color: #4b5563;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.fselect:after {
content: "▼";
color: #9ca3af;
}
/* Custom icon for el-select to match batch action button icon ▼ */
.custom-fselect .el-input__suffix-inner > .el-icon > svg {
display: none !important;
}
.custom-fselect .el-input__suffix-inner > .el-icon::before {
content: "▼";
color: #9ca3af;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.btn-reset {
background: transparent;
border: 1px solid #e5e7eb;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
}
.btn-settings {
background: transparent;
border: 1px solid #e5e7eb;
padding: 6px;
border-radius: 6px;
cursor: pointer;
display: flex;
color: #6b7280;
}
.table-wrap {
width: 100%;
}
.xtable {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 13px;
table-layout: fixed;
}
.xtable th {
background: #fafafa;
padding: 12px 20px;
font-weight: 500;
color: #4b5563;
border-bottom: 1px solid #f3f4f6;
}
.xtable td {
padding: 12px 20px;
border-bottom: 1px solid #f3f4f6;
color: #111827;
}
.xtable tr:hover td {
background: #f9fafb;
}
.xtable tr.selected td {
background: #f0f7ff;
}
.domain-table th,
.domain-table td {
vertical-align: middle;
}
.domain-table th:nth-child(9),
.domain-table td:nth-child(9),
.domain-table th:nth-child(10),
.domain-table td:nth-child(10),
.domain-table th:nth-child(11),
.domain-table td:nth-child(11) {
white-space: nowrap;
}
.domain-table th:nth-child(10) svg {
vertical-align: middle;
}
.domain-table td:nth-child(9),
.domain-table td:nth-child(10) {
overflow: hidden;
text-overflow: ellipsis;
}
.domain-link {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-dot.green {
background: #22c55e;
}
.status-dot.orange {
background: #f97316;
}
.status-dot.red {
background: #ef4444;
}
.risk-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
border: 1px solid transparent;
}
.risk-badge.low {
background: #f0fdf4;
color: #22c55e;
border-color: #bbf7d0;
}
.risk-badge.mid {
background: #fff7ed;
color: #ea580c;
border-color: #fed7aa;
}
.risk-badge.high {
background: #fef2f2;
color: #ef4444;
border-color: #fecaca;
}
.cell-sub {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.cell-sub.green {
color: #22c55e;
}
.action-links a {
color: #2563eb;
text-decoration: none;
margin-right: 12px;
}
.action-links a:hover {
text-decoration: underline;
}
.action-dots {
color: #6b7280;
cursor: pointer;
font-weight: bold;
}
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
}
.tfoot-left {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #4b5563;
}
.btn-batch {
background: #fff;
border: 1px solid #e5e7eb;
padding: 4px 10px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: #2563eb;
}
.tfoot-right {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: #4b5563;
}
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
min-width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
background: transparent;
color: #4b5563;
}
.page-btn.active {
background: #2563eb;
color: #fff;
}
.page-btn.outline {
border-color: #e5e7eb;
}
.page-size {
border: 1px solid #e5e7eb;
padding: 4px 8px;
border-radius: 6px;
background: #fff;
}
/* Recent Follow Up */
.recent-area {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.recent-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.recent-title {
font-weight: 600;
font-size: 15px;
color: #111827;
}
.recent-link {
color: #2563eb;
font-size: 13px;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recent-item {
display: flex;
align-items: center;
font-size: 13px;
}
.r-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2563eb;
margin-right: 12px;
}
.r-time {
color: #6b7280;
width: 140px;
}
.r-tag {
padding: 2px 6px;
border-radius: 4px;
margin-right: 12px;
font-size: 12px;
}
.r-tag.blue {
color: #2563eb;
background: #eff6ff;
}
.r-tag.orange {
color: #ea580c;
background: #fff7ed;
}
.r-tag.red {
color: #ef4444;
background: #fef2f2;
}
.r-tag.green {
color: #22c55e;
background: #f0fdf4;
}
.r-text {
color: #374151;
flex: 1;
}
.r-action {
color: #2563eb;
text-decoration: none;
}
/* Drawer */
.domain-mode .domain-mode .domain-mode .detail-panel {
background: #fff;
width: 400px !important;
max-width: 400px !important;
border-left: 1px solid #e5e7eb;
box-shadow: -4px 0 15px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
}
.drawer-head {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.drawer-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.drawer-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.drawer-status {
padding: 2px 8px;
border-radius: 12px;
background: #f0fdf4;
color: #22c55e;
font-size: 12px;
border: 1px solid #bbf7d0;
}
.drawer-close {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
}
.drawer-close svg {
width: 20px;
height: 20px;
}
.drawer-tabs {
display: flex;
padding: 0 20px;
border-bottom: 1px solid #e5e7eb;
gap: 24px;
}
.dtab {
padding: 10px 0;
font-size: 14px;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.dtab.active {
color: #2563eb;
border-bottom-color: #2563eb;
font-weight: 500;
}
.drawer-body {
padding: 20px;
flex: 1;
overflow-y: auto;
}
.dgrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px 16px;
margin-bottom: 32px;
}
.ditem {
display: flex;
flex-direction: column;
gap: 6px;
}
.ditem.full {
grid-column: 1 / -1;
}
.dlabel {
font-size: 12px;
color: #6b7280;
}
.dvalue {
font-size: 13px;
color: #111827;
display: flex;
align-items: center;
gap: 6px;
}
.d-recent h4 {
font-size: 14px;
font-weight: 600;
margin: 0 0 16px 0;
}
.dtimeline {
display: flex;
flex-direction: column;
gap: 16px;
}
.dtime-item {
display: flex;
gap: 12px;
}
.dtime-dot-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.dtime-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 4px;
}
.dtime-dot.blue {
background: #2563eb;
}
.dtime-dot.green {
background: #22c55e;
}
.dtime-dot.gray {
background: #9ca3af;
}
.dtime-line {
width: 1px;
flex: 1;
background: #e5e7eb;
margin-top: 4px;
}
.dtime-content {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
padding-bottom: 4px;
}
.dtime-row1 {
display: flex;
gap: 12px;
}
.dtime-date {
color: #6b7280;
}
.dtime-tag {
color: #2563eb;
}
.dtime-tag.green {
color: #22c55e;
}
.dtime-tag.gray {
color: #6b7280;
}
.dtime-text {
color: #374151;
}
.drawer-foot {
padding: 16px 20px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-foot-close {
background: #fff;
border: 1px solid #e5e7eb;
padding: 8px 24px;
border-radius: 6px;
font-size: 13px;
color: #374151;
cursor: pointer;
}
.btn-foot-submit {
background: #2563eb;
border: 1px solid #2563eb;
padding: 8px 24px;
border-radius: 6px;
font-size: 13px;
color: #fff;
cursor: pointer;
}
.domain-mode .th-action {
text-align: center;
}
.domain-mode .td-action {
text-align: center;
}
.domain-mode .action-links {
white-space: nowrap;
}
.domain-mode .td-action {
white-space: nowrap;
}
.domain-mode .td-action a,
.domain-mode .td-action .action-dots {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.domain-mode .action-links a {
margin: 0 6px;
}
.domain-mode .action-dots {
margin: 0 6px;
}
/* =========================================================
UI Refinement (Hover, Interaction, Action Buttons)
========================================================= */
/* Table Container Refinement */
.table-area-refined {
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* Table Row Interactions */
.domain-table-refined {
border-collapse: separate;
border-spacing: 0;
}
.domain-table-refined tbody tr {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.domain-table-refined tbody td {
background: #ffffff;
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.domain-table-refined tbody tr td:first-child {
border-left: 3px solid transparent;
}
/* Hover Effect */
.domain-table-refined tbody tr:hover td {
background: #f8fafc;
}
.domain-table-refined tbody tr:hover td:first-child {
border-left-color: #cbd5e1;
}
/* Selected Effect */
.domain-table-refined tbody tr.selected td {
background: #f0f6ff;
}
.domain-table-refined tbody tr.selected td:first-child {
border-left-color: #3b82f6;
}
/* Action Column Buttons */
.action-cluster {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
}
.action-pill {
height: 28px;
border-radius: 6px;
border: none;
background: transparent;
color: #475569;
font-size: 13px;
font-weight: 500;
padding: 0 8px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
}
.action-pill:hover {
background: #f1f5f9;
color: #0f172a;
}
.action-pill-primary {
color: #2563eb;
}
.action-pill-primary:hover {
background: #eff6ff;
color: #1d4ed8;
}
.action-icon {
width: 28px;
height: 28px;
border-radius: 6px;
border: none;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
}
.action-icon:hover {
background: #f1f5f9;
color: #0f172a;
}
.action-icon svg path {
fill: currentColor !important;
}
/* 消除原来的外边距 */
.domain-mode .action-dots {
margin: 0;
}
.wechat-list-table {
table-layout: fixed;
}
.table-wrap-refined {
overflow-x: auto;
overflow-y: hidden;
}
.wechat-location-cell {
white-space: normal;
word-break: break-word;
}
.wechat-image-cell {
display: flex;
align-items: center;
}
.wechat-image-thumb {
width: 84px;
height: 84px;
border-radius: 10px;
overflow: hidden;
border: 1px solid #dbe4f0;
background: #f8fafc;
}
.wechat-image-thumb .el-image__inner {
width: 100%;
height: 100%;
object-fit: cover;
}
.wechat-image-empty {
color: #94a3b8;
font-size: 12px;
}
.wechat-action-cluster {
flex-wrap: wrap;
justify-content: flex-start;
gap: 6px;
}
.action-pill-danger {
color: #dc2626;
}
.action-pill-danger:hover:not(:disabled) {
background: #fef2f2;
color: #b91c1c;
}
.action-pill:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.table-area-refined {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
.table-toolbar-refined {
flex: 0 0 auto;
}
.table-wrap-refined {
flex: 1 1 auto;
min-height: 0;
height: 0;
overflow-x: auto;
overflow-y: auto;
}
#wechatListRoot,
#phoneListRoot {
min-height: 0;
}
#vue-router-app,
.legacy-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
height: 100%;
}
#pageHeader {
flex: 0 0 auto;
}
#pageBody {
display: flex !important;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#wechatListRoot,
#phoneListRoot,
#overviewListRoot {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
#wechatListRoot[style*='display: block'],
#phoneListRoot[style*='display: block'],
#overviewListRoot[style*='display: block'] {
display: flex !important;
flex-direction: column;
}
.table-area-refined {
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.table-wrap-refined {
flex: 1 1 auto !important;
min-height: 0 !important;
height: 0 !important;
overflow-x: auto !important;
overflow-y: auto !important;
}
.toolbar-primary-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Phone card filter toggle buttons and collapsible filter rows */
.phone-toolbar-main {
flex-wrap: nowrap;
}
.phone-toolbar-main .toolbar-left {
flex: 0 0 auto;
min-width: 0;
}
.phone-toolbar-actions {
flex: 1 1 auto;
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
}
.filter-toggle-btn {
flex: 0 0 auto;
height: 32px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
color: #4b5563;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.filter-toggle-btn:hover {
border-color: #93c5fd;
color: #2563eb;
}
.filter-toggle-btn.is-active {
background: #eff6ff;
border-color: #3b82f6;
color: #2563eb;
font-weight: 600;
}
.phone-search-input {
flex: 0 0 220px;
}
.phone-filter-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 12px 20px;
border-bottom: 1px solid #f3f4f6;
background: #fafbfc;
}
.phone-filter-row .el-select,
.phone-filter-row .el-date-editor,
.phone-filter-row .el-input {
margin: 0;
}
.phone-date-range.el-date-editor.el-input__wrapper,
.phone-date-range {
width: 190px !important;
flex: 0 0 190px;
}
.phone-date-range .el-range-input {
min-width: 0 !important;
width: 68px !important;
}
.phone-date-range .el-range-separator {
padding: 0 2px !important;
}
.phone-table-header-nowrap .cell {
white-space: nowrap !important;
text-overflow: clip !important;
padding: 0 8px !important;
}
/* Phone card image column cell: thumbnail + hover preview/delete actions */
.phone-image-cell {
display: flex;
gap: 8px;
align-items: center;
min-height: 52px;
}
.phone-image-thumb {
position: relative;
width: 52px;
height: 52px;
border-radius: 10px;
overflow: hidden;
border: 1px solid #e5e7eb;
background: #f8fafc;
flex: 0 0 auto;
}
.phone-image-thumb--empty {
border: 1px dashed #cbd5e1;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
.phone-image-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.phone-image-fallback {
display: none;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 11px;
color: #BBBFCF !important;
background: #f8fafc;
}
.phone-table-header-nowrap span[title="双击编辑"] {
color: #BBBFCF !important;
}
.phone-image-thumb--empty .phone-image-fallback {
display: flex;
}
.phone-image-actions {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
background: rgba(17, 24, 39, 0.5);
opacity: 0;
transition: opacity 0.18s ease;
}
.phone-image-thumb:hover .phone-image-actions {
opacity: 1;
}
.phone-image-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: #bfbfbf;
cursor: pointer;
line-height: 1;
transition: color 0.15s ease, background 0.15s ease;
}
.phone-image-action svg {
width: 16px;
height: 16px;
display: block;
}
.phone-image-action--preview:hover {
color: #fff;
background: rgba(255, 255, 255, 0.18);
}
.phone-image-action--delete:hover {
color: #ef4444;
background: rgba(255, 255, 255, 0.18);
}
/* Hidden file input used by the phone image cell for double-click upload */
.phone-image-input {
display: none;
}
/* Empty placeholder: hint interactivity on hover and double-click */
.phone-image-thumb--empty {
cursor: pointer;
user-select: none;
}
.phone-image-thumb--empty:hover {
border-color: #BBBFCF;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
}
/* Inline edit cell: input/select used when double-clicking an editable table cell */
.phone-inline-edit-cell {
width: 100%;
min-width: 80px;
}
.phone-inline-edit-cell .el-input__wrapper,
.phone-inline-edit-cell .el-select .el-input__wrapper {
border-color: #BBBFCF !important;
box-shadow: none !important;
background: #ffffff;
}
.phone-inline-edit-cell .el-input__wrapper:hover,
.phone-inline-edit-cell .el-select .el-input__wrapper:hover {
border-color: #9ca3af !important;
}
.phone-inline-edit-cell .el-input__inner {
font-size: 13px;
color: #111827;
}
/* =========================================================
Phone Card Add/Edit Dialog
Fixed header / footer with a scrollable form body.
========================================================= */
.phone-form-dialog-modal .el-overlay-dialog {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 16px;
box-sizing: border-box;
}
.phone-form-dialog-modal .el-dialog {
width: 860px !important;
max-width: calc(100vw - 32px) !important;
height: 750px !important;
max-height: calc(100vh - 32px) !important;
display: flex;
flex-direction: column;
margin: 0 !important;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.14);
}
.phone-form-dialog-modal .el-dialog .el-dialog__header {
position: relative;
flex-shrink: 0;
margin-right: 0;
padding: 20px 56px 16px 24px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.phone-form-dialog-modal .el-dialog .el-dialog__headerbtn {
top: 19px;
right: 16px;
width: 32px;
height: 32px;
}
.phone-form-dialog-modal .el-dialog .el-dialog__close { color: #6b7280; }
.phone-form-dialog-modal .el-dialog .el-dialog__body {
flex: 1;
min-height: 0;
padding: 24px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
background: #fff;
}
.phone-form-dialog-modal .el-dialog .el-dialog__body::-webkit-scrollbar { width: 6px; }
.phone-form-dialog-modal .el-dialog .el-dialog__body::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 999px; }
.phone-form-dialog-modal .el-dialog .el-dialog__footer {
flex-shrink: 0;
padding: 16px 24px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
.phone-form-dialog__title { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.phone-form-dialog__title-text { color: #111827; font-size: 18px; font-weight: 600; line-height: 1.35; }
.phone-form-dialog__title-hint { color: #6b7280; font-size: 13px; line-height: 1.35; }
.phone-form-dialog__section-title { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.phone-form-dialog__section-no { color: #6b7280; font-size: 12px; font-weight: 400; line-height: 1; }
.phone-form-dialog__section-name { color: #111827; font-size: 14px; font-weight: 700; line-height: 1; }
.phone-form-dialog__section-sub { color: #9ca3af; font-size: 12px; line-height: 1; }
.phone-form-dialog__section-tag { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; color: #dc2626; background: #fef2f2; border-radius: 4px; font-size: 11px; font-weight: 600; line-height: 1; }
.phone-form-dialog__divider { height: 1px; margin: 24px 0; background: #e5e7eb; }
.phone-form-dialog__image-slots { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.phone-form-dialog__image-slot { position: relative; min-height: 112px; overflow: hidden; cursor: pointer; background: #fafafa; border: 1px dashed #d1d5db; border-radius: 8px; }
.phone-form-dialog__image-item, .phone-form-dialog__image-empty { position: absolute; inset: 0; display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; border-radius: inherit; }
.phone-form-dialog__image-item { overflow: hidden; }
.phone-form-dialog__image-img { display: block; width: 100%; height: 100%; object-fit: cover; }
.phone-form-dialog__image-empty { flex-direction: column; gap: 8px; color: #6b7280; background: #fafafa; transition: color 0.2s ease, background 0.2s ease; }
.phone-form-dialog__image-slot:hover .phone-form-dialog__image-empty { color: #374151; background: #f9fafb; }
.phone-form-dialog__upload-icon { color: #6b7280; font-size: 23px; font-weight: 400; line-height: 1; }
.phone-form-dialog__image-empty-text, .phone-form-dialog__image-hint { color: #6b7280; font-size: 12px; }
.phone-form-dialog__image-hint { margin-top: 8px; }
.phone-form-dialog__image-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 6px; background: rgba(17, 24, 39, 0.56); opacity: 0; transition: opacity 0.2s ease; }
.phone-form-dialog__image-item:hover .phone-form-dialog__image-overlay { opacity: 1; }
.phone-form-dialog__image-preview-btn, .phone-form-dialog__image-remove { padding: 3px 8px; color: #1f2937; cursor: pointer; background: rgba(255, 255, 255, 0.94); border: 0; border-radius: 4px; font-size: 11px; }
.phone-form-dialog__section--primary .el-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px 24px; margin: 0 0 15px !important; }
.phone-form-dialog__section--primary .el-row:last-child { margin-bottom: 0 !important; }
.phone-form-dialog__section--primary .el-col { width: auto !important; max-width: none !important; padding: 0 !important; }
.phone-form-dialog__section--primary .el-form-item { width: 100%; min-width: 0; margin: 0; align-items: center; }
.phone-form-dialog__section--primary .el-form-item__label { flex: 0 0 104px; height: 36px; padding: 0 14px 0 0; color: #4b5563; font-size: 13px; font-weight: 500; line-height: 36px; text-align: right; box-sizing: border-box; }
.phone-form-dialog__section--primary .el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label::before { margin-right: 4px; color: #ef4444; font-weight: 700; }
.phone-form-dialog__section--primary .el-form-item__content { flex: 1; min-width: 0; margin-left: 0 !important; line-height: 36px; }
.phone-form-dialog__section--primary .el-input, .phone-form-dialog__section--primary .el-select { width: 100%; }
.phone-form-dialog__section--primary .el-input__wrapper, .phone-form-dialog__section--primary .el-select__wrapper { min-height: 36px; padding: 0 11px; background: #fff; border-radius: 6px; box-shadow: 0 0 0 1px #d1d5db inset !important; box-sizing: border-box; }
.phone-form-dialog__section--primary .el-input__wrapper:hover, .phone-form-dialog__section--primary .el-select__wrapper:hover, .phone-form-dialog__section--primary .el-input__wrapper.is-focus, .phone-form-dialog__section--primary .el-select__wrapper.is-focused { box-shadow: 0 0 0 1px #9ca3af inset !important; }
.phone-form-dialog__section--primary .el-input__inner, .phone-form-dialog__section--primary .el-select__selected-item, .phone-form-dialog__section--primary .el-select__placeholder { font-size: 13px; }
.phone-form-dialog__settings-list { border-top: 1px solid #e5e7eb; }
.phone-form-dialog__setting-row { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e5e7eb; }
.phone-form-dialog__setting-name { color: #374151; font-size: 13px; font-weight: 500; }
.phone-form-dialog__switch { display: inline-flex; min-width: 108px; align-items: center; justify-content: flex-end; gap: 10px; }
.phone-form-dialog__switch-status { min-width: 52px; color: #6b7280; font-size: 13px; text-align: right; white-space: nowrap; }
.phone-form-dialog__switch-status.is-on { color: #374151; }
.phone-form-dialog__switch .el-switch { flex: 0 0 auto; }
.phone-form-dialog__footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
.phone-form-dialog__footer .el-button { height: 36px; margin-left: 0 !important; padding: 0 18px; border-radius: 6px; font-size: 14px; font-weight: 500; }
.phone-form-dialog__footer .el-button:not(.phone-form-dialog__submit-btn) { color: #374151; background: #fff; border-color: #d1d5db; }
.phone-form-dialog__submit-btn { padding: 0 22px !important; color: #fff !important; background: #111827 !important; border-color: #111827 !important; box-shadow: none !important; }
.phone-form-dialog__submit-btn:hover { background: #374151 !important; border-color: #374151 !important; transform: none !important; }
@media (max-width: 740px) {
.phone-form-dialog-modal .el-overlay-dialog { padding: 12px; }
.phone-form-dialog-modal .el-dialog { max-width: calc(100vw - 24px) !important; max-height: calc(100vh - 24px) !important; }
.phone-form-dialog-modal .el-dialog .el-dialog__body { padding: 20px 16px; }
.phone-form-dialog__section--primary .el-row { grid-template-columns: minmax(0, 1fr); }
}
\ No newline at end of file
:root {
--page-bg: #f5f7fb;
--surface: #ffffff;
--surface-soft: #fafcff;
--surface-muted: #f3f6fb;
--line: #e7edf6;
--line-strong: #d9e2f2;
--text-main: #1f2a44;
--text-sub: #667085;
--text-soft: #98a2b3;
--brand: #2563eb;
--brand-deep: #1d4ed8;
--brand-soft: rgba(37, 99, 235, 0.08);
--green: #22c55e;
--green-soft: rgba(34, 197, 94, 0.12);
--orange: #ff8a00;
--orange-soft: rgba(255, 138, 0, 0.12);
--red: #ef4444;
--red-soft: rgba(239, 68, 68, 0.11);
--purple: #7c4dff;
--purple-soft: rgba(124, 77, 255, 0.12);
--blue-soft: rgba(37, 99, 235, 0.1);
--nav-bg: linear-gradient(180deg, #071224 0%, #0c1730 54%, #0b162d 100%);
--nav-active: rgba(66, 113, 213, 0.28);
--nav-line: rgba(255, 255, 255, 0.08);
--nav-text: rgba(255, 255, 255, 0.92);
--nav-soft: rgba(255, 255, 255, 0.64);
--shadow-card: 0 10px 30px rgba(13, 44, 100, 0.06);
--shadow-soft: 0 2px 6px rgba(13, 44, 100, 0.05);
--radius-xl: 18px;
--radius-lg: 14px;
--radius-md: 12px;
--radius-sm: 10px;
--sidebar-width: 238px;
--sidebar-collapsed-width: 72px;
--detail-width: 338px;
--font-ui: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
min-height: 100%;
overflow: hidden;
background: var(--page-bg);
color: var(--text-main);
font-family: var(--font-ui);
}
body {
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
.workspace {
display: flex;
align-items: stretch;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: var(--page-bg);
}
.workspace.sidebar-collapsed {
align-items: stretch;
}
.shell-nav {
display: flex;
flex-direction: column;
gap: 24px;
min-height: 100vh;
padding: 22px 12px 18px;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
max-width: var(--sidebar-width);
box-sizing: border-box;
flex-shrink: 0;
overflow: hidden;
background: var(--nav-bg);
color: var(--nav-text);
}
.brand-panel {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 12px;
min-height: 40px;
padding: 2px 4px 10px;
box-sizing: border-box;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: linear-gradient(180deg, #2e8fff 0%, #2453eb 100%);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.28);
}
.brand-badge {
display: block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 5px 5px 9px 9px;
position: relative;
}
.brand-badge::before {
content: "";
position: absolute;
top: 2px;
left: 4px;
width: 4px;
height: 7px;
border-right: 2px solid rgba(255, 255, 255, 0.92);
border-bottom: 2px solid rgba(255, 255, 255, 0.92);
transform: rotate(40deg);
}
.brand-copy {
min-width: 0;
}
.brand-copy h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.25;
color: #fff;
}
.nav-settings,
.nav-toggle-button,
.detail-close,
.icon-button,
.pagination-button,
.view-mode-button,
.action-square {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: transparent;
}
.nav-main {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.primary-nav {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
min-width: 0;
}
.nav-link,
.nav-toggle-button {
display: flex;
align-items: center;
width: 100%;
min-height: 48px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 14px;
box-sizing: border-box;
color: var(--nav-text);
background: transparent;
text-align: left;
transition: background 0.2s ease, border-color 0.2s ease;
}
.nav-link {
gap: 12px;
}
.nav-link:hover,
.nav-link.is-active,
.nav-toggle-button:hover {
background: var(--nav-active);
border-color: rgba(122, 163, 255, 0.16);
}
.nav-glyph,
.nav-toggle-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
min-width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.94);
line-height: 1;
text-align: center;
flex-shrink: 0;
}
.nav-glyph {
font-size: 18px;
}
.nav-toggle-button {
justify-content: flex-start;
gap: 6px;
}
.nav-toggle-glyph span {
display: block;
width: 8px;
height: 8px;
border-top: 1.8px solid #fff;
border-left: 1.8px solid #fff;
}
.nav-toggle-glyph span:first-child {
transform: rotate(-45deg) translate(1px, 1px);
}
.nav-toggle-glyph span:last-child {
margin-left: -3px;
transform: rotate(-45deg) translate(1px, 1px);
}
.nav-label,
.nav-toggle-label {
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
.nav-bottom {
display: flex;
flex-direction: column;
gap: 18px;
flex-shrink: 0;
margin-top: auto;
}
.nav-toggle-wrap {
padding-top: 18px;
border-top: 1px solid var(--nav-line);
}
.nav-footer {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 24px;
align-items: center;
gap: 12px;
}
.nav-user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(180deg, #d8e3ff 0%, #eef3ff 100%);
color: #3c4a63;
font-size: 13px;
}
.nav-user-copy p {
margin: 0;
}
.nav-user-name {
font-size: 15px;
font-weight: 700;
color: #fff;
}
.nav-user-role {
margin-top: 3px;
font-size: 12px;
color: var(--nav-soft);
}
.nav-settings {
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
}
.shell-main {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #fff;
}
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 24px 14px;
border-bottom: 1px solid var(--line);
}
.page-title-block {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.page-title-block h2 {
margin: 0;
font-size: 24px;
line-height: 1.2;
font-weight: 700;
color: #101828;
}
.page-title-block p {
margin: 0;
color: var(--text-sub);
font-size: 13px;
}
.header-tools {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.header-search {
display: flex;
align-items: center;
gap: 10px;
min-width: 430px;
height: 44px;
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: #fff;
color: var(--text-soft);
}
.header-search::before {
content: "\1F50D";
font-size: 14px;
}
.header-search input {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--text-main);
}
.header-shortcut {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 24px;
padding: 0 8px;
border-radius: 8px;
background: var(--surface-muted);
color: var(--text-sub);
font-size: 12px;
font-weight: 700;
}
.status-pill,
.status-chip,
.metric-link,
.info-chip,
.small-note {
display: inline-flex;
align-items: center;
}
.status-pill {
gap: 8px;
height: 38px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
font-size: 13px;
color: var(--text-sub);
white-space: nowrap;
}
.status-pill::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
.status-pill.sync::before,
.status-pill.info::before {
display: none;
}
.status-pill strong {
color: var(--brand-deep);
}
.header-button,
.header-button-primary,
.header-button-muted,
.footer-button,
.footer-button-primary,
.filter-button,
.filter-button-primary,
.toolbar-button,
.toolbar-button-primary,
.batch-button,
.batch-button-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.header-button,
.header-button-muted,
.footer-button,
.filter-button,
.toolbar-button,
.batch-button {
border: 1px solid var(--line-strong);
background: #fff;
color: var(--text-main);
}
.header-button-primary,
.footer-button-primary,
.filter-button-primary,
.toolbar-button-primary,
.batch-button-primary {
border: 1px solid var(--brand);
background: linear-gradient(180deg, #3177ff 0%, #2462eb 100%);
color: #fff;
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18);
}
.shell-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
min-height: 0;
overflow-y: auto;
padding: 18px 24px 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.stats-grid.overview {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card {
position: relative;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 86px;
align-items: start;
gap: 16px;
min-height: 140px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(180deg, #fff 0%, #fbfdff 100%);
box-shadow: var(--shadow-soft);
}
.metric-card[data-tone="blue"] {
border-color: rgba(59, 130, 246, 0.24);
background: linear-gradient(180deg, rgba(240, 247, 255, 0.88) 0%, #ffffff 100%);
}
.metric-card[data-tone="green"] {
border-color: rgba(34, 197, 94, 0.22);
background: linear-gradient(180deg, rgba(242, 252, 246, 0.96) 0%, #ffffff 100%);
}
.metric-card[data-tone="orange"] {
border-color: rgba(255, 138, 0, 0.22);
background: linear-gradient(180deg, rgba(255, 247, 239, 0.96) 0%, #ffffff 100%);
}
.metric-card[data-tone="red"] {
border-color: rgba(239, 68, 68, 0.22);
background: linear-gradient(180deg, rgba(255, 243, 243, 0.98) 0%, #ffffff 100%);
}
.metric-card[data-tone="purple"] {
border-color: rgba(124, 77, 255, 0.2);
background: linear-gradient(180deg, rgba(247, 243, 255, 0.98) 0%, #ffffff 100%);
}
.metric-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
font-size: 24px;
background: #eef4ff;
color: var(--brand);
}
.metric-card[data-tone="green"] .metric-icon {
background: #e9f9ef;
color: var(--green);
}
.metric-card[data-tone="orange"] .metric-icon {
background: #fff2e4;
color: var(--orange);
}
.metric-card[data-tone="red"] .metric-icon {
background: #ffeaeb;
color: var(--red);
}
.metric-card[data-tone="purple"] .metric-icon {
background: #f3efff;
color: var(--purple);
}
.metric-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.metric-title {
font-size: 14px;
font-weight: 700;
color: #24324a;
}
.metric-value {
font-size: 20px;
font-weight: 700;
color: #101828;
}
.metric-number {
display: block;
margin-top: 6px;
font-size: 22px;
line-height: 1;
font-weight: 800;
}
.metric-number.large {
font-size: 44px;
color: var(--brand);
}
.metric-card[data-tone="orange"] .metric-number.large {
color: var(--orange);
}
.metric-card[data-tone="green"] .metric-number.large {
color: var(--green);
}
.metric-card[data-tone="red"] .metric-number.large,
.metric-card[data-tone="purple"] .metric-number.large {
color: var(--red);
}
.metric-sub {
font-size: 13px;
color: var(--text-sub);
}
.metric-link {
align-self: end;
justify-self: end;
font-size: 13px;
font-weight: 700;
color: var(--brand);
text-decoration: none;
}
.metric-card svg {
align-self: end;
width: 80px;
height: 34px;
}
.metric-card path {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.metric-card[data-tone="blue"] path {
stroke: #2563eb;
}
.metric-card[data-tone="green"] path {
stroke: #22c55e;
}
.metric-card[data-tone="orange"] path {
stroke: #ff8a00;
}
.metric-card[data-tone="red"] path,
.metric-card[data-tone="purple"] path {
stroke: #ff5d5d;
}
.panel-card,
.table-card,
.preview-card,
.filter-card,
.empty-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow-card);
}
.panel-card,
.filter-card,
.table-card,
.empty-card {
padding: 16px 18px;
}
.card-tabs {
display: flex;
align-items: center;
gap: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.card-tab {
position: relative;
padding: 0 0 10px;
border: 0;
background: transparent;
color: var(--text-sub);
font-size: 16px;
font-weight: 600;
}
.card-tab.is-active {
color: var(--brand);
}
.card-tab.is-active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -13px;
height: 3px;
border-radius: 999px;
background: var(--brand);
}
.filter-row,
.filter-row-split,
.toolbar-row,
.table-footer,
.preview-footer,
.preview-head,
.filter-actions {
display: flex;
align-items: center;
gap: 12px;
}
.filter-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.filter-row {
flex-wrap: wrap;
}
.filter-field,
.filter-search,
.filter-date,
.fake-select,
.fake-input {
display: flex;
align-items: center;
height: 38px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: #fff;
color: var(--text-sub);
}
.filter-field,
.fake-select,
.fake-input {
padding: 0 12px;
}
.filter-field.small {
min-width: 112px;
}
.filter-field.medium {
min-width: 132px;
}
.filter-field.large {
min-width: 196px;
}
.filter-field.xlarge,
.filter-search {
min-width: 240px;
}
.filter-date {
min-width: 286px;
justify-content: space-between;
padding: 0 12px;
}
.filter-search::before {
content: "\1F50D";
margin-left: 12px;
color: var(--text-soft);
}
.filter-search input,
.fake-input input {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--text-main);
}
.filter-search input {
padding: 0 12px 0 10px;
}
.filter-label {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.filter-label span {
font-size: 13px;
font-weight: 600;
color: #344054;
}
.section-head,
.table-head,
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title,
.preview-title,
.detail-section-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1d2939;
}
.section-kicker,
.preview-kicker,
.detail-note {
margin: 0;
color: var(--text-sub);
font-size: 13px;
}
.toolbar-row {
justify-content: space-between;
margin-top: 14px;
}
.toolbar-left,
.toolbar-right,
.footer-left,
.footer-right,
.preview-stats,
.batch-left,
.batch-right {
display: flex;
align-items: center;
gap: 10px;
}
.selected-pill {
display: inline-flex;
align-items: center;
height: 34px;
padding: 0 12px;
border-radius: 10px;
background: rgba(37, 99, 235, 0.1);
color: var(--brand);
font-size: 13px;
font-weight: 700;
}
.action-square,
.view-mode-button,
.icon-button {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: #fff;
color: var(--text-sub);
}
.view-mode-button.is-active {
color: var(--brand);
border-color: rgba(37, 99, 235, 0.22);
background: rgba(37, 99, 235, 0.05);
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin-top: 14px;
font-size: 13px;
}
.data-table thead th {
padding: 12px 14px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #fbfcff;
color: #475467;
font-weight: 700;
text-align: left;
white-space: nowrap;
}
.data-table thead th:first-child {
border-left: 1px solid var(--line);
border-top-left-radius: 12px;
}
.data-table thead th:last-child {
border-right: 1px solid var(--line);
border-top-right-radius: 12px;
}
.data-table tbody td {
padding: 11px 14px;
border-bottom: 1px solid var(--line);
color: #344054;
background: #fff;
vertical-align: middle;
}
.data-table tbody tr td:first-child {
border-left: 1px solid var(--line);
}
.data-table tbody tr td:last-child {
border-right: 1px solid var(--line);
}
.data-table tbody tr:last-child td:first-child {
border-bottom-left-radius: 12px;
}
.data-table tbody tr:last-child td:last-child {
border-bottom-right-radius: 12px;
}
.data-table tbody tr.is-selected td {
background: #f7fbff;
box-shadow: inset 0 1px 0 rgba(37, 99, 235, 0.48), inset 0 -1px 0 rgba(37, 99, 235, 0.48);
}
.data-table tbody tr.is-selected td:first-child {
box-shadow: inset 1px 0 0 rgba(37, 99, 235, 0.48), inset 0 1px 0 rgba(37, 99, 235, 0.48), inset 0 -1px 0 rgba(37, 99, 235, 0.48);
}
.data-table tbody tr.is-selected td:last-child {
box-shadow: inset -1px 0 0 rgba(37, 99, 235, 0.48), inset 0 1px 0 rgba(37, 99, 235, 0.48), inset 0 -1px 0 rgba(37, 99, 235, 0.48);
}
.checkbox-cell {
width: 16px;
height: 16px;
border: 1.6px solid #d0d5dd;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
}
.checkbox-cell.checked {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.checkbox-cell.checked::before {
content: "\2713";
font-size: 11px;
line-height: 1;
}
.platform-cell,
.name-cell,
.account-cell,
.preview-row,
.detail-top-card,
.detail-mini-meta,
.detail-image-strip,
.related-list,
.log-list,
.phone-links {
display: flex;
align-items: center;
gap: 10px;
}
.platform-logo,
.name-avatar,
.preview-avatar,
.detail-main-avatar,
.detail-number-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.platform-logo {
width: 24px;
height: 24px;
border-radius: 8px;
background: rgba(37, 99, 235, 0.08);
color: var(--brand);
font-size: 11px;
font-weight: 700;
}
.name-avatar,
.preview-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(180deg, #ebf4ff 0%, #dbeafe 100%);
color: #2156d7;
font-size: 12px;
font-weight: 700;
}
.platform-copy,
.name-copy,
.preview-copy,
.timeline-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.platform-name,
.row-primary,
.preview-name {
color: #344054;
font-weight: 600;
}
.link-text {
color: var(--brand);
font-weight: 600;
}
.row-secondary,
.preview-sub,
.meta-sub,
.timeline-time,
.tag-inline {
color: var(--text-sub);
font-size: 12px;
}
.stack-date {
display: flex;
flex-direction: column;
gap: 2px;
}
.stack-date .warn {
color: var(--orange);
font-size: 12px;
font-weight: 600;
}
.stack-date .danger {
color: var(--red);
font-size: 12px;
font-weight: 600;
}
.status-chip {
gap: 6px;
height: 24px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.status-chip.success {
color: #16a34a;
background: #ecfdf3;
border-color: #b7efcc;
}
.status-chip.warning {
color: #ea7b00;
background: #fff6ea;
border-color: #ffd7ab;
}
.status-chip.danger {
color: #ef4444;
background: #fff2f3;
border-color: #fecdd3;
}
.status-chip.neutral {
color: #4b6bff;
background: #eef3ff;
border-color: #d8e3ff;
}
.action-link,
.more-link {
color: var(--brand);
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.ellipsis {
color: var(--text-soft);
font-weight: 700;
letter-spacing: 2px;
}
.table-footer {
justify-content: space-between;
margin-top: 14px;
color: var(--text-sub);
font-size: 13px;
}
.pagination {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-button,
.pagination-number,
.page-size {
min-width: 28px;
height: 28px;
padding: 0 8px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #fff;
color: #475467;
font-size: 13px;
}
.pagination-number.is-active {
border-color: var(--brand);
background: var(--brand);
color: #fff;
}
.page-size {
min-width: 92px;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.preview-card {
padding: 16px;
}
.preview-badges,
.related-tags,
.detail-tags,
.summary-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.preview-badge,
.info-chip {
height: 24px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
.preview-badge {
background: #f4f6fb;
color: #475467;
}
.preview-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.preview-row {
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid #eef2f7;
}
.preview-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.preview-footer {
justify-content: flex-end;
margin-top: 14px;
}
.empty-card {
min-height: 420px;
display: grid;
place-items: center;
text-align: center;
}
.empty-card h3 {
margin: 0;
font-size: 22px;
}
.empty-card p {
margin: 8px 0 0;
color: var(--text-sub);
}
.detail-panel {
display: flex;
flex: 0 0 var(--detail-width);
flex-direction: column;
width: var(--detail-width);
min-width: var(--detail-width);
max-width: var(--detail-width);
min-height: 0;
overflow: hidden;
background: #fff;
border-left: 1px solid var(--line);
}
.detail-shell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 18px 18px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.detail-shell-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
min-height: 0;
padding: 14px 18px 18px;
overflow-y: auto;
}
.detail-close {
width: 28px;
height: 28px;
border-radius: 50%;
color: #667085;
}
.detail-close:hover {
background: #f5f7fb;
}
.detail-title {
margin: 0;
font-size: 17px;
font-weight: 700;
color: #111827;
}
.detail-subtabs {
display: flex;
gap: 26px;
border-bottom: 1px solid var(--line);
}
.detail-subtab {
position: relative;
padding: 0 0 12px;
border: 0;
background: transparent;
color: var(--text-sub);
font-size: 15px;
font-weight: 700;
}
.detail-subtab.is-active {
color: var(--brand);
}
.detail-subtab.is-active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
border-radius: 99px;
background: var(--brand);
}
.detail-top-card {
align-items: flex-start;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.detail-main-avatar {
width: 46px;
height: 46px;
border-radius: 14px;
background: #edf4ff;
color: var(--brand);
font-size: 24px;
}
.detail-main-avatar.photo {
overflow: hidden;
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
}
.detail-main-avatar.photo::before {
content: "";
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0.02));
}
.detail-title-wrap {
display: grid;
gap: 6px;
min-width: 0;
}
.detail-primary-line,
.detail-meta-line,
.detail-action-row,
.notes-box {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.detail-primary-name {
font-size: 18px;
font-weight: 700;
color: #111827;
}
.detail-meta-line {
color: var(--text-sub);
font-size: 13px;
}
.detail-group {
display: grid;
gap: 14px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
}
.detail-item {
display: grid;
gap: 4px;
}
.detail-item.full {
grid-column: 1 / -1;
}
.detail-item label {
color: var(--text-sub);
font-size: 12px;
font-weight: 600;
}
.detail-item span,
.detail-item p {
margin: 0;
color: #344054;
font-size: 14px;
line-height: 1.45;
}
.detail-divider {
height: 1px;
background: var(--line);
}
.detail-image-strip {
gap: 10px;
flex-wrap: wrap;
}
.image-thumb,
.image-thumb.more {
width: 68px;
height: 58px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: linear-gradient(180deg, #eff4fb 0%, #e4ebf7 100%);
position: relative;
overflow: hidden;
}
.image-thumb.card {
width: 72px;
height: 64px;
}
.image-thumb::before {
content: "";
position: absolute;
inset: 8px;
border-radius: 8px;
background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(37,99,235,0.18));
}
.image-thumb.more {
display: inline-flex;
align-items: center;
justify-content: center;
color: #344054;
font-weight: 700;
}
.related-list,
.phone-links {
flex-direction: column;
align-items: stretch;
}
.related-row,
.phone-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.info-chip {
border: 1px solid transparent;
}
.info-chip.blue {
background: #eef3ff;
color: #3b5bdb;
}
.info-chip.orange {
background: #fff4e8;
color: #ff8a00;
}
.info-chip.green {
background: #ecfdf3;
color: #16a34a;
}
.info-chip.red {
background: #fff1f2;
color: #ef4444;
}
.log-list {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.timeline-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
gap: 12px;
}
.timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
background: #98a2b3;
}
.note-box {
padding: 12px;
border-radius: 10px;
background: #f6f8fc;
color: #475467;
font-size: 13px;
line-height: 1.55;
}
.detail-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.detail-footer .footer-button,
.detail-footer .footer-button-primary {
flex: 1;
}
.hidden {
display: none !important;
}
.workspace.sidebar-collapsed .shell-nav {
width: var(--sidebar-collapsed-width);
min-width: var(--sidebar-collapsed-width);
max-width: var(--sidebar-collapsed-width);
padding: 20px 8px 18px;
}
.workspace.sidebar-collapsed .brand-panel {
grid-template-columns: 32px 0;
justify-content: center;
gap: 0;
padding-left: 0;
padding-right: 0;
}
.workspace.sidebar-collapsed .brand-copy,
.workspace.sidebar-collapsed .nav-label,
.workspace.sidebar-collapsed .nav-toggle-label,
.workspace.sidebar-collapsed .nav-user-copy,
.workspace.sidebar-collapsed .nav-settings {
display: none;
}
.workspace.sidebar-collapsed .nav-link,
.workspace.sidebar-collapsed .nav-toggle-button {
width: 56px;
height: 48px;
min-height: 48px;
justify-content: center;
padding: 0;
gap: 0;
margin: 0 auto;
border-radius: 12px;
}
.workspace.sidebar-collapsed .primary-nav {
align-items: center;
gap: 10px;
}
.workspace.sidebar-collapsed .nav-toggle-wrap {
padding-top: 16px;
}
.workspace.sidebar-collapsed .nav-toggle-glyph {
margin: 0 auto;
}
.workspace.sidebar-collapsed .nav-toggle-glyph span:first-child {
transform: rotate(135deg) translate(1px, 1px);
}
.workspace.sidebar-collapsed .nav-toggle-glyph span:last-child {
margin-left: -3px;
transform: rotate(135deg) translate(1px, 1px);
}
.workspace.sidebar-collapsed .nav-footer {
grid-template-columns: 40px;
justify-content: center;
}
@media (max-width: 1480px) {
.workspace,
.workspace.sidebar-collapsed {
grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr);
}
.detail-panel {
display: none;
}
.shell-nav {
width: var(--sidebar-collapsed-width);
min-width: var(--sidebar-collapsed-width);
max-width: var(--sidebar-collapsed-width);
padding: 20px 8px 18px;
}
.brand-copy,
.nav-label,
.nav-toggle-label,
.nav-user-copy,
.nav-settings {
display: none;
}
.brand-panel {
grid-template-columns: 32px 0;
justify-content: center;
gap: 0;
padding-left: 0;
padding-right: 0;
}
.primary-nav {
align-items: center;
gap: 10px;
}
.nav-link,
.nav-toggle-button {
width: 56px;
height: 48px;
min-height: 48px;
justify-content: center;
padding: 0;
gap: 0;
margin: 0 auto;
border-radius: 12px;
}
.nav-toggle-glyph span:first-child {
transform: rotate(135deg) translate(1px, 1px);
}
.nav-toggle-glyph span:last-child {
margin-left: -3px;
transform: rotate(135deg) translate(1px, 1px);
}
.nav-footer {
grid-template-columns: 40px;
justify-content: center;
}
}
@media (max-width: 1180px) {
.header-tools {
flex-wrap: wrap;
justify-content: flex-end;
}
.header-search {
min-width: 280px;
}
.stats-grid,
.stats-grid.overview,
.preview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 840px) {
.workspace,
.workspace.sidebar-collapsed {
grid-template-columns: 1fr;
}
.shell-nav {
display: none;
}
.shell-header {
flex-direction: column;
align-items: stretch;
}
.header-search {
min-width: 0;
}
.stats-grid,
.stats-grid.overview,
.preview-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.shell-body {
padding-left: 14px;
padding-right: 14px;
}
.shell-header {
padding-left: 14px;
padding-right: 14px;
}
}
/* UI polish override: push the current implementation closer to the approved mockups. */
:root {
--page-bg: #f4f7fb;
--shadow-card: 0 12px 32px rgba(13, 44, 100, 0.07);
--shadow-soft: 0 4px 14px rgba(13, 44, 100, 0.05);
--radius-xl: 20px;
--radius-lg: 16px;
--sidebar-width: 240px;
--sidebar-collapsed-width: 72px;
--detail-width: 344px;
}
.workspace {
transition: none;
}
.shell-nav {
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
transition: width 0.22s ease, min-width 0.22s ease, max-width 0.22s ease, padding 0.22s ease;
}
.brand-panel {
min-height: 42px;
}
.brand-copy h1 {
font-size: 15px;
line-height: 1.3;
}
.nav-link,
.nav-toggle-button {
min-height: 50px;
}
.nav-link:hover,
.nav-link.is-active,
.nav-toggle-button:hover {
border-color: rgba(122, 163, 255, 0.2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.nav-label,
.nav-toggle-label {
font-size: 14px;
}
.workspace.sidebar-collapsed .nav-link:hover,
.workspace.sidebar-collapsed .nav-link.is-active,
.workspace.sidebar-collapsed .nav-toggle-button:hover {
width: 56px;
height: 48px;
}
.shell-main {
background: linear-gradient(180deg, #fefefe 0%, #f8fbff 100%);
}
.shell-header {
min-height: 76px;
padding: 14px 24px;
background: rgba(255, 255, 255, 0.92);
}
.page-title-block h2 {
font-size: 22px;
}
.header-search {
min-width: 436px;
height: 42px;
padding: 0 13px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.02);
}
.status-pill {
height: 40px;
}
.header-button,
.header-button-primary,
.header-button-muted,
.footer-button,
.footer-button-primary,
.filter-button,
.filter-button-primary,
.toolbar-button,
.toolbar-button-primary,
.batch-button,
.batch-button-primary {
height: 42px;
}
.shell-body {
gap: 16px;
padding: 16px 24px 20px;
}
.metric-card {
grid-template-columns: 68px minmax(0, 1fr) 84px;
gap: 14px;
min-height: 152px;
padding: 18px 20px;
}
.metric-icon {
width: 54px;
height: 54px;
font-size: 23px;
}
.metric-title {
line-height: 1.35;
}
.metric-number.large {
font-size: 41px;
}
.metric-sub {
margin-top: 2px;
}
.metric-link {
margin-bottom: 2px;
}
.panel-card,
.filter-card,
.table-card,
.empty-card {
padding: 16px 16px 14px;
}
.card-tabs {
padding: 2px 4px 12px;
}
.card-tab {
font-size: 15px;
}
.filter-stack {
gap: 12px;
}
.filter-field,
.filter-search,
.filter-date,
.fake-select,
.fake-input {
height: 40px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.02);
}
.toolbar-row {
margin-top: 12px;
}
.selected-pill {
height: 36px;
}
.data-table {
margin-top: 12px;
}
.data-table thead th {
padding: 11px 14px;
}
.data-table tbody td {
padding: 12px 14px;
}
.table-footer {
margin-top: 12px;
}
.preview-card {
padding: 16px 16px 14px;
}
.detail-panel {
box-shadow: -10px 0 28px rgba(15, 23, 42, 0.03);
}
.detail-shell-header {
min-height: 62px;
padding: 16px 18px 10px;
}
.detail-shell-body {
gap: 16px;
padding: 16px 18px 18px;
}
.detail-title {
font-size: 16px;
}
.detail-top-card {
padding-bottom: 12px;
}
.detail-main-avatar {
width: 48px;
height: 48px;
}
.detail-primary-name {
font-size: 17px;
}
.detail-group {
gap: 12px;
}
.detail-grid {
gap: 12px 16px;
}
.detail-item span,
.detail-item p {
font-size: 13px;
}
@media (max-width: 1180px) {
.header-search {
min-width: 320px;
}
}
/* Table toolbar + pagination polish for 2026-06-25 list update */
.toolbar-row {
justify-content: flex-end;
}
.toolbar-right-fixed {
justify-content: flex-end;
flex-wrap: wrap;
margin-left: auto;
}
.table-footer {
align-items: center;
flex-wrap: wrap;
row-gap: 12px;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12px;
margin-left: auto;
}
.page-size-group,
.page-nav {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.page-size-button,
.pagination-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: #fff;
color: #475467;
font-size: 13px;
font-weight: 600;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.page-size-button:hover,
.pagination-button:hover:not(:disabled) {
border-color: rgba(37, 99, 235, 0.28);
color: var(--brand);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.page-size-button.is-active {
border-color: rgba(37, 99, 235, 0.22);
background: rgba(37, 99, 235, 0.08);
color: var(--brand);
}
.pagination-button {
min-width: 32px;
padding: 0 10px;
}
.pagination-button:disabled {
opacity: 0.42;
cursor: not-allowed;
box-shadow: none;
}
.pagination-number {
min-width: 32px;
height: 32px;
padding: 0 10px;
border-radius: 10px;
}
.pagination-total {
color: var(--text-sub);
font-size: 13px;
font-weight: 600;
}
.preview-grid,
.preview-grid-full {
grid-template-columns: minmax(0, 1fr);
}
.preview-card-wide {
width: 100%;
}
.preview-card-wide .preview-list {
margin-top: 16px;
}
@media (max-width: 1180px) {
.preview-grid,
.preview-grid-full {
grid-template-columns: 1fr;
}
}
@media (max-width: 840px) {
.toolbar-right-fixed,
.pagination,
.page-size-group,
.page-nav {
width: 100%;
justify-content: flex-start;
}
}
/* Sidebar image icons + brand logo update */
.brand-mark {
overflow: hidden;
background: rgba(255, 255, 255, 0.12);
}
.brand-logo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.nav-glyph {
font-size: 0;
}
.nav-icon {
display: block;
width: 20px;
height: 20px;
object-fit: contain;
}
/* Deep pagination style update based on 2026-06-25 reference */
.pagination-shell {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 14px;
margin-left: auto;
}
.pagination-main {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.12);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.pagination-pages {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pagination-arrow,
.pagination-page-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 10px;
background: #ffffff;
color: #344054;
font-size: 14px;
font-weight: 600;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, opacity 0.18s ease;
}
.pagination-arrow:hover:not(:disabled),
.pagination-page-button:hover {
background: #f8fafc;
border-color: rgba(15, 23, 42, 0.12);
color: #111827;
}
.pagination-page-button.is-active {
background: #111827;
border-color: #111827;
color: #ffffff;
}
.pagination-arrow:disabled {
opacity: 0.26;
cursor: not-allowed;
}
.pagination-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 32px;
color: #98a2b3;
font-size: 13px;
letter-spacing: 1px;
}
.pagination-jump {
display: inline-flex;
align-items: center;
gap: 10px;
color: #111827;
font-size: 14px;
font-weight: 600;
}
.pagination-jump-label {
color: #111827;
}
.pagination-jump-input {
width: 56px;
height: 40px;
padding: 0 10px;
border: 1px solid rgba(15, 23, 42, 0.16);
border-radius: 8px;
background: #ffffff;
color: #111827;
font-size: 14px;
font-weight: 600;
text-align: center;
outline: none;
box-sizing: border-box;
}
.pagination-jump-input:focus {
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.pagination-jump-input::-webkit-outer-spin-button,
.pagination-jump-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.pagination-size-select-wrap {
position: relative;
display: inline-flex;
align-items: center;
min-width: 136px;
height: 48px;
padding: 6px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.12);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.pagination-size-select {
width: 100%;
height: 100%;
padding: 0 38px 0 14px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 12px;
background: #ffffff;
color: #111827;
font-size: 14px;
font-weight: 600;
outline: none;
appearance: none;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.pagination-size-select:hover {
background: #f8fafc;
border-color: rgba(15, 23, 42, 0.16);
}
.pagination-size-select:focus {
border-color: rgba(59, 130, 246, 0.5);
}
.pagination-size-arrow {
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-50%);
color: #667085;
font-size: 12px;
pointer-events: none;
}
@media (max-width: 960px) {
.pagination-shell {
width: 100%;
justify-content: flex-start;
}
.pagination-main {
order: 1;
}
.pagination-jump {
order: 2;
}
.pagination-size-select-wrap {
order: 3;
}
}
@media (max-width: 840px) {
.pagination-shell,
.pagination-main,
.pagination-jump,
.pagination-size-select-wrap {
width: 100%;
}
.pagination-main {
justify-content: center;
}
.pagination-jump {
justify-content: flex-start;
}
.pagination-size-select-wrap {
min-width: 0;
}
}
/* Right workspace recreation: keep sidebar untouched, only restyle shell-main and detail panel. */
.shell-main {
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 28%),
linear-gradient(180deg, #f6f8fc 0%, #eef3f9 100%);
}
.shell-header {
padding: 28px 28px 20px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 251, 255, 0.88) 100%);
}
.workbench-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 28px;
}
.hero-copy {
display: grid;
gap: 12px;
max-width: 760px;
}
.hero-copy-top {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hero-kicker,
.detail-shell-kicker {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #6980a0;
}
.hero-badge {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.1);
color: #2452cf;
font-size: 12px;
font-weight: 700;
}
.page-title {
margin: 0;
font-size: 31px;
line-height: 1.15;
font-weight: 800;
color: #0f172a;
}
.hero-summary,
.preview-summary,
.table-head-note {
margin: 0;
color: #667085;
font-size: 14px;
line-height: 1.6;
}
.hero-pills {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.header-tools {
display: flex;
flex: 0 0 470px;
flex-direction: column;
align-items: stretch;
gap: 14px;
}
.header-search {
min-width: 0;
width: 100%;
height: 50px;
padding: 0 16px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
}
.header-search::before {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #eef4ff;
color: #2452cf;
font-size: 13px;
}
.header-search input {
font-size: 14px;
}
.header-shortcut {
min-width: 42px;
height: 28px;
border-radius: 10px;
background: #f3f6fb;
}
.header-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.header-button,
.header-button-primary,
.toolbar-button,
.toolbar-button-primary,
.footer-button,
.footer-button-primary {
height: 44px;
padding: 0 18px;
border-radius: 14px;
font-weight: 700;
}
.header-button,
.toolbar-button,
.footer-button {
border-color: rgba(148, 163, 184, 0.26);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
}
.header-button-primary,
.toolbar-button-primary,
.footer-button-primary {
border-color: #2563eb;
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.22);
}
.header-button-suffix {
font-size: 12px;
}
.status-pill {
height: 34px;
border-radius: 999px;
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04);
}
.status-chip {
height: 28px;
padding: 0 10px;
border-radius: 999px;
}
.shell-body {
gap: 20px;
padding: 20px 28px 28px;
background: transparent;
}
.stats-grid,
.stats-grid.overview {
gap: 16px;
}
.metric-card-premium {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 196px;
padding: 20px 22px;
border-radius: 22px;
border: 1px solid rgba(226, 232, 240, 0.9);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.metric-card-premium::after {
content: "";
position: absolute;
right: -30px;
top: -36px;
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.38);
}
.metric-card-premium[data-tone="blue"] {
background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%);
}
.metric-card-premium[data-tone="green"] {
background: linear-gradient(180deg, #edfdf3 0%, #ffffff 100%);
}
.metric-card-premium[data-tone="orange"] {
background: linear-gradient(180deg, #fff5ea 0%, #ffffff 100%);
}
.metric-card-premium[data-tone="red"] {
background: linear-gradient(180deg, #fff0f2 0%, #ffffff 100%);
}
.metric-card-top,
.metric-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.metric-icon-shell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.7);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.metric-icon {
width: auto;
height: auto;
background: transparent;
font-size: 24px;
}
.metric-card-premium[data-tone="green"] .metric-icon {
color: #16a34a;
}
.metric-card-premium[data-tone="orange"] .metric-icon {
color: #f97316;
}
.metric-card-premium[data-tone="red"] .metric-icon {
color: #ef4444;
}
.metric-tag,
.toolbar-pill {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.06);
color: #475467;
font-size: 12px;
font-weight: 700;
}
.metric-copy {
gap: 8px;
}
.metric-title {
font-size: 15px;
color: #24324a;
}
.metric-value-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.metric-value {
margin: 0;
font-size: 42px;
line-height: 0.95;
font-weight: 800;
}
.metric-unit {
padding-bottom: 6px;
color: #667085;
font-size: 13px;
font-weight: 700;
}
.metric-sub {
font-size: 14px;
line-height: 1.6;
}
.metric-foot-note,
.metric-foot-link {
font-size: 13px;
font-weight: 700;
}
.metric-foot-note {
color: #667085;
}
.metric-foot-link {
color: #2563eb;
}
.table-card-shell,
.preview-card-shell,
.empty-card-shell {
border-radius: 24px;
border: 1px solid rgba(226, 232, 240, 0.86);
box-shadow: 0 22px 44px rgba(15, 23, 42, 0.07);
background: rgba(255, 255, 255, 0.96);
}
.table-card-shell,
.preview-card-shell {
overflow: hidden;
}
.table-card-shell {
padding: 0;
}
.table-head-v2 {
padding: 22px 22px 18px;
}
.table-head-copy {
display: grid;
gap: 6px;
}
.table-selection-chip {
display: grid;
gap: 4px;
min-width: 220px;
padding: 12px 14px;
border-radius: 18px;
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
border: 1px solid rgba(37, 99, 235, 0.12);
}
.table-selection-label {
color: #6980a0;
font-size: 12px;
font-weight: 700;
}
.table-selection-chip strong {
color: #0f172a;
font-size: 14px;
}
.toolbar-row-v2 {
margin-top: 0;
padding: 0 22px 18px;
justify-content: space-between;
}
.toolbar-left-v2 {
gap: 12px;
}
.toolbar-note,
.footer-hint {
color: #667085;
font-size: 13px;
}
.table-scroll-shell {
padding: 0 18px;
}
.data-table {
margin-top: 0;
font-size: 13px;
}
.data-table thead th {
background: #f7f9fd;
color: #526177;
}
.data-table tbody tr {
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.data-table tbody tr:hover td {
background: #fafcff;
}
.data-table tbody tr.is-selected td {
background: #f2f7ff;
box-shadow: inset 0 1px 0 rgba(37, 99, 235, 0.4), inset 0 -1px 0 rgba(37, 99, 235, 0.4);
}
.table-footer-v2 {
padding: 18px 22px 22px;
border-top: 1px solid rgba(226, 232, 240, 0.72);
}
.footer-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.footer-total {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.pagination-shell {
gap: 12px;
}
.pagination-main,
.pagination-size-select-wrap {
border-radius: 18px;
border-color: rgba(148, 163, 184, 0.22);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
}
.pagination-page-button.is-active {
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.18);
}
.pagination-jump-input,
.pagination-size-select {
font-weight: 700;
}
.preview-card-shell {
padding: 0;
}
.preview-head-v2 {
padding: 22px 22px 16px;
border-bottom: 1px solid rgba(226, 232, 240, 0.72);
}
.preview-status-wrap {
display: inline-flex;
align-items: center;
}
.preview-list-v2 {
display: grid;
gap: 12px;
padding: 14px 22px 22px;
}
.preview-row-v2 {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(226, 232, 240, 0.8);
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
}
.preview-row-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #eef4ff;
color: #2452cf;
font-size: 13px;
font-weight: 800;
}
.detail-panel {
width: 372px;
min-width: 372px;
max-width: 372px;
border-left: 1px solid rgba(226, 232, 240, 0.9);
background: linear-gradient(180deg, #fbfdff 0%, #f3f7fc 100%);
box-shadow: -18px 0 36px rgba(15, 23, 42, 0.05);
}
.detail-shell-header {
min-height: 82px;
padding: 22px 22px 14px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
}
.detail-shell-title-wrap {
display: grid;
gap: 6px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #0f172a;
}
.detail-shell-body {
padding: 18px 20px 22px;
gap: 18px;
}
.detail-group-shell {
gap: 18px;
}
.detail-top-card-v2 {
align-items: flex-start;
padding: 18px;
border-radius: 22px;
border: 1px solid rgba(37, 99, 235, 0.1);
background: linear-gradient(180deg, #ffffff 0%, #f3f8ff 100%);
}
.detail-main-avatar {
width: 54px;
height: 54px;
border-radius: 18px;
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
color: #fff;
font-size: 24px;
font-weight: 800;
}
.detail-primary-name {
font-size: 18px;
}
.detail-summary-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-section-shell {
padding: 16px;
border-radius: 20px;
border: 1px solid rgba(226, 232, 240, 0.86);
background: rgba(255, 255, 255, 0.92);
}
.detail-section-head {
display: grid;
gap: 4px;
margin-bottom: 14px;
}
.detail-grid {
gap: 12px;
}
.detail-item {
padding: 12px 13px;
border-radius: 16px;
background: #f8fafc;
}
.detail-item label {
color: #6980a0;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.detail-item span,
.detail-item p {
font-size: 13px;
line-height: 1.55;
}
.log-list {
gap: 12px;
}
.timeline-row {
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(226, 232, 240, 0.82);
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.timeline-dot {
width: 10px;
height: 10px;
margin-top: 5px;
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
}
.detail-footer {
margin-top: 2px;
}
.note-box,
.empty-card-shell {
border-radius: 22px;
border: 1px solid rgba(226, 232, 240, 0.82);
background: rgba(255, 255, 255, 0.92);
}
.empty-card-shell {
min-height: 360px;
}
@media (max-width: 1360px) {
.workbench-hero,
.table-head-v2,
.toolbar-row-v2,
.table-footer-v2 {
gap: 16px;
}
.workbench-hero,
.table-head-v2,
.toolbar-row-v2,
.table-footer-v2 {
flex-direction: column;
align-items: stretch;
}
.header-tools {
flex: 1 1 auto;
}
.table-selection-chip {
min-width: 0;
}
}
@media (max-width: 1180px) {
.shell-header,
.shell-body {
padding-left: 18px;
padding-right: 18px;
}
.page-title {
font-size: 28px;
}
.header-search {
min-width: 0;
}
.preview-row-v2 {
grid-template-columns: 32px minmax(0, 1fr);
}
.preview-row-v2 .status-chip {
grid-column: 2;
justify-self: start;
}
}
@media (max-width: 840px) {
.shell-header,
.shell-body {
padding-left: 14px;
padding-right: 14px;
}
.page-title {
font-size: 24px;
}
.metric-card-premium,
.table-selection-chip,
.detail-section-shell,
.preview-row-v2 {
border-radius: 18px;
}
}
/* Professional admin dashboard visual override */
.shell-main { background: linear-gradient(180deg, #f7f9fc 0%, #eff4f9 100%); }
.legacy-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
height: 100%;
}
#pageHeader {
flex: 0 0 auto;
}
#pageBody {
flex: 1 1 auto;
min-height: 0;
}
#pageBody > #wechatListRoot,
#pageBody > #phoneListRoot,
#pageBody > #overviewListRoot {
height: 100%;
min-height: 0;
}
.shell-header { padding: 20px 28px 16px; border-bottom: 1px solid rgba(15, 23, 42, 0.06); background: rgba(255, 255, 255, 0.92); }
.workspace-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; flex-wrap: wrap; min-height: 88px; }
.heading-copy { display: grid; gap: 8px; flex: 1 1 420px; min-width: 0; max-width: 760px; align-content: start; }
.heading-caption-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.heading-caption { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #6b7a90; }
.breadcrumb-nav { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 42px; font-size: 14px; line-height: 1.5; color: #667085; }
.breadcrumb-item { color: #667085; font-weight: 600; }
.breadcrumb-item.current { color: #0f172a; font-weight: 800; }
.breadcrumb-separator { color: #98a2b3; font-weight: 600; }
.heading-badge { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border-radius: 999px; background: #eef4ff; color: #2554cf; font-size: 12px; font-weight: 700; }
.page-title { margin: 0; font-size: 30px; line-height: 1.08; font-weight: 800; color: #0f172a; }
.page-summary { margin: 0; color: #667085; font-size: 14px; line-height: 1.5; }
.admin-summary-pills { display: flex; flex-wrap: wrap; gap: 10px; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border-radius: 999px; border: 1px solid rgba(148, 163, 184, 0.18); background: #fff; box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04); font-size: 13px; color: #475467; }
.status-pill strong { color: #0f172a; }
.status-pill.blue { background: #eef5ff; color: #2554cf; }
.status-pill.green { background: #edfdf3; color: #177245; }
.status-pill.orange { background: #fff6ea; color: #c76a00; }
.status-pill.red { background: #fff1f3; color: #d92d20; }
.header-tools { display: flex; flex: 1 1 360px; min-width: 280px; max-width: 460px; margin-left: auto; flex-direction: column; gap: 14px; }
.header-search { display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; height: 52px; padding: 0 16px; border: 1px solid rgba(148, 163, 184, 0.24); border-radius: 16px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05); }
.header-search::before { content: none; }
.header-search-icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: #eef4ff; color: #2554cf; font-size: 14px; }
.header-search input { font-size: 14px; }
.header-actions { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
.header-button, .header-button-primary, .toolbar-button, .footer-button, .footer-button-primary, .preview-link { height: 42px; padding: 0 16px; border-radius: 14px; font-weight: 700; }
.header-button, .toolbar-button, .footer-button, .preview-link { border: 1px solid rgba(148, 163, 184, 0.2); background: #fff; box-shadow: 0 8px 22px rgba(15, 23, 42, 0.05); }
.header-button-primary, .footer-button-primary { border: 1px solid #2563eb; background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); color: #fff; box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24); }
.shell-body { display: flex; flex: 1 1 auto; flex-direction: column; gap: 16px; min-height: 0; overflow: hidden; padding: 16px 28px 20px; background: transparent; }
.stats-grid { gap: 16px; }
.metric-card-premium { position: relative; display: flex; flex-direction: column; gap: 16px; min-height: 190px; padding: 20px 22px; border-radius: 22px; border: 1px solid rgba(226, 232, 240, 0.92); box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06); overflow: hidden; }
.metric-card-premium[data-tone="blue"] { background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%); }
.metric-card-premium[data-tone="orange"] { background: linear-gradient(180deg, #fff5ea 0%, #ffffff 100%); }
.metric-card-premium[data-tone="red"] { background: linear-gradient(180deg, #fff0f2 0%, #ffffff 100%); }
.metric-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.metric-icon-shell { display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 18px; background: rgba(255, 255, 255, 0.78); }
.metric-icon { font-size: 24px; }
.metric-trend { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border-radius: 999px; background: rgba(15, 23, 42, 0.06); color: #475467; font-size: 12px; font-weight: 700; }
.metric-title { margin: 0; font-size: 15px; color: #24324a; }
.metric-value { margin: 0; font-size: 42px; line-height: 0.95; font-weight: 800; }
.metric-sub { margin: 0; font-size: 14px; line-height: 1.6; color: #667085; }
.metric-line span { display: block; width: 96px; height: 3px; border-radius: 999px; background: rgba(37, 99, 235, 0.25); }
.table-card-shell, .preview-card-shell, .empty-card-shell { border-radius: 24px; border: 1px solid rgba(226, 232, 240, 0.88); background: rgba(255, 255, 255, 0.97); box-shadow: 0 22px 44px rgba(15, 23, 42, 0.07); overflow: hidden; }
.table-head-v3 { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 22px 18px; }
.table-head-copy { display: grid; gap: 6px; }
.section-kicker { margin: 0; color: #6980a0; font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.section-title { margin: 0; font-size: 22px; color: #0f172a; }
.table-head-note { margin: 0; color: #667085; font-size: 14px; line-height: 1.6; }
.table-selection-chip { display: grid; gap: 4px; min-width: 220px; padding: 12px 14px; border-radius: 18px; background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%); border: 1px solid rgba(37, 99, 235, 0.12); }
.table-selection-label { color: #6980a0; font-size: 12px; font-weight: 700; }
.filter-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 22px 18px; }
.filter-group { display: flex; flex-wrap: wrap; gap: 10px; }
.filter-chip { display: inline-flex; align-items: center; gap: 10px; height: 40px; padding: 0 14px; border: 1px solid rgba(148, 163, 184, 0.24); border-radius: 12px; background: #fff; color: #344054; }
.filter-caret { color: #98a2b3; font-size: 12px; }
.filter-actions { display: flex; gap: 10px; }
.table-scroll-shell { padding: 0 18px; }
.data-table { margin-top: 0; font-size: 13px; }
.data-table thead th { background: #f7f9fd; color: #526177; }
.data-table tbody tr { cursor: pointer; transition: transform 0.18s ease, box-shadow 0.18s ease; }
.data-table tbody tr:hover td { background: #fafcff; }
.data-table tbody tr.is-selected td { background: #f2f7ff; box-shadow: inset 0 1px 0 rgba(37, 99, 235, 0.35), inset 0 -1px 0 rgba(37, 99, 235, 0.35); }
.table-check { display: inline-flex; width: 16px; height: 16px; border-radius: 5px; border: 1px solid #cbd5e1; background: #fff; }
.table-check.is-active { border-color: #2563eb; background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); box-shadow: inset 0 0 0 3px #fff; }
.row-link { color: #2452cf; font-weight: 700; }
.row-secondary { color: #98a2b3; font-size: 12px; }
.tag-chip { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 24px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.tag-chip.success { background: #edfdf3; color: #177245; }
.tag-chip.warning { background: #fff6ea; color: #c76a00; }
.tag-chip.danger { background: #fff1f3; color: #d92d20; }
.action-links { display: flex; gap: 8px; }
.action-links button { padding: 0; border: 0; background: transparent; color: #2452cf; font-weight: 700; }
.table-footer-v3 { padding: 18px 22px 22px; border-top: 1px solid rgba(226, 232, 240, 0.72); }
.footer-left { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.footer-total { color: #0f172a; font-size: 14px; font-weight: 700; }
.footer-hint { color: #667085; font-size: 13px; }
.preview-head-v3 { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 22px 16px; border-bottom: 1px solid rgba(226, 232, 240, 0.72); }
.preview-list-v3 { display: grid; gap: 12px; padding: 14px 22px 22px; }
.preview-row-v3 { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 14px 16px; border-radius: 18px; border: 1px solid rgba(226, 232, 240, 0.8); background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%); }
.preview-row-marker { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #eef4ff; color: #2452cf; font-size: 13px; font-weight: 800; }
.preview-copy-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.preview-tag { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.preview-tag.success { background: #edfdf3; color: #177245; }
.preview-tag.warning { background: #fff6ea; color: #c76a00; }
.preview-tag.danger { background: #fff1f3; color: #d92d20; }
.detail-panel { width: 388px; min-width: 388px; max-width: 388px; border-left: 1px solid rgba(226, 232, 240, 0.9); background: linear-gradient(180deg, #fbfdff 0%, #f3f7fc 100%); box-shadow: -18px 0 36px rgba(15, 23, 42, 0.05); }
.detail-shell-header { min-height: 82px; padding: 22px 22px 14px; background: rgba(255, 255, 255, 0.72); backdrop-filter: blur(12px); }
.detail-title { margin: 0; font-size: 20px; font-weight: 800; color: #0f172a; }
.detail-shell-body { padding: 18px 20px 22px; gap: 18px; }
.detail-tab-bar { display: flex; gap: 8px; }
.detail-tab { height: 34px; padding: 0 12px; border: 0; border-radius: 999px; background: #eef2f6; color: #667085; font-weight: 700; }
.detail-tab.is-active { background: #eef4ff; color: #2452cf; }
.detail-top-card-v3 { display: flex; align-items: flex-start; gap: 14px; padding: 18px; border-radius: 22px; border: 1px solid rgba(37, 99, 235, 0.1); background: linear-gradient(180deg, #ffffff 0%, #f3f8ff 100%); }
.detail-main-avatar { display: inline-flex; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 18px; background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%); color: #fff; font-size: 24px; font-weight: 800; }
.detail-primary-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.detail-primary-name { font-size: 18px; font-weight: 800; color: #0f172a; }
.detail-meta-line { color: #667085; font-size: 12px; }
.detail-summary-badges { display: flex; flex-wrap: wrap; gap: 8px; }
.info-chip { display: inline-flex; align-items: center; height: 26px; padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.info-chip.blue { background: #eef4ff; color: #2452cf; }
.info-chip.neutral { background: #f2f4f7; color: #667085; }
.detail-section-shell { padding: 16px; border-radius: 20px; border: 1px solid rgba(226, 232, 240, 0.86); background: rgba(255, 255, 255, 0.92); }
.detail-section-head { display: grid; gap: 4px; margin-bottom: 14px; }
.detail-section-title { margin: 0; font-size: 16px; color: #0f172a; }
.detail-note { color: #667085; font-size: 12px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.detail-item { display: grid; gap: 6px; padding: 12px 13px; border-radius: 16px; background: #f8fafc; }
.detail-item.full { grid-column: 1 / -1; }
.detail-item label { color: #6980a0; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
.detail-item span { font-size: 13px; line-height: 1.55; }
.log-list { display: grid; gap: 12px; }
.timeline-row { display: flex; gap: 12px; padding: 14px; border-radius: 16px; border: 1px solid rgba(226, 232, 240, 0.82); background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); }
.timeline-dot { width: 10px; height: 10px; margin-top: 5px; border-radius: 50%; background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); }
.timeline-copy { display: grid; gap: 4px; }
.timeline-time { color: #98a2b3; font-size: 12px; }
.detail-footer { display: flex; gap: 10px; }
.detail-footer > * { flex: 1; }
@media (max-width: 1360px) { .workspace-heading, .table-head-v3, .filter-toolbar, .preview-head-v3 { flex-direction: column; align-items: stretch; } .header-tools { flex: 1 1 auto; min-width: 0; max-width: none; margin-left: 0; } .table-selection-chip { min-width: 0; } }
@media (max-width: 1180px) { .shell-header, .shell-body { padding-left: 18px; padding-right: 18px; } .page-title { font-size: 28px; } .preview-row-v3 { grid-template-columns: 32px minmax(0, 1fr); } .preview-link { grid-column: 2; justify-self: start; } }
@media (max-width: 840px) { .shell-header, .shell-body { padding-left: 14px; padding-right: 14px; } .page-title { font-size: 24px; } .detail-grid { grid-template-columns: 1fr; } }
@media (min-width: 1280px) {
.workspace:not(.sidebar-collapsed) .shell-nav { width: var(--sidebar-width); min-width: var(--sidebar-width); max-width: var(--sidebar-width); }
.workspace:not(.sidebar-collapsed) .brand-copy,
.workspace:not(.sidebar-collapsed) .nav-label,
.workspace:not(.sidebar-collapsed) .nav-toggle-label,
.workspace:not(.sidebar-collapsed) .nav-user-copy,
.workspace:not(.sidebar-collapsed) .nav-settings { display: block; }
.workspace:not(.sidebar-collapsed) .nav-link,
.workspace:not(.sidebar-collapsed) .nav-toggle-button { justify-content: flex-start; gap: 12px; padding: 0 14px; }
}
import { expect, test } from '@playwright/test';
const targetPages = [
{ module: 'wechat', hash: '#/wechat', requestPath: '/api/wechat-records', rootSelector: '#wechatListRoot .table-area' },
{ module: 'phone', hash: '#/phone-card', requestPath: '/api/wx-phones', rootSelector: '#phoneListRoot .table-area' }
];
const sourcePages = ['#/overview', '#/domain', '#/alerts', '#/phone-card'];
/**
* 代码作用(白话):为每个测试页面注入仅测试使用的 Runtime 挂载计数容器,使测试能观察真实页面是否重复挂载;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):Playwright addInitScript -> 页面启动 -> app-v2.js 读取计数容器 -> 测试断言。
*/
async function installMountCounters(page) {
/**
* 代码作用(白话):在业务脚本执行前建立测试计数对象,让生产代码仅在该对象存在时上报挂载次数;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):addInitScript 回调 -> window.__viteLifecycleCounters -> recordRuntimeMount() -> readMountCount()。
*/
await page.addInitScript(() => {
window.__viteLifecycleCounters = { mounts: { wechat: 0, phone: 0 } };
});
}
/**
* 代码作用(白话):拦截两个列表请求并返回与真实接口一致的成功空列表,只统计当前目标接口,隔离本机后端数据但保留页面真实渲染流程;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):页面 Runtime -> fetch 相对 /api 地址 -> Playwright route -> 成功 JSON -> 列表根节点。
*/
async function mockInitialListRequest(page, requestPath) {
let requestCount = 0;
const unexpectedMethods = [];
for (const listPath of ['/api/wechat-records', '/api/wx-phones']) {
/**
* 代码作用(白话):拦截单个列表接口,记录其 HTTP 方法,并返回受控空列表;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):浏览器 fetch -> route 回调 -> 方法检查/目标计数 -> fulfill JSON -> Runtime 列表渲染。
*/
await page.route(`**${listPath}`, async (route) => {
const method = route.request().method();
if (method !== 'GET') {
unexpectedMethods.push({ listPath, method });
}
if (listPath === requestPath) {
requestCount += 1;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'ok', data: [] })
});
});
}
return {
/**
* 代码作用(白话):返回当前目标列表的初始请求次数;关联文件:F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):route 回调累加 -> count() -> 每个生命周期场景断言。
*/
count: () => requestCount,
/**
* 代码作用(白话):确认被拦截的列表请求没有从既有 GET 契约变成其他方法;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):route 回调记录非 GET -> assertOnlyGet() -> 生命周期场景失败或通过。
*/
assertOnlyGet: () => expect(unexpectedMethods).toEqual([]),
/**
* 代码作用(白话):在刷新前清除上一轮页面加载的目标请求计数,单独验证刷新这一轮;关联文件:F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):首次加载 -> reset() -> page.reload() -> count()。
*/
reset: () => { requestCount = 0; }
};
}
/**
* 代码作用(白话):读取页面在测试容器中记录的目标 Runtime 挂载次数,直接验证真实挂载结果;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime() -> 测试计数容器 -> readMountCount() -> 断言。
*/
async function readMountCount(page, module) {
/**
* 代码作用(白话):在浏览器页面里取出指定模块的测试挂载计数,缺失时按 0 处理;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):page.evaluate 回调 -> window.__viteLifecycleCounters -> Playwright 期望值。
*/
return page.evaluate((moduleKey) => window.__viteLifecycleCounters?.mounts?.[moduleKey] ?? 0, module);
}
for (const target of targetPages) {
/**
* 代码作用(白话):验证直接打开目标 Hash 页只会挂载一次 Runtime、读取一次 GET 列表;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):page.goto -> RouterView.mounted() -> renderModule() -> Runtime/列表请求 -> 断言。
*/
test(`${target.module} direct entry mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, target.requestPath);
await page.goto(`/${target.hash}`);
await expect(page.locator(target.rootSelector)).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, target.module)).toBe(1);
});
/**
* 代码作用(白话):验证刷新目标 Hash 页后,新的一轮页面加载仍只有一次 Runtime 和一次 GET;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):首次加载 -> reset -> page.reload -> RouterView.mounted() -> Runtime/列表请求 -> 断言。
*/
test(`${target.module} refresh mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, target.requestPath);
await page.goto(`/${target.hash}`);
await expect(page.locator(target.rootSelector)).toBeVisible();
listRequest.reset();
await page.reload();
await expect(page.locator(target.rootSelector)).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, target.module)).toBe(1);
});
}
for (const sourceHash of sourcePages) {
/**
* 代码作用(白话):验证从每个其余 Hash 路由切到企微页时只有一次目标 Runtime 和 GET;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):来源路由 -> 侧栏点击 -> router.push -> RouterView.mounted -> 企微 Runtime -> 断言。
*/
test(`wechat route transition from ${sourceHash} mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, '/api/wechat-records');
await page.goto(`/${sourceHash}`);
await page.locator('[data-module="wechat"]').click();
await expect(page.locator('#wechatListRoot .table-area')).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, 'wechat')).toBe(1);
});
}
for (const sourceHash of ['#/overview', '#/domain', '#/wechat', '#/alerts']) {
/**
* 代码作用(白话):验证从每个其余 Hash 路由切到手机号页时只有一次目标 Runtime 和 GET;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):来源路由 -> 侧栏点击 -> router.push -> RouterView.mounted -> 手机号 Runtime -> 断言。
*/
test(`phone route transition from ${sourceHash} mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, '/api/wx-phones');
await page.goto(`/${sourceHash}`);
await page.locator('[data-module="phone"]').click();
await expect(page.locator('#phoneListRoot .table-area')).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, 'phone')).toBe(1);
});
}
/**
* 代码作用(白话):验证路由替换 pageBody 后,新的行点击和抽屉关闭事件仍绑定在新 DOM 上;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):路由切换 -> preparePageShell() -> bindLayoutEvents() -> 行点击打开抽屉 -> 关闭按钮隐藏抽屉。
*/
test('route replacement keeps page body and drawer close events bound', async ({ page }) => {
await page.goto('/#/overview');
await page.locator('[data-module="domain"]').click();
await page.locator('#pageBody [data-record-id][data-source-key]').first().click();
await expect(page.locator('#detailDrawer')).not.toHaveClass(/hidden/);
await page.locator('#drawerClose').click();
await expect(page.locator('#detailDrawer')).toHaveClass(/hidden/);
});
/**
* 文件用途(白话):统一定义本地 Vite 服务与构建预览的访问地址,并把前端 `/api` 请求转发给本机 Spring Boot。
* 关联文件:package.json、src/main.js、src/modules/shared/wechat-api-client.js、src/modules/shared/phone-api-client.js。
* 关联逻辑:浏览器 `/assets/` -> Vite :5173/:4173;浏览器 `/api/...` -> Vite 代理 -> Spring Boot :8888。
*/
import { defineConfig } from 'vite';
export default defineConfig({
base: '/assets/',
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
},
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:8888',
changeOrigin: true
}
}
},
preview: {
port: 4173,
strictPort: true
}
});
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment