Commit f7030db4 by DaiJiezhang

feat: sync frontend backend and clean local artifacts

parent 0804c22b
......@@ -7,3 +7,7 @@ node_modules/
.agents/
.claude/
*.log
backend/target/
F_TMP_WRITE_TEST.txt
localhost
wechat-page.png
const DATA_SOURCES = {
const DATA_SOURCES = {
domain: [
{
id: 1,
domain: "example.com",
platform: "Aliyun",
platformCode: "ALI",
expirationDate: "2025-05-28",
expirationHint: "3 days",
autoRenewal: "enabled",
sslExpiry: "2025-06-15",
sslHint: "21 days",
accountId: "aliyun_admin",
renewalResult: "success",
dns: "Aliyun DNS",
owner: "Zhang San",
remark: "企业主站",
detail状态: "normal",
updatedAt: "2025-05-25 10:30",
log: [["2025-05-25 10:30", "Renewal completed"], ["2024-05-28 09:15", "Renewal completed"]]
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",
platformCode: "TEN",
expirationDate: "2025-06-01",
expirationHint: "7 days",
autoRenewal: "enabled",
sslExpiry: "2025-06-20",
sslHint: "26 days",
accountId: "tencent_admin",
renewalResult: "pending",
dns: "DNSPod",
owner: "Li Si",
remark: "活动备用域名",
detail状态: "normal",
updatedAt: "2025-05-23 14:30",
log: [["2025-05-23 14:30", "Waiting for auto-renew payment"], ["2024-06-01 10:20", "Renewal completed"]]
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",
platformCode: "WEST",
expirationDate: "2025-06-10",
expirationHint: "16 days",
autoRenewal: "enabled",
sslExpiry: "2025-06-25",
sslHint: "31 days",
accountId: "west_123",
renewalResult: "failed",
dns: "West DNS",
owner: "Zhao Liu",
remark: "商城二级站点",
detail状态: "normal",
updatedAt: "2025-05-24 09:42",
log: [["2025-05-24 09:42", "Renewal failed due to balance"], ["2024-06-10 12:00", "Renewal completed"]]
domain: 'shop.net',
platform: 'West CN',
expirationDate: '2025-06-10',
owner: '赵六',
remark: '商城二级站点',
updatedAt: '2025-05-24 09:42'
}
],
wechat: [
{
id: 101,
real姓名: "Zhang San",
alias: "A San",
account: "zhangsan_88",
department: "Marketing",
phone: "138****8888",
owner: "Zhang San",
owner状态: "normal",
wxId: "wxc_3123abcd4567",
createdAt: "2023-03-15 14:32",
role: "Marketing Specialist",
remark: "华南区域负责人",
updatedAt: "2024-05-12 16:30",
notes: [["2024-05-10 10:30", "Added by admin"], ["2024-05-12 16:30", "Assigned as main contact"]]
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姓名: "Li Si",
alias: "Old Li",
account: "lisi_2023",
department: "Operations",
phone: "139****1234",
owner: "Li Si",
owner状态: "abnormal",
wxId: "wxid_lisi00932",
createdAt: "2023-04-12 09:20",
role: "Operations Specialist",
remark: "实名信息待复核",
updatedAt: "2024-04-30 12:00",
notes: [["2024-04-30 12:00", "Waiting for identity review"]]
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,
phone: "138****8888",
realPerson: "Zhang San",
city: "Shenzhen",
usageLocation: "Shenzhen Nanshan",
carrier: "China Mobile",
card状态: "normal",
wechat: "enabled",
outbound: "available",
iccid: "8986 0117 0230 1234 5678 912",
note: "用于客户联系",
owner: "Zhang San",
updatedAt: "2024-03-12 10:25",
log: [["2024-03-12 10:23", "Card activated"], ["2024-03-12 10:25", "Service opened"]]
},
{
id: 202,
phone: "176****1234",
realPerson: "Wang Wu",
city: "Hangzhou",
usageLocation: "Hangzhou",
carrier: "China Telecom",
card状态: "paused",
wechat: "disabled",
outbound: "blocked",
iccid: "8986 0117 8844 2134 9087 122",
note: "号卡暂停待复核",
owner: "Wang Wu",
updatedAt: "2024-05-01 11:00",
log: [["2024-05-01 09:20", "Pause detected"], ["2024-05-01 11:00", "Sent to manual review"]]
phoneNumber: '13800008888',
realPerson: '张三',
iccid: '89860117023012345678912',
city: '深圳',
cardStatus: '正常',
cardUsageLocation: '深圳南山',
wecom: true,
wechat: true,
outboundCall: true,
channelOperator: '中国移动',
numberStatus: true,
linkedWecom: '张三企业微信',
numberRetentionStatus: true,
updateTime: '2024-03-12 10:25',
note: '用于客户联系'
}
],
alerts: [
{ id: 301, title: "demo.ai 需要续费跟进", module: "domain", owner: "Li Si", dueAt: "2025-06-01", status: "pending", updatedAt: "2025-05-23 14:30", remark: "付款仍待处理" }
{
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" }]
title: '资产管理台',
description: '查看域名、企微、手机号卡和提醒概览。',
actions: [{ text: '导出数据', kind: 'secondary' }, { text: '新增记录', kind: 'primary' }]
},
domain: {
title: "域名资产",
description: "查看域名归属、续费和 SSL 状态。",
actions: [{ text: "同步域名", kind: "secondary" }, { text: "新增域名", kind: "primary" }]
title: '域名资产',
description: '查看域名归属、续费和 SSL 状态。',
actions: [{ text: '同步域名', kind: 'secondary' }, { text: '新增域名', kind: 'primary' }]
},
wechat: {
title: "企微资产",
description: "管理企微资料和相关详情。",
actions: [{ text: "同步数据", kind: "secondary" }, { text: "新增资料", kind: "primary" }]
title: '企微资产',
description: '管理企微资料和相关详情。',
actions: [{ text: '同步数据', kind: 'secondary' }, { text: '新增资料', kind: 'primary' }]
},
phone: {
title: "手机号卡",
description: "跟踪手机号、负责人和号卡状态。",
actions: [{ text: "导入号码", kind: "secondary" }, { text: "新增号卡", kind: "primary" }]
title: '手机号卡',
description: '跟踪手机号、负责人和号卡状态。',
actions: [{ text: '导入号码', kind: 'secondary' }, { text: '新增号卡', kind: 'primary' }]
},
alerts: {
title: "提醒中心",
description: "查看需要人工跟进的提醒。",
actions: [{ text: "Export 提醒中心", kind: "secondary" }]
title: '提醒中心',
description: '查看需要人工跟进的提醒。',
actions: [{ text: '导出提醒', kind: 'secondary' }]
}
};
const state = {
activeModule: "",
activeModule: '',
selectedBySource: { domain: 1, wechat: 101, phone: 201, alerts: 301 }
};
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");
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;
/** 代码作用(白话):在 Router查看 每次重新创建页面壳子后,重新抓取 pageHeader、pageBody、drawer 等 DOM 节点,避免旧引用还指向已经被 Vue 替换掉的节点;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“Router查看 mounted -> updateDOMRefs() -> renderModule()/updateActiveNav()”,数据流是“新 DOM -> 全局节点引用刷新 -> 后续渲染函数写入新节点”。 */
window.updateDOMRefs = function updateDOMRefs() {
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");
};
/**
* 代码作用(白话):路由壳层重新挂载后,刷新页面上会被旧脚本复用的 DOM 引用,避免引用指向已经被替换的节点;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/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');
}
/** 代码作用(白话):把字符串里的特殊字符转义掉,避免渲染到 innerHTML 时把页面结构打坏;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:数据流是“记录对象 -> escapeHtml() -> renderHeader/renderBody/renderDetail 输出 HTML”。 */
window.refreshDOMRefs = refreshDOMRefs;
window.updateDOMRefs = refreshDOMRefs;
/**
* 代码作用(白话):对字符串做 HTML 转义,防止记录内容直接写进 innerHTML 时破坏页面结构;关联文件:F:/Project/xyw_console/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;");
return String(value ?? '-')
.replaceAll('&', '&amp;')
.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 复用 domain,alerts 走 alerts;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“renderModule() -> getModuleRecords() -> renderBody()/renderDetail()”。 */
/**
* 代码作用(白话):给简单模块取对应数据源,overview 继续复用域名数据;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):renderModule() -> getModuleRecords() -> renderSimpleBody()/renderDetail()。
*/
function getModuleRecords(moduleKey) {
if (moduleKey === "overview") return DATA_SOURCES.domain;
if (moduleKey === 'overview') {
return DATA_SOURCES.domain;
}
return DATA_SOURCES[moduleKey] || [];
}
/** 代码作用(白话):读取当前模块里正在选中的那条记录,没有显式选中时自动退回第一条;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“表格点击 -> state.selectedBySource 更新 -> renderDetail() 读取当前记录”。 */
function get已选Record(moduleKey) {
const = getModuleRecords(moduleKey);
if (!.length) return null;
const sourceKey = moduleKey === "overview" ? "domain" : moduleKey;
const selectedId = state.selectedBySource[sourceKey] ?? [0].id;
return .find((record) => record.id === selectedId) || [0];
/**
* 代码作用(白话):从当前模块记录里找到已选中对象,没有命中时回退到第一条,保证简单详情面板有稳定数据;关联文件:F:/Project/xyw_console/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];
}
/** 代码作用(白话):把顶部标题、说明和操作按钮渲染到 pageHeader,保证每个模块切换后都有稳定的页头;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“renderModule() -> renderHeader() -> pageHeader.innerHTML”。 */
/**
* 代码作用(白话):渲染页面顶部的标题、说明和操作按钮,让路由切换后头部信息能跟着模块变化;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):renderModule() -> renderHeader() -> pageHeader.innerHTML。
*/
function renderHeader(moduleKey) {
if (!pageHeader) return;
if (!pageHeader) {
return;
}
const config = MODULE_CONFIG[moduleKey] || MODULE_CONFIG.overview;
const actionsHtml = (config.actions || []).map((action) => `<button type="button" class="header-action ${escapeHtml(action.kind)}">${escapeHtml(action.text)}</button>`).join("");
const actionsHtml = (config.actions || [])
.map((action) => `<button type="button" class="header-action ${escapeHtml(action.kind)}">${escapeHtml(action.text)}</button>`)
.join('');
pageHeader.innerHTML = `
<div class="workspace-heading">
<div class="heading-copy">
<p class="heading-caption">本地资产后台</p>
<h2 class="page-title">${escapeHtml(config.title)}</h2>
<p class="page-summary">${escapeHtml(config.description || "")}</p>
<p class="page-summary">${escapeHtml(config.description || '')}</p>
</div>
<div class="header-actions">${actionsHtml}</div>
</div>
`;
}
/** 代码作用(白话):把 domain/overview/alerts 这类纯表格模块渲染成可点击列表,让用户至少能查看并切换记录;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/styles-v2.css;关联逻辑:调用链是“renderModule() -> renderSimpleBody() -> 用户点行 -> handlePageBodyClick() -> renderDetail()”。 */
/**
* 代码作用(白话):渲染简单列表模块,让 overview、domain、alerts 继续保留基础查看能力;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/styles-v2.css;关联逻辑(调用链/消息链/数据流):renderModule() -> renderSimpleBody() -> handlePageBodyClick() -> renderDetail()。
*/
function renderSimpleBody(moduleKey) {
if (!pageBody) return;
const = getModuleRecords(moduleKey);
if (!.length) {
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 = .map((record) => {
const label = record.domain || record.title || record.real姓名 || 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 || "-";
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>
......@@ -254,187 +290,401 @@ function renderSimpleBody(moduleKey) {
<span class="row-meta">${escapeHtml(meta)}</span>
</button>
`;
}).join("");
}).join('');
pageBody.innerHTML = `<div class="simple-list">${rowsHtml}</div>`;
}
/** 代码作用(白话):把 wechat/phone 模块交给已有的 Vue 子模块运行时去挂载,这样不需要在这次修复里重写那两块复杂列表;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/modules/wechat/*.js、F:/Project/xyw_console/src/modules/phone/*.js;关联逻辑:调用链是“renderModule() -> renderExternalRuntime() -> 对应 mountXxxListComponent() -> 子模块自己渲染列表/抽屉”。 */
/**
* 代码作用(白话):统一生成分页按钮显示列表,让企微模块也能复用一套页码截断规则;关联文件: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-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;
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 (moduleKey === "wechat" && window.WechatListRuntime?.mountWechatListComponent) {
try { wechatAppInstance?.unmount?.(); } catch (error) {}
/** 代码作用(白话):把企业微信子模块启动时必需的文案、状态和回调一次性传进去,避免子模块模板在首屏渲染时读到空 props;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“renderModule() -> renderExternalRuntime() -> mountWechatListComponent() -> WechatListTable render”。 */
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: "全部记录",
reset: "重置",
rowSetting: "列设置",
real姓名: "姓名",
department: "部门",
phone: "手机号",
wxId: "微信 ID",
status: "状态",
createdAt: "创建时间",
owner: "负责人",
updatedAt: "更新时间",
action: "操作",
view: "查看",
edit: "编辑",
selected: "已选",
items: "项",
batchAction: "批量操作",
total: "共",
records: "条",
pageSuffix: " 条/页"
allRecords: '全部记录',
add: '新增资料',
reset: '重置',
realName: '姓名',
department: '部门',
phone: '手机号',
wxId: '微信 ID',
status: '状态',
owner: '负责人',
updatedAt: '更新时间',
action: '操作',
view: '查看',
edit: '编辑',
selected: '已选',
items: '项',
batchAction: '批量操作',
total: '共',
records: '条',
pageSuffix: ' 条/页',
searchPlaceholder: '搜索姓名、微信 ID、手机号或部门',
loadFailed: '加载企微列表失败,请确认本地后端已启动',
openFailed: '打开企微详情失败,请稍后重试'
},
wechatTableTotal: DATA_SOURCES.wechat.length,
wechatTableFilterLabels: ["全部", "正常", "异常", "项目", "搜索"],
wechatTablePageSizes: [10, 20, 50, 100],
mountState: { currentPage: 1, pageSize: 10, selectedIds: [] },
mountState: moduleMountState.wechat,
globalState: state,
detailDrawer,
/** 代码作用(白话):把原始企微数据转换成微信列表组件能直接显示的行数据,补齐状态和项目字段;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/router/index.js;关联逻辑:数据流是“DATA_SOURCES.wechat -> getWechatRowsByPage() -> WechatListTable.setup() -> visibleRows/template”。 */
getWechatRowsByPage: () => DATA_SOURCES.wechat.map((row) => ({
...row,
status: row.owner状态 === "normal" ? "姝e父" : "寮傚父",
project: row.remark || "-"
})),
/** 代码作用(白话):给微信列表返回当前可见页码,这里先保持单页占位,避免分页组件读到空值;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑:调用链是“WechatListTable.setup() -> buildPageNumbers() -> getVisiblePageNumbers() -> 分页按钮渲染”。 */
getVisiblePageNumbers: () => [1],
/** 代码作用(白话):把企微记录状态映射成列表小圆点颜色,保证模板里的状态视觉反馈可用;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑:调用链是“WechatListTable template -> getStatusClass() -> getWechatStatusDotClass() -> CSS class 输出”。 */
getWechatStatusDotClass: (status) => (status === "正常" || status === "normal" ? "green" : "red"),
/** 代码作用(白话):把当前企微记录交给详情抽屉模块打开,避免列表组件自己重复维护详情渲染;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js、F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑:调用链是“列表行点击/查看按钮 -> renderWechatDetailDrawer() -> WechatDetailDrawerAPI.open()”。 */
renderWechatDetailDrawer: (record) => window.WechatDetailDrawerAPI?.open?.(record),
/** 代码作用(白话):按记录 ID 从原始企微数据里找出完整对象,供查看和编辑动作复用;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js;关联逻辑:调用链是“handleRowAction() -> openDrawer()/renderWechatDetailDrawer() -> getWechatRecordById() -> 找到目标记录”。 */
getWechatRecordById: (id) => DATA_SOURCES.wechat.find((row) => row.id === id),
/** 代码作用(白话):根据操作类型决定打开企微详情抽屉还是编辑弹窗,保持列表操作入口兼容旧页面;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑:消息链是“操作按钮 -> openDrawer(mode,id) -> 查记录 -> 打开对应弹窗/抽屉”。 */
openDrawer: (mode, id) => {
const record = DATA_SOURCES.wechat.find((row) => row.id === id);
if (mode === "edit") window.WechatAddDialogAPI?.open?.("edit", record);
if (mode === "view") window.WechatDetailDrawerAPI?.open?.(record);
},
mountTarget: "#wechatListRoot"
loadWechatRows,
openAddDialog: openWechatAddDialog,
openDrawer: openWechatDrawer,
getVisiblePageNumbers,
getWechatStatusDotClass,
notifyError: showError,
mountTarget: '#wechatListRoot'
});
}
if (moduleKey === "phone" && window.手机号ListRuntime?.mount手机号ListComponent) {
try { phoneAppInstance?.unmount?.(); } catch (error) {}
/** 代码作用(白话):把手机号卡子模块启动时必需的文案、状态和回调一次性传进去,避免子模块模板在首屏渲染时读到空 props;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“renderModule() -> renderExternalRuntime() -> mount手机号ListComponent() -> 手机号ListTable render”。 */
phoneAppInstance = window.手机号ListRuntime.mount手机号ListComponent({
if (moduleKey === 'phone' && window.PhoneListRuntime?.mountPhoneListComponent) {
phoneAppInstance = window.PhoneListRuntime.mountPhoneListComponent({
phoneText: {
allRecords: "全部记录",
reset: "重置",
rowSetting: "列设置",
selected: "已选",
items: "项",
batchAction: "批量操作",
total: "共",
records: "条",
pageSuffix: " 条/页"
add: '新增号卡',
reset: '重置',
searchPlaceholder: '搜索手机号、实名人或城市',
deleteConfirm: '确认删除这条手机号记录吗?',
deleteFailed: '删除失败,请稍后重试',
loadFailed: '加载手机号列表失败,请确认本地后端已启动'
},
phoneTable: DATA_SOURCES.phone.length,
phoneTableFilterLabels: ["全部", "正常", "暂停", "更新"],
phoneTableFilterLabels: ['全部', '正常', '异常停机', '保号中'],
phoneTablePageSizes: [10, 20, 50, 100],
mountState: { currentPage: 1, pageSize: 10, selectedIds: [] },
mountState: moduleMountState.phone,
globalState: state,
detailDrawer,
get手机号RowsByPage: () => DATA_SOURCES.phone.map((row) => ({
...row,
status: row.card状态 === "normal" ? "姝e父" : "寮傚父",
wechat状态: row.wechat === "enabled" ? "姝e父" : "鏈紑閫?",
outbound: row.outbound === "available" ? "可外呼" : "不可外呼",
project: row.note || "-"
})),
/** 代码作用(白话):给微信列表返回当前可见页码,这里先保持单页占位,避免分页组件读到空值;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js;关联逻辑:调用链是“WechatListTable.setup() -> buildPageNumbers() -> getVisiblePageNumbers() -> 分页按钮渲染”。 */
getVisiblePageNumbers: () => [1],
get手机号状态DotClass: (status) => (status === "正常" || status === "normal" ? "green" : "orange"),
render手机号DetailDrawer: (record) => window.手机号DetailDrawerAPI?.open?.(record),
get手机号RecordById: (id) => DATA_SOURCES.phone.find((row) => row.id === id),
/** 代码作用(白话):根据操作类型决定打开企微详情抽屉还是编辑弹窗,保持列表操作入口兼容旧页面;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-list-runtime.js、F:/Project/xyw_console/src/modules/wechat/wechat-add-dialog.js、F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑:消息链是“操作按钮 -> openDrawer(mode,id) -> 查记录 -> 打开对应弹窗/抽屉”。 */
openDrawer: (mode, id) => {
const record = DATA_SOURCES.phone.find((row) => row.id === id);
if (mode === "edit") window.手机号AddDialogAPI?.open?.("edit", record);
if (mode === "view") window.手机号DetailDrawerAPI?.open?.(record);
loadPhoneRows,
deletePhoneRecord,
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 || '打开详情失败');
}
},
mountTarget: "#phoneListRoot"
getPhoneStatusDotClass: (status) => (status === '正常' || status === '可外呼' ? 'green' : 'orange'),
notifyError: showError,
mountTarget: '#phoneListRoot'
});
}
}
/** 代码作用(白话):把当前选中记录的关键信息渲染到右侧抽屉,让用户切换列表后能看到对应详情;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“renderModule() / handlePageBodyClick() -> renderDetail() -> detailDrawer/detailBody 更新”。 */
/**
* 代码作用(白话):渲染简单模块的详情面板,复杂列表模块仍走自己的抽屉,不复用旧详情壳;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):handlePageBodyClick() -> renderDetail() -> detailDrawer 内容更新。
*/
function renderDetail(moduleKey) {
if (!detailDrawer || !detailShellTitle || !detailBody) return;
const record = get已选Record(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 = "详情";
detailDrawer.classList.add('hidden');
detailShellTitle.textContent = '详情';
detailBody.innerHTML = '<div class="note-box">暂无详情数据。</div>';
return;
}
const title = record.domain || record.title || record.real姓名 || record.phone || `记录 ${record.id}`;
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")
.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("");
.join('');
detailShellTitle.textContent = title;
detailBody.innerHTML = `<div class="detail-grid">${fields}</div>`;
detailDrawer.classList.remove("hidden");
detailDrawer.classList.remove('hidden');
}
/** 代码作用(白话):同步左侧导航高亮,让当前路由对应的菜单项保持选中;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“Router查看 mounted -> updateActiveNav()” 和 “renderModule() -> updateActiveNav()”。 */
/**
* 代码作用(白话):根据当前激活模块高亮左侧导航按钮;关联文件:F:/Project/xyw_console/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);
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);
});
}
/** 代码作用(白话):统一根据当前模块刷新页头、主体和详情,是 Router 壳子和旧渲染逻辑之间的总入口;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“Router查看 mounted -> renderModule()”,数据流是“state.activeModule -> renderHeader/renderSimpleBody 或 renderExternalRuntime -> renderDetail/updateActiveNav()”。 */
/**
* 代码作用(白话):按当前模块决定渲染简单列表还是复杂运行时模块,是页面切换后的总入口;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):Router mounted -> renderModule() -> renderHeader() -> renderSimpleBody()/renderExternalRuntime() -> updateActiveNav()。
*/
function renderModule() {
if (!state.activeModule) return;
if (!pageHeader || !pageBody) return;
if (!state.activeModule || !pageHeader || !pageBody) {
return;
}
renderHeader(state.activeModule);
if (state.activeModule === "wechat" || state.activeModule === "phone") {
if (state.activeModule === 'wechat' || state.activeModule === 'phone') {
renderExternalRuntime(state.activeModule);
} else {
renderSimpleBody(state.activeModule);
}
renderDetail(state.activeModule);
}
updateActiveNav();
}
/** 代码作用(白话):响应列表点击,把用户点中的记录写回状态,再刷新右侧详情;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:消息链是“pageBody click -> handlePageBodyClick() -> state.selectedBySource 更新 -> renderDetail()”。 */
/**
* 代码作用(白话):处理简单模块列表项点击,把当前记录写进选中状态并刷新详情面板;关联文件:F:/Project/xyw_console/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;
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/index.html;关联逻辑:消息链是“drawerClose click -> handleDrawerCloseClick() -> detailDrawer.hidden”。 */
/**
* 代码作用(白话):关闭旧详情壳层的抽屉,只影响简单模块详情显示;关联文件:F:/Project/xyw_console/index.html;关联逻辑(调用链/消息链/数据流):drawerClose click -> handleDrawerCloseClick() -> detailDrawer.hidden。
*/
function handleDrawerCloseClick() {
detailDrawer?.classList.add("hidden");
detailDrawer?.classList.add('hidden');
}
/** 代码作用(白话):把静态事件绑定到当前页面壳子上,保证抽屉关闭和简单列表点击在 Router 反复切页后仍然有效;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“DOMContentLoaded/initPage() -> bindLayoutEvents()”,之后由用户点击触发具体处理函数。 */
/**
* 代码作用(白话):根据侧边栏是否折叠,同步按钮文案和可访问性属性;关联文件:F:/Project/xyw_console/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/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/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);
pageBody?.removeEventListener('click', handlePageBodyClick);
pageBody?.addEventListener('click', handlePageBodyClick);
drawerClose?.removeEventListener('click', handleDrawerCloseClick);
drawerClose?.addEventListener('click', handleDrawerCloseClick);
sidebarToggle?.removeEventListener('click', handleSidebarToggleClick);
sidebarToggle?.addEventListener('click', handleSidebarToggleClick);
}
/** 代码作用(白话):页面初始化入口,先刷新 DOM 引用和事件,再在已有 activeModule 时补一次渲染;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“DOMContentLoaded -> initPage() -> updateDOMRefs()/bindLayoutEvents() -> renderModule()(有模块时)”。 */
/**
* 代码作用(白话):页面初始化时刷新 DOM 引用、重绑布局事件,并在已有 activeModule 时恢复当前页面;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):DOMContentLoaded -> initPage() -> refreshDOMRefs()/bindLayoutEvents() -> renderModule()。
*/
function initPage() {
window.updateDOMRefs();
refreshDOMRefs();
bindLayoutEvents();
updateSidebarToggleState();
if (state.activeModule) {
renderModule();
}
......@@ -442,8 +692,7 @@ function initPage() {
window.updateActiveNav = updateActiveNav;
window.renderModule = renderModule;
document.addEventListener("DOMContentLoaded", initPage);
document.addEventListener('DOMContentLoaded', initPage);
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.1</version>
<relativePath />
</parent>
<groupId>com.xyw</groupId>
<artifactId>xyw-console-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>xyw-console-backend</name>
<description>Wechat asset backend for xyw_console</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package com.xyw.console;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.xyw.console.wechat.mapper")
public class XywConsoleBackendApplication {
/**
* 代码作用(白话):启动企微后端服务,让前端可以通过 localhost:8080 访问真实接口;关联文件:F:/Project/xyw_console/backend/src/main/resources/application.yml、F:/Project/xyw_console/start.bat;关联逻辑(调用链/消息链/数据流):mvn spring-boot:run -> main() -> Spring 容器启动 -> Controller 对外提供 HTTP 接口。
*/
public static void main(String[] args) {
SpringApplication.run(XywConsoleBackendApplication.class, args);
}
}
package com.xyw.console.common;
public record ApiResponse<T>(Integer code, String message, T data) {
/**
* 代码作用(白话):构造成功返回体,统一输出前端正在使用的 code/message/data 结构;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/controller/WxDataController.java、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):Controller 成功分支 -> ApiResponse.success() -> 前端 parseJsonResponse() 判断成功。
*/
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", 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;关联逻辑(调用链/消息链/数据流):Controller 保存成功 -> ApiResponse.success(message, data) -> 前端保存成功提示。
*/
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(200, message, data);
}
/**
* 代码作用(白话):构造失败返回体,让前端即使拿到 4xx 响应也能读到明确错误信息;关联文件:F:/Project/xyw_console/backend/src/main/java/com/xyw/console/wechat/controller/WxDataController.java、F:/Project/xyw_console/src/modules/shared/wechat-api-client.js;关联逻辑(调用链/消息链/数据流):Controller 失败分支 -> ApiResponse.error() -> 前端 parseJsonResponse() 抛出 message。
*/
public static <T> ApiResponse<T> error(Integer code, String message) {
return new ApiResponse<>(code, message, null);
}
}
package com.xyw.console.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* 代码作用(白话):允许本地 8000 端口的前端页面跨域调用 8080 后端接口,否则浏览器会直接拦截请求;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/start.bat;关联逻辑(调用链/消息链/数据流):浏览器 fetch(http://localhost:8080) -> addCorsMappings() 放行 -> Controller 正常接收请求。
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000")
.allowedMethods("GET", "POST", "PUT", "OPTIONS")
.allowedHeaders("*");
}
}
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("账号位置")
private String accountLocation;
@JsonProperty("phone_image")
@TableField("手机图片")
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;
}
}
server:
port: 8080
spring:
application:
name: xyw-console-backend
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${XYW_DB_URL:jdbc:mysql://localhost:3306/xyw_private_data?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${XYW_DB_USERNAME:root}
password: ${XYW_DB_PASSWORD:}
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
......@@ -5,6 +5,7 @@
<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" />
<link rel="stylesheet" href="node_modules/element-plus/dist/index.css" />
<link rel="stylesheet" href="styles-v2.css" />
</head>
......@@ -14,57 +15,56 @@
<aside class="shell-nav" id="sidebar">
<div class="brand-panel">
<div class="brand-mark">
<img class="brand-logo" src="img/学有为logo.jpg" alt="瀛︽湁涓?LOGO" />
<img class="brand-logo" src="img/学有为logo.jpg" alt="学有为 LOGO" />
</div>
<div class="brand-copy">
<h1>&#23398;&#26377;&#20026;&#25968;&#25454;&#36164;&#20135;&#21518;&#21488;</h1>
<h1>学有为数据资产后台</h1>
</div>
</div>
<div class="nav-main">
<nav class="primary-nav" aria-label="&#20027;&#23548;&#33322;">
<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">&#24635;&#35272;</span>
<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">&#22495;&#21517;&#36164;&#26009;</span>
<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">&#20225;&#24494;&#36164;&#26009;</span>
<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">&#25163;&#26426;&#21495;&#21345;</span>
<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">&#25552;&#37266;&#20013;&#24515;</span>
<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="&#25910;&#36215;&#20391;&#36793;&#23548;&#33322;" aria-expanded="true">
<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">&#25910;&#36215;</span>
<span class="nav-toggle-label">收起</span>
</button>
</div>
<div class="nav-footer">
<div class="nav-user-avatar">&#9679;</div>
<div class="nav-user-avatar"></div>
<div class="nav-user-copy">
<p class="nav-user-name">&#31649;&#29702;&#21592;</p>
<p class="nav-user-role">&#36229;&#32423;&#31649;&#29702;&#21592;</p>
<p class="nav-user-name">管理员</p>
<p class="nav-user-role">超级管理员</p>
</div>
<button class="nav-settings" type="button" aria-label="&#35774;&#32622;">&#9881;</button>
<button class="nav-settings" type="button" aria-label="设置"></button>
</div>
</div>
</aside>
......@@ -92,10 +92,11 @@
<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-list-runtime.js"></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>
(function initPhoneAddDialog() {
(function initPhoneAddDialog() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
......@@ -30,36 +30,13 @@
const rules = {
phoneNumber: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
channelOperator: [{ required: true, message: '请输入渠道运营商', trigger: 'blur' }]
channelOperator: [{ required: true, message: '请选择运营商', trigger: 'change' }]
};
const open = (mode = 'add', record = null) => {
visible.value = true;
isEdit.value = mode === 'edit';
if (formRef.value) {
formRef.value.resetFields();
}
if (mode === 'edit' && record) {
formData.id = record.id;
formData.phoneNumber = record.phoneNumber || '';
formData.realPerson = record.realPerson || '';
formData.iccid = record.iccid || '';
formData.city = record.city || '';
formData.cardStatus = record.cardStatus || '正常';
formData.cardUsageLocation = record.cardUsageLocation || '';
formData.wecom = !!record.wecom;
formData.wechat = !!record.wechat;
formData.outboundCall = !!record.outboundCall;
formData.douyinAccount = record.douyinAccount || '';
formData.miniProgramFiling = !!record.miniProgramFiling;
formData.packageChange5yuan = !!record.packageChange5yuan;
formData.channelOperator = record.channelOperator || '';
formData.numberStatus = record.numberStatus !== false;
formData.linkedWecom = record.linkedWecom || '';
formData.numberRetentionStatus = record.numberRetentionStatus !== false;
} else {
/**
* 代码作用(白话):把手机号表单恢复成新增状态,避免上一次编辑数据残留到下一次打开弹窗;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):open('add') -> resetFormData() -> formData 默认值 -> 表单渲染。
*/
function resetFormData() {
formData.id = null;
formData.phoneNumber = '';
formData.realPerson = '';
......@@ -78,44 +55,88 @@
formData.linkedWecom = '';
formData.numberRetentionStatus = true;
}
};
const close = () => {
visible.value = false;
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (valid) {
try {
const url = formData.id ? `http://localhost:8080/api/wx-phones/${formData.id}` : 'http://localhost:8080/api/wx-phones';
const method = formData.id ? 'PUT' : 'POST';
/**
* 代码作用(白话):把外层传入的手机号记录回填到表单里,兼容列表记录和详情记录两种字段名;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js;关联逻辑(调用链/消息链/数据流):编辑按钮/详情抽屉编辑 -> PhoneAddDialogAPI.open('edit', record) -> populateFormData() -> 表单回填。
*/
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.status || '正常';
formData.cardUsageLocation = record.cardUsageLocation || record.usageLocation || '';
formData.wecom = record.wecom === true;
formData.wechat = record.wechat === true;
formData.outboundCall = record.outboundCall === true || record.outbound === '可外呼';
formData.douyinAccount = record.douyinAccount || '';
formData.miniProgramFiling = record.miniProgramFiling === true;
formData.packageChange5yuan = record.packageChange5yuan === true;
formData.channelOperator = record.channelOperator || record.carrier || '';
formData.numberStatus = record.numberStatus !== false;
formData.linkedWecom = record.linkedWecom || '';
formData.numberRetentionStatus = record.numberRetentionStatus !== false;
}
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
/**
* 代码作用(白话):按新增或编辑模式打开手机号弹窗,并决定是重置表单还是回填旧记录;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js;关联逻辑(调用链/消息链/数据流):新增/编辑按钮 -> PhoneAddDialogAPI.open() -> open() -> visible/formData 更新。
*/
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/app-v2.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):保存按钮 -> handleSubmit() -> PhoneAddDialogActions.save() -> afterSave() -> phone 列表刷新。
*/
async function submitAfterValidate(valid) {
if (!valid) {
return false;
}
const json = await res.json();
if (json.code === 200) {
try {
const actions = window.PhoneAddDialogActions || {};
if (typeof actions.save !== 'function') {
throw new Error('手机号保存动作尚未初始化');
}
await actions.save({ ...formData });
ElementPlus.ElMessage.success(formData.id ? '修改成功' : '新增成功');
close();
// 触发全局事件,让列表刷新
window.dispatchEvent(new CustomEvent('phone-list-refresh'));
} else {
ElementPlus.ElMessage.error(json.message || '操作失败');
if (typeof actions.afterSave === 'function') {
await actions.afterSave();
}
} catch (err) {
console.error(err);
ElementPlus.ElMessage.error('网络请求失败,请确保后端服务已启动');
}
} else {
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
......@@ -139,7 +160,6 @@
</div>
</template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="120px" label-position="right">
<div style="font-size: 15px; font-weight: 600; color: #303133; margin: 0 0 16px 0; line-height: 1;">基础信息</div>
<el-row :gutter="24">
<el-col :span="12">
......@@ -148,14 +168,14 @@
</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 label="实名联系人" prop="realPerson">
<el-input v-model="formData.realPerson" placeholder="请输入实名联系人"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="ICCID卡号" prop="iccid">
<el-form-item label="ICCID 卡号" prop="iccid">
<el-input v-model="formData.iccid" placeholder="请输入 ICCID"></el-input>
</el-form-item>
</el-col>
......@@ -188,11 +208,11 @@
<div style="font-size: 15px; font-weight: 600; color: #303133; margin: 0 0 16px 0; line-height: 1;">状态与保留</div>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="卡状态" prop="cardStatus">
<el-form-item label="卡状态" prop="cardStatus">
<el-select v-model="formData.cardStatus" 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-select>
</el-form-item>
</el-col>
......@@ -209,7 +229,7 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可变更5元套餐" prop="packageChange5yuan">
<el-form-item label="可变更 5 元套餐" prop="packageChange5yuan">
<el-switch v-model="formData.packageChange5yuan"></el-switch>
</el-form-item>
</el-col>
......@@ -237,7 +257,7 @@
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="绑定企业微信" prop="linkedWecom">
<el-input v-model="formData.linkedWecom" placeholder="关联的企账号"></el-input>
<el-input v-model="formData.linkedWecom" placeholder="关联的企业微信账号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
......@@ -253,7 +273,6 @@
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
......
(function attachPhoneListRuntime() {
(function attachPhoneListRuntime() {
const PHONE_TABLE_COLUMN_WIDTHS = [
'48px',
'calc((100% - 48px) * 0.12)',
......@@ -16,164 +16,161 @@
name: 'PhoneListTable',
props: {
phoneText: { type: Object, required: true },
phoneTableTotal: { type: Number, required: true },
phoneTableFilterLabels: { type: Array, required: true },
phoneTablePageSizes: { type: Array, required: true },
mountState: { type: Object, required: true },
globalState: { type: Object, required: true },
detailDrawer: { type: Object, required: true },
getPhoneRowsByPage: { type: Function, required: true },
getVisiblePageNumbers: { type: Function, required: true },
loadPhoneRows: { type: Function, required: true },
deletePhoneRecord: { type: Function, required: true },
openAddDialog: { type: Function, required: true },
openDrawer: { type: Function, required: true },
getPhoneStatusDotClass: { type: Function, required: true },
renderPhoneDetailDrawer: { type: Function, required: true },
getPhoneRecordById: { type: Function, required: true },
openDrawer: { type: Function, required: true }
notifyError: { type: Function, required: true }
},
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.phone || 201);
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 allRecordsCache = ref([]);
const loading = ref(false);
const multipleSelection = ref([]);
const multipleTableRef = ref(null);
const totalCount = computed(() => allRecordsCache.value.length);
const normalCount = computed(() => allRecordsCache.value.filter((record) => record.status === '正常').length);
const abnormalCount = computed(() => allRecordsCache.value.filter((record) => record.status !== '正常').length);
/**
* 代码作用(白话):把分页、每页条数和勾选结果同步回外层挂载状态,保证路由切换后还能恢复现场;关联文件: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);
}
const fetchAllRecords = async () => {
/**
* 代码作用(白话):向外层统一数据入口请求手机号列表,并把结果缓存到当前组件;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):页面挂载/删除成功/保存成功 -> loadAllRecords() -> loadPhoneRows() -> 列表渲染。
*/
async function loadAllRecords() {
loading.value = true;
try {
const res = await fetch('http://localhost:8080/api/wx-phones');
const json = await res.json();
if (json.code === 200 && Array.isArray(json.data)) {
// Map the Spring Boot fields to frontend table expected structure
allRecordsCache.value = json.data.map(item => ({
id: item.id,
phone: item.phoneNumber || '',
realPerson: item.realPerson || '',
city: item.city || '',
carrier: item.channelOperator || '',
status: item.cardStatus || '正常',
wechatStatus: (item.numberStatus !== false) ? '正常' : '异常停机',
outbound: item.outboundCall ? '可外呼' : '不可外呼',
updatedAt: item.updateTime || '', // Map updateTime from DB if available
...item // Keep original fields for the drawer
}));
} else {
const rows = await props.loadPhoneRows();
allRecordsCache.value = Array.isArray(rows) ? rows : [];
} catch (error) {
allRecordsCache.value = [];
}
} catch (err) {
console.error('Failed to fetch wx-phones', err);
props.notifyError(error.message || props.phoneText.loadFailed);
} finally {
loading.value = false;
}
};
window.Vue.onMounted(() => {
fetchAllRecords();
window.addEventListener('phone-list-refresh', fetchAllRecords);
});
window.Vue.onUnmounted(() => {
window.removeEventListener('phone-list-refresh', fetchAllRecords);
});
}
const filteredRecords = computed(() => {
let result = allRecordsCache.value;
if (filterStatus.value !== 'all') {
result = result.filter(r => r.status === filterStatus.value);
result = result.filter((record) => record.status === filterStatus.value);
}
if (filterCarrier.value !== 'all') {
result = result.filter(r => r.carrier === filterCarrier.value);
result = result.filter((record) => record.carrier === filterCarrier.value);
}
if (filterOutbound.value !== 'all') {
result = result.filter(r => r.outbound === filterOutbound.value);
result = result.filter((record) => record.outbound === filterOutbound.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.updatedAt) return false;
const t = new Date(r.updatedAt).getTime();
return t >= start && t < end;
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 q = searchQuery.value.toLowerCase();
result = result.filter(r =>
(r.phone && r.phone.toLowerCase().includes(q)) ||
(r.realPerson && r.realPerson.toLowerCase().includes(q)) ||
(r.city && r.city.toLowerCase().includes(q))
);
const query = searchQuery.value.toLowerCase();
result = result.filter((record) => {
return (record.phone && record.phone.toLowerCase().includes(query)) ||
(record.realPerson && record.realPerson.toLowerCase().includes(query)) ||
(record.city && record.city.toLowerCase().includes(query));
});
}
return result;
});
const totalFiltered = computed(() => filteredRecords.value.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalFiltered.value / pageSize.value)));
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
return filteredRecords.value.slice(start, start + pageSize.value);
});
window.Vue.watch([filterStatus, filterCarrier, filterOutbound, filterDate, searchQuery], () => {
currentPage.value = 1;
syncMountState();
});
function syncMountState() {
props.mountState.currentPage = currentPage.value;
props.mountState.pageSize = pageSize.value;
props.mountState.selectedIds = [].concat(checkedIds.value);
}
function buildTotalPages() {
return Math.max(1, Math.ceil(totalFiltered.value / pageSize.value));
}
function buildVisibleRows() {
const start = (currentPage.value - 1) * pageSize.value;
return filteredRecords.value.slice(start, start + pageSize.value);
/**
* 代码作用(白话):记录当前聚焦的手机号行,并同步给外层全局状态,保证查看详情和返回列表时选中项一致;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js;关联逻辑(调用链/消息链/数据流):表格行点击/操作按钮 -> focusRow() -> globalState.selectedBySource.phone -> 详情抽屉或下次挂载。
*/
function focusRow(recordId) {
selectedRecordId.value = recordId;
props.globalState.selectedBySource.phone = recordId;
syncMountState();
}
const totalPages = computed(buildTotalPages);
const visibleRows = computed(buildVisibleRows);
/**
* 代码作用(白话):判断一行手机号是否为当前选中行,用于表格高亮;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):selectedRecordId -> isRowSelected() -> 行 class 绑定。
*/
function isRowSelected(recordId) {
return selectedRecordId.value === recordId;
}
/**
* 代码作用(白话):把状态文字映射到外层约定的圆点颜色 class,保持状态样式统一;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):row.status/wechatStatus/outbound -> getStatusClass() -> status-dot class。
*/
function getStatusClass(status) {
return props.getPhoneStatusDotClass(status);
}
function focusRow(recordId) {
selectedRecordId.value = recordId;
props.globalState.selectedBySource.phone = recordId;
syncMountState();
}
const multipleSelection = ref([]);
const multipleTableRef = ref(null);
function handleSelectionChange(val) {
multipleSelection.value = val;
checkedIds.value = val.map(r => r.id);
/**
* 代码作用(白话):同步 Element Plus 表格的多选结果,给底部统计和路由恢复使用;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):selection-change -> handleSelectionChange() -> checkedIds/mountState。
*/
function handleSelectionChange(rows) {
multipleSelection.value = rows;
checkedIds.value = rows.map((record) => record.id);
syncMountState();
}
function handleCurrentChange(val) {
currentPage.value = val;
/**
* 代码作用(白话):处理分页页码变化,并把结果写回外层挂载状态;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):分页器 current-change -> handleCurrentChange() -> currentPage/mountState。
*/
function handleCurrentChange(pageNumber) {
currentPage.value = Math.min(Math.max(1, pageNumber), totalPages.value);
syncMountState();
}
function handleSizeChange(val) {
pageSize.value = val;
/**
* 代码作用(白话):处理每页条数变化,并把当前页重置回第一页,避免落在空页;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):分页器 size-change -> handleSizeChange() -> pageSize/currentPage -> visibleRows。
*/
function handleSizeChange(size) {
pageSize.value = size;
currentPage.value = 1;
syncMountState();
}
/**
* 代码作用(白话):把筛选、搜索、分页和多选状态恢复成初始值,方便用户重新开始筛选;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):重置按钮 -> resetTableState() -> 各筛选状态归零 -> mountState 同步。
*/
function resetTableState() {
currentPage.value = 1;
pageSize.value = props.phoneTablePageSizes[0];
checkedIds.value = [selectedRecordId.value];
checkedIds.value = selectedRecordId.value ? [selectedRecordId.value] : [];
filterStatus.value = 'all';
filterCarrier.value = 'all';
filterOutbound.value = 'all';
......@@ -185,35 +182,51 @@
syncMountState();
}
function handleRowAction(mode, recordId) {
/**
* 代码作用(白话):统一处理手机号列表的查看、编辑、删除动作,把副作用交给外层注入的动作入口;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):操作按钮 -> handleRowAction() -> openDrawer()/deletePhoneRecord() -> 详情抽屉或接口删除 -> reload。
*/
async function handleRowAction(mode, recordId) {
focusRow(recordId);
const record = allRecordsCache.value.find(r => r.id === recordId);
if (mode === 'edit') {
if (window.PhoneAddDialogAPI) {
window.PhoneAddDialogAPI.open('edit', record);
const record = allRecordsCache.value.find((item) => item.id === recordId);
if (!record) {
return;
}
} else if (mode === 'delete') {
if (confirm('确认删除该号卡记录吗?')) {
fetch('http://localhost:8080/api/wx-phones/' + recordId, { method: 'DELETE' })
.then(() => fetchAllRecords());
if (mode === 'view' || mode === 'edit') {
props.openDrawer(mode, recordId);
return;
}
} else {
if (window.PhoneDetailDrawerAPI) {
window.PhoneDetailDrawerAPI.open(record);
if (!confirm(props.phoneText.deleteConfirm)) {
return;
}
loading.value = true;
try {
await props.deletePhoneRecord(recordId);
await loadAllRecords();
} catch (error) {
props.notifyError(error.message || props.phoneText.deleteFailed);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):打开手机号新增弹窗,让新增动作也走外层统一入口;关联文件: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() {
if (window.PhoneAddDialogAPI) {
window.PhoneAddDialogAPI.open('add');
}
props.openAddDialog('add');
}
window.Vue.onMounted(loadAllRecords);
syncMountState();
return {
PHONE_TABLE_COLUMN_WIDTHS,
totalCount,
normalCount,
abnormalCount,
currentPage,
pageSize,
visibleRows,
......@@ -239,19 +252,52 @@
};
},
template: `
<div style="padding: 20px; box-sizing: border-box; display: flex; flex-direction: column; gap: 20px;">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>号卡管理</el-breadcrumb-item>
<el-breadcrumb-item>号卡列表</el-breadcrumb-item>
</el-breadcrumb>
<div style="padding: 24px; box-sizing: border-box; display: flex; flex-direction: column; gap: 24px;">
<div class="cards-row">
<div class="kcard">
<div class="kcard-left">
<div class="kcard-icon blue">
<svg viewBox="0 0 24 24"><path d="M17 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Zm-5 18a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/></svg>
</div>
<div class="kcard-info">
<div class="kcard-title">手机号总数</div>
<div class="kcard-num">{{ totalCount }}</div>
<div class="kcard-sub">全部在管实名号卡</div>
</div>
</div>
</div>
<div class="kcard">
<div class="kcard-left">
<div class="kcard-icon green">
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div class="kcard-info">
<div class="kcard-title">正常在用</div>
<div class="kcard-num">{{ normalCount }}</div>
<div class="kcard-sub">状态正常且可继续使用</div>
</div>
</div>
</div>
<div class="kcard">
<div class="kcard-left">
<div class="kcard-icon orange">
<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4m0 4h.01"/></svg>
</div>
<div class="kcard-info">
<div class="kcard-title">异常或停机</div>
<div class="kcard-num">{{ abnormalCount }}</div>
<div class="kcard-sub">需要复核或已停机</div>
</div>
</div>
</div>
</div>
<div class="table-area table-area-refined" style="background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; flex-direction: column; gap: 16px;">
<div class="table-toolbar table-toolbar-refined" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px;">
<div class="toolbar-left" style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
<div class="table-area table-area-refined">
<div class="table-toolbar table-toolbar-refined">
<div class="toolbar-left">
<div class="tabs tabs-refined">
<button
v-for="label in phoneTableFilterLabels.slice(0,4)"
v-for="label in phoneTableFilterLabels"
:key="label"
type="button"
class="tab-btn tab-btn-refined"
......@@ -259,18 +305,17 @@
@click="filterStatus = (label === '全部' ? 'all' : label)"
>{{ label }}</button>
</div>
<div style="width: 1px; height: 24px; background: #e5e7eb;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<div class="fselect">运营商</div>
<el-select v-model="filterCarrier" placeholder="运营商" style="width: 120px;">
<div style="width: 1px; height: 24px; background: #e5e7eb; margin: 0 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;" class="filter-selects filter-selects-refined">
<el-select v-model="filterCarrier" placeholder="运营商" 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-option label="虚商" value="虚商"></el-option>
</el-select>
<div class="fselect">外呼</div>
<el-select v-model="filterOutbound" placeholder="外呼权限" style="width: 120px;">
<el-select v-model="filterOutbound" placeholder="外呼权限" 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>
......@@ -281,46 +326,44 @@
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px;"
style="width: 240px; height: 32px;"
></el-date-picker>
<el-input
v-model="searchQuery"
placeholder="搜索手机号、实名人或城市"
clearable
style="width: 260px;"
>
<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 class="search-box">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" v-model="searchQuery" :placeholder="phoneText.searchPlaceholder">
</div>
</div>
<div class="toolbar-right" style="display: flex; gap: 8px; align-items: center;">
<button class="btn-reset btn-reset-refined" style="background: #111827; color: white; border-color: #111827; border-radius: 6px; padding: 0 16px; height: 32px; font-weight: 500;" type="button" @click="handleAdd">添加号卡</button>
<button class="btn-reset btn-reset-refined" type="button" @click="resetTableState">重置</button>
</div>
<div class="toolbar-right">
<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" style="height: 32px;">{{ phoneText.reset }}</button>
</div>
</div>
<div class="table-wrap table-wrap-refined">
<el-table
ref="multipleTableRef"
:data="visibleRows"
row-key="id"
style="width: 100%"
class="domain-table domain-table-refined"
@selection-change="handleSelectionChange"
v-loading="loading"
header-cell-style="{ background: '#fafafa', color: '#4b5563', fontWeight: 500, padding: '12px 20px', borderBottom: '1px solid #f3f4f6' }"
cell-style="{ padding: '12px 20px', borderBottom: '1px solid #f3f4f6', color: '#111827' }"
>
<el-table-column type="selection" width="50" />
<el-table-column label="手机号" min-width="140">
<template #default="scope">
<a href="#" class="domain-link" @click.prevent="focusRow(scope.row.id)">{{ scope.row.phone }}</a>
<div class="domain-inline-meta">{{ scope.row.project || '—' }}</div>
<div class="cell-sub">{{ scope.row.project || '-' }}</div>
</template>
</el-table-column>
<el-table-column label="实名人" min-width="120">
<template #default="scope">
<div class="date-stack">
<span>{{ scope.row.realPerson }}</span>
<div class="cell-sub">{{ scope.row.owner || '—' }}</div>
<div class="cell-sub">{{ scope.row.owner || '-' }}</div>
</div>
</template>
</el-table-column>
......@@ -328,12 +371,12 @@
<template #default="scope">
<div class="date-stack">
<span>{{ scope.row.city }}</span>
<div class="cell-sub">{{ scope.row.usageLocation || '—' }}</div>
<div class="cell-sub">{{ scope.row.usageLocation || '-' }}</div>
</div>
</template>
</el-table-column>
<el-table-column property="carrier" label="运营商" min-width="100" />
<el-table-column label="状态" min-width="100">
<el-table-column label="号卡状态" min-width="100">
<template #default="scope">
<span class="status-chip-wrap">
<span class="status-dot" :class="getStatusClass(scope.row.status)"></span>
......@@ -361,20 +404,23 @@
<el-table-column label="操作" width="160" fixed="right">
<template #default="scope">
<div class="action-cluster" style="display: flex; gap: 8px;">
<el-button link type="primary" @click.stop="handleRowAction('view', scope.row.id)" style="padding: 0;">查看</el-button>
<el-button link type="primary" @click.stop="handleRowAction('edit', scope.row.id)" style="padding: 0;">编辑</el-button>
<el-button link type="danger" @click.stop="handleRowAction('delete', scope.row.id)" style="padding: 0;">删除</el-button>
<button class="action-pill action-pill-primary" type="button" @click.stop="handleRowAction('view', scope.row.id)" style="border: none; background: transparent; color: #2563eb; cursor: pointer; padding: 4px; font-weight: 500;">查看</button>
<button class="action-pill" type="button" @click.stop="handleRowAction('edit', scope.row.id)" style="border: none; background: transparent; color: #4b5563; cursor: pointer; padding: 4px;">编辑</button>
<button class="action-pill" type="button" @click.stop="handleRowAction('delete', scope.row.id)" style="border: none; background: transparent; color: #ef4444; cursor: pointer; padding: 4px;">删除</button>
</div>
</template>
</el-table-column>
</el-table>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px;">
<div style="color: #6b7280; font-size: 14px;">已选 {{ checkedIds.length }} 项</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">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:page-sizes="phoneTablePageSizes"
layout="total, sizes, prev, pager, next, jumper"
:total="totalFiltered"
@size-change="handleSizeChange"
......@@ -383,9 +429,14 @@
</div>
</div>
</div>
</div>
</div>
`
};
/**
* 代码作用(白话):把手机号列表组件挂载到指定 DOM 根节点,并注入 Element Plus 能力;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime('phone') -> mountPhoneListComponent() -> createApp().mount()。
*/
function mountPhoneListComponent(options) {
const { createApp } = window.Vue;
const app = createApp(PhoneListTable, options);
......@@ -400,5 +451,3 @@
mountPhoneListComponent
};
})();
(function attachPhoneApiClient() {
const BASE_URL = 'http://localhost:8080/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;
}
/**
* 代码作用(白话):从本地手机号接口拉取完整列表,返回后端 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 response = await fetch(BASE_URL);
const json = await parseJsonResponse(response);
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;
const response = await fetch(targetUrl, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await parseJsonResponse(response);
return json.data || json;
}
/**
* 代码作用(白话):删除指定手机号记录,并把失败信息统一抛给外层处理;关联文件: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 response = await fetch(`${BASE_URL}/${id}`, {
method: 'DELETE'
});
const json = await parseJsonResponse(response);
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 || '暂无备注';
return {
...source,
realName: source.realName || source.real_name || '',
real_name: source.real_name || source.realName || '',
alias: source.alias || source.alias_name || source.aliasName || '',
alias_name: source.alias_name || source.alias || source.aliasName || '',
account: source.account || source.wxId || '',
department: departmentMap[source.department] || source.department || source.department_name || source.departmentName || '未分配',
department_name: source.department_name || source.department || source.departmentName || '',
phone: source.phone || source.phone_number || source.phoneNumber || '',
phone_number: source.phone_number || source.phone || source.phoneNumber || '',
owner: source.owner || source.real_name_owner || source.realNameOwner || '',
real_name_owner: source.real_name_owner || source.owner || source.realNameOwner || '',
ownerStatus: status,
real_name_owner_status: status === '正常' ? 1 : 0,
status,
wxId: source.wxId || source.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: source.account_location || source.accountLocation || source['账号位置'] || '',
phone_image: source.phone_image || source.phoneImage || source['手机图片'] || ''
};
}
/**
* 代码作用(白话):把手机号原始记录整理成列表、详情抽屉、编辑弹窗共用的统一字段,避免每个组件自己猜字段名;关联文件: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 注入 -> 手机号列表/详情/编辑弹窗。
*/
function normalizePhoneRecord(row) {
const source = row || {};
const cardStatus = source.cardStatus || source.status || '正常';
const phoneNumber = source.phoneNumber || source.phone || '';
const channelOperator = source.channelOperator || source.carrier || '';
const updatedAt = source.updatedAt || source.updateTime || '';
const usageLocation = source.cardUsageLocation || source.usageLocation || '';
const wechatStatus = source.wechatStatus || (source.numberStatus === false ? '异常停机' : '正常');
const outbound = source.outbound || (source.outboundCall ? '可外呼' : '不可外呼');
return {
...source,
id: source.id,
phone: phoneNumber,
phoneNumber,
realPerson: source.realPerson || '',
city: source.city || '',
usageLocation,
cardUsageLocation: usageLocation,
carrier: channelOperator,
channelOperator,
status: cardStatus,
cardStatus,
wechatStatus,
outbound,
outboundCall: source.outboundCall === true || source.outbound === 'available' || source.outbound === '可外呼',
wechat: source.wechat === true || source.wechat === 'enabled',
wecom: source.wecom === true || source.wecom === 'enabled',
updatedAt,
updateTime: source.updateTime || updatedAt,
owner: source.owner || source.realPerson || '',
project: source.project || source.note || '暂无备注',
note: source.note || source.project || '',
iccid: source.iccid || '',
douyinAccount: source.douyinAccount || '',
miniProgramFiling: source.miniProgramFiling === true,
packageChange5yuan: source.packageChange5yuan === true,
numberStatus: source.numberStatus !== false,
linkedWecom: source.linkedWecom || '',
numberRetentionStatus: source.numberRetentionStatus !== false
};
}
window.RecordAdapters = {
normalizeWechatRecord,
normalizePhoneRecord
};
})();
(function attachWechatApiClient() {
const BASE_URL = 'http://localhost:8080/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;
}
/**
* 代码作用(白话):从后端拉取企微资料全量列表,交给页面继续做本地筛选和分页;关联文件: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 response = await fetch(BASE_URL);
const json = await parseJsonResponse(response);
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 response = await fetch(`${BASE_URL}/${id}`);
const json = await parseJsonResponse(response);
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 response = await fetch(targetUrl, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await parseJsonResponse(response);
return json.data || json;
}
window.WechatApiClient = {
listWechatRecords,
getWechatRecord,
saveWechatRecord
};
})();
(function initWechatAddDialog() {
(function initWechatAddDialog() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
const WechatAddDialog = {
setup() {
const visible = ref(false);
const formRef = ref(null);
const isEditMode = ref(false);
const currentId = ref(null);
const formData = reactive({
/**
* 代码作用(白话):给企微新增/编辑弹窗准备一份干净的默认表单对象,避免多次打开时残留上一次输入;关联文件: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: '',
......@@ -20,71 +17,137 @@
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' }]
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' }]
};
const open = (mode = 'add', record = null) => {
isEditMode.value = mode === 'edit';
visible.value = true;
/**
* 代码作用(白话):把当前表单恢复成新增默认值,避免编辑态数据串到下一次打开的新增弹窗;关联文件: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());
}
if (formRef.value) {
formRef.value.resetFields();
/**
* 代码作用(白话):根据已有记录推导实名状态下拉框的默认值,兼容详情态和列表态两种字段命名;关联文件: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;
}
if (mode === 'edit' && record) {
currentId.value = record.id;
/**
* 代码作用(白话):把外部记录回填到编辑表单里,统一兼容前端适配后的字段和后端原始字段;关联文件: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 || '';
formData.department_name = record.department || record.department_name || record.departmentName || '';
formData.gender = record.gender || '男';
formData.phone_number = record.phone || record.phone_number || '';
formData.real_name_owner = record.owner || record.real_name_owner || '';
formData.account_location = record.account_location || record['账号位置'] || '';
formData.phone_image = record.phone_image || record['手机图片'] || '';
let statusVal = 1;
if (record.ownerStatus === '异常' || record.real_name_owner_status === 0) {
statusVal = 0;
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);
}
formData.real_name_owner_status = statusVal;
/**
* 代码作用(白话):按新增或编辑模式打开企微弹窗,并在打开时决定是重置表单还是回填旧记录;关联文件: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 {
currentId.value = null;
formData.real_name = '';
formData.account = '';
formData.alias_name = '';
formData.department_name = '';
formData.gender = '男';
formData.phone_number = '';
formData.real_name_owner = '';
formData.account_location = '';
formData.phone_image = '';
formData.real_name_owner_status = 1;
resetFormData();
}
window.setTimeout(() => {
formRef.value?.clearValidate?.();
}, 0);
}
};
const close = () => {
/**
* 代码作用(白话):关闭当前企微弹窗,只负责收起弹窗显示状态;关联文件: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;
};
}
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate((valid) => {
if (valid) {
/**
* 代码作用(白话):在表单校验通过后调用外层保存动作,并在成功后刷新企微列表;关联文件: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();
} else {
return false;
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
......@@ -96,6 +159,7 @@
formRef,
formData,
rules,
submitting,
close,
handleSubmit
};
......@@ -111,7 +175,7 @@
>
<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.5px;">
<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'">
......@@ -122,25 +186,23 @@
<div style="padding: 0 40px 20px 40px;">
<el-form :model="formData" :rules="rules" ref="formRef" label-position="top">
<!-- Section: 基础属性 -->
<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.5px;">基础属性</h4>
<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-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 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-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>
......@@ -155,61 +217,50 @@
</el-row>
</div>
<!-- Section: 账号与实名 -->
<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.5px;">账号与安全</h4>
<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-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-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 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">
<span style="display: inline-flex; align-items: center; gap: 8px;">
<span style="width: 6px; height: 6px; border-radius: 50%; background: #22c55e;"></span> 正常
</span>
</el-option>
<el-option label="异常 / 封号" :value="0">
<span style="display: inline-flex; align-items: center; gap: 8px;">
<span style="width: 6px; height: 6px; border-radius: 50%; background: #ef4444;"></span> 异常 / 封号
</span>
</el-option>
<el-option label="正常" :value="1"></el-option>
<el-option label="异常 / 封号" :value="0"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- Section: 硬件及图片 -->
<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.5px;">设备与存档</h4>
<el-form-item label="账号常驻物理位置" prop="account_location" style="margin-bottom: 16px;">
<el-input v-model="formData.account_location" placeholder="例如:深圳南山区科技园工位 2A" size="large"></el-input>
<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="输入 URL (支持 HTTPS 外部图片)" size="large"></el-input>
<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'"/>
<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>
......@@ -218,8 +269,8 @@
<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" 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)'">
保存记录
<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>
......
(function initWechatDetailDrawer() {
const { createApp, ref, reactive } = window.Vue;
(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);
const open = (record) => {
if (!record) return;
/**
* 代码作用(白话):接收外层选中的企微记录并打开详情抽屉,是右侧详情面板的统一入口;关联文件: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;
};
}
const close = () => {
/**
* 代码作用(白话):关闭当前详情抽屉,只负责收起右侧面板;关联文件:F:/Project/xyw_console/src/modules/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):关闭按钮 -> close() -> visible=false。
*/
function close() {
visible.value = false;
};
}
const handleEdit = () => {
/**
* 代码作用(白话):从详情抽屉直接跳到编辑弹窗,复用当前记录的编辑能力;关联文件: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);
}
};
}
// Expose to global scope so app-v2.js can call it
window.WechatDetailDrawerAPI = {
open
};
......@@ -44,25 +57,19 @@
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 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.5px; display: flex; align-items: center; gap: 10px;">
<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="{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: '8px', height: '8px', borderRadius: '50%',
background: (recordData.ownerStatus === '异常' || recordData.real_name_owner_status === 0) ? '#ef4444' : '#22c55e'
}"></span>
<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; font-weight: 400; letter-spacing: 0.2px;">
{{ recordData.account || '—' }} &nbsp;&middot;&nbsp; {{ recordData.department || recordData.department_name || '未知部门' }}
<div style="font-size: 14px; color: #6b7280;">
{{ recordData.account || '-' }} &nbsp;&middot;&nbsp; {{ recordData.department || recordData.department_name || '未分配部门' }}
</div>
</div>
</div>
......@@ -72,61 +79,53 @@
</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>
<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>
<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>
<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 || '—' }}</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.createdAt || recordData.created_at || '—' }}</span>
</div>
<div style="grid-column: 1 / -1; 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; line-height: 1.5;">{{ recordData.remark || '暂无备注说明' }}</span>
<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 2: Realname & Device -->
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px;">实名与设备</h4>
<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>
<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>
<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['账号位置'] || '—' }}</span>
<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['手机图片']" 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; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);">
<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['手机图片']"
:preview-src-list="[recordData.phone_image || recordData['手机图片']]"
: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>
......@@ -134,30 +133,8 @@
</div>
</div>
</section>
<!-- Section 3: Activity Logs -->
<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 v-if="recordData.notes && recordData.notes.length > 0" style="display: flex; flex-direction: column; gap: 24px;">
<div v-for="(note, index) in recordData.notes" :key="index" style="display: flex; gap: 16px;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 4px;">
<div :style="{ width: '8px', height: '8px', borderRadius: '50%', background: index === 0 ? '#111827' : '#d1d5db', flexShrink: 0 }"></div>
<div v-if="index !== recordData.notes.length - 1" style="width: 1px; flex: 1; background: #e5e7eb;"></div>
</div>
<div style="display: flex; flex-direction: column; gap: 4px; padding-bottom: index !== recordData.notes.length - 1 ? 24 : 0;">
<span :style="{ fontSize: '14px', fontWeight: index === 0 ? '500' : '400', color: index === 0 ? '#111827' : '#4b5563', lineHeight: '1.4' }">{{ note[1] }}</span>
<span style="font-size: 12px; color: #9ca3af; font-family: ui-monospace, SFMono-Regular, monospace;">{{ note[0] }}</span>
</div>
</div>
</div>
<div v-else style="padding: 24px; background: #f9fafb; border-radius: 12px; border: 1px dashed #e5e7eb; text-align: center; color: #9ca3af; font-size: 13px;">
暂无跟进动态
</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'">
关闭面板
......
(function attachWechatListRuntime() {
const WECHAT_TABLE_COLUMN_WIDTHS = [
'48px',
'calc((100% - 48px) * 0.14)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.14)',
'calc((100% - 48px) * 0.16)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.15)',
'calc((100% - 48px) * 0.08)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.09)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)'
];
......@@ -16,168 +15,262 @@
name: 'WechatListTable',
props: {
wechatText: { type: Object, required: true },
wechatTableTotal: { type: Number, required: true },
wechatTableFilterLabels: { type: Array, required: true },
wechatTablePageSizes: { type: Array, required: true },
mountState: { type: Object, required: true },
globalState: { type: Object, required: true },
detailDrawer: { type: Object, required: true },
getWechatRowsByPage: { type: Function, required: true },
loadWechatRows: { type: Function, required: true },
openAddDialog: { type: Function, required: true },
openDrawer: { type: Function, required: true },
getVisiblePageNumbers: { type: Function, required: true },
getWechatStatusDotClass: { type: Function, required: true },
renderWechatDetailDrawer: { type: Function, required: true },
getWechatRecordById: { type: Function, required: true },
openDrawer: { 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);
const pageSize = ref(props.mountState.pageSize);
const selectedRecordId = ref(props.globalState.selectedBySource.wechat || 101);
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);
const allRecordsCache = ref(null);
function getAllRecords() {
if (!allRecordsCache.value) {
allRecordsCache.value = props.getWechatRowsByPage(1, props.wechatTableTotal);
/**
* 代码作用(白话):把分页和勾选状态同步回外层挂载状态,保证路由切走再切回来时还能恢复现场;关联文件: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;
}
return allRecordsCache.value;
}
const filteredRecords = computed(() => {
let result = getAllRecords();
/**
* 代码作用(白话):按状态、部门、项目、日期和搜索词过滤企微列表,让页面仍保留现有的本地筛选体验;关联文件: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(r => r.status === filterStatus.value);
result = result.filter((record) => record.status === filterStatus.value);
}
if (filterDepartment.value !== 'all') {
result = result.filter(r => r.department === filterDepartment.value);
result = result.filter((record) => record.department === filterDepartment.value);
}
if (filterProject.value !== 'all') {
result = result.filter(r => r.project === filterProject.value);
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(r => {
if (!r.updatedAt) return false;
const t = new Date(r.updatedAt).getTime();
return t >= start && t < end;
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 q = searchQuery.value.toLowerCase();
result = result.filter(r =>
(r.realName && r.realName.toLowerCase().includes(q)) ||
(r.phone && r.phone.toLowerCase().includes(q)) ||
(r.wxId && r.wxId.toLowerCase().includes(q)) ||
(r.department && r.department.toLowerCase().includes(q))
const query = searchQuery.value.toLowerCase();
result = result.filter((record) =>
(record.realName && record.realName.toLowerCase().includes(query)) ||
(record.phone && record.phone.toLowerCase().includes(query)) ||
(record.wxId && record.wxId.toLowerCase().includes(query)) ||
(record.department && record.department.toLowerCase().includes(query))
);
}
return result;
});
}
const totalFiltered = computed(() => filteredRecords.value.length);
/**
* 代码作用(白话):从当前全量列表中提取部门选项,避免写死部门值导致后端真实数据筛不出来;关联文件: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)));
}
window.Vue.watch([filterStatus, filterDepartment, filterProject, filterDate, searchQuery], () => {
currentPage.value = 1;
});
/**
* 代码作用(白话):从当前全量列表中提取项目选项,让后端真实备注字段也能参与筛选;关联文件: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)));
}
function syncMountState() {
props.mountState.currentPage = currentPage.value;
props.mountState.pageSize = pageSize.value;
props.mountState.selectedIds = [].concat(checkedIds.value);
/**
* 代码作用(白话):在任一筛选条件变化时把当前页重置回第一页,避免用户停留在旧页码看到空表格;关联文件: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;
for (let index = 0; index < visibleRows.value.length; index += 1) {
if (!checkedIds.value.includes(visibleRows.value[index].id)) return false;
if (!visibleRows.value.length) {
return false;
}
return true;
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();
if (!props.detailDrawer.classList.contains('hidden')) {
props.renderWechatDetailDrawer(props.getWechatRecordById(recordId));
}
}
/**
* 代码作用(白话):切换单行复选框时,把当前记录加入或移出批量勾选集合;关联文件: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);
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);
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);
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);
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];
checkedIds.value = selectedRecordId.value ? [selectedRecordId.value] : [];
filterStatus.value = 'all';
filterDepartment.value = 'all';
filterProject.value = 'all';
......@@ -186,11 +279,29 @@
syncMountState();
}
function handleRowAction(mode, recordId) {
/**
* 代码作用(白话):打开企微新增弹窗,让新增入口也走外层统一装配点;关联文件: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/wechat/wechat-detail-drawer.js;关联逻辑(调用链/消息链/数据流):操作按钮 -> handleRowAction() -> openDrawer() -> 详情抽屉或编辑弹窗打开。
*/
async function handleRowAction(mode, recordId) {
focusRow(recordId);
props.openDrawer(mode, recordId);
loading.value = true;
try {
await props.openDrawer(mode, recordId);
} catch (error) {
props.notifyError(error.message || props.wechatText.openFailed);
} finally {
loading.value = false;
}
}
window.Vue.onMounted(loadAllRecords);
syncMountState();
return {
......@@ -201,6 +312,8 @@
pageNumbers,
checkedIds,
allVisibleChecked,
departmentOptions,
projectOptions,
isRowChecked,
isRowSelected,
getStatusClass,
......@@ -211,13 +324,16 @@
changePage,
handlePageSizeChange,
resetTableState,
handleAdd,
handleRowAction,
filterDate,
searchQuery,
filterStatus,
filterDepartment,
filterProject,
totalFiltered
totalFiltered,
totalPages,
loading
};
},
template: `
......@@ -230,21 +346,16 @@
<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="姝e父" value="姝e父"></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-select v-model="filterDepartment" class="custom-fselect" style="width: 130px; height: 32px;">
<el-select v-model="filterDepartment" class="custom-fselect" style="width: 140px; 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 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: 130px; height: 32px;">
<el-select v-model="filterProject" class="custom-fselect" style="width: 160px; height: 32px;">
<el-option label="项目:全部" value="all"></el-option>
<el-option label="华南投放" value="华南投放"></el-option>
<el-option label="增长实验" value="增长实验"></el-option>
<el-option label="KA 客户" value="KA 客户"></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"
......@@ -254,10 +365,9 @@
end-placeholder="结束日期"
style="width: 240px; height: 32px;"
></el-date-picker>
<div class="fselect">{{ wechatTableFilterLabels[4] }}</div>
<el-input
v-model="searchQuery"
placeholder="搜索姓名、微信 ID、手机号或部门"
:placeholder="wechatText.searchPlaceholder"
clearable
style="width: 260px; height: 32px;"
>
......@@ -268,11 +378,11 @@
</div>
</div>
<div class="toolbar-right">
<button class="btn btn-primary" type="button" @click="handleAdd" style="height: 32px; padding: 0 16px;">{{ wechatText.add }}</button>
<button class="btn-reset btn-reset-refined" type="button" @click="resetTableState">{{ wechatText.reset }}</button>
<button class="btn-settings btn-settings-refined" type="button" :aria-label="wechatText.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">
<div class="table-wrap table-wrap-refined" v-loading="loading">
<table class="xtable domain-table domain-table-refined">
<colgroup>
<col v-for="width in WECHAT_TABLE_COLUMN_WIDTHS" :key="width" :style="{ width }" />
......@@ -285,33 +395,44 @@
<th>{{ wechatText.phone }}</th>
<th>{{ wechatText.wxId }}</th>
<th>{{ wechatText.status }}</th>
<th>{{ wechatText.createdAt }}</th>
<th>{{ wechatText.owner }}</th>
<th>{{ wechatText.updatedAt }} <svg viewBox="0 0 24 24" width="12" height="12"><path d="M7 10l5 5 5-5z"/></svg></th>
<th>{{ wechatText.updatedAt }}</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)">{{ row.realName }}</a><div class="domain-inline-meta">{{ row.alias || row.role || '—' }}</div></td>
<td><div class="date-stack"><span>{{ row.department }}</span><div class="cell-sub">{{ row.project || '—' }}</div></div></td>
<td class="td-domain">
<a href="#" class="domain-link" @click.prevent="focusRow(row.id)">{{ row.realName }}</a>
<div class="domain-inline-meta">{{ row.alias || row.alias_name || '-' }}</div>
</td>
<td>
<div class="date-stack">
<span>{{ row.department }}</span>
<div class="cell-sub">{{ row.project || row.remark || '暂无备注' }}</div>
</div>
</td>
<td>{{ row.phone }}</td>
<td>{{ row.wxId }}</td>
<td><span class="status-chip-wrap"><span class="status-dot" :class="getStatusClass(row.status)"></span><span>{{ row.status }}</span></span></td>
<td>{{ row.createdAt }}</td>
<td>
<span class="status-chip-wrap">
<span class="status-dot" :class="getStatusClass(row.status)"></span>
<span>{{ row.status }}</span>
</span>
</td>
<td>{{ row.owner }}</td>
<td>{{ row.updatedAt }}</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)">{{ wechatText.view }}</button>
<button class="action-pill" type="button" @click.stop="handleRowAction('edit', row.id)">{{ wechatText.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>
<tr v-if="!visibleRows.length">
<td colspan="9" style="padding: 24px; text-align: center; color: #9ca3af;">暂无企微资料</td>
</tr>
</tbody>
</table>
</div>
......@@ -329,7 +450,7 @@
<span v-else>...</span>
</template>
</div>
<button class="page-btn outline" type="button" @click="changePage('next')" :disabled="currentPage === Math.ceil(totalFiltered / pageSize)">&#8250;</button>
<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>
......@@ -339,6 +460,9 @@
`
};
/**
* 代码作用(白话):把企微列表组件挂载到指定 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);
......@@ -353,6 +477,3 @@
mountWechatListComponent
};
})();
// Reusable wrapper template that provides the DOM structure required by app-v2.js
// Reusable wrapper template that provides the DOM structure required by app-v2.js
const LegacyShellTemplate = `
<div class="legacy-wrapper">
<div id="pageHeader"></div>
......@@ -11,7 +11,7 @@ const LegacyShellTemplate = `
<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>
<button class="drawer-close" id="drawerClose">×</button>
</div>
</div>
`;
......@@ -19,22 +19,19 @@ const LegacyShellTemplate = `
function createLegacyWrapper(moduleName) {
return {
template: LegacyShellTemplate,
/** 代码作用(白话):在当前路由页面挂载后,把旧版 app-v2 的状态和渲染入口接起来,这样 Vue Router 只是负责切页,真正的页面内容仍沿用原来的渲染逻辑;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“RouterView mounted -> updateDOMRefs() -> state.activeModule 切换 -> renderModule() -> updateActiveNav()”。 */
/** 代码作用(白话):路由页面挂载后,把旧版 app-v2 的状态和渲染入口重新接到当前 DOM 上,这样切页后还能沿用原来的页面渲染;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“RouterView mounted -> updateDOMRefs()/refreshDOMRefs() -> state.activeModule 切换 -> renderModule() -> updateActiveNav()”。 */
mounted() {
// Re-initialize the DOM refs because the DOM was just created by Vue.
if (window.updateDOMRefs) {
window.updateDOMRefs();
const domRefUpdater = window.updateDOMRefs || window.refreshDOMRefs;
if (typeof domRefUpdater === 'function') {
domRefUpdater();
}
// Defensive initialization: if the legacy runtime has not exposed state yet, stop here instead of crashing the page.
if (!window.state) {
return;
}
// Force app-v2 state to update.
window.state.activeModule = moduleName;
// Let app-v2 render the module and sync nav highlight.
if (window.renderModule) {
window.renderModule();
}
......@@ -65,36 +62,33 @@ const router = window.VueRouter.createRouter({
routes
});
// Setup global Vue app that mounts the router
const app = window.Vue.createApp({});
app.use(router);
// We need to wait for DOMContentLoaded to mount the Vue app
document.addEventListener('DOMContentLoaded', () => {
// Let the sidebar buttons trigger router.push instead of the old vanilla js navigation
const sidebar = document.getElementById('sidebar');
if (sidebar) {
sidebar.addEventListener('click', (event) => {
/** 代码作用(白话):拦截左侧导航点击,把旧按钮的 `data-module` 映射成 Vue Router 路由地址,避免旧脚本和新路由重复处理一次点击;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/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;
// Update UI active state manually or let updateActiveNav do it
// Map module to path
let path = '/' + mod;
if (mod === 'phone') path = '/phone-card';
const mod = button.dataset.module;
let path = `/${mod}`;
if (mod === 'phone') {
path = '/phone-card';
}
router.push(path);
// We stop propagation so app-v2.js doesn't try to handle it
// (Wait, we can just remove the old listener from app-v2.js)
event.preventDefault();
event.stopPropagation();
}, true); // use capture to intercept
}
/** 代码作用(白话):在页面初次加载时绑定路由壳层需要的导航事件,并把整个 Vue Router 应用挂到主容器;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/app-v2.js;关联逻辑:调用链是“DOMContentLoaded -> initRouterApp() -> 绑定 sidebar 路由点击 -> app.mount('#vue-router-app')”。 */
function initRouterApp() {
const sidebar = document.getElementById('sidebar');
if (sidebar) {
sidebar.addEventListener('click', handleSidebarRouteClick, true);
}
// Mount router to the shell-main
app.mount('#vue-router-app');
});
}
document.addEventListener('DOMContentLoaded', initRouterApp);
@echo off
chcp 65001 >nul
set "BACKEND_DIR=%~dp0backend"
set "PROJECT_JAVA_HOME=F:\jdk-17.0.12_windows-x64_bin\jdk-17.0.12"
set "XYW_DB_USERNAME=root"
set "XYW_DB_PASSWORD=root"
echo ===================================================
echo [Starting Backend]
echo Backend Dir: %BACKEND_DIR%
echo Backend URL: http://localhost:8080
echo JAVA_HOME: %PROJECT_JAVA_HOME%
echo DB User: %XYW_DB_USERNAME%
echo ===================================================
echo.
if not exist "%BACKEND_DIR%\pom.xml" (
echo [Error] Backend pom.xml not found: %BACKEND_DIR%\pom.xml
pause
exit /b 1
)
if not exist "%PROJECT_JAVA_HOME%\bin\java.exe" (
echo [Error] java.exe not found: %PROJECT_JAVA_HOME%\bin\java.exe
pause
exit /b 1
)
if not exist "%PROJECT_JAVA_HOME%\bin\javac.exe" (
echo [Error] javac.exe not found: %PROJECT_JAVA_HOME%\bin\javac.exe
pause
exit /b 1
)
set "JAVA_HOME=%PROJECT_JAVA_HOME%"
set "PATH=%JAVA_HOME%\bin;%PATH%"
java -version
cd /d "%BACKEND_DIR%"
echo (Press Ctrl+C or close this window to stop backend)
mvn spring-boot:run
\ 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
:: Switch code page to UTF-8 to prevent garbled text
chcp 65001 >nul
set PORT=8000
set URL=http://localhost:%PORT%
:: ==========================================
:: Please set BACKEND_DIR to your Spring Boot project path
:: Example: set BACKEND_DIR=..\phone-card-backend
:: ==========================================
set BACKEND_DIR=backend
set FRONTEND_PORT=8000
set FRONTEND_URL=http://localhost:%FRONTEND_PORT%
set BACKEND_URL=http://localhost:8080
set FRONTEND_SCRIPT=%~dp0start-frontend.bat
set BACKEND_SCRIPT=%~dp0start-backend.bat
echo ===================================================
echo [Starting Project]
echo Frontend URL: %URL%
echo Backend URL: http://localhost:8080
echo Frontend URL: %FRONTEND_URL%
echo Backend URL: %BACKEND_URL%
echo ===================================================
:: Copy to clipboard
echo | set /p dummy="%URL%" | clip
echo [Success] URL copied to clipboard!
echo | set /p dummy="%FRONTEND_URL%" | clip
echo [Success] Frontend URL copied to clipboard!
echo.
echo [1/2] Starting Spring Boot Backend...
if exist "%BACKEND_DIR%\pom.xml" (
start "Backend Service" cmd /k "cd /d %BACKEND_DIR% && mvn spring-boot:run"
echo Backend is starting in a new window...
if exist "%BACKEND_SCRIPT%" (
echo [1/2] Starting backend in a separate window...
start "XYW Backend" "%BACKEND_SCRIPT%"
) else (
echo [Warning] Backend directory '%BACKEND_DIR%' not found or no pom.xml inside!
echo Please edit start.bat and change BACKEND_DIR to your Spring Boot project.
echo [Warning] Missing backend launcher: %BACKEND_SCRIPT%
)
:: Open in browser
start "" "%URL%#/phone-card"
echo.
echo [2/2] Starting Python HTTP server for Frontend...
echo (Press Ctrl+C or close this window to stop)
if exist "%FRONTEND_SCRIPT%" (
echo [2/2] Starting frontend in a separate window...
start "XYW Frontend" "%FRONTEND_SCRIPT%"
start "" "%FRONTEND_URL%/#/phone-card"
) else (
echo [Warning] Missing frontend launcher: %FRONTEND_SCRIPT%
)
echo.
python -m http.server %PORT%
echo [Done] Frontend and backend launchers were triggered.
pause
\ No newline at end of file
@echo off
set PORT=8000
setlocal EnableExtensions EnableDelayedExpansion
chcp 65001 >nul
set "FRONTEND_PORT=8000"
set "BACKEND_PORT=8080"
echo ===================================================
echo [Stopping Project]
echo Looking for processes on port %PORT%...
echo Frontend Port: %FRONTEND_PORT%
echo Backend Port: %BACKEND_PORT%
echo ===================================================
echo.
for /f "tokens=5" %%a in ('netstat -aon ^| findstr "LISTENING" ^| findstr ":%PORT%"') do (
echo Found process PID: %%a, terminating...
taskkill /F /PID %%a
echo Server successfully stopped!
goto :end
)
call :stopByPort %FRONTEND_PORT% Frontend
call :stopByPort %BACKEND_PORT% Backend
echo No local server found running on port %PORT%.
:end
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.bat.
REM Related flow: stop.bat -> stopByPort -> killProcess -> release port 8000 or 8080.
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
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