Commit 2c8c26b2 by 过去只有如今

Initial commit

parents
node_modules/
.codegraph/
.codex/
.cursor/
.playwright-mcp/
.impeccable/
.agents/
.claude/
*.log
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"]]
},
{
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"]]
},
{
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"]]
}
],
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"]]
},
{
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"]]
}
],
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"]]
}
],
alerts: [
{ id: 301, title: "demo.ai 需要续费跟进", module: "domain", owner: "Li Si", dueAt: "2025-06-01", status: "pending", updatedAt: "2025-05-23 14:30", remark: "付款仍待处理" }
]
};
const MODULE_CONFIG = {
overview: {
title: "资产管理台",
description: "查看域名、企微、手机号卡和提醒概览。",
actions: [{ text: "导出数据", kind: "secondary" }, { text: "新增记录", kind: "primary" }]
},
domain: {
title: "域名资产",
description: "查看域名归属、续费和 SSL 状态。",
actions: [{ text: "同步域名", kind: "secondary" }, { text: "新增域名", kind: "primary" }]
},
wechat: {
title: "企微资产",
description: "管理企微资料和相关详情。",
actions: [{ text: "同步数据", kind: "secondary" }, { text: "新增资料", kind: "primary" }]
},
phone: {
title: "手机号卡",
description: "跟踪手机号、负责人和号卡状态。",
actions: [{ text: "导入号码", kind: "secondary" }, { text: "新增号卡", kind: "primary" }]
},
alerts: {
title: "提醒中心",
description: "查看需要人工跟进的提醒。",
actions: [{ text: "Export 提醒中心", kind: "secondary" }]
}
};
const state = {
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");
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");
};
/** 代码作用(白话):把字符串里的特殊字符转义掉,避免渲染到 innerHTML 时把页面结构打坏;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:数据流是“记录对象 -> escapeHtml() -> renderHeader/renderBody/renderDetail 输出 HTML”。 */
function escapeHtml(value) {
return String(value ?? "-")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/** 代码作用(白话):根据当前模块名找到对应的数据数组,overview 复用 domain,alerts 走 alerts;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“renderModule() -> getModuleRecords() -> renderBody()/renderDetail()”。 */
function getModuleRecords(moduleKey) {
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];
}
/** 代码作用(白话):把顶部标题、说明和操作按钮渲染到 pageHeader,保证每个模块切换后都有稳定的页头;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“renderModule() -> renderHeader() -> pageHeader.innerHTML”。 */
function renderHeader(moduleKey) {
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("");
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>
</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()”。 */
function renderSimpleBody(moduleKey) {
if (!pageBody) return;
const = getModuleRecords(moduleKey);
if (!.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 || "-";
return `
<button type="button" class="simple-list-row" data-record-id="${record.id}" data-source-key="${escapeHtml(sourceKey)}">
<span class="row-main">${escapeHtml(label)}</span>
<span class="row-sub">${escapeHtml(sub)}</span>
<span class="row-meta">${escapeHtml(meta)}</span>
</button>
`;
}).join("");
pageBody.innerHTML = `<div class="simple-list">${rowsHtml}</div>`;
}
/** 代码作用(白话):把 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() -> 子模块自己渲染列表/抽屉”。 */
function renderExternalRuntime(moduleKey) {
if (!pageBody) return;
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”。 */
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: " 条/页"
},
wechatTableTotal: DATA_SOURCES.wechat.length,
wechatTableFilterLabels: ["全部", "正常", "异常", "项目", "搜索"],
wechatTablePageSizes: [10, 20, 50, 100],
mountState: { currentPage: 1, pageSize: 10, selectedIds: [] },
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"
});
}
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({
phoneText: {
allRecords: "全部记录",
reset: "重置",
rowSetting: "列设置",
selected: "已选",
items: "项",
batchAction: "批量操作",
total: "共",
records: "条",
pageSuffix: " 条/页"
},
phoneTable: DATA_SOURCES.phone.length,
phoneTableFilterLabels: ["全部", "正常", "暂停", "更新"],
phoneTablePageSizes: [10, 20, 50, 100],
mountState: { currentPage: 1, pageSize: 10, selectedIds: [] },
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);
},
mountTarget: "#phoneListRoot"
});
}
}
/** 代码作用(白话):把当前选中记录的关键信息渲染到右侧抽屉,让用户切换列表后能看到对应详情;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“renderModule() / handlePageBodyClick() -> renderDetail() -> detailDrawer/detailBody 更新”。 */
function renderDetail(moduleKey) {
if (!detailDrawer || !detailShellTitle || !detailBody) return;
const record = get已选Record(moduleKey);
if (!record) {
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 fields = Object.entries(record)
.filter(([key, value]) => key !== "log" && key !== "notes" && typeof value !== "object")
.map(([key, value]) => `<div class="detail-item"><label>${escapeHtml(key)}</label><span>${escapeHtml(value)}</span></div>`)
.join("");
detailShellTitle.textContent = title;
detailBody.innerHTML = `<div class="detail-grid">${fields}</div>`;
detailDrawer.classList.remove("hidden");
}
/** 代码作用(白话):同步左侧导航高亮,让当前路由对应的菜单项保持选中;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“Router查看 mounted -> updateActiveNav()” 和 “renderModule() -> updateActiveNav()”。 */
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);
});
}
/** 代码作用(白话):统一根据当前模块刷新页头、主体和详情,是 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()”。 */
function renderModule() {
if (!state.activeModule) return;
if (!pageHeader || !pageBody) return;
renderHeader(state.activeModule);
if (state.activeModule === "wechat" || state.activeModule === "phone") {
renderExternalRuntime(state.activeModule);
} else {
renderSimpleBody(state.activeModule);
}
renderDetail(state.activeModule);
updateActiveNav();
}
/** 代码作用(白话):响应列表点击,把用户点中的记录写回状态,再刷新右侧详情;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:消息链是“pageBody click -> handlePageBodyClick() -> state.selectedBySource 更新 -> renderDetail()”。 */
function handlePageBodyClick(event) {
const button = event.target.closest("[data-record-id][data-source-key]");
if (!button) return;
state.selectedBySource[button.dataset.sourceKey] = Number(button.dataset.recordId);
renderDetail(state.activeModule);
}
/** 代码作用(白话):处理详情抽屉关闭按钮点击,只负责隐藏当前抽屉;关联文件:F:/Project/xyw_console/index.html;关联逻辑:消息链是“drawerClose click -> handleDrawerCloseClick() -> detailDrawer.hidden”。 */
function handleDrawerCloseClick() {
detailDrawer?.classList.add("hidden");
}
/** 代码作用(白话):把静态事件绑定到当前页面壳子上,保证抽屉关闭和简单列表点击在 Router 反复切页后仍然有效;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“DOMContentLoaded/initPage() -> bindLayoutEvents()”,之后由用户点击触发具体处理函数。 */
function bindLayoutEvents() {
pageBody?.removeEventListener("click", handlePageBodyClick);
pageBody?.addEventListener("click", handlePageBodyClick);
drawerClose?.removeEventListener("click", handleDrawerCloseClick);
drawerClose?.addEventListener("click", handleDrawerCloseClick);
}
/** 代码作用(白话):页面初始化入口,先刷新 DOM 引用和事件,再在已有 activeModule 时补一次渲染;关联文件:F:/Project/xyw_console/index.html、F:/Project/xyw_console/src/router/index.js;关联逻辑:调用链是“DOMContentLoaded -> initPage() -> updateDOMRefs()/bindLayoutEvents() -> renderModule()(有模块时)”。 */
function initPage() {
window.updateDOMRefs();
bindLayoutEvents();
if (state.activeModule) {
renderModule();
}
}
window.updateActiveNav = updateActiveNav;
window.renderModule = renderModule;
document.addEventListener("DOMContentLoaded", initPage);
const DATA_SOURCES = {
domain: [
{ id: 1, platform: "阿里云", platformCode: "ALI", domain: "example.com", expirationDate: "2025-05-28", expirationHint: "3天后", autoRenewal: "已开启", sslExpiry: "2025-06-15", sslHint: "21天后", accountId: "aliyun_admin", renewalResult: "续费成功", dns: "阿里云 DNS", owner: "张三", remark: "企业官网主域名", detailStatus: "正常", log: [["2025-05-25 10:30", "续费成功(1年)"], ["2024-05-28 09:15", "续费成功(1年)"]] },
{ id: 2, platform: "腾讯云", platformCode: "TEN", domain: "demo.ai", expirationDate: "2025-06-01", expirationHint: "7天后", autoRenewal: "已开启", sslExpiry: "2025-06-20", sslHint: "26天后", accountId: "tencent_admin", renewalResult: "待处理", dns: "腾讯云 DNSPod", owner: "李四", remark: "投放活动备用域名", detailStatus: "正常", log: [["2025-05-23 14:30", "自动续费待支付"], ["2024-06-01 10:20", "续费成功(1年)"]] },
{ id: 3, platform: "西部数码", platformCode: "WEST", domain: "shop.net", expirationDate: "2025-06-10", expirationHint: "16天后", autoRenewal: "已开启", sslExpiry: "2025-06-25", sslHint: "31天后", accountId: "west_123", renewalResult: "续费失败", dns: "西部数码 DNS", owner: "赵六", remark: "商城二级主站", detailStatus: "正常", log: [["2025-05-24 09:42", "续费失败:余额不足"], ["2024-06-10 12:00", "续费成功(1年)"]] }
],
wechat: [
{ id: 101, realName: "张三", alias: "阿三", account: "zhangsan_88", department: "市场部", phone: "138****8888", owner: "张三", ownerStatus: "正常", wxId: "wxc_3123abcd4567", createdAt: "2023-03-15 14:32", role: "市场专员", remark: "负责华南区域市场推广", notes: [["2024-05-10 10:30", "由管理员添加"], ["2024-05-12 16:30", "客户对接主负责人"]] },
{ id: 102, realName: "李四", alias: "老李", account: "lisi_2023", department: "运营部", phone: "139****1234", owner: "李四", ownerStatus: "异常", wxId: "wxid_lisi00932", createdAt: "2023-04-12 09:20", role: "运营专员", remark: "资料待核对实名一致性", notes: [["2024-04-30 12:00", "实名状态待复核"]] }
],
phone: [
{ id: 201, phone: "138****8888", realPerson: "张三", city: "广东 深圳", usageLocation: "广东 深圳 南山区", carrier: "中国移动", cardStatus: "正常", wechat: "已开通", outbound: "可外呼", iccid: "8986 0117 0230 1234 5678 912", note: "主要用于私域客户联系与外呼。", log: [["2024-03-12 10:23", "号码激活"], ["2024-03-12 10:25", "业务开通完成"]] },
{ id: 202, phone: "176****1234", realPerson: "王五", city: "浙江 杭州", usageLocation: "浙江 杭州", carrier: "中国电信", cardStatus: "异常停机", wechat: "未开通", outbound: "不可外呼", iccid: "8986 0117 8844 2134 9087 122", note: "号码停机,需要核查是否保留。", log: [["2024-05-01 09:20", "检测到停机"], ["2024-05-01 11:00", "已进入人工复核"]] }
]
};
const MODULE_CONFIG = {
overview: { title: "账号与域名资产管理台", searchPlaceholder: "搜索域名、账号、备注等", searchShortcut: "/", pills: [{ text: "系统正常", type: "status" }, { text: "数据更新: <strong>5 分钟前</strong> ↻", type: "sync" }], actions: [{ text: "导出数据", kind: "secondary" }, { text: "新建记录", kind: "primary" }], stats: [{ title: "即将到期域名", value: "18", sub: "7 天内到期 12 个", tone: "blue", icon: "◎", link: "查看详情" }, { title: "实名异常微信", value: "7", sub: "需要处理 7 个", tone: "orange", icon: "◉", link: "查看详情" }, { title: "异常手机号卡", value: "9", sub: "需关注 9 个", tone: "red", icon: "▣", link: "查看详情" }] },
domain: { title: "域名资料管理", searchPlaceholder: "搜索域名、网站平台或绑定账号 ID", searchShortcut: "⌘K", pills: [{ text: "数据更新: <strong>5 分钟前</strong> ↻", type: "sync" }], actions: [{ text: "导出数据", kind: "secondary" }, { text: "同步域名", kind: "secondary" }, { text: "添加域名", kind: "primary" }], stats: [{ title: "即将到期", value: "18", sub: "占全部 12.5%", tone: "blue", icon: "◎", link: "查看列表" }, { title: "自动续费已开启", value: "156", sub: "占全部 75.0%", tone: "green", icon: "✓", link: "查看列表" }, { title: "续费失败", value: "6", sub: "占全部 2.9%", tone: "red", icon: "✕", link: "查看列表" }] },
wechat: { title: "企微资料管理", searchPlaceholder: "搜索姓名、微信号、手机号、部门…", searchShortcut: "", pills: [], actions: [{ text: "同步数据", kind: "secondary" }, { text: "新增资料", kind: "primary" }], stats: [{ title: "资料总数", value: "1,248", sub: "较昨日 ↑ 223", tone: "blue", icon: "◉", link: "查看详情" }, { title: "实名异常", value: "56", sub: "占比 4.49%", tone: "orange", icon: "!", link: "查看详情" }] },
phone: { title: "手机号卡管理", searchPlaceholder: "搜索手机号、联系人、城市、ICCID、运营商等", searchShortcut: "", pills: [], actions: [{ text: "导入号码", kind: "secondary" }, { text: "添加号卡", kind: "primary" }], stats: [{ title: "可用号码", value: "128", sub: "占比 64.0%", tone: "blue", icon: "◔", link: "查看详情" }, { title: "异常号码", value: "16", sub: "占比 8.0%", tone: "orange", icon: "⌂", link: "查看详情" }] },
alerts: { title: "提醒中心", searchPlaceholder: "搜索提醒标题、负责人、模块", searchShortcut: "", pills: [], actions: [{ text: "导出提醒", kind: "secondary" }], stats: [] }
};
const PAGE_SIZE_OPTIONS = [10, 20, 40, 100];
const state = {
activeModule: "overview",
selectedBySource: { domain: 1, wechat: 101, phone: 201 },
paginationBySource: { domain: { page: 1, pageSize: 10 }, wechat: { page: 1, pageSize: 10 }, phone: { page: 1, pageSize: 10 } }
};
const appShell = document.getElementById("appShell");
const pageHeader = document.getElementById("pageHeader");
const pageBody = document.getElementById("pageBody");
const detailDrawer = document.getElementById("detailDrawer");
const detailShellTitle = document.getElementById("detailShellTitle");
const detailBody = document.getElementById("detailBody");
const sidebar = document.getElementById("sidebar");
const sidebarToggle = document.getElementById("sidebarToggle");
const sidebarToggleLabel = sidebarToggle?.querySelector(".nav-toggle-label");
const drawerClose = document.getElementById("drawerClose");
/** 代码作用(白话):转义动态文本,避免字符串里的标签或特殊符号把页面结构顶坏;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:所有 innerHTML 输出前都先走这里,数据流是“数据源 -> escapeHtml() -> 页面片段”。 */
function escapeHtml(value) {
return String(value ?? "—")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/** 代码作用(白话):把模块名转成真实数据源名,让 overview 复用域名数据、alerts 走空态;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderBody()、renderTable()、renderDetail() 都先通过它判断当前该读哪组数据。 */
function getDataSourceKey(moduleKey) {
if (moduleKey === "overview") return "domain";
if (moduleKey === "alerts") return "";
return moduleKey;
}
/** 代码作用(白话):读取当前模块对应的数据数组;关联文件:F:/Project/xyw_console/app.js;关联逻辑:表格、详情、分页都通过它拿基础数据,调用链是“renderModule() -> renderBody()/renderDetail() -> getModuleRecords()”。 */
function getModuleRecords(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
return sourceKey ? (DATA_SOURCES[sourceKey] || []) : [];
}
/** 代码作用(白话):找到当前模块里被选中的那条记录,没有选中时自动兜底第一条;关联文件:F:/Project/xyw_console/app.js;关联逻辑:表格高亮、最近动态、详情抽屉都依赖它,数据流是“state.selectedBySource -> getSelectedRecord() -> 多个渲染方法”。 */
function getSelectedRecord(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
const records = getModuleRecords(moduleKey);
if (!sourceKey || !records.length) return null;
const selectedId = state.selectedBySource[sourceKey] ?? records[0].id;
return records.find((record) => record.id === selectedId) || records[0];
}
/** 代码作用(白话):规范化当前模块的分页状态,保证页码和每页条数都落在合法范围;关联文件:F:/Project/xyw_console/app.js、styles.css;关联逻辑:renderPagination()、getPagedRecords()、翻页事件都会先经过这里,避免分页 UI 和数据切片不同步。 */
function getPaginationState(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
const records = getModuleRecords(moduleKey);
if (!sourceKey || !records.length) return { page: 1, pageSize: PAGE_SIZE_OPTIONS[0], totalPages: 1, totalRecords: records.length };
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
if (!PAGE_SIZE_OPTIONS.includes(paginationState.pageSize)) paginationState.pageSize = PAGE_SIZE_OPTIONS[0];
const totalPages = Math.max(1, Math.ceil(records.length / paginationState.pageSize));
paginationState.page = Math.min(Math.max(1, paginationState.page), totalPages);
return { page: paginationState.page, pageSize: paginationState.pageSize, totalPages, totalRecords: records.length };
}
/** 代码作用(白话):按当前分页状态切出表格真正要显示的记录;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 只渲染它返回的这一页数据,数据流是“完整 records -> getPaginationState() -> getPagedRecords() -> tbody”。 */
function getPagedRecords(moduleKey) {
const records = getModuleRecords(moduleKey);
const pagination = getPaginationState(moduleKey);
const startIndex = (pagination.page - 1) * pagination.pageSize;
return records.slice(startIndex, startIndex + pagination.pageSize);
}
/** 代码作用(白话):根据总页数和当前页,算出分页条应该显示哪些页码和省略号;关联文件:F:/Project/xyw_console/app.js、F:/Project/xyw_console/styles.css;关联逻辑:renderPagination() 先调用它,再把数字渲染成页码按钮。 */
function getVisiblePageNumbers(totalPages, currentPage) {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1);
if (currentPage <= 4) return [1, 2, 3, 4, 5, "ellipsis", totalPages];
if (currentPage >= totalPages - 3) return [1, "ellipsis", totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, "ellipsis", currentPage - 1, currentPage, currentPage + 1, "ellipsis", totalPages];
}
/** 代码作用(白话):把用户输入的目标页码收口到合法范围,再写回分页状态并刷新页面;关联文件:F:/Project/xyw_console/app.js;关联逻辑:handlePaginationChange() 和 handlePaginationKeydown() 都把输入值交给它,消息链是“输入框 -> applyPaginationJump() -> renderModule()”。 */
function applyPaginationJump(rawValue) {
const sourceKey = getDataSourceKey(state.activeModule);
if (!sourceKey) return false;
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
const pagination = getPaginationState(state.activeModule);
const nextPage = Math.min(Math.max(1, Number(rawValue) || pagination.page), pagination.totalPages);
if (nextPage === pagination.page) return false;
paginationState.page = nextPage;
renderModule();
return true;
}
/** 代码作用(白话):让“当前选中记录”始终留在当前页里,避免翻页后右侧详情还指向不可见数据;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderModule() 每次刷新前先调用它,数据流是“分页变化 -> syncSelectedRecordWithPage() -> 详情同步回当前页首条”。 */
function syncSelectedRecordWithPage(moduleKey) {
const sourceKey = getDataSourceKey(moduleKey);
const pagedRecords = getPagedRecords(moduleKey);
if (!sourceKey || !pagedRecords.length) return;
const selectedRecord = getSelectedRecord(moduleKey);
if (!selectedRecord || !pagedRecords.some((record) => record.id === selectedRecord.id)) state.selectedBySource[sourceKey] = pagedRecords[0].id;
}
/** 代码作用(白话):把业务状态词映射成成功、警告、危险这几种视觉色;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:formatStatusChip() 会用它统一生成表格、动态、详情里的状态颜色。 */
function getToneByValue(value) {
if (["续费失败", "异常", "异常停机", "未开通", "未绑定", "不可外呼", "已关闭", "已释放"].includes(value)) return "danger";
if (["待处理", "实名异常", "未实名", "保留中", "未备案"].includes(value)) return "warning";
if (["续费成功", "正常", "已开启", "已开通", "已绑定", "可外呼", "已备案"].includes(value)) return "success";
return "neutral";
}
/** 代码作用(白话):输出统一的状态标签 HTML,让不同模块共用同一套状态外观;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTableCell()、renderPreviewGrid()、renderDetail() 都会直接调用它。 */
function formatStatusChip(label) {
return `<span class="status-chip ${getToneByValue(label)}">${escapeHtml(label)}</span>`;
}
/** 代码作用(白话):给每个模块准备右侧头部的文案说明和小标记,补齐效果图里的信息层级;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderHeader() 读取它,消息链是“当前模块 -> getHeaderMeta() -> 头部标题区”。 */
function getHeaderMeta(moduleKey) {
const metaMap = {
overview: { badge: "总览模式", description: "聚合域名、企微、手机号卡等核心资产,先看风险,再落到单条记录处理。" },
domain: { badge: "域名资产", description: "统一查看平台、到期时间、自动续费和 SSL 状态,优先处理临近到期记录。" },
wechat: { badge: "企微资料", description: "按人员、部门和实名状态巡检资料完整度,减少资料错配和责任不清。" },
phone: { badge: "号码资产", description: "聚焦号码可用性、归属地、实名信息和外呼状态,快速定位异常号卡。" },
alerts: { badge: "提醒中心", description: "集中承接待处理提醒,后续可继续补入真实告警和跟进记录。" }
};
return metaMap[moduleKey] || metaMap.overview;
}
/** 代码作用(白话):给主表格准备分区标题和辅助说明,让数据列表更接近设计稿里的“主工作台”气质;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 读取它,再把结果写进表格头部和工具栏。 */
function getTableMeta(moduleKey) {
const metaMap = {
overview: { kicker: "核心清单", note: "默认展示域名资产列表,点击任意行可同步右侧详情。", emphasis: "高风险优先" },
domain: { kicker: "域名清单", note: "聚焦续费结果、到期时间和平台归属,适合日常巡检。", emphasis: "到期风险监控" },
wechat: { kicker: "资料清单", note: "聚焦人员账号、部门、手机号和实名状态,便于排查异常资料。", emphasis: "实名异常优先" },
phone: { kicker: "号码清单", note: "聚焦归属地、运营商和外呼能力,适合号码资产盘点。", emphasis: "停机号码优先" },
alerts: { kicker: "提醒清单", note: "当前暂无数据,后续可以把真实提醒流接进来。", emphasis: "等待数据" }
};
return metaMap[moduleKey] || metaMap.overview;
}
/** 代码作用(白话):把头部状态胶囊渲染成可直接插入页面的 HTML;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderHeader() 调用它,把 MODULE_CONFIG 里的 pills 变成顶部状态条。 */
function renderPills(pills) {
return pills.map((pill) => `<span class="status-pill ${escapeHtml(pill.type || "")}">${pill.text}</span>`).join("");
}
/** 代码作用(白话):把头部操作按钮渲染成主按钮和次按钮,保留原有操作入口;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderHeader() 调用它,把 config.actions 输出到头部操作区。 */
function renderActions(actions) {
return actions.map((action) => {
const className = action.kind === "primary" ? "header-button-primary" : "header-button";
const suffix = action.suffix ? `<span class="header-button-suffix">${escapeHtml(action.suffix)}</span>` : "";
return `<button class="${className}" type="button">${escapeHtml(action.text)}${suffix}</button>`;
}).join("");
}
/** 代码作用(白话):渲染右侧头部,让标题、说明、搜索框、状态胶囊和主操作按钮按效果图节奏排开;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 先刷新它,再继续刷新表格和详情。 */
function renderHeader() {
const config = MODULE_CONFIG[state.activeModule];
const meta = getHeaderMeta(state.activeModule);
const shortcut = config.searchShortcut ? `<span class="header-shortcut">${escapeHtml(config.searchShortcut)}</span>` : "";
const pills = config.pills.length ? `<div class="hero-pills">${renderPills(config.pills)}</div>` : "";
const actions = config.actions.length ? renderActions(config.actions) : "";
pageHeader.innerHTML = `
<div class="workbench-hero">
<div class="hero-copy">
<div class="hero-copy-top">
<p class="hero-kicker">工作台</p>
<span class="hero-badge">${escapeHtml(meta.badge)}</span>
</div>
<h2 class="page-title">${escapeHtml(config.title)}</h2>
<p class="hero-summary">${escapeHtml(meta.description)}</p>
${pills}
</div>
<div class="header-tools">
<label class="header-search">
<input type="search" placeholder="${escapeHtml(config.searchPlaceholder)}" />
${shortcut}
</label>
<div class="header-actions">${actions}</div>
</div>
</div>
`;
}
/** 代码作用(白话):把顶部统计卡渲染成更接近效果图的三段式卡片,包括图标、数字和底部去向;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 会先输出它,再继续输出表格和最近动态。 */
function renderMetricCards(stats, moduleKey) {
if (!stats.length) return "";
const gridClass = moduleKey === "overview" ? "stats-grid overview" : "stats-grid";
return `
<section class="${gridClass}">
${stats.map((item) => `
<article class="metric-card metric-card-premium" data-tone="${escapeHtml(item.tone || "blue")}">
<div class="metric-card-top">
<span class="metric-icon-shell"><span class="metric-icon">${escapeHtml(item.icon || "•")}</span></span>
<span class="metric-tag">重点巡检</span>
</div>
<div class="metric-copy">
<p class="metric-title">${escapeHtml(item.title)}</p>
<div class="metric-value-row">
<h3 class="metric-value">${escapeHtml(item.value)}</h3>
<span class="metric-unit">项</span>
</div>
<p class="metric-sub">${escapeHtml(item.sub || "")}</p>
</div>
<div class="metric-card-footer">
<span class="metric-foot-note">风险台账</span>
<span class="metric-foot-link">${escapeHtml(item.link || "查看详情")}</span>
</div>
</article>
`).join("")}
</section>
`;
}
/** 代码作用(白话):返回表格列顺序,让不同模块仍旧沿用各自的字段布局;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 先拿列配置,再去渲染表头和每一行单元格。 */
function getTableColumns(moduleKey) {
if (moduleKey === "wechat") return ["realName", "account", "department", "phone", "wxId", "ownerStatus"];
if (moduleKey === "phone") return ["phone", "realPerson", "city", "carrier", "cardStatus", "wechat", "outbound"];
return ["domain", "platform", "expirationDate", "autoRenewal", "sslExpiry", "accountId", "renewalResult"];
}
/** 代码作用(白话):把字段 key 转成给用户看的中文列名;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 渲染表头时逐列调用它。 */
function getTableLabel(columnKey) {
const map = {
domain: "域名",
platform: "平台",
expirationDate: "到期时间",
autoRenewal: "自动续费",
sslExpiry: "SSL 到期",
accountId: "账号 ID",
renewalResult: "续费结果",
realName: "姓名",
account: "账号",
department: "部门",
phone: "手机号",
wxId: "微信 ID",
ownerStatus: "状态",
realPerson: "实名人",
city: "归属地",
carrier: "运营商",
cardStatus: "卡状态",
wechat: "微信",
outbound: "外呼"
};
return map[columnKey] || columnKey;
}
/** 代码作用(白话):把不同字段渲染成表格里的最终内容块,比如双行文字、状态标签、时间信息;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 渲染 tbody 时对每一个单元格都走这里。 */
function renderTableCell(columnKey, record) {
if (columnKey === "domain") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.domain)}</span><span class="row-secondary">${escapeHtml(record.remark || "—")}</span></div>`;
if (columnKey === "platform") return `<div class="platform-copy"><span class="platform-name">${escapeHtml(record.platform)}</span><span class="row-secondary">${escapeHtml(record.platformCode || "—")}</span></div>`;
if (columnKey === "expirationDate") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.expirationDate)}</span><span class="warn">${escapeHtml(record.expirationHint || "—")}</span></div>`;
if (columnKey === "sslExpiry") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.sslExpiry || "—")}</span><span class="row-secondary">${escapeHtml(record.sslHint || "—")}</span></div>`;
if (["renewalResult", "ownerStatus", "cardStatus", "wechat", "outbound", "autoRenewal"].includes(columnKey)) return formatStatusChip(record[columnKey] || "正常");
if (columnKey === "realName") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realName)}</span><span class="row-secondary">${escapeHtml(record.alias || "—")}</span></div>`;
if (columnKey === "account") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.account)}</span><span class="row-secondary">${escapeHtml(record.owner || "—")}</span></div>`;
if (columnKey === "department") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.department)}</span><span class="row-secondary">${escapeHtml(record.role || "—")}</span></div>`;
if (columnKey === "phone") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.phone)}</span><span class="row-secondary">${escapeHtml(record.city || record.owner || "—")}</span></div>`;
if (columnKey === "wxId") return `<span class="link-text">${escapeHtml(record.wxId)}</span>`;
if (columnKey === "realPerson") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realPerson)}</span><span class="row-secondary">${escapeHtml(record.iccid || "—")}</span></div>`;
if (columnKey === "city") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.city)}</span><span class="row-secondary">${escapeHtml(record.usageLocation || "—")}</span></div>`;
return escapeHtml(record[columnKey] || "—");
}
/** 代码作用(白话):渲染表格底部的分页器,包括页码、上一页下一页、前往输入框和每页条数;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 在表格底部调用它,点击和输入再交给分页事件处理函数。 */
function renderPagination(moduleKey) {
const pagination = getPaginationState(moduleKey);
const pageNumbers = getVisiblePageNumbers(pagination.totalPages, pagination.page).map((item) => {
if (item === "ellipsis") return `<span class="pagination-ellipsis">···</span>`;
return `<button class="pagination-page-button${item === pagination.page ? " is-active" : ""}" type="button" data-page-number="${item}" aria-current="${item === pagination.page ? "page" : "false"}">${item}</button>`;
}).join("");
const pageSizeOptions = PAGE_SIZE_OPTIONS.map((size) => `<option value="${size}"${size === pagination.pageSize ? " selected" : ""}>每页 ${size}</option>`).join("");
return `
<div class="pagination-shell">
<div class="pagination-main">
<button class="pagination-arrow" type="button" data-page-action="prev"${pagination.page === 1 ? " disabled" : ""} aria-label="上一页">‹</button>
<div class="pagination-pages">${pageNumbers}</div>
<button class="pagination-arrow" type="button" data-page-action="next"${pagination.page === pagination.totalPages ? " disabled" : ""} aria-label="下一页">›</button>
</div>
<label class="pagination-jump">
<span class="pagination-jump-label">前往</span>
<input class="pagination-jump-input" type="number" min="1" max="${pagination.totalPages}" value="${pagination.page}" data-page-jump-input />
</label>
<label class="pagination-size-select-wrap">
<select class="pagination-size-select" data-page-size-select aria-label="每页条数">${pageSizeOptions}</select>
<span class="pagination-size-arrow" aria-hidden="true">⌄</span>
</label>
</div>
`;
}
/** 代码作用(白话):渲染主表格卡片,把标题、工具栏、数据表、分页和说明组合成右侧主工作区;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 调用它输出列表主体,表格点击再通过 handleTableClick() 反向刷新详情。 */
function renderTable(moduleKey) {
const records = getModuleRecords(moduleKey);
const pagedRecords = getPagedRecords(moduleKey);
const sourceKey = getDataSourceKey(moduleKey);
const selectedRecord = getSelectedRecord(moduleKey);
const columns = getTableColumns(moduleKey);
const pagination = getPaginationState(moduleKey);
const tableMeta = getTableMeta(moduleKey);
const selectedLabel = selectedRecord ? escapeHtml(selectedRecord.domain || selectedRecord.realName || selectedRecord.phone) : "未选中记录";
const headHtml = columns.map((columnKey) => `<th>${escapeHtml(getTableLabel(columnKey))}</th>`).join("");
const bodyHtml = pagedRecords.map((record) => `
<tr class="${selectedRecord && record.id === selectedRecord.id ? "is-selected" : ""}" data-record-id="${record.id}" data-source-key="${escapeHtml(sourceKey)}">
${columns.map((columnKey) => `<td>${renderTableCell(columnKey, record)}</td>`).join("")}
</tr>
`).join("");
return `
<section class="table-card table-card-shell">
<div class="table-head table-head-v2">
<div class="table-head-copy">
<p class="section-kicker">${escapeHtml(tableMeta.kicker)}</p>
<h3 class="section-title">${escapeHtml(MODULE_CONFIG[moduleKey].title)}</h3>
<p class="table-head-note">${escapeHtml(tableMeta.note)}</p>
</div>
<div class="table-selection-chip">
<span class="table-selection-label">当前聚焦</span>
<strong>${selectedLabel}</strong>
</div>
</div>
<div class="toolbar-row toolbar-row-v2">
<div class="toolbar-left toolbar-left-v2">
<span class="toolbar-pill">${escapeHtml(tableMeta.emphasis)}</span>
<span class="toolbar-note">第 ${pagination.page} / ${pagination.totalPages} 页</span>
</div>
<div class="toolbar-right toolbar-right-fixed">
<button class="toolbar-button" type="button">筛选条件</button>
<button class="toolbar-button" type="button">视图切换</button>
<button class="toolbar-button-primary" type="button">刷新数据</button>
</div>
</div>
<div class="table-scroll-shell">
<table class="data-table">
<thead><tr>${headHtml}</tr></thead>
<tbody>${bodyHtml}</tbody>
</table>
</div>
<div class="table-footer table-footer-v2">
<div class="footer-left">
<span class="footer-total">共 ${escapeHtml(records.length)} 条记录</span>
<span class="footer-hint">点击表格行后,右侧详情会同步切换</span>
</div>
${renderPagination(moduleKey)}
</div>
</section>
`;
}
/** 代码作用(白话):渲染底部“最近动态”整行卡片,把当前选中记录的处理记录压成更接近效果图的时间线列表;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 在表格下方调用它,数据流是“selectedRecord.log/notes -> renderPreviewGrid() -> 最近动态卡”。 */
function renderPreviewGrid(moduleKey) {
const record = getSelectedRecord(moduleKey);
if (!record) return "";
const focusName = record.domain || record.realName || record.phone || "当前记录";
const statusValue = record.renewalResult || record.ownerStatus || record.cardStatus || record.detailStatus || "正常";
const logRows = (record.log || record.notes || []).slice(0, 3).map((item, index) => {
const time = Array.isArray(item) ? item[0] : "";
const text = Array.isArray(item) ? item[1] : item;
return `
<div class="preview-row preview-row-v2">
<div class="preview-row-marker">${index + 1}</div>
<div class="preview-copy">
<span class="preview-name">${escapeHtml(text)}</span>
<span class="preview-sub">${escapeHtml(time || "最近更新")}</span>
</div>
${formatStatusChip(statusValue)}
</div>
`;
}).join("") || `
<div class="preview-row preview-row-v2">
<div class="preview-row-marker">1</div>
<div class="preview-copy">
<span class="preview-name">暂无动态</span>
<span class="preview-sub">等待后续记录</span>
</div>
${formatStatusChip(statusValue)}
</div>
`;
return `
<section class="preview-grid preview-grid-full">
<article class="preview-card preview-card-wide preview-card-shell">
<div class="preview-head preview-head-v2">
<div>
<p class="preview-kicker">最近动态</p>
<h3 class="preview-title">跟进记录</h3>
<p class="preview-summary">当前聚焦:${escapeHtml(focusName)},最近 3 条处理记录会优先展示在这里。</p>
</div>
<div class="preview-status-wrap">${formatStatusChip(statusValue)}</div>
</div>
<div class="preview-list preview-list-v2">${logRows}</div>
</article>
</section>
`;
}
/** 代码作用(白话):输出空态卡片,让没有数据的模块也保持和新右侧视觉一致;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 在 alerts 这类空模块时直接返回它。 */
function renderEmptyState(moduleKey) {
return `
<section class="empty-card empty-card-shell">
<div>
<h3>${escapeHtml(MODULE_CONFIG[moduleKey].title)}</h3>
<p>当前模块暂无可渲染数据,后续可以继续补充真实记录。</p>
</div>
</section>
`;
}
/** 代码作用(白话):渲染右侧主内容区,把统计卡、主表格和最近动态按效果图顺序串起来;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 调用它输出主工作区,数据流是“模块状态 -> renderBody() -> pageBody.innerHTML”。 */
function renderBody() {
const moduleKey = state.activeModule;
const records = getModuleRecords(moduleKey);
pageBody.innerHTML = records.length
? `${renderMetricCards(MODULE_CONFIG[moduleKey].stats, moduleKey)}${renderTable(moduleKey)}${renderPreviewGrid(moduleKey)}`
: renderEmptyState(moduleKey);
}
/** 代码作用(白话):按模块挑选详情抽屉需要展示的字段,并给每个字段补上小标题;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderDetail() 循环渲染它返回的字段数组,数据流是“record -> getDetailFields() -> 详情网格”。 */
function getDetailFields(moduleKey, record) {
if (!record) return [];
if (moduleKey === "wechat") {
return [
["姓名", record.realName],
["账号", record.account],
["部门", record.department],
["手机号", record.phone],
["微信 ID", record.wxId],
["负责人", record.owner],
["创建时间", record.createdAt],
["备注", record.remark, true]
];
}
if (moduleKey === "phone") {
return [
["手机号", record.phone],
["实名人", record.realPerson],
["归属地", record.city],
["使用地", record.usageLocation],
["卡状态", record.cardStatus],
["运营商", record.carrier],
["ICCID", record.iccid],
["备注", record.note, true]
];
}
return [
["域名", record.domain],
["平台", record.platform],
["平台账号", record.accountId],
["到期时间", record.expirationDate],
["SSL 到期", record.sslExpiry],
["自动续费", record.autoRenewal],
["DNS", record.dns],
["备注", record.remark, true]
];
}
/** 代码作用(白话):渲染右侧详情抽屉,把概要、字段、时间线和底部操作做成和效果图一致的辅助工作区;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 最后刷新这里,表格点选后会通过 renderDetail() 同步显示新记录。 */
function renderDetail() {
const moduleKey = state.activeModule;
const record = getSelectedRecord(moduleKey);
detailShellTitle.innerHTML = `
<div class="detail-shell-title-wrap">
<p class="detail-shell-kicker">实时详情</p>
<h3 class="detail-title">资产详情</h3>
</div>
`;
if (!record) {
detailBody.innerHTML = `<div class="note-box">当前模块没有详情数据。</div>`;
return;
}
const title = moduleKey === "domain" || moduleKey === "overview" ? record.domain : (record.realName || record.phone);
const statusValue = record.renewalResult || record.ownerStatus || record.cardStatus || record.detailStatus || "正常";
const fieldHtml = getDetailFields(moduleKey, record).map(([label, value, full]) => `
<div class="detail-item${full ? " full" : ""}">
<label>${escapeHtml(label)}</label>
<span>${escapeHtml(value)}</span>
</div>
`).join("");
const logs = (record.log || record.notes || []).map((item) => {
const time = Array.isArray(item) ? item[0] : "";
const text = Array.isArray(item) ? item[1] : item;
return `
<div class="timeline-row">
<span class="timeline-dot"></span>
<span class="timeline-copy">
<span class="preview-name">${escapeHtml(text)}</span>
<span class="timeline-time">${escapeHtml(time || "最近更新")}</span>
</span>
</div>
`;
}).join("") || `<div class="note-box">暂无更多跟进记录。</div>`;
detailBody.innerHTML = `
<section class="detail-group detail-group-shell">
<div class="detail-top-card detail-top-card-v2">
<div class="detail-main-avatar">${escapeHtml(String(title).slice(0, 1))}</div>
<div class="detail-title-wrap">
<div class="detail-primary-line">
<span class="detail-primary-name">${escapeHtml(title)}</span>
${formatStatusChip(statusValue)}
</div>
<div class="detail-meta-line">模块:${escapeHtml(moduleKey)} · 更新时间:2026-06-25</div>
<div class="detail-summary-badges">
<span class="info-chip blue">已同步到详情视图</span>
<span class="info-chip orange">支持继续处理</span>
</div>
</div>
</div>
<div class="detail-section-shell">
<div class="detail-section-head">
<h4 class="detail-section-title">关键信息</h4>
<span class="detail-note">用于快速确认当前记录归属和状态</span>
</div>
<div class="detail-grid">${fieldHtml}</div>
</div>
<div class="detail-section-shell">
<div class="detail-section-head">
<h4 class="detail-section-title">处理时间线</h4>
<span class="detail-note">最近处理记录会按时间倒序展示</span>
</div>
<div class="log-list">${logs}</div>
</div>
<div class="detail-footer">
<button class="footer-button" type="button">导出详情</button>
<button class="footer-button-primary" type="button">继续处理</button>
</div>
</section>
`;
}
/** 代码作用(白话):同步左侧导航高亮,让当前模块在菜单栏里保持选中状态;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 每次切模块都调用,但不会改菜单栏样式规则本身。 */
function updateActiveNav() {
sidebar.querySelectorAll(".nav-link[data-module]").forEach((button) => button.classList.toggle("is-active", button.dataset.module === state.activeModule));
}
/** 代码作用(白话):统一刷新右侧页面的头部、主体和详情抽屉,是整个界面联动的总入口;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:导航点击、表格点击、分页和首屏初始化最终都会走到 renderModule()。 */
function renderModule() {
updateActiveNav();
syncSelectedRecordWithPage(state.activeModule);
renderHeader();
renderBody();
renderDetail();
}
/** 代码作用(白话):处理左侧菜单点击,把 activeModule 切到对应模块后刷新右侧;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:bindTabEvents() 把 sidebar click 事件委托给它,再流向 renderModule()。 */
function handleNavClick(event) {
const button = event.target.closest(".nav-link[data-module]");
if (!button) return;
state.activeModule = button.dataset.module;
renderModule();
}
/** 代码作用(白话):给左侧菜单注册点击事件;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:页面启动时 initPage() 调用它,后续菜单点击都统一进入 handleNavClick()。 */
function bindTabEvents() {
sidebar.addEventListener("click", handleNavClick);
}
/** 代码作用(白话):处理表格行点击,把当前记录设成选中项后刷新右侧详情;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:handlePageBodyClick() 在不是分页点击时会落到这里,消息链是“tbody 行点击 -> handleTableClick() -> renderModule()”。 */
function handleTableClick(event) {
const row = event.target.closest("tr[data-record-id][data-source-key]");
if (!row) return;
state.selectedBySource[row.dataset.sourceKey] = Number(row.dataset.recordId);
renderModule();
}
/** 代码作用(白话):响应分页条里的页码按钮和左右箭头,只处理翻页动作;关联文件:F:/Project/xyw_console/app.js、F:/Project/xyw_console/styles.css;关联逻辑:handlePageBodyClick() 先把点击交给它,命中后直接更新分页状态并重渲染。 */
function handlePaginationClick(event) {
const sourceKey = getDataSourceKey(state.activeModule);
if (!sourceKey) return false;
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
const pageNumberButton = event.target.closest("[data-page-number]");
if (pageNumberButton) {
paginationState.page = Number(pageNumberButton.dataset.pageNumber);
renderModule();
return true;
}
const pageActionButton = event.target.closest("[data-page-action]");
if (!pageActionButton || pageActionButton.disabled) return false;
const pagination = getPaginationState(state.activeModule);
if (pageActionButton.dataset.pageAction === "prev") paginationState.page = Math.max(1, pagination.page - 1);
if (pageActionButton.dataset.pageAction === "next") paginationState.page = Math.min(pagination.totalPages, pagination.page + 1);
renderModule();
return true;
}
/** 代码作用(白话):统一接收主内容区点击,优先把分页点击和表格点击分流到各自处理函数;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:bindTableEvents() 把 pageBody click 绑定到这里,消息链是“pageBody click -> handlePaginationClick() 或 handleTableClick() -> renderModule()”。 */
function handlePageBodyClick(event) {
if (handlePaginationClick(event)) return;
handleTableClick(event);
}
/** 代码作用(白话):处理分页区里的下拉选择和输入框失焦变更,让每页条数和目标页码真正生效;关联文件:F:/Project/xyw_console/app.js、F:/Project/xyw_console/styles.css;关联逻辑:bindTableEvents() 把 pageBody change 交给它,再分别流向 pageSize 或 applyPaginationJump()。 */
function handlePaginationChange(event) {
const sourceKey = getDataSourceKey(state.activeModule);
if (!sourceKey) return;
const pageSizeSelect = event.target.closest("[data-page-size-select]");
if (pageSizeSelect) {
const paginationState = state.paginationBySource[sourceKey] || (state.paginationBySource[sourceKey] = { page: 1, pageSize: PAGE_SIZE_OPTIONS[0] });
paginationState.pageSize = Number(pageSizeSelect.value);
paginationState.page = 1;
renderModule();
return;
}
const pageJumpInput = event.target.closest("[data-page-jump-input]");
if (pageJumpInput) {
const didJump = applyPaginationJump(pageJumpInput.value);
if (!didJump) {
const pagination = getPaginationState(state.activeModule);
pageJumpInput.value = String(pagination.page);
}
}
}
/** 代码作用(白话):处理分页输入框里的回车键,让用户输入目标页后按回车就能跳转;关联文件:F:/Project/xyw_console/app.js;关联逻辑:bindTableEvents() 把 pageBody keydown 交给它,再通过 applyPaginationJump() 更新当前页。 */
function handlePaginationKeydown(event) {
const pageJumpInput = event.target.closest("[data-page-jump-input]");
if (!pageJumpInput || event.key !== "Enter") return;
event.preventDefault();
const didJump = applyPaginationJump(pageJumpInput.value);
if (!didJump) {
const pagination = getPaginationState(state.activeModule);
pageJumpInput.value = String(pagination.page);
}
}
/** 代码作用(白话):给主内容区注册点击、变更和键盘事件,让表格、分页、跳页输入框都走统一入口;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:initPage() 调用它后,pageBody 的 click/change/keydown 都会被分流到对应分页或表格处理函数。 */
function bindTableEvents() {
pageBody.addEventListener("click", handlePageBodyClick);
pageBody.addEventListener("change", handlePaginationChange);
pageBody.addEventListener("keydown", handlePaginationKeydown);
}
/** 代码作用(白话):同步侧边栏收起按钮的 aria 状态,保证菜单栏辅助信息还正确;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:首屏和点击收起时都会调用,但不会改菜单栏视觉方案。 */
function updateSidebarToggleState() {
const isCollapsed = appShell.classList.contains("sidebar-collapsed");
sidebarToggle.setAttribute("aria-expanded", String(!isCollapsed));
sidebarToggle.setAttribute("aria-label", isCollapsed ? "展开侧边导航" : "收起侧边导航");
if (sidebarToggleLabel) sidebarToggleLabel.textContent = "收起";
}
/** 代码作用(白话):处理侧边栏收起按钮,让左侧菜单保持原有收起能力;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:bindLayoutEvents() 把 sidebarToggle click 绑定到这里,再同步 aria。 */
function handleSidebarToggleClick() {
sidebar.classList.toggle("is-collapsed");
appShell.classList.toggle("sidebar-collapsed");
updateSidebarToggleState();
}
/** 代码作用(白话):处理详情抽屉关闭按钮,只控制抽屉的隐藏和显示;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:bindLayoutEvents() 把 drawerClose click 绑定到这里。 */
function handleDrawerCloseClick() {
detailDrawer.classList.toggle("hidden");
}
/** 代码作用(白话):注册布局相关事件,让左侧收起按钮和右侧详情关闭按钮继续可用;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:initPage() 调用它后,布局层交互就接通了。 */
function bindLayoutEvents() {
sidebarToggle.addEventListener("click", handleSidebarToggleClick);
drawerClose.addEventListener("click", handleDrawerCloseClick);
updateSidebarToggleState();
}
/** 代码作用(白话):页面启动入口,先把事件接上,再渲染第一屏;关联文件:F:/Project/xyw_console/index.html、app.js;关联逻辑:文件加载完成后立即执行 initPage(),消息链是“initPage() -> 绑定事件 -> renderModule()”。 */
function initPage() {
bindTabEvents();
bindTableEvents();
bindLayoutEvents();
renderModule();
}
initPage();
/* Professional admin dashboard override: fake data + right workspace redesign */
DATA_SOURCES.domain = [
{ id: 1, domain: "example.com", status: "正常", riskLevel: "低", expirationDate: "2025-07-18", expirationHint: "剩余 45 天", project: "官网项目组", owner: "张三", renewalResult: "待续费", registrar: "阿里云", updatedAt: "2025-06-30 10:23", registrationDate: "2020-07-18", renewalMethod: "手动续费", dnsProvider: "dns1.hichina.com, dns2.hichina.com", remark: "公司官网主域名。", relatedAssets: ["企业官网", "SEO 监控"], logs: [{ time: "2025-06-30 10:23", tag: "续费提醒", text: "张三 更新了 example.com 的续费方式。" }, { time: "2025-06-20 14:11", tag: "信息更新", text: "张三 更新了域名备注与备案信息。" }] },
{ id: 2, domain: "datahub.cn", status: "风险", riskLevel: "中", expirationDate: "2025-06-15", expirationHint: "剩余 12 天", project: "数据中台", owner: "李四", renewalResult: "续费中", registrar: "腾讯云", updatedAt: "2025-06-02 15:42", registrationDate: "2021-06-15", renewalMethod: "自动续费", dnsProvider: "dnspod.tencentcloudapi.com", remark: "数据中台统一域名。", relatedAssets: ["BI 报表"], logs: [{ time: "2025-06-02 15:42", tag: "续费操作", text: "李四 提交了 datahub.cn 的续费申请。" }] },
{ id: 3, domain: "ai-platform.net", status: "风险", riskLevel: "高", expirationDate: "2025-06-05", expirationHint: "剩余 2 天", project: "AI 平台", owner: "王五", renewalResult: "未续费", registrar: "西部数码", updatedAt: "2025-06-01 09:18", registrationDate: "2022-06-05", renewalMethod: "手动续费", dnsProvider: "dns.west.cn", remark: "AI 平台主域名。", relatedAssets: ["AI 控制台", "API 文档"], logs: [{ time: "2025-06-01 09:18", tag: "风险提示", text: "系统检测到续费未处理。" }] },
{ id: 4, domain: "metrics.io", status: "过期", riskLevel: "高", expirationDate: "2025-05-28", expirationHint: "已过期 6 天", project: "监控平台", owner: "赵六", renewalResult: "未续费", registrar: "阿里云", updatedAt: "2025-05-28 11:07", registrationDate: "2021-05-28", renewalMethod: "余额续费", dnsProvider: "alidns.aliyuncs.com", remark: "监控平台展示域名。", relatedAssets: ["告警页"], logs: [{ time: "2025-05-28 11:07", tag: "到期提醒", text: "metrics.io 已过期 6 天。" }] },
{ id: 5, domain: "docs.example.com", status: "正常", riskLevel: "低", expirationDate: "2025-09-30", expirationHint: "剩余 119 天", project: "文档中心", owner: "张三", renewalResult: "自动续费", registrar: "阿里云", updatedAt: "2025-06-03 08:55", registrationDate: "2023-09-30", renewalMethod: "自动续费", dnsProvider: "alidns.aliyuncs.com", remark: "开发者文档域名。", relatedAssets: ["帮助中心"], logs: [{ time: "2025-06-03 08:55", tag: "状态同步", text: "证书与到期信息同步完成。" }] }
];
DATA_SOURCES.wechat = [
{ id: 101, realName: "张三", alias: "阿三", department: "市场部", phone: "138****8888", wxId: "wxc_3123abcd4567", owner: "张三", status: "正常", createdAt: "2023-03-15", updatedAt: "2025-06-02 10:20", role: "市场专员", project: "华南投放", remark: "负责华南线索承接。", logs: [{ time: "2025-06-02 10:20", tag: "资料同步", text: "实名信息与手机号一致。" }] },
{ id: 102, realName: "李四", alias: "老李", department: "运营部", phone: "139****1234", wxId: "wxid_lisi00932", owner: "李四", status: "待复核", createdAt: "2023-04-12", updatedAt: "2025-06-01 15:35", role: "运营专员", project: "增长实验", remark: "实名信息待确认。", logs: [{ time: "2025-06-01 15:35", tag: "待复核", text: "系统标记需要二次复核。" }] },
{ id: 103, realName: "王五", alias: "Wendy", department: "销售部", phone: "137****5521", wxId: "wxid_sales_5521", owner: "王五", status: "异常", createdAt: "2022-12-08", updatedAt: "2025-05-30 11:48", role: "销售顾问", project: "KA 客户", remark: "手机号与实名信息不一致。", logs: [{ time: "2025-05-30 11:48", tag: "异常提醒", text: "资料进入异常队列。" }] }
];
DATA_SOURCES.phone = [
{ id: 201, phone: "138****8888", realPerson: "张三", city: "广东 深圳", carrier: "中国移动", status: "正常", wechatStatus: "已开通", outbound: "可外呼", updatedAt: "2025-06-02 09:18", usageLocation: "广东 深圳 南山区", iccid: "8986 0117 0230 1234 5678 912", owner: "张三", project: "私域运营", remark: "主要用于私域外呼。", logs: [{ time: "2025-06-02 09:18", tag: "状态同步", text: "号卡状态正常。" }] },
{ id: 202, phone: "176****1234", realPerson: "王五", city: "浙江 杭州", carrier: "中国电信", status: "异常停机", wechatStatus: "未开通", outbound: "不可外呼", updatedAt: "2025-06-01 11:00", usageLocation: "浙江 杭州", iccid: "8986 0117 8844 2134 9087 122", owner: "王五", project: "测试号池", remark: "号码停机,需要复核。", logs: [{ time: "2025-06-01 11:00", tag: "异常提醒", text: "系统检测到该号码停机。" }] },
{ id: 203, phone: "153****7654", realPerson: "李四", city: "上海 浦东", carrier: "中国联通", status: "保留中", wechatStatus: "已开通", outbound: "不可外呼", updatedAt: "2025-05-31 17:26", usageLocation: "上海 浦东", iccid: "8986 0302 1945 0012 7766 320", owner: "李四", project: "品牌活动", remark: "活动结束后保留号码。", logs: [{ time: "2025-05-31 17:26", tag: "保留中", text: "号码进入保留流程。" }] }
];
DATA_SOURCES.alerts = [
{ id: 301, title: "ai-platform.net 即将到期,请尽快续费", module: "域名资料", level: "高", owner: "王五", dueAt: "2025-06-05", status: "待处理", updatedAt: "2025-06-01 09:18", remark: "AI 平台主域名进入高风险窗口。", logs: [{ time: "2025-06-01 09:18", tag: "风险提示", text: "已提醒负责人尽快处理。" }] },
{ id: 302, title: "176****1234 号卡停机,等待人工复核", module: "手机号卡", level: "中", owner: "王五", dueAt: "2025-06-03", status: "待处理", updatedAt: "2025-06-01 11:00", remark: "号码停机可能影响测试外呼。", logs: [{ time: "2025-06-01 11:00", tag: "异常提醒", text: "号码停机告警已同步到提醒中心。" }] }
];
Object.assign(MODULE_CONFIG.overview, { title: "资料总览", searchPlaceholder: "搜索域名、所有者、备注等", pills: [{ text: "全部 <strong>1,256</strong>", type: "status blue" }, { text: "正常 <strong>982</strong>", type: "status green" }, { text: "风险 <strong>168</strong>", type: "status orange" }, { text: "过期 <strong>106</strong>", type: "status red" }], actions: [{ text: "导出数据", kind: "secondary" }, { text: "新建记录", kind: "primary" }], stats: [{ title: "正常域名", value: "982", sub: "占比 78.21%", tone: "blue", icon: "✓", link: "稳定" }, { title: "风险域名", value: "168", sub: "占比 13.38%", tone: "orange", icon: "!", link: "需跟进" }, { title: "过期域名", value: "106", sub: "占比 8.41%", tone: "red", icon: "×", link: "高优先" }] });
Object.assign(MODULE_CONFIG.domain, MODULE_CONFIG.overview, { title: "域名资料" });
Object.assign(MODULE_CONFIG.wechat, { title: "企微资料", searchPlaceholder: "搜索姓名、微信 ID、手机号、部门等", pills: [{ text: "全部 <strong>648</strong>", type: "status blue" }, { text: "正常 <strong>532</strong>", type: "status green" }, { text: "待复核 <strong>73</strong>", type: "status orange" }, { text: "异常 <strong>43</strong>", type: "status red" }], actions: [{ text: "同步资料", kind: "secondary" }, { text: "新增资料", kind: "primary" }], stats: [{ title: "正常资料", value: "532", sub: "资料同步完成", tone: "blue", icon: "✓", link: "稳定" }, { title: "待复核", value: "73", sub: "需要管理员复核", tone: "orange", icon: "?", link: "处理中" }, { title: "异常资料", value: "43", sub: "实名信息异常", tone: "red", icon: "!", link: "待修复" }] });
Object.assign(MODULE_CONFIG.phone, { title: "手机号卡", searchPlaceholder: "搜索手机号、实名人、城市、项目等", pills: [{ text: "全部 <strong>324</strong>", type: "status blue" }, { text: "正常 <strong>241</strong>", type: "status green" }, { text: "保留中 <strong>46</strong>", type: "status orange" }, { text: "停机 <strong>37</strong>", type: "status red" }], actions: [{ text: "导入号卡", kind: "secondary" }, { text: "新增号卡", kind: "primary" }], stats: [{ title: "正常号卡", value: "241", sub: "可正常使用", tone: "blue", icon: "✓", link: "稳定" }, { title: "保留中", value: "46", sub: "已暂停外呼", tone: "orange", icon: "•", link: "待决策" }, { title: "异常停机", value: "37", sub: "需要人工复核", tone: "red", icon: "!", link: "高优先" }] });
Object.assign(MODULE_CONFIG.alerts, { title: "提醒中心", searchPlaceholder: "搜索提醒标题、负责人、模块", pills: [{ text: "全部 <strong>37</strong>", type: "status blue" }, { text: "待处理 <strong>19</strong>", type: "status orange" }, { text: "今日到期 <strong>6</strong>", type: "status red" }, { text: "已完成 <strong>12</strong>", type: "status green" }], actions: [{ text: "导出提醒", kind: "secondary" }, { text: "新建提醒", kind: "primary" }], stats: [{ title: "待处理提醒", value: "19", sub: "需要负责人跟进", tone: "orange", icon: "!", link: "处理中" }, { title: "今日到期", value: "6", sub: "建议优先处理", tone: "red", icon: "×", link: "高优先" }, { title: "已完成", value: "12", sub: "最近 24 小时", tone: "blue", icon: "✓", link: "已关闭" }] });
state.selectedBySource.alerts = 301;
state.paginationBySource.alerts = { page: 1, pageSize: 10 };
/** 代码作用(白话):让提醒中心也能走真实数据源;关联文件:F:/Project/xyw_console/app.js;关联逻辑:表格、详情、分页都会先通过它判断当前数据源。 */
function getDataSourceKey(moduleKey) { return moduleKey === "overview" ? "domain" : moduleKey; }
/** 代码作用(白话):统一返回记录主名称;关联文件:F:/Project/xyw_console/app.js;关联逻辑:当前聚焦、详情标题、最近跟进都会共用它。 */
function getRecordDisplayName(moduleKey, record) { if (!record) return "未选中记录"; if (["overview", "domain"].includes(moduleKey)) return record.domain; if (moduleKey === "wechat") return record.realName; if (moduleKey === "phone") return record.phone; return record.title; }
/** 代码作用(白话):把状态词映射成新的后台视觉色;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:状态标签、时间线标签都会共用它。 */
function getToneByValue(value) { if (["过期", "异常", "异常停机", "未开通", "不可外呼", "未续费", "高"].includes(value)) return "danger"; if (["风险", "待处理", "待复核", "保留中", "待确认", "续费中", "中"].includes(value)) return "warning"; if (["正常", "自动续费", "已开通", "可外呼", "低", "待续费"].includes(value)) return "success"; return "neutral"; }
/** 代码作用(白话):补充风险等级标签;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:域名表格和提醒中心会直接调用它。 */
function formatRiskChip(level) { const tone = level === "高" ? "danger" : level === "中" ? "warning" : "success"; return `<span class="tag-chip ${tone}">${escapeHtml(level)}</span>`; }
/** 代码作用(白话):准备右侧头部说明文案;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderHeader() 会读取它生成标题说明区。 */
function getHeaderMeta(moduleKey) { const metaMap = { overview: { badge: "资料管理后台", description: "把域名、企微、号卡与提醒统一放进一个高密度但可读的工作区里。" }, domain: { badge: "域名资产", description: "统一管理域名状态、风险等级、到期时间与续费进度。" }, wechat: { badge: "企微资料", description: "统一巡检人员资料、实名状态与项目归属。" }, phone: { badge: "号码资料", description: "聚焦号卡状态、开通情况与外呼能力。" }, alerts: { badge: "提醒流", description: "集中承接待处理提醒,按优先级排队处理。" } }; return metaMap[moduleKey] || metaMap.overview; }
/** 代码作用(白话):准备表格头部说明;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 会读取它渲染表格头部和焦点说明。 */
function getTableMeta(moduleKey) { const metaMap = { overview: { kicker: "全部域名", note: "点击任意一行后,右侧详情与最近跟进会同步切换。", emphasis: "资料总览模式" }, domain: { kicker: "全部域名", note: "状态、风险等级、到期时间和续费结果都放在同一张表里。", emphasis: "域名资料清单" }, wechat: { kicker: "全部资料", note: "按人员资料、部门归属、手机号和微信 ID 统一巡检。", emphasis: "企微资料清单" }, phone: { kicker: "全部号卡", note: "聚焦号卡状态、开通情况与外呼能力。", emphasis: "号卡资料清单" }, alerts: { kicker: "全部提醒", note: "把高优先级提醒集中排队处理。", emphasis: "提醒处理视图" } }; return metaMap[moduleKey] || metaMap.overview; }
/** 代码作用(白话):准备筛选条文案;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 通过它生成表格上方筛选入口。 */
function getFilterOptions(moduleKey) { const filterMap = { overview: [{ label: "状态", value: "全部" }, { label: "风险等级", value: "全部" }, { label: "到期时间", value: "全部" }, { label: "所属项目", value: "全部" }], domain: [{ label: "状态", value: "全部" }, { label: "风险等级", value: "全部" }, { label: "到期时间", value: "全部" }, { label: "所属项目", value: "全部" }], wechat: [{ label: "状态", value: "全部" }, { label: "部门", value: "全部" }, { label: "项目", value: "全部" }, { label: "更新时间", value: "全部" }], phone: [{ label: "状态", value: "全部" }, { label: "运营商", value: "全部" }, { label: "城市", value: "全部" }, { label: "更新时间", value: "全部" }], alerts: [{ label: "状态", value: "全部" }, { label: "优先级", value: "全部" }, { label: "模块", value: "全部" }, { label: "到期时间", value: "全部" }] }; return filterMap[moduleKey] || filterMap.overview; }
/** 代码作用(白话):重做右侧头部结构;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 先刷新它,再继续刷新表格和详情。 */
function renderHeader() { const config = MODULE_CONFIG[state.activeModule]; const meta = getHeaderMeta(state.activeModule); const pills = config.pills.length ? `<div class="hero-pills admin-summary-pills">${renderPills(config.pills)}</div>` : ""; const actions = config.actions.length ? renderActions(config.actions) : ""; pageHeader.innerHTML = `<div class="workspace-heading"><div class="heading-copy"><div class="heading-caption-row"><p class="heading-caption">专业资料管理后台</p><span class="heading-badge">${escapeHtml(meta.badge)}</span></div><h2 class="page-title">${escapeHtml(config.title)}</h2><p class="page-summary">${escapeHtml(meta.description)}</p>${pills}</div><div class="header-tools"><label class="header-search"><span class="header-search-icon" aria-hidden="true">⌕</span><input type="search" placeholder="${escapeHtml(config.searchPlaceholder)}" /></label><div class="header-actions">${actions}</div></div></div>`; }
/** 代码作用(白话):返回新表格列顺序;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 先拿它再渲染表头和每一行。 */
function getTableColumns(moduleKey) { if (moduleKey === "wechat") return ["selector", "realName", "department", "phone", "wxId", "status", "createdAt", "owner", "updatedAt", "actions"]; if (moduleKey === "phone") return ["selector", "phone", "realPerson", "city", "carrier", "status", "wechatStatus", "outbound", "updatedAt", "actions"]; if (moduleKey === "alerts") return ["selector", "title", "module", "level", "owner", "dueAt", "status", "updatedAt", "actions"]; return ["selector", "domain", "status", "riskLevel", "expirationDate", "project", "owner", "renewalResult", "registrar", "updatedAt", "actions"]; }
/** 代码作用(白话):把字段 key 转成新表格的中文列名;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderTable() 渲染表头时逐列调用它。 */
function getTableLabel(columnKey) { const map = { selector: "", domain: "域名", status: "状态", riskLevel: "风险等级", expirationDate: "到期时间", project: "所属项目", owner: "所有者", renewalResult: "续费结果", registrar: "注册商", updatedAt: "更新时间", actions: "操作", realName: "姓名", department: "部门", phone: "手机号", wxId: "微信 ID", createdAt: "创建时间", realPerson: "实名人", city: "归属地", carrier: "运营商", wechatStatus: "微信开通", outbound: "外呼能力", title: "提醒标题", module: "来源模块", level: "优先级", dueAt: "到期时间" }; return map[columnKey] || columnKey; }
/** 代码作用(白话):把字段渲染成新的专业后台表格单元格;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 渲染 tbody 时每个单元格都走这里。 */
function renderTableCell(columnKey, record) { const selectedRecord = getSelectedRecord(state.activeModule); const isSelected = selectedRecord && record.id === selectedRecord.id; if (columnKey === "selector") return `<span class="table-check${isSelected ? " is-active" : ""}" aria-hidden="true"></span>`; if (columnKey === "domain") return `<div class="name-copy"><span class="row-link">${escapeHtml(record.domain)}</span><span class="row-secondary">${escapeHtml(record.remark || "—")}</span></div>`; if (columnKey === "status") return formatStatusChip(record.status || "正常"); if (columnKey === "riskLevel" || columnKey === "level") return formatRiskChip(record[columnKey] || "低"); if (columnKey === "expirationDate") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.expirationDate)}</span><span class="row-secondary">${escapeHtml(record.expirationHint || "—")}</span></div>`; if (["renewalResult", "wechatStatus", "outbound"].includes(columnKey)) return formatStatusChip(record[columnKey] || "正常"); if (columnKey === "realName") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realName)}</span><span class="row-secondary">${escapeHtml(record.alias || record.role || "—")}</span></div>`; if (columnKey === "department") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.department)}</span><span class="row-secondary">${escapeHtml(record.project || "—")}</span></div>`; if (columnKey === "phone" && state.activeModule === "wechat") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.phone)}</span><span class="row-secondary">${escapeHtml(record.owner || "—")}</span></div>`; if (columnKey === "wxId") return `<span class="row-link">${escapeHtml(record.wxId)}</span>`; if (columnKey === "phone" && state.activeModule === "phone") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.phone)}</span><span class="row-secondary">${escapeHtml(record.project || "—")}</span></div>`; if (columnKey === "realPerson") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.realPerson)}</span><span class="row-secondary">${escapeHtml(record.owner || "—")}</span></div>`; if (columnKey === "city") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.city)}</span><span class="row-secondary">${escapeHtml(record.usageLocation || "—")}</span></div>`; if (columnKey === "title") return `<div class="name-copy"><span class="row-primary">${escapeHtml(record.title)}</span><span class="row-secondary">${escapeHtml(record.remark || "—")}</span></div>`; if (columnKey === "dueAt") return `<div class="stack-date"><span class="row-primary">${escapeHtml(record.dueAt)}</span><span class="row-secondary">建议优先处理</span></div>`; if (columnKey === "actions") return `<div class="action-links"><button type="button">查看</button><button type="button">编辑</button></div>`; return `<span class="row-primary">${escapeHtml(record[columnKey] || "—")}</span>`; }
/** 代码作用(白话):渲染表格上方筛选条;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderTable() 在表格头部和表格内容之间调用它。 */
function renderFilterToolbar(moduleKey) { const filters = getFilterOptions(moduleKey).map((item) => `<button class="filter-chip" type="button"><span>${escapeHtml(item.label)}${escapeHtml(item.value)}</span><span class="filter-caret" aria-hidden="true">⌄</span></button>`).join(""); return `<div class="filter-toolbar"><div class="filter-group">${filters}</div><div class="filter-actions"><button class="toolbar-button" type="button">更多筛选</button><button class="toolbar-button" type="button">重置</button></div></div>`; }
/** 代码作用(白话):重做主表格卡片;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 输出列表主体,表格点击再反向刷新详情。 */
function renderTable(moduleKey) { const records = getModuleRecords(moduleKey); const pagedRecords = getPagedRecords(moduleKey); const sourceKey = getDataSourceKey(moduleKey); const selectedRecord = getSelectedRecord(moduleKey); const columns = getTableColumns(moduleKey); const tableMeta = getTableMeta(moduleKey); const headHtml = columns.map((columnKey) => `<th>${escapeHtml(getTableLabel(columnKey))}</th>`).join(""); const bodyHtml = pagedRecords.map((record) => `<tr class="${selectedRecord && record.id === selectedRecord.id ? "is-selected" : ""}" data-record-id="${record.id}" data-source-key="${escapeHtml(sourceKey)}">${columns.map((columnKey) => `<td>${renderTableCell(columnKey, record)}</td>`).join("")}</tr>`).join(""); return `<section class="table-card table-card-shell"><div class="table-head table-head-v3"><div class="table-head-copy"><p class="section-kicker">${escapeHtml(tableMeta.kicker)} (${escapeHtml(String(records.length))})</p><h3 class="section-title">${escapeHtml(MODULE_CONFIG[moduleKey].title)}</h3><p class="table-head-note">${escapeHtml(tableMeta.note)}</p></div><div class="table-selection-chip"><span class="table-selection-label">当前聚焦</span><strong>${escapeHtml(getRecordDisplayName(moduleKey, selectedRecord))}</strong></div></div>${renderFilterToolbar(moduleKey)}<div class="table-scroll-shell"><table class="data-table"><thead><tr>${headHtml}</tr></thead><tbody>${bodyHtml}</tbody></table></div><div class="table-footer table-footer-v3"><div class="footer-left"><span class="footer-total">共 ${escapeHtml(String(records.length))} 条假数据记录</span><span class="footer-hint">${escapeHtml(tableMeta.emphasis)} · 点击表格行后,右侧详情会同步切换</span></div>${renderPagination(moduleKey)}</div></section>`; }
/** 代码作用(白话):重做底部最近跟进模块;关联文件:F:/Project/xyw_console/styles.css、app.js;关联逻辑:renderBody() 在表格下方调用它。 */
function renderPreviewGrid(moduleKey) { const record = getSelectedRecord(moduleKey); if (!record) return ""; const logs = (record.logs || []).slice(0, 5).map((item, index) => `<div class="preview-row preview-row-v3"><div class="preview-row-marker">${index + 1}</div><div class="preview-copy"><div class="preview-copy-top"><span class="preview-name">${escapeHtml(item.text)}</span>${item.tag ? `<span class="preview-tag ${getToneByValue(item.tag)}">${escapeHtml(item.tag)}</span>` : ""}</div><span class="preview-sub">${escapeHtml(item.time || "最近更新")}</span></div><button class="preview-link" type="button">查看详情</button></div>`).join(""); return `<section class="preview-grid preview-grid-full"><article class="preview-card preview-card-shell"><div class="preview-head preview-head-v3"><div><p class="preview-kicker">最近跟进</p><h3 class="preview-title">${escapeHtml(getRecordDisplayName(moduleKey, record))}</h3><p class="preview-summary">这里集中展示当前记录最近的处理动作。</p></div><button class="toolbar-button" type="button">查看全部</button></div><div class="preview-list preview-list-v3">${logs || `<div class="note-box">暂无更多跟进记录。</div>`}</div></article></section>`; }
/** 代码作用(白话):按模块挑选详情抽屉要展示的字段;关联文件:F:/Project/xyw_console/app.js;关联逻辑:renderDetail() 循环渲染它返回的字段数组。 */
function getDetailFields(moduleKey, record) { if (!record) return []; if (moduleKey === "wechat") return [["姓名", record.realName], ["微信 ID", record.wxId], ["部门", record.department], ["项目归属", record.project], ["手机号", record.phone], ["负责人", record.owner], ["创建时间", record.createdAt], ["更新时间", record.updatedAt], ["备注", record.remark, true]]; if (moduleKey === "phone") return [["手机号", record.phone], ["实名人", record.realPerson], ["归属地", record.city], ["使用地", record.usageLocation], ["运营商", record.carrier], ["微信开通", record.wechatStatus], ["外呼能力", record.outbound], ["ICCID", record.iccid], ["备注", record.remark, true]]; if (moduleKey === "alerts") return [["提醒标题", record.title], ["来源模块", record.module], ["优先级", record.level], ["负责人", record.owner], ["到期时间", record.dueAt], ["状态", record.status], ["更新时间", record.updatedAt], ["备注", record.remark, true]]; return [["域名", record.domain], ["状态", record.status], ["风险等级", record.riskLevel], ["到期时间", `${record.expirationDate}${record.expirationHint})`], ["所有者", record.owner], ["所属项目", record.project], ["注册商", record.registrar], ["注册时间", record.registrationDate], ["续费结果", record.renewalResult], ["续费方式", record.renewalMethod], ["DNS 服务器", record.dnsProvider], ["备注", record.remark, true]]; }
/** 代码作用(白话):重做右侧详情抽屉;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:表格点选后会通过 renderDetail() 同步显示新记录。 */
function renderDetail() { const moduleKey = state.activeModule; const record = getSelectedRecord(moduleKey); const title = getRecordDisplayName(moduleKey, record); const statusValue = record?.status || record?.renewalResult || "正常"; detailShellTitle.innerHTML = `<div class="detail-shell-title-wrap"><h3 class="detail-title">${escapeHtml(title)}</h3>${record ? formatStatusChip(statusValue) : ""}</div>`; if (!record) { detailBody.innerHTML = `<div class="note-box">当前模块没有详情数据。</div>`; return; } const fieldHtml = getDetailFields(moduleKey, record).map(([label, value, full]) => `<div class="detail-item${full ? " full" : ""}"><label>${escapeHtml(label)}</label><span>${escapeHtml(value)}</span></div>`).join(""); const relatedAssets = (record.relatedAssets || []).map((asset) => `<span class="info-chip blue">${escapeHtml(asset)}</span>`).join("") || `<span class="info-chip neutral">暂无关联资产</span>`; const logs = (record.logs || []).map((item) => `<div class="timeline-row"><span class="timeline-dot"></span><span class="timeline-copy"><span class="preview-name">${escapeHtml(item.text)}</span><span class="timeline-time">${escapeHtml(item.time || "最近更新")}</span></span></div>`).join("") || `<div class="note-box">暂无更多跟进记录。</div>`; detailBody.innerHTML = `<section class="detail-group detail-group-shell"><div class="detail-tab-bar"><button class="detail-tab is-active" type="button">基本信息</button><button class="detail-tab" type="button">更多信息</button><button class="detail-tab" type="button">关联资产</button></div><div class="detail-top-card detail-top-card-v3"><div class="detail-main-avatar">${escapeHtml(String(title).slice(0, 1))}</div><div class="detail-title-wrap"><div class="detail-primary-line"><span class="detail-primary-name">${escapeHtml(title)}</span>${formatStatusChip(statusValue)}</div><div class="detail-meta-line">模块:${escapeHtml(MODULE_CONFIG[moduleKey].title)} · 更新时间:${escapeHtml(record.updatedAt || record.createdAt || "2025-06-25")}</div><div class="detail-summary-badges">${relatedAssets}</div></div></div><div class="detail-section-shell"><div class="detail-section-head"><h4 class="detail-section-title">关键信息</h4><span class="detail-note">用于快速确认当前记录归属、状态和处理方式</span></div><div class="detail-grid">${fieldHtml}</div></div><div class="detail-section-shell"><div class="detail-section-head"><h4 class="detail-section-title">最近动态</h4><span class="detail-note">最近处理记录会按时间倒序展示</span></div><div class="log-list">${logs}</div></div><div class="detail-footer"><button class="footer-button" type="button">关闭</button><button class="footer-button-primary" type="button">继续处理</button></div></section>`; }
/** 代码作用(白话):重做右侧主内容区的内容串联;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:renderModule() 调用它输出统计卡、表格和最近跟进。 */
function renderBody() { const moduleKey = state.activeModule; const records = getModuleRecords(moduleKey); pageBody.innerHTML = records.length ? `${renderMetricCards(MODULE_CONFIG[moduleKey].stats, moduleKey)}${renderTable(moduleKey)}${renderPreviewGrid(moduleKey)}` : renderEmptyState(moduleKey); }
renderModule();
/** 代码作用(白话):修正侧边栏收起按钮的文字状态;关联文件:F:/Project/xyw_console/index.html、styles.css、app.js;关联逻辑:点击收起按钮后会重新走这里同步 aria 与按钮文案。 */
function updateSidebarToggleState() {
const isCollapsed = appShell.classList.contains("sidebar-collapsed");
sidebarToggle.setAttribute("aria-expanded", String(!isCollapsed));
sidebarToggle.setAttribute("aria-label", isCollapsed ? "展开侧边导航" : "收起侧边导航");
if (sidebarToggleLabel) sidebarToggleLabel.textContent = isCollapsed ? "展开" : "收起";
}
updateSidebarToggleState();
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>域名资料 - Vue Element Plus重构</title>
<!-- 引入 Element Plus CSS -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
.table-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
/* 强化的列宽对齐控制,避免Element Plus默认挤压 */
.el-table {
table-layout: fixed;
}
.domain-cell {
display: flex;
flex-direction: column;
}
.domain-name {
font-weight: 500;
color: #303133;
font-size: 14px;
}
.domain-remark {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.date-cell {
display: flex;
flex-direction: column;
}
.date-primary {
font-size: 14px;
color: #303133;
}
.date-hint {
font-size: 12px;
margin-top: 4px;
}
.date-hint.danger {
color: #f56c6c;
}
.date-hint.warning {
color: #e6a23c;
}
.date-hint.success {
color: #67c23a;
}
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.footer-pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
</head>
<body>
<div id="app">
<div class="table-container">
<div class="header-toolbar">
<h2>域名资料清单</h2>
<div>
<el-input v-model="searchQuery" placeholder="搜索域名、所有者、备注等" style="width: 250px; margin-right: 15px;">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary">新建记录</el-button>
<el-button>导出数据</el-button>
</div>
</div>
<el-table
:data="filteredData"
style="width: 100%"
border
stripe
>
<el-table-column type="selection" width="50" align="center"></el-table-column>
<el-table-column label="域名" min-width="180">
<template #default="scope">
<div class="domain-cell">
<span class="domain-name">{{ scope.row.domain }}</span>
<span class="domain-remark">{{ scope.row.remark || '—' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)" effect="light">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="风险等级" width="100" align="center">
<template #default="scope">
<el-tag :type="getRiskType(scope.row.riskLevel)" effect="dark" size="small">
{{ scope.row.riskLevel }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" min-width="140">
<template #default="scope">
<div class="date-cell">
<span class="date-primary">{{ scope.row.expirationDate }}</span>
<span :class="['date-hint', getHintClass(scope.row.expirationHint)]">
{{ scope.row.expirationHint }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="project" label="所属项目" min-width="120"></el-table-column>
<el-table-column prop="owner" label="所有者" width="100"></el-table-column>
<el-table-column label="续费结果" width="100" align="center">
<template #default="scope">
<el-tag :type="getRenewalResultType(scope.row.renewalResult)" effect="plain" size="small">
{{ scope.row.renewalResult }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="registrar" label="注册商" min-width="120"></el-table-column>
<el-table-column prop="updatedAt" label="更新时间" min-width="140"></el-table-column>
<!-- 修正最右侧操作列对齐问题,设置fixed="right"并固定合适宽度 -->
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="handleView(scope.row)">查看</el-button>
<el-button link type="primary" size="small">编辑</el-button>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="footer-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="tableData.length"
/>
</div>
</div>
</div>
<!-- 引入 Vue 3 和 Element Plus 及其图标 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script>
const { createApp, ref, computed } = Vue;
const { Search } = ElementPlusIconsVue;
const app = createApp({
setup() {
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(10);
// 从 app-v2.js 提取的真实域名数据列表
const tableData = ref([
{ id: 1, domain: "example.com", status: "正常", riskLevel: "低", expirationDate: "2025-07-18", expirationHint: "剩余 45 天", project: "官网项目组", owner: "张三", renewalResult: "待续费", registrar: "阿里云", updatedAt: "2025-06-30 10:23", registrationDate: "2020-07-18", renewalMethod: "手动续费", dnsProvider: "dns1.hichina.com, dns2.hichina.com", remark: "公司官网主域名。", relatedAssets: ["企业官网", "SEO 监控"], logs: [{ time: "2025-06-30 10:23", tag: "续费提醒", text: "张三 更新了 example.com 的续费方式。" }, { time: "2025-06-20 14:11", tag: "信息更新", text: "张三 更新了域名备注与备案信息。" }] },
{ id: 2, domain: "datahub.cn", status: "风险", riskLevel: "中", expirationDate: "2025-06-15", expirationHint: "剩余 12 天", project: "数据中台", owner: "李四", renewalResult: "续费中", registrar: "腾讯云", updatedAt: "2025-06-02 15:42", registrationDate: "2021-06-15", renewalMethod: "自动续费", dnsProvider: "dnspod.tencentcloudapi.com", remark: "数据中台统一域名。", relatedAssets: ["BI 报表"], logs: [{ time: "2025-06-02 15:42", tag: "续费操作", text: "李四 提交了 datahub.cn 的续费申请。" }] },
{ id: 3, domain: "ai-platform.net", status: "风险", riskLevel: "高", expirationDate: "2025-06-05", expirationHint: "剩余 2 天", project: "AI 平台", owner: "王五", renewalResult: "未续费", registrar: "西部数码", updatedAt: "2025-06-01 09:18", registrationDate: "2022-06-05", renewalMethod: "手动续费", dnsProvider: "dns.west.cn", remark: "AI 平台主域名。", relatedAssets: ["AI 控制台", "API 文档"], logs: [{ time: "2025-06-01 09:18", tag: "风险提示", text: "系统检测到续费未处理。" }] },
{ id: 4, domain: "metrics.io", status: "过期", riskLevel: "高", expirationDate: "2025-05-28", expirationHint: "已过期 6 天", project: "监控平台", owner: "赵六", renewalResult: "未续费", registrar: "阿里云", updatedAt: "2025-05-28 11:07", registrationDate: "2021-05-28", renewalMethod: "余额续费", dnsProvider: "alidns.aliyuncs.com", remark: "监控平台展示域名。", relatedAssets: ["告警页"], logs: [{ time: "2025-05-28 11:07", tag: "到期提醒", text: "metrics.io 已过期 6 天。" }] },
{ id: 5, domain: "docs.example.com", status: "正常", riskLevel: "低", expirationDate: "2025-09-30", expirationHint: "剩余 119 天", project: "文档中心", owner: "张三", renewalResult: "自动续费", registrar: "阿里云", updatedAt: "2025-06-03 08:55", registrationDate: "2023-09-30", renewalMethod: "自动续费", dnsProvider: "alidns.aliyuncs.com", remark: "开发者文档域名。", relatedAssets: ["帮助中心"], logs: [{ time: "2025-06-03 08:55", tag: "状态同步", text: "证书与到期信息同步完成。" }] }
]);
const filteredData = computed(() => {
let data = tableData.value;
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
data = data.filter(item =>
item.domain.toLowerCase().includes(query) ||
item.owner.toLowerCase().includes(query) ||
(item.remark && item.remark.toLowerCase().includes(query))
);
}
// 分页逻辑
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return data.slice(start, end);
});
const getStatusType = (status) => {
const map = { '正常': 'success', '风险': 'warning', '过期': 'danger' };
return map[status] || 'info';
};
const getRiskType = (level) => {
const map = { '低': 'success', '中': 'warning', '高': 'danger' };
return map[level] || 'info';
};
const getRenewalResultType = (result) => {
const map = {
'自动续费': 'success',
'待续费': 'warning',
'续费中': 'primary',
'未续费': 'danger'
};
return map[result] || 'info';
};
const getHintClass = (hint) => {
if (!hint) return '';
if (hint.includes('过期') || hint.includes('2 天')) return 'danger';
if (hint.includes('12 天')) return 'warning';
return 'success';
};
const handleView = (row) => {
console.log('查看详情', row);
};
return {
searchQuery,
currentPage,
pageSize,
tableData,
filteredData,
getStatusType,
getRiskType,
getRenewalResultType,
getHintClass,
handleView
};
}
});
app.use(ElementPlus);
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.mount('#app');
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>学有为资产管理台</title>
<link rel="stylesheet" href="node_modules/element-plus/dist/index.css" />
<link rel="stylesheet" href="styles-v2.css" />
</head>
<body>
<div class="workspace" id="appShell">
<aside class="shell-nav" id="sidebar">
<div class="brand-panel">
<div class="brand-mark">
<img class="brand-logo" src="img/学有为logo.jpg" alt="瀛︽湁涓?LOGO" />
</div>
<div class="brand-copy">
<h1>&#23398;&#26377;&#20026;&#25968;&#25454;&#36164;&#20135;&#21518;&#21488;</h1>
</div>
</div>
<div class="nav-main">
<nav class="primary-nav" aria-label="&#20027;&#23548;&#33322;">
<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>
</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>
</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>
</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>
</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>
</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">
<span class="nav-toggle-glyph" aria-hidden="true">
<span></span>
<span></span>
</span>
<span class="nav-toggle-label">&#25910;&#36215;</span>
</button>
</div>
<div class="nav-footer">
<div class="nav-user-avatar">&#9679;</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>
</div>
<button class="nav-settings" type="button" aria-label="&#35774;&#32622;">&#9881;</button>
</div>
</div>
</aside>
<main class="shell-main" id="vue-router-app">
<router-view></router-view>
</main>
</div>
<div id="addDomainDialogRoot"></div>
<div id="wechatDialogRoot"></div>
<div id="wechatDetailDrawerRoot"></div>
<div id="phoneDialogRoot"></div>
<div id="phoneDetailDrawerRoot"></div>
<script src="node_modules/vue/dist/vue.global.prod.js"></script>
<script src="node_modules/vue-router/dist/vue-router.global.prod.js"></script>
<script src="node_modules/element-plus/dist/index.full.min.js"></script>
<script src="src/modules/domain/domain-add-dialog.js"></script>
<script src="src/modules/domain/domain-list-runtime.js"></script>
<script src="src/modules/overview/overview-list-runtime.js"></script>
<script src="src/modules/wechat/wechat-add-dialog.js"></script>
<script src="src/modules/wechat/wechat-detail-drawer.js"></script>
<script src="src/modules/wechat/wechat-list-runtime.js"></script>
<script src="src/modules/phone/phone-add-dialog.js"></script>
<script src="src/modules/phone/phone-detail-drawer.js"></script>
<script src="src/modules/phone/phone-list-runtime.js"></script>
<script src="src/router/index.js"></script>
<script src="app-v2.js"></script>
</body>
</html>
{
"name": "xyw_console",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"element-plus": "^2.14.2",
"jsdom": "^29.1.1",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
"integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
"integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/shared": "3.5.39",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-core/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.39",
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.15",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.39",
"@vue/runtime-core": "3.5.39",
"@vue/shared": "3.5.39",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
"integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39"
},
"peerDependencies": {
"vue": "3.5.39"
}
},
"node_modules/@vue/shared": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/element-plus": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
"integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.7.6",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
"@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "14.3.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.20",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.3"
},
"peerDependencies": {
"vue": "^3.3.7"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz",
"integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.5"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz",
"integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==",
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/vue": {
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-sfc": "3.5.39",
"@vue/runtime-dom": "3.5.39",
"@vue/server-renderer": "3.5.39",
"@vue/shared": "3.5.39"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz",
"integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==",
"license": "MIT"
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
}
}
}
{
"dependencies": {
"element-plus": "^2.14.2",
"jsdom": "^29.1.1",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
}
}
(function initDomainAddDialog() {
const { createApp, ref, reactive, watch } = window.Vue;
const ElementPlus = window.ElementPlus;
const DomainAddDialog = {
/** 代码作用(白话):创建“新增域名”弹窗的表单状态、校验规则和提交行为;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“页面操作按钮 -> DomainAddDialogAPI.open() -> 弹窗显示 -> handleSubmit() 提交校验”。 */
setup() {
const visible = ref(false);
const formRef = ref(null);
const createDefaultFormData = () => ({
domain: '',
websiteName: '',
registrant: '',
accountId: '',
bindAccountId: '',
phone: '',
email: '',
password: '',
registerDate: '',
expirationDate: '',
autoRenewal: '0',
renewalStatus: 'pending',
sslInfo: '',
sslExpirationDate: ''
});
const formData = reactive(createDefaultFormData());
const rules = {
domain: [{ required: true, message: '请输入域名地址', trigger: 'blur' }]
};
watch(() => formData.autoRenewal, (newVal) => {
if (newVal === '1') {
formData.renewalStatus = 'success';
}
});
/** 代码作用(白话):打开弹窗并把表单重置到初始状态,避免上一次填写内容残留;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/index.html;关联逻辑:消息链是“页面按钮 -> DomainAddDialogAPI.open() -> visible=true -> 弹窗展示”。 */
const open = () => {
visible.value = true;
if (formRef.value) {
formRef.value.resetFields();
}
Object.assign(formData, createDefaultFormData());
};
/** 代码作用(白话):关闭当前域名新增弹窗;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/index.html;关联逻辑:消息链是“取消按钮/提交成功 -> close() -> visible=false”。 */
const close = () => {
visible.value = false;
};
/** 代码作用(白话):校验表单并给出保存成功提示,当前版本只做前端校验和反馈,不直接请求后端;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“提交按钮 -> handleSubmit() -> formRef.validate() -> ElMessage.success() -> close()”。 */
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate((valid) => {
if (!valid) return false;
ElementPlus.ElMessage.success('提交保存成功');
close();
return true;
});
};
window.DomainAddDialogAPI = { open };
return {
visible,
formRef,
formData,
rules,
close,
handleSubmit
};
},
template: `
<el-dialog v-model="visible" width="760px" top="8vh">
<template #header>
<div style="font-size: 18px; font-weight: bold; color: #303133;">新增域名记录</div>
</template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="110px" 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">
<el-form-item label="域名地址" prop="domain">
<el-input v-model="formData.domain" placeholder="请输入域名地址,例如 example.com"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="网站名称/平台" prop="websiteName">
<el-input v-model="formData.websiteName" placeholder="请输入网站名称或所属平台"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="24">
<el-form-item label="注册主体" prop="registrant">
<el-input v-model="formData.registrant" placeholder="请输入注册主体,例如个人或企业全称"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider border-style="dashed" style="margin: 16px 0;"></el-divider>
<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="账号 ID" prop="accountId">
<el-input v-model="formData.accountId" placeholder="请输入托管平台账号 ID"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="绑定账号 ID" prop="bindAccountId">
<el-input v-model="formData.bindAccountId" placeholder="请输入内部绑定账号 ID"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入绑定手机号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="电子邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入绑定邮箱"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="24">
<el-form-item label="登录密码" prop="password">
<el-input v-model="formData.password" placeholder="请输入平台登录密码" show-password></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider border-style="dashed" style="margin: 16px 0;"></el-divider>
<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="registerDate">
<el-date-picker v-model="formData.registerDate" type="date" placeholder="请选择日期" style="width: 100%"></el-date-picker>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="到期日期" prop="expirationDate">
<el-date-picker v-model="formData.expirationDate" type="date" placeholder="请选择日期" style="width: 100%"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="自动续费" prop="autoRenewal">
<el-radio-group v-model="formData.autoRenewal">
<el-radio label="0">未开启</el-radio>
<el-radio label="1">已开启</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最新续费状态" prop="renewalStatus">
<el-select v-model="formData.renewalStatus" placeholder="请选择续费状态" style="width: 100%">
<el-option label="待续费" value="pending"></el-option>
<el-option label="续费成功" value="success"></el-option>
<el-option label="续费失败" value="failed"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-divider border-style="dashed" style="margin: 16px 0;"></el-divider>
<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="SSL 证书信息" prop="sslInfo">
<el-input v-model="formData.sslInfo" placeholder="请输入证书类型或服务商,例如 Let's Encrypt"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="SSL 到期时间" prop="sslExpirationDate">
<el-date-picker v-model="formData.sslExpirationDate" type="date" placeholder="请选择日期" style="width: 100%"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交保存</el-button>
</span>
</template>
</el-dialog>
`
};
const app = createApp(DomainAddDialog);
app.use(ElementPlus);
app.mount('#addDomainDialogRoot');
})();
(function attachDomainListRuntime() {
const DOMAIN_TABLE_COLUMN_WIDTHS = [
'48px',
'calc((100% - 48px) * 0.16)',
'calc((100% - 48px) * 0.08)',
'calc((100% - 48px) * 0.08)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.08)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.14)'
];
/** 代码作用(白话):定义“全部域名列表”组件,让表格结构从大脚本里拆出来,但仍复用项目原字段;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/styles-v2.css、F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:mountDomainListComponent() 会把 app-v2.js 传来的字段数据、分页方法、抽屉方法交给这个组件,再由组件模板渲染整张域名列表。 */
const DomainListTable = {
name: 'DomainListTable',
props: {
domainText: { type: Object, required: true },
domainTableTotal: { type: Number, required: true },
domainTableFilterLabels: { type: Array, required: true },
domainTablePageSizes: { type: Array, required: true },
mountState: { type: Object, required: true },
globalState: { type: Object, required: true },
detailDrawer: { type: Object, required: true },
getDomainRowsByPage: { type: Function, required: true },
getVisiblePageNumbers: { type: Function, required: true },
getDomainStatusDotClass: { type: Function, required: true },
getDomainRiskBadgeClass: { type: Function, required: true },
renderDomainDetailDrawer: { type: Function, required: true },
getDomainRecordById: { type: Function, required: true },
openDrawer: { type: Function, required: true }
},
/** 代码作用(白话):初始化域名列表组件自己的页码、勾选和选中行状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:mountDomainListComponent() 挂载组件后先进入 setup(),再把状态和操作方法交给模板、抽屉联动和分页按钮。 */
setup(props) {
const { computed, ref } = window.Vue;
const currentPage = ref(props.mountState.currentPage);
const pageSize = ref(props.mountState.pageSize);
const selectedRecordId = ref(props.globalState.selectedBySource.domain || 1);
const checkedIds = ref([].concat(props.mountState.selectedIds || []));
const filterDate = ref('');
const searchQuery = ref('');
const filterStatus = ref('all');
const filterRisk = ref('all');
const filterOwner = ref('all');
const allRecordsCache = ref(null);
function getAllRecords() {
if (!allRecordsCache.value) {
allRecordsCache.value = props.getDomainRowsByPage(1, props.domainTableTotal);
}
return allRecordsCache.value;
}
const filteredRecords = computed(() => {
let result = getAllRecords();
if (filterStatus.value !== 'all') {
result = result.filter(r => r.status === filterStatus.value);
}
if (filterRisk.value !== 'all') {
result = result.filter(r => r.riskLevel === filterRisk.value);
}
if (filterOwner.value !== 'all') {
result = result.filter(r => r.owner === filterOwner.value);
}
if (filterDate.value && filterDate.value.length === 2) {
const start = new Date(filterDate.value[0]).getTime();
const end = new Date(filterDate.value[1]).getTime() + 86400000;
result = result.filter(r => {
if (!r.expirationDate) return false;
const t = new Date(r.expirationDate).getTime();
return t >= start && t < end;
});
}
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase();
result = result.filter(r =>
(r.domain && r.domain.toLowerCase().includes(q)) ||
(r.project && r.project.toLowerCase().includes(q)) ||
(r.owner && r.owner.toLowerCase().includes(q))
);
}
return result;
});
const totalFiltered = computed(() => filteredRecords.value.length);
window.Vue.watch([filterStatus, filterRisk, filterOwner, filterDate, searchQuery], () => {
currentPage.value = 1;
});
/** 代码作用(白话):把组件内部状态同步回外层共享状态,保证切换模块后页码和勾选还能保留;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:分页、勾选、行点击都会在动作完成后调用它,再把结果写回 domainListMountState。 */
function syncMountState() {
props.mountState.currentPage = currentPage.value;
props.mountState.pageSize = pageSize.value;
props.mountState.selectedIds = [].concat(checkedIds.value);
}
/** 代码作用(白话):计算域名列表总页数;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:分页按钮和页码条都会通过 computed(totalPages) 读取它的结果,不这样算,翻页边界会错。 */
function buildTotalPages() {
return Math.max(1, Math.ceil(totalFiltered.value / pageSize.value));
}
/** 代码作用(白话):按当前页码和每页条数取出当前页记录;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:computed(visibleRows) 调用它,再把 app-v2.js 里保留的原字段记录渲染到表格 tbody。 */
function buildVisibleRows() {
const start = (currentPage.value - 1) * pageSize.value;
return filteredRecords.value.slice(start, start + pageSize.value);
}
/** 代码作用(白话):生成分页栏要显示的页码列表;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:computed(pageNumbers) 调用它,再复用原项目的页码显示规则。 */
function buildPageNumbers() {
return props.getVisiblePageNumbers(totalPages.value, currentPage.value);
}
/** 代码作用(白话):判断当前页是否已经全选;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:表头复选框会通过 computed(allVisibleChecked) 读取这里的结果,不这样判断,全选状态会失真。 */
function buildAllVisibleChecked() {
if (!visibleRows.value.length) return false;
for (let index = 0; index < visibleRows.value.length; index += 1) {
if (!checkedIds.value.includes(visibleRows.value[index].id)) return false;
}
return true;
}
const totalPages = computed(buildTotalPages);
const visibleRows = computed(buildVisibleRows);
const pageNumbers = computed(buildPageNumbers);
const allVisibleChecked = computed(buildAllVisibleChecked);
/** 代码作用(白话):判断某一行是否被勾选;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:表格每一行的复选框都会调它决定 checked 状态。 */
function isRowChecked(recordId) {
return checkedIds.value.includes(recordId);
}
/** 代码作用(白话):判断某一行是否是当前聚焦行;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:表格高亮 class 通过它判断当前行是否需要加 selected。 */
function isRowSelected(recordId) {
return selectedRecordId.value === recordId;
}
/** 代码作用(白话):把状态字段转换成原项目表格使用的颜色类名;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:表格状态圆点会先调它,再复用 app-v2.js 里现有状态映射。 */
function getStatusClass(status) {
return props.getDomainStatusDotClass(status);
}
/** 代码作用(白话):把风险等级字段转换成原项目表格使用的徽标类名;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:表格风险等级标签会调它,再复用 app-v2.js 里现有风险映射。 */
function getRiskClass(riskLevel) {
return props.getDomainRiskBadgeClass(riskLevel);
}
/** 代码作用(白话):聚焦某一行域名记录,并在抽屉已打开时同步刷新详情;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:点击行、点击域名、点击操作列前都会先进这里,再更新 selectedBySource.domain 和抽屉内容。 */
function focusRow(recordId) {
selectedRecordId.value = recordId;
props.globalState.selectedBySource.domain = recordId;
syncMountState();
if (!props.detailDrawer.classList.contains('hidden')) {
props.renderDomainDetailDrawer(props.getDomainRecordById(recordId));
}
}
/** 代码作用(白话):切换单行勾选状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:行内复选框 change 事件会调用它,再同步 footer 已选数量和外层共享状态。 */
function toggleRowChecked(recordId, checked) {
const nextIds = new Set(checkedIds.value);
if (checked) nextIds.add(recordId);
else nextIds.delete(recordId);
checkedIds.value = Array.from(nextIds);
syncMountState();
}
/** 代码作用(白话):切换当前页全部行的勾选状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:表头复选框会调用它,再逐行同步当前页记录的勾选结果。 */
function toggleAllVisible(checked) {
const nextIds = new Set(checkedIds.value);
for (let index = 0; index < visibleRows.value.length; index += 1) {
const row = visibleRows.value[index];
if (checked) nextIds.add(row.id);
else nextIds.delete(row.id);
}
checkedIds.value = Array.from(nextIds);
syncMountState();
}
/** 代码作用(白话):跳到指定页码,并把结果限制在合法范围内;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:页码按钮、上一页、下一页和页大小变更都会最终流向这里。 */
function goToPage(pageNumber) {
currentPage.value = Math.min(Math.max(1, pageNumber), totalPages.value);
syncMountState();
}
/** 代码作用(白话):处理上一页和下一页按钮;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:分页箭头按钮通过它把动作翻译成具体页码,再流向 goToPage()。 */
function changePage(direction) {
if (direction === 'prev') goToPage(currentPage.value - 1);
if (direction === 'next') goToPage(currentPage.value + 1);
}
/** 代码作用(白话):处理每页条数变化,并把页码重置到第一页;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:页大小下拉框变化后先更新 pageSize,再通过这里收口到第一页,避免落在空页。 */
function handlePageSizeChange() {
props.mountState.pageSize = pageSize.value;
goToPage(1);
}
/** 代码作用(白话):把域名列表恢复到默认分页和默认勾选状态;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js;关联逻辑:工具栏“重置”按钮会调用它,同时恢复页码、页大小和已选项。 */
function resetTableState() {
currentPage.value = 1;
pageSize.value = props.domainTablePageSizes[0];
checkedIds.value = [selectedRecordId.value];
syncMountState();
}
/** 代码作用(白话):处理“查看/编辑”操作按钮,先聚焦当前行,再走原项目抽屉打开链路;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:操作列点击后会先流向这里,再交给 window.openDrawer() 打开详情抽屉。 */
function handleRowAction(mode, recordId) {
focusRow(recordId);
props.openDrawer(mode, recordId);
}
syncMountState();
return {
DOMAIN_TABLE_COLUMN_WIDTHS,
currentPage,
pageSize,
visibleRows,
pageNumbers,
checkedIds,
allVisibleChecked,
isRowChecked,
isRowSelected,
getStatusClass,
getRiskClass,
focusRow,
toggleRowChecked,
toggleAllVisible,
goToPage,
changePage,
handlePageSizeChange,
resetTableState,
handleRowAction,
filterDate,
searchQuery,
filterStatus,
filterRisk,
filterOwner,
totalFiltered
};
},
template: `
<div class="table-area table-area-refined">
<div class="table-toolbar table-toolbar-refined">
<div class="toolbar-left">
<div class="toolbar-title-wrap">
<div class="toolbar-title">{{ domainText.allDomains }} ({{ totalFiltered.toLocaleString('zh-CN') }})</div>
</div>
<div class="filter-selects filter-selects-refined" style="display: flex; align-items: center; gap: 8px;">
<el-select v-model="filterStatus" class="custom-fselect" style="width: 120px; height: 32px;">
<el-option label="状态: 全部" value="all"></el-option>
<el-option label="正常" value="正常"></el-option>
<el-option label="风险" value="风险"></el-option>
<el-option label="过期" value="过期"></el-option>
</el-select>
<el-select v-model="filterRisk" class="custom-fselect" style="width: 130px; height: 32px;">
<el-option label="风险等级: 全部" value="all"></el-option>
<el-option label="低" value="低"></el-option>
<el-option label="中" value="中"></el-option>
<el-option label="高" value="高"></el-option>
</el-select>
<el-date-picker
v-model="filterDate"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px; height: 32px;"
></el-date-picker>
<el-select v-model="filterOwner" class="custom-fselect" style="width: 120px; height: 32px;">
<el-option label="所有者: 全部" value="all"></el-option>
<el-option label="张三" value="张三"></el-option>
<el-option label="李四" value="李四"></el-option>
<el-option label="王五" value="王五"></el-option>
<el-option label="赵六" value="赵六"></el-option>
<el-option label="周七" value="周七"></el-option>
</el-select>
<div class="fselect">{{ domainTableFilterLabels[4] }}</div>
<el-input
v-model="searchQuery"
placeholder="搜索域名、所属项目、所有者"
clearable
style="width: 260px; height: 32px;"
>
<template #prefix>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</template>
</el-input>
</div>
</div>
<div class="toolbar-right">
<button class="btn-reset btn-reset-refined" type="button" @click="resetTableState">{{ domainText.reset }}</button>
<button class="btn-settings btn-settings-refined" type="button" :aria-label="domainText.rowSetting"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.56-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.49-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></button>
</div>
</div>
<div class="table-wrap table-wrap-refined">
<table class="xtable domain-table domain-table-refined">
<colgroup>
<col v-for="width in DOMAIN_TABLE_COLUMN_WIDTHS" :key="width" :style="{ width }" />
</colgroup>
<thead>
<tr>
<th class="th-selector"><input type="checkbox" :checked="allVisibleChecked" @change="toggleAllVisible($event.target.checked)" /></th>
<th>{{ domainText.domain }}</th>
<th>{{ domainText.status }}</th>
<th>{{ domainText.riskLevel }}</th>
<th>{{ domainText.expirationDate }}</th>
<th>{{ domainText.project }}</th>
<th>{{ domainText.owner }}</th>
<th>{{ domainText.renewalResult }}</th>
<th>{{ domainText.registrar }}</th>
<th>{{ domainText.updatedAt }} <svg viewBox="0 0 24 24" width="12" height="12"><path d="M7 10l5 5 5-5z"/></svg></th>
<th class="th-action">{{ domainText.action }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ selected: isRowSelected(row.id) }" @click="focusRow(row.id)">
<td class="td-selector"><input type="checkbox" :checked="isRowChecked(row.id)" @click.stop @change="toggleRowChecked(row.id, $event.target.checked)" /></td>
<td class="td-domain"><a href="#" class="domain-link" @click.prevent="focusRow(row.id)">{{ row.domain }}</a><div class="domain-inline-meta">{{ row.project }}</div></td>
<td><span class="status-chip-wrap"><span class="status-dot" :class="getStatusClass(row.status)"></span><span>{{ row.status }}</span></span></td>
<td><span class="risk-badge" :class="getRiskClass(row.riskLevel)">{{ row.riskLevel }}</span></td>
<td><div class="date-stack"><span>{{ row.expirationDate }}</span><div class="cell-sub" :class="{ green: row.status === domainText.normal && row.riskLevel === domainText.low }">{{ row.expirationHint }}</div></div></td>
<td>{{ row.project }}</td>
<td>{{ row.owner }}</td>
<td>{{ row.renewalResult }}</td>
<td>{{ row.registrar }}</td>
<td>{{ row.updatedAt }}</td>
<td class="td-action"><div class="action-cluster"><button class="action-pill action-pill-primary" type="button" @click.stop="handleRowAction('view', row.id)">{{ domainText.view }}</button><button class="action-pill" type="button" @click.stop="handleRowAction('edit', row.id)">{{ domainText.edit }}</button><button class="action-icon" type="button" aria-label="更多操作" @click.stop><svg t="1782455023712" class="icon action-dots" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2600" width="16" height="16"><path d="M170.666667 512m-85.333334 0a85.333333 85.333333 0 1 0 170.666667 0 85.333333 85.333333 0 1 0-170.666667 0Z" fill="#BBBBBB" p-id="2601"></path><path d="M512 512m-85.333333 0a85.333333 85.333333 0 1 0 170.666666 0 85.333333 85.333333 0 1 0-170.666666 0Z" fill="#BBBBBB" p-id="2602"></path><path d="M853.333333 512m-85.333333 0a85.333333 85.333333 0 1 0 170.666667 0 85.333333 85.333333 0 1 0-170.666667 0Z" fill="#BBBBBB" p-id="2603"></path></svg></button></div></td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer table-footer-refined">
<div class="tfoot-left tfoot-left-refined">
<span>{{ domainText.selected }} {{ checkedIds.length }} {{ domainText.items }}</span>
<button class="btn-batch btn-batch-refined" type="button">{{ domainText.batchAction }} &#9662;</button>
</div>
<div class="tfoot-right tfoot-right-refined">
<span>{{ domainText.total }} {{ totalFiltered.toLocaleString('zh-CN') }} {{ domainText.records }}</span>
<button class="page-btn outline" type="button" @click="changePage('prev')" :disabled="currentPage === 1">&#8249;</button>
<div class="pagination">
<template v-for="item in pageNumbers" :key="String(item)">
<button v-if="item !== 'ellipsis'" class="page-btn" :class="{ active: currentPage === item }" type="button" @click="goToPage(item)">{{ item }}</button>
<span v-else>...</span>
</template>
</div>
<button class="page-btn outline" type="button" @click="changePage('next')" :disabled="currentPage === Math.ceil(totalFiltered / pageSize)">&#8250;</button>
<select class="page-size" v-model.number="pageSize" @change="handlePageSizeChange">
<option v-for="size in domainTablePageSizes" :key="size" :value="size">{{ size }}{{ domainText.pageSuffix }}</option>
</select>
</div>
</div>
</div>
`
};
/** 代码作用(白话):把域名列表组件挂到指定 DOM 容器上;关联文件:F:/Project/xyw_console/src/modules/domain/domain-list-runtime.js、F:/Project/xyw_console/app-v2.js;关联逻辑:app-v2.js 里的 mountDomainListApp() 会把当前项目原字段和原逻辑方法打包后交给这里,再由这里创建 Vue 实例。 */
function mountDomainListComponent(options) {
const { createApp } = window.Vue;
const app = createApp(DomainListTable, options);
if (window.ElementPlus) {
app.use(window.ElementPlus);
}
app.mount(options.mountTarget);
return app;
}
window.DomainListRuntime = {
mountDomainListComponent
};
})();
window.OverviewListRuntime = (function () {
const { createApp, ref, computed, onMounted } = Vue;
function mountOverviewListComponent(config) {
if (!document.querySelector(config.mountTarget)) {
return null;
}
const app = createApp({
setup() {
const stats = ref(config.stats || []);
const records = ref(config.getRows() || []);
// Flatten logs from records for Recent Activities
const recentLogs = computed(() => {
let allLogs = [];
records.value.forEach(record => {
if (record.logs && record.logs.length > 0) {
record.logs.forEach(log => {
allLogs.push({
...log,
domainId: record.id,
domainName: record.domain
});
});
}
});
// Sort by time descending (simple string compare works for YYYY-MM-DD HH:mm)
allLogs.sort((a, b) => b.time.localeCompare(a.time));
return allLogs.slice(0, 10); // Take top 10 recent
});
const getToneColor = (tone) => {
const colors = {
blue: '#3b82f6',
green: '#10b981',
orange: '#f59e0b',
red: '#ef4444'
};
return colors[tone] || '#64748b';
};
const getToneBg = (tone) => {
const colors = {
blue: '#eff6ff',
green: '#ecfdf5',
orange: '#fffbeb',
red: '#fef2f2'
};
return colors[tone] || '#f8fafc';
};
const getStatusTagType = (status) => {
if (status.includes('正常')) return 'success';
if (status.includes('风险')) return 'warning';
if (status.includes('异常') || status.includes('过期')) return 'danger';
return 'info';
};
return {
stats,
records,
recentLogs,
getToneColor,
getToneBg,
getStatusTagType
};
},
template: `
<div class="overview-container" style="padding: 24px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 32px;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2 style="margin: 0; font-size: 24px; font-weight: 600; color: #111827;">资料总览</h2>
<p style="margin: 4px 0 0 0; font-size: 14px; color: #6b7280;">账号与域名资产管理,全局概况数据</p>
</div>
<el-button type="primary" size="large" style="border-radius: 8px;">导出数据</el-button>
</div>
<!-- 数据大屏 (Data Dashboard) - Bento Grid -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px;">
<div v-for="(stat, index) in stats" :key="index"
style="background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px -4px rgba(0,0,0,0.05); border: 1px solid #f1f5f9; display: flex; flex-direction: column; gap: 16px; transition: transform 0.2s ease, box-shadow 0.2s ease;"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 8px 30px -4px rgba(0,0,0,0.08)';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 20px -4px rgba(0,0,0,0.05)';">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 15px; font-weight: 500; color: #64748b;">{{ stat.title }}</span>
<div :style="{ width: '32px', height: '32px', borderRadius: '8px', background: getToneBg(stat.tone), color: getToneColor(stat.tone), display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 'bold' }">
{{ stat.icon }}
</div>
</div>
<div style="font-size: 36px; font-weight: 700; color: #0f172a; line-height: 1;">
{{ stat.value }}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: auto; padding-top: 16px; border-top: 1px dashed #e2e8f0;">
<span style="font-size: 13px; color: #64748b;">{{ stat.sub }}</span>
<span :style="{ fontSize: '13px', fontWeight: '500', color: getToneColor(stat.tone) }">{{ stat.link }}</span>
</div>
</div>
</div>
<!-- 下方两列布局:数据表 + 最近动态 -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 24px;">
<!-- 首页数据表 (Data Table) -->
<div style="background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px -4px rgba(0,0,0,0.05); border: 1px solid #f1f5f9;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #111827;">重点关注资产</h3>
<el-button link type="primary">查看全部</el-button>
</div>
<el-table :data="records.slice(0, 5)" style="width: 100%; border-radius: 8px; overflow: hidden;" :header-cell-style="{ background: '#f8fafc', color: '#475569', fontWeight: 500, borderBottom: 'none' }" :cell-style="{ borderBottom: '1px solid #f1f5f9' }">
<el-table-column prop="domain" label="域名/资产" min-width="160">
<template #default="scope">
<div style="font-weight: 500; color: #0f172a;">{{ scope.row.domain }}</div>
<div style="font-size: 12px; color: #64748b;">{{ scope.row.project }}</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row.status)" size="small" effect="light" style="border-radius: 6px;">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="expirationDate" label="到期时间" min-width="140">
<template #default="scope">
<div style="color: #334155;">{{ scope.row.expirationDate }}</div>
<div :style="{ fontSize: '12px', color: scope.row.status.includes('过期') || scope.row.status.includes('风险') ? '#ef4444' : '#10b981' }">
{{ scope.row.expirationHint }}
</div>
</template>
</el-table-column>
<el-table-column prop="owner" label="责任人" width="100" />
</el-table>
</div>
<!-- 最近动态 (Recent Activities) -->
<div style="background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px -4px rgba(0,0,0,0.05); border: 1px solid #f1f5f9;">
<h3 style="margin: 0 0 24px 0; font-size: 16px; font-weight: 600; color: #111827;">最近动态</h3>
<div v-if="recentLogs.length > 0" style="display: flex; flex-direction: column; gap: 20px;">
<div v-for="(log, idx) in recentLogs" :key="idx" style="display: flex; gap: 16px;">
<div style="display: flex; flex-direction: column; align-items: center; margin-top: 4px;">
<div style="width: 8px; height: 8px; border-radius: 50%; background: #3b82f6;"></div>
<div v-if="idx !== recentLogs.length - 1" style="width: 1px; flex: 1; background: #e2e8f0; margin-top: 4px; min-height: 24px;"></div>
</div>
<div style="flex: 1; padding-bottom: idx === recentLogs.length - 1 ? 0 : 8px;">
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px;">
<span style="font-size: 13px; font-weight: 500; color: #111827;">{{ log.tag }}</span>
<span style="font-size: 12px; color: #94a3b8;">{{ log.time }}</span>
</div>
<div style="font-size: 13px; color: #475569; line-height: 1.5;">
{{ log.text }}
</div>
</div>
</div>
</div>
<div v-else style="padding: 40px 0; text-align: center; color: #94a3b8; font-size: 14px;">
暂无近期动态
</div>
</div>
</div>
</div>
`
});
app.use(ElementPlus);
app.mount(config.mountTarget);
return app;
}
return {
mountOverviewListComponent
};
})();
(function initPhoneAddDialog() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
const PhoneAddDialog = {
setup() {
const visible = ref(false);
const formRef = ref(null);
const isEdit = ref(false);
const formData = reactive({
id: null,
phoneNumber: '',
realPerson: '',
iccid: '',
city: '',
cardStatus: '正常',
cardUsageLocation: '',
wecom: false,
wechat: false,
outboundCall: false,
douyinAccount: '',
miniProgramFiling: false,
packageChange5yuan: false,
channelOperator: '',
numberStatus: true,
linkedWecom: '',
numberRetentionStatus: true
});
const rules = {
phoneNumber: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
channelOperator: [{ required: true, message: '请输入渠道运营商', trigger: 'blur' }]
};
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 {
formData.id = null;
formData.phoneNumber = '';
formData.realPerson = '';
formData.iccid = '';
formData.city = '';
formData.cardStatus = '正常';
formData.cardUsageLocation = '';
formData.wecom = false;
formData.wechat = false;
formData.outboundCall = false;
formData.douyinAccount = '';
formData.miniProgramFiling = false;
formData.packageChange5yuan = false;
formData.channelOperator = '';
formData.numberStatus = true;
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';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const json = await res.json();
if (json.code === 200) {
ElementPlus.ElMessage.success(formData.id ? '修改成功' : '新增成功');
close();
// 触发全局事件,让列表刷新
window.dispatchEvent(new CustomEvent('phone-list-refresh'));
} else {
ElementPlus.ElMessage.error(json.message || '操作失败');
}
} catch (err) {
console.error(err);
ElementPlus.ElMessage.error('网络请求失败,请确保后端服务已启动');
}
} else {
return false;
}
});
};
window.PhoneAddDialogAPI = {
open
};
return {
visible,
isEdit,
formRef,
formData,
rules,
close,
handleSubmit
};
},
template: `
<el-dialog v-model="visible" width="760px" top="8vh">
<template #header>
<div style="font-size: 18px; font-weight: bold; color: #303133;">
{{ isEdit ? '编辑手机号卡' : '新增手机号卡' }}
</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">
<el-form-item label="手机号" prop="phoneNumber">
<el-input v-model="formData.phoneNumber" placeholder="请输入手机号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="真实联系人" prop="realPerson">
<el-input v-model="formData.realPerson" placeholder="请输入实名认证人"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="ICCID卡号" prop="iccid">
<el-input v-model="formData.iccid" placeholder="请输入 ICCID"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="渠道运营商" prop="channelOperator">
<el-select v-model="formData.channelOperator" placeholder="请选择运营商" style="width: 100%;">
<el-option label="中国移动" value="中国移动"></el-option>
<el-option label="中国联通" value="中国联通"></el-option>
<el-option label="中国电信" value="中国电信"></el-option>
<el-option label="中国广电" value="中国广电"></el-option>
<el-option label="虚商" value="虚商"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="所在城市" prop="city">
<el-input v-model="formData.city" placeholder="请输入号码归属地"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="卡使用地点" prop="cardUsageLocation">
<el-input v-model="formData.cardUsageLocation" placeholder="请输入当前使用地点"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider border-style="dashed" style="margin: 16px 0;"></el-divider>
<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-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-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="号码状态" prop="numberStatus">
<el-switch v-model="formData.numberStatus" active-text="正常" inactive-text="异常"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="是否保留号码" prop="numberRetentionStatus">
<el-switch v-model="formData.numberRetentionStatus" active-text="保留" inactive-text="注销"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可变更5元套餐" prop="packageChange5yuan">
<el-switch v-model="formData.packageChange5yuan"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-divider border-style="dashed" style="margin: 16px 0;"></el-divider>
<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="8">
<el-form-item label="开通微信" prop="wechat" label-width="90px">
<el-switch v-model="formData.wechat"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="开通企业微信" prop="wecom" label-width="110px">
<el-switch v-model="formData.wecom"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="可外呼" prop="outboundCall" label-width="80px">
<el-switch v-model="formData.outboundCall"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="绑定企业微信" prop="linkedWecom">
<el-input v-model="formData.linkedWecom" placeholder="关联的企微账号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="抖音账号" prop="douyinAccount">
<el-input v-model="formData.douyinAccount" placeholder="关联的抖音号"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="小程序备案" prop="miniProgramFiling">
<el-switch v-model="formData.miniProgramFiling" active-text="已备案" inactive-text="未备案"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交保存</el-button>
</span>
</template>
</el-dialog>
`
};
const app = createApp(PhoneAddDialog);
app.use(ElementPlus);
app.mount('#phoneDialogRoot');
})();
(function initPhoneDetailDrawer() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
const PhoneDetailDrawer = {
setup() {
const visible = ref(false);
const recordData = ref(null);
const open = (record) => {
if (!record) return;
recordData.value = { ...record };
visible.value = true;
};
const close = () => {
visible.value = false;
};
const handleEdit = () => {
if (window.PhoneAddDialogAPI && recordData.value) {
window.PhoneAddDialogAPI.open('edit', recordData.value);
}
};
// Expose to global scope so app-v2.js can call it
window.PhoneDetailDrawerAPI = {
open,
close
};
return {
visible,
recordData,
close,
handleEdit
};
},
template: `
<el-drawer
v-model="visible"
:with-header="false"
size="480px"
direction="rtl"
custom-class="wechat-detail-drawer-vue premium-drawer"
>
<div v-if="recordData" class="wechat-drawer-inner" style="height: 100%; display: flex; flex-direction: column; background: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<!-- Hero Header -->
<div style="padding: 40px 32px 24px 32px; position: relative;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="display: flex; align-items: center; gap: 20px;">
<div style="width: 56px; height: 56px; border-radius: 14px; background: #f3f4f6; border: 1px solid #e5e7eb; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 600; color: #111827; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
📱
</div>
<div>
<h3 style="margin: 0 0 4px 0; font-size: 24px; font-weight: 700; color: #111827; letter-spacing: -0.5px; display: flex; align-items: center; gap: 10px;">
{{ recordData.phoneNumber || '未知号码' }}
<span :style="{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: '8px', height: '8px', borderRadius: '50%',
background: (recordData.cardStatus === '异常停机' || recordData.numberStatus === false) ? '#ef4444' : '#22c55e'
}"></span>
</h3>
<div style="font-size: 14px; color: #6b7280; font-weight: 400; letter-spacing: 0.2px;">
{{ recordData.realPerson || '未登记实名' }} &nbsp;&middot;&nbsp; {{ recordData.channelOperator || '未知运营商' }}
</div>
</div>
</div>
<button @click="close" style="background: none; border: none; cursor: pointer; color: #9ca3af; padding: 4px; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: background 0.2s;" onmouseover="this.style.background='#f3f4f6'; this.style.color='#4b5563'" onmouseout="this.style.background='none'; this.style.color='#9ca3af'">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div>
<!-- Body -->
<div style="flex: 1; overflow-y: auto; padding: 0 32px 40px 32px; display: flex; flex-direction: column; gap: 40px;">
<!-- Section 1: Basic Info Bento Grid -->
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px;">基础属性</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px 16px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">实名人</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.realPerson || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">ICCID</span>
<span style="font-size: 15px; font-weight: 500; color: #111827; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;">{{ recordData.iccid || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">归属地</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.city || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">使用地</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.cardUsageLocation || '—' }}</span>
</div>
</div>
</section>
<!-- Section 2: 状态与生态 -->
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px;">状态与生态</h4>
<div style="display: grid; grid-template-columns: 1fr; gap: 24px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">外呼</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.outboundCall ? '支持' : '不支持' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">微信</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.wechat ? '已开通' : '未开通' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">企微</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.wecom ? '已开通' : '未开通' }}</span>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">绑定企微号</span>
<span style="font-size: 15px; font-weight: 400; color: #374151;">{{ recordData.linkedWecom || '—' }}</span>
</div>
<div 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.douyinAccount || '—' }}</span>
</div>
</div>
</section>
</div>
<!-- Footer -->
<div style="padding: 24px 32px; display: flex; gap: 16px; background: #ffffff; border-top: 1px solid #f3f4f6;">
<button @click="close" style="flex: 1; padding: 12px 0; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 14px; font-weight: 500; color: #374151; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='#ffffff'">
关闭面板
</button>
<button @click="handleEdit" style="flex: 1; padding: 12px 0; background: #111827; border: 1px solid #111827; border-radius: 8px; font-size: 14px; font-weight: 500; color: #ffffff; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);" onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 6px 8px -1px rgba(0,0,0,0.15)'" onmouseout="this.style.transform='none'; this.style.boxShadow='0 4px 6px -1px rgba(0,0,0,0.1)'">
编辑号卡
</button>
</div>
</div>
</el-drawer>
`
};
const app = createApp(PhoneDetailDrawer);
app.use(ElementPlus);
app.mount('#phoneDetailDrawerRoot');
})();
(function attachPhoneListRuntime() {
const PHONE_TABLE_COLUMN_WIDTHS = [
'48px',
'calc((100% - 48px) * 0.12)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.10)',
'calc((100% - 48px) * 0.16)',
'calc((100% - 48px) * 0.12)'
];
const PhoneListTable = {
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 },
getPhoneStatusDotClass: { type: Function, required: true },
renderPhoneDetailDrawer: { type: Function, required: true },
getPhoneRecordById: { type: Function, required: true },
openDrawer: { 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 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 fetchAllRecords = async () => {
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 {
allRecordsCache.value = [];
}
} catch (err) {
console.error('Failed to fetch wx-phones', err);
} 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);
}
if (filterCarrier.value !== 'all') {
result = result.filter(r => r.carrier === filterCarrier.value);
}
if (filterOutbound.value !== 'all') {
result = result.filter(r => r.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;
});
}
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))
);
}
return result;
});
const totalFiltered = computed(() => filteredRecords.value.length);
window.Vue.watch([filterStatus, filterCarrier, filterOutbound, filterDate, searchQuery], () => {
currentPage.value = 1;
});
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);
}
const totalPages = computed(buildTotalPages);
const visibleRows = computed(buildVisibleRows);
function isRowSelected(recordId) {
return selectedRecordId.value === recordId;
}
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);
syncMountState();
}
function handleCurrentChange(val) {
currentPage.value = val;
syncMountState();
}
function handleSizeChange(val) {
pageSize.value = val;
currentPage.value = 1;
syncMountState();
}
function resetTableState() {
currentPage.value = 1;
pageSize.value = props.phoneTablePageSizes[0];
checkedIds.value = [selectedRecordId.value];
filterStatus.value = 'all';
filterCarrier.value = 'all';
filterOutbound.value = 'all';
filterDate.value = '';
searchQuery.value = '';
if (multipleTableRef.value) {
multipleTableRef.value.clearSelection();
}
syncMountState();
}
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);
}
} else if (mode === 'delete') {
if (confirm('确认删除该号卡记录吗?')) {
fetch('http://localhost:8080/api/wx-phones/' + recordId, { method: 'DELETE' })
.then(() => fetchAllRecords());
}
} else {
if (window.PhoneDetailDrawerAPI) {
window.PhoneDetailDrawerAPI.open(record);
}
}
}
function handleAdd() {
if (window.PhoneAddDialogAPI) {
window.PhoneAddDialogAPI.open('add');
}
}
syncMountState();
return {
PHONE_TABLE_COLUMN_WIDTHS,
currentPage,
pageSize,
visibleRows,
checkedIds,
isRowSelected,
getStatusClass,
focusRow,
resetTableState,
handleRowAction,
filterDate,
searchQuery,
filterStatus,
filterCarrier,
filterOutbound,
totalFiltered,
loading,
handleAdd,
multipleSelection,
multipleTableRef,
handleSelectionChange,
handleCurrentChange,
handleSizeChange
};
},
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 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="tabs tabs-refined">
<button
v-for="label in phoneTableFilterLabels.slice(0,4)"
:key="label"
type="button"
class="tab-btn tab-btn-refined"
:class="{ active: filterStatus === (label === '全部' ? 'all' : label) }"
@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;">
<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-select>
<div class="fselect">外呼</div>
<el-select v-model="filterOutbound" placeholder="外呼权限" style="width: 120px;">
<el-option label="全部" value="all"></el-option>
<el-option label="可外呼" value="可外呼"></el-option>
<el-option label="不可外呼" value="不可外呼"></el-option>
</el-select>
<el-date-picker
v-model="filterDate"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px;"
></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>
</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>
<el-table
ref="multipleTableRef"
:data="visibleRows"
row-key="id"
style="width: 100%"
@selection-change="handleSelectionChange"
v-loading="loading"
>
<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>
</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>
</template>
</el-table-column>
<el-table-column label="城市" min-width="120">
<template #default="scope">
<div class="date-stack">
<span>{{ scope.row.city }}</span>
<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">
<template #default="scope">
<span class="status-chip-wrap">
<span class="status-dot" :class="getStatusClass(scope.row.status)"></span>
<span>{{ scope.row.status }}</span>
</span>
</template>
</el-table-column>
<el-table-column label="微信状态" min-width="100">
<template #default="scope">
<span class="status-chip-wrap">
<span class="status-dot" :class="getStatusClass(scope.row.wechatStatus)"></span>
<span>{{ scope.row.wechatStatus }}</span>
</span>
</template>
</el-table-column>
<el-table-column label="外呼状态" min-width="100">
<template #default="scope">
<span class="status-chip-wrap">
<span class="status-dot" :class="getStatusClass(scope.row.outbound)"></span>
<span>{{ scope.row.outbound }}</span>
</span>
</template>
</el-table-column>
<el-table-column property="updatedAt" label="更新时间" min-width="160" />
<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>
</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>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="totalFiltered"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
`
};
function mountPhoneListComponent(options) {
const { createApp } = window.Vue;
const app = createApp(PhoneListTable, options);
if (window.ElementPlus) {
app.use(window.ElementPlus);
}
app.mount(options.mountTarget);
return app;
}
window.PhoneListRuntime = {
mountPhoneListComponent
};
})();
(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({
real_name: '',
account: '',
alias_name: '',
department_name: '',
gender: '男',
phone_number: '',
real_name_owner: '',
account_location: '',
phone_image: '',
real_name_owner_status: 1
});
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' }]
};
const open = (mode = 'add', record = null) => {
isEditMode.value = mode === 'edit';
visible.value = true;
if (formRef.value) {
formRef.value.resetFields();
}
if (mode === 'edit' && record) {
currentId.value = record.id;
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.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.real_name_owner_status = statusVal;
} 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;
}
};
const close = () => {
visible.value = false;
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate((valid) => {
if (valid) {
ElementPlus.ElMessage.success(isEditMode.value ? '编辑企微资料成功' : '新增企微资料成功');
close();
} else {
return false;
}
});
};
window.WechatAddDialogAPI = {
open
};
return {
visible,
isEditMode,
formRef,
formData,
rules,
close,
handleSubmit
};
},
template: `
<el-dialog
v-model="visible"
width="640px"
top="6vh"
:show-close="false"
custom-class="premium-dialog"
style="border-radius: 16px; overflow: hidden; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"
>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 32px 40px 16px 40px;">
<h2 style="margin: 0; font-size: 24px; font-weight: 700; color: #111827; letter-spacing: -0.5px;">
{{ isEditMode ? '编辑企微资料' : '新增企微记录' }}
</h2>
<button @click="close" style="background: #f3f4f6; border: none; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #6b7280; transition: background 0.2s;" onmouseover="this.style.background='#e5e7eb'" onmouseout="this.style.background='#f3f4f6'">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</template>
<div style="padding: 0 40px 20px 40px;">
<el-form :model="formData" :rules="rules" ref="formRef" label-position="top">
<!-- 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>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="真实姓名" prop="real_name" style="margin-bottom: 16px;">
<el-input v-model="formData.real_name" placeholder="使用者真实姓名" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="别名 (花名)" prop="alias_name" style="margin-bottom: 16px;">
<el-input v-model="formData.alias_name" placeholder="内部称呼" size="large"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所在部门" prop="department_name" style="margin-bottom: 0;">
<el-input v-model="formData.department_name" placeholder="例如:市场部" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender" style="margin-bottom: 0;">
<el-radio-group v-model="formData.gender" size="large" style="width: 100%;">
<el-radio-button label="男" style="width: 50%; text-align: center;"></el-radio-button>
<el-radio-button label="女" style="width: 50%; text-align: center;"></el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 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>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="企微账号 / 微信 ID" prop="account" style="margin-bottom: 16px;">
<el-input v-model="formData.account" placeholder="登录账号" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="绑定手机号" prop="phone_number" style="margin-bottom: 16px;">
<el-input v-model="formData.phone_number" placeholder="关联手机号码" size="large"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="实名认证人" prop="real_name_owner" style="margin-bottom: 0;">
<el-input v-model="formData.real_name_owner" placeholder="实名认证者姓名" size="large"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账号状态" prop="real_name_owner_status" style="margin-bottom: 0;">
<el-select v-model="formData.real_name_owner_status" size="large" style="width: 100%;">
<el-option label="正常" :value="1">
<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-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>
</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>
<div v-if="formData.phone_image" style="margin-top: 12px; border-radius: 8px; overflow: hidden; border: 1px solid #e5e7eb; width: fit-content; max-height: 120px;">
<img :src="formData.phone_image" style="height: 120px; object-fit: cover; display: block;" alt="手机图片预览" onerror="this.style.display='none'"/>
</div>
</el-form-item>
</div>
</el-form>
</div>
<template #footer>
<div style="padding: 0 40px 32px 40px; display: flex; gap: 16px;">
<button @click="close" style="flex: 1; padding: 12px 0; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 15px; font-weight: 600; color: #374151; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background='#ffffff'">
取消
</button>
<button @click="handleSubmit" 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>
</div>
</template>
</el-dialog>
`
};
const app = createApp(WechatAddDialog);
app.use(ElementPlus);
app.mount('#wechatDialogRoot');
})();
(function initWechatDetailDrawer() {
const { createApp, ref, reactive } = window.Vue;
const ElementPlus = window.ElementPlus;
const WechatDetailDrawer = {
setup() {
const visible = ref(false);
const recordData = ref(null);
const open = (record) => {
if (!record) return;
recordData.value = { ...record };
visible.value = true;
};
const close = () => {
visible.value = false;
};
const handleEdit = () => {
if (window.WechatAddDialogAPI && recordData.value) {
window.WechatAddDialogAPI.open('edit', recordData.value);
}
};
// Expose to global scope so app-v2.js can call it
window.WechatDetailDrawerAPI = {
open
};
return {
visible,
recordData,
close,
handleEdit
};
},
template: `
<el-drawer
v-model="visible"
:with-header="false"
size="480px"
direction="rtl"
custom-class="wechat-detail-drawer-vue premium-drawer"
>
<div v-if="recordData" class="wechat-drawer-inner" style="height: 100%; display: flex; flex-direction: column; background: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<!-- 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);">
{{ (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;">
{{ 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>
</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>
</div>
</div>
<button @click="close" style="background: none; border: none; cursor: pointer; color: #9ca3af; padding: 4px; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: background 0.2s;" onmouseover="this.style.background='#f3f4f6'; this.style.color='#4b5563'" onmouseout="this.style.background='none'; this.style.color='#9ca3af'">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div>
<!-- Body -->
<div style="flex: 1; overflow-y: auto; padding: 0 32px 40px 32px; display: flex; flex-direction: column; gap: 40px;">
<!-- Section 1: Basic Info Bento Grid -->
<section>
<h4 style="margin: 0 0 16px 0; font-size: 13px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px;">基础属性</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px 16px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">别名(花名)</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.alias || recordData.alias_name || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">性别</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.gender || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">微信 ID</span>
<span style="font-size: 15px; font-weight: 500; color: #111827; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;">{{ recordData.wxId || '—' }}</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>
</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>
<div style="display: grid; grid-template-columns: 1fr; gap: 24px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">绑定手机号</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.phone || recordData.phone_number || '—' }}</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">实名人</span>
<span style="font-size: 15px; font-weight: 500; color: #111827;">{{ recordData.owner || recordData.real_name_owner || '—' }}</span>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 13px; color: #6b7280;">账号常驻位置</span>
<span style="font-size: 15px; font-weight: 400; color: #374151;">{{ recordData.account_location || recordData['账号位置'] || '—' }}</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);">
<el-image
:src="recordData.phone_image || recordData['手机图片']"
:preview-src-list="[recordData.phone_image || recordData['手机图片']]"
fit="cover"
style="width: 100%; display: block;"
></el-image>
</div>
</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'">
关闭面板
</button>
<button @click="handleEdit" style="flex: 1; padding: 12px 0; background: #111827; border: 1px solid #111827; border-radius: 8px; font-size: 14px; font-weight: 500; color: #ffffff; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);" onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 6px 8px -1px rgba(0,0,0,0.15)'" onmouseout="this.style.transform='none'; this.style.boxShadow='0 4px 6px -1px rgba(0,0,0,0.1)'">
编辑资料
</button>
</div>
</div>
</el-drawer>
`
};
const app = createApp(WechatDetailDrawer);
app.use(ElementPlus);
app.mount('#wechatDetailDrawerRoot');
})();
(function attachWechatListRuntime() {
const WECHAT_TABLE_COLUMN_WIDTHS = [
'48px',
'calc((100% - 48px) * 0.12)',
'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)'
];
const WechatListTable = {
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 },
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 }
},
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 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(null);
function getAllRecords() {
if (!allRecordsCache.value) {
allRecordsCache.value = props.getWechatRowsByPage(1, props.wechatTableTotal);
}
return allRecordsCache.value;
}
const filteredRecords = computed(() => {
let result = getAllRecords();
if (filterStatus.value !== 'all') {
result = result.filter(r => r.status === filterStatus.value);
}
if (filterDepartment.value !== 'all') {
result = result.filter(r => r.department === filterDepartment.value);
}
if (filterProject.value !== 'all') {
result = result.filter(r => r.project === 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;
});
}
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))
);
}
return result;
});
const totalFiltered = computed(() => filteredRecords.value.length);
window.Vue.watch([filterStatus, filterDepartment, filterProject, filterDate, searchQuery], () => {
currentPage.value = 1;
});
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);
}
function buildPageNumbers() {
return props.getVisiblePageNumbers(totalPages.value, currentPage.value);
}
function buildAllVisibleChecked() {
if (!visibleRows.value.length) return false;
for (let index = 0; index < visibleRows.value.length; index += 1) {
if (!checkedIds.value.includes(visibleRows.value[index].id)) return false;
}
return true;
}
const totalPages = computed(buildTotalPages);
const visibleRows = computed(buildVisibleRows);
const pageNumbers = computed(buildPageNumbers);
const allVisibleChecked = computed(buildAllVisibleChecked);
function isRowChecked(recordId) {
return checkedIds.value.includes(recordId);
}
function isRowSelected(recordId) {
return selectedRecordId.value === recordId;
}
function getStatusClass(status) {
return props.getWechatStatusDotClass(status);
}
function focusRow(recordId) {
selectedRecordId.value = recordId;
props.globalState.selectedBySource.wechat = recordId;
syncMountState();
if (!props.detailDrawer.classList.contains('hidden')) {
props.renderWechatDetailDrawer(props.getWechatRecordById(recordId));
}
}
function toggleRowChecked(recordId, checked) {
const nextIds = new Set(checkedIds.value);
if (checked) nextIds.add(recordId);
else nextIds.delete(recordId);
checkedIds.value = Array.from(nextIds);
syncMountState();
}
function toggleAllVisible(checked) {
const nextIds = new Set(checkedIds.value);
for (let index = 0; index < visibleRows.value.length; index += 1) {
const row = visibleRows.value[index];
if (checked) nextIds.add(row.id);
else nextIds.delete(row.id);
}
checkedIds.value = Array.from(nextIds);
syncMountState();
}
function goToPage(pageNumber) {
currentPage.value = Math.min(Math.max(1, pageNumber), totalPages.value);
syncMountState();
}
function changePage(direction) {
if (direction === 'prev') goToPage(currentPage.value - 1);
if (direction === 'next') goToPage(currentPage.value + 1);
}
function handlePageSizeChange() {
props.mountState.pageSize = pageSize.value;
goToPage(1);
}
function resetTableState() {
currentPage.value = 1;
pageSize.value = props.wechatTablePageSizes[0];
checkedIds.value = [selectedRecordId.value];
filterStatus.value = 'all';
filterDepartment.value = 'all';
filterProject.value = 'all';
filterDate.value = '';
searchQuery.value = '';
syncMountState();
}
function handleRowAction(mode, recordId) {
focusRow(recordId);
props.openDrawer(mode, recordId);
}
syncMountState();
return {
WECHAT_TABLE_COLUMN_WIDTHS,
currentPage,
pageSize,
visibleRows,
pageNumbers,
checkedIds,
allVisibleChecked,
isRowChecked,
isRowSelected,
getStatusClass,
focusRow,
toggleRowChecked,
toggleAllVisible,
goToPage,
changePage,
handlePageSizeChange,
resetTableState,
handleRowAction,
filterDate,
searchQuery,
filterStatus,
filterDepartment,
filterProject,
totalFiltered
};
},
template: `
<div class="table-area table-area-refined">
<div class="table-toolbar table-toolbar-refined">
<div class="toolbar-left">
<div class="toolbar-title-wrap">
<div class="toolbar-title">{{ wechatText.allRecords }} ({{ totalFiltered.toLocaleString('zh-CN') }})</div>
</div>
<div class="filter-selects filter-selects-refined" style="display: flex; align-items: center; gap: 8px;">
<el-select v-model="filterStatus" class="custom-fselect" style="width: 120px; height: 32px;">
<el-option label="状态:全部" value="all"></el-option>
<el-option label="姝e父" value="姝e父"></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-option label="部门:全部" value="all"></el-option>
<el-option label="市场部" value="市场部"></el-option>
<el-option label="运营部" value="运营部"></el-option>
<el-option label="销售部" value="销售部"></el-option>
</el-select>
<el-select v-model="filterProject" class="custom-fselect" style="width: 130px; height: 32px;">
<el-option label="项目:全部" value="all"></el-option>
<el-option label="华南投放" value="华南投放"></el-option>
<el-option label="增长实验" value="增长实验"></el-option>
<el-option label="KA 客户" value="KA 客户"></el-option>
</el-select>
<el-date-picker
v-model="filterDate"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px; height: 32px;"
></el-date-picker>
<div class="fselect">{{ wechatTableFilterLabels[4] }}</div>
<el-input
v-model="searchQuery"
placeholder="搜索姓名、微信 ID、手机号或部门"
clearable
style="width: 260px; height: 32px;"
>
<template #prefix>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</template>
</el-input>
</div>
</div>
<div class="toolbar-right">
<button class="btn-reset btn-reset-refined" type="button" @click="resetTableState">{{ 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">
<table class="xtable domain-table domain-table-refined">
<colgroup>
<col v-for="width in WECHAT_TABLE_COLUMN_WIDTHS" :key="width" :style="{ width }" />
</colgroup>
<thead>
<tr>
<th class="th-selector"><input type="checkbox" :checked="allVisibleChecked" @change="toggleAllVisible($event.target.checked)" /></th>
<th>{{ wechatText.realName }}</th>
<th>{{ wechatText.department }}</th>
<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 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>{{ 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>{{ row.owner }}</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>
</tbody>
</table>
</div>
<div class="table-footer table-footer-refined">
<div class="tfoot-left tfoot-left-refined">
<span>{{ wechatText.selected }} {{ checkedIds.length }} {{ wechatText.items }}</span>
<button class="btn-batch btn-batch-refined" type="button">{{ wechatText.batchAction }} &#9662;</button>
</div>
<div class="tfoot-right tfoot-right-refined">
<span>{{ wechatText.total }} {{ totalFiltered.toLocaleString('zh-CN') }} {{ wechatText.records }}</span>
<button class="page-btn outline" type="button" @click="changePage('prev')" :disabled="currentPage === 1">&#8249;</button>
<div class="pagination">
<template v-for="item in pageNumbers" :key="String(item)">
<button v-if="item !== 'ellipsis'" class="page-btn" :class="{ active: currentPage === item }" type="button" @click="goToPage(item)">{{ item }}</button>
<span v-else>...</span>
</template>
</div>
<button class="page-btn outline" type="button" @click="changePage('next')" :disabled="currentPage === Math.ceil(totalFiltered / pageSize)">&#8250;</button>
<select class="page-size" v-model.number="pageSize" @change="handlePageSizeChange">
<option v-for="size in wechatTablePageSizes" :key="size" :value="size">{{ size }}{{ wechatText.pageSuffix }}</option>
</select>
</div>
</div>
</div>
`
};
function mountWechatListComponent(options) {
const { createApp } = window.Vue;
const app = createApp(WechatListTable, options);
if (window.ElementPlus) {
app.use(window.ElementPlus);
}
app.mount(options.mountTarget);
return app;
}
window.WechatListRuntime = {
mountWechatListComponent
};
})();
// Reusable wrapper template that provides the DOM structure required by app-v2.js
const LegacyShellTemplate = `
<div class="legacy-wrapper">
<div id="pageHeader"></div>
<div class="shell-body" id="pageBody">
<!-- Vue runtime roots for other modules will be moved or recreated here -->
<div id="overviewListRoot" style="display:none;"></div>
<div id="wechatListRoot" style="display:none;"></div>
<div id="phoneListRoot" style="display:none;"></div>
</div>
<div id="detailDrawer" class="detail-drawer hidden">
<div class="detail-drawer-head" id="detailShellTitle"></div>
<div class="detail-drawer-body" id="detailBody"></div>
<button class="drawer-close" id="drawerClose">脳</button>
</div>
</div>
`;
function createLegacyWrapper(moduleName) {
return {
template: LegacyShellTemplate,
/** 代码作用(白话):在当前路由页面挂载后,把旧版 app-v2 的状态和渲染入口接起来,这样 Vue Router 只是负责切页,真正的页面内容仍沿用原来的渲染逻辑;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/index.html;关联逻辑:调用链是“RouterView mounted -> updateDOMRefs() -> state.activeModule 切换 -> renderModule() -> updateActiveNav()”。 */
mounted() {
// Re-initialize the DOM refs because the DOM was just created by Vue.
if (window.updateDOMRefs) {
window.updateDOMRefs();
}
// 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();
}
if (window.updateActiveNav) {
window.updateActiveNav();
}
}
};
}
const OverviewView = createLegacyWrapper('overview');
const DomainView = createLegacyWrapper('domain');
const WechatView = createLegacyWrapper('wechat');
const PhoneView = createLegacyWrapper('phone');
const AlertsView = createLegacyWrapper('alerts');
const routes = [
{ path: '/', redirect: '/overview' },
{ path: '/overview', component: OverviewView },
{ path: '/domain', component: DomainView },
{ path: '/wechat', component: WechatView },
{ path: '/phone-card', component: PhoneView },
{ path: '/alerts', component: AlertsView }
];
const router = window.VueRouter.createRouter({
history: window.VueRouter.createWebHashHistory(),
routes
});
// 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) => {
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';
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
}
// Mount router to the shell-main
app.mount('#vue-router-app');
});
@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
echo ===================================================
echo [Starting Project]
echo Frontend URL: %URL%
echo Backend URL: http://localhost:8080
echo ===================================================
:: Copy to clipboard
echo | set /p dummy="%URL%" | clip
echo [Success] 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...
) 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.
)
:: 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)
echo.
python -m http.server %PORT%
@echo off
set PORT=8000
echo ===================================================
echo [Stopping Project]
echo Looking for processes on port %PORT%...
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
)
echo No local server found running on port %PORT%.
:end
echo.
pause
/* Import original styles first */
@import url('styles.css');
/* Overrides for right side replication */
.domain-mode .domain-mode .domain-mode .shell-main {
background: #f7f8fa;
padding: 16px 24px;
display: flex;
flex-direction: column;
gap: 16px;
height: 100vh;
overflow-y: auto;
}
.domain-mode .domain-mode .domain-mode .shell-header {
background: transparent;
padding: 0;
border: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title {
font-size: 20px;
font-weight: bold;
color: #111827;
margin: 0;
}
.header-tags {
display: flex;
gap: 8px;
}
.htag {
font-size: 13px;
padding: 4px 10px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.htag-all {
background: #eef2ff;
color: #3b82f6;
}
.htag-normal {
background: #f0fdf4;
color: #22c55e;
}
.htag-risk {
background: #fff7ed;
color: #f97316;
}
.htag-expired {
background: #fef2f2;
color: #ef4444;
}
.htag .num {
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.search-box {
display: flex;
align-items: center;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 6px 12px;
width: 240px;
}
.search-box svg {
width: 16px;
height: 16px;
fill: #9ca3af;
margin-right: 8px;
}
.search-box input {
border: none;
outline: none;
font-size: 13px;
width: 100%;
color: #374151;
}
.btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn-default {
background: #fff;
border-color: #e5e7eb;
color: #374151;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-icon {
padding: 6px 8px;
border-left: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0 6px 6px 0;
}
.btn-primary.main {
border-radius: 6px 0 0 6px;
}
.btn-group {
display: flex;
}
.btn-close-top {
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
padding: 4px;
display: flex;
}
/* Cards */
.cards-row {
display: flex;
gap: 16px;
}
.kcard {
flex: 1;
background: #fff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.kcard-left {
display: flex;
align-items: center;
gap: 16px;
}
.kcard-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.kcard-icon.blue {
background: #eff6ff;
color: #2563eb;
}
.kcard-icon.orange {
background: #fff7ed;
color: #ea580c;
}
.kcard-icon.red {
background: #fef2f2;
color: #dc2626;
}
.kcard-icon svg {
width: 24px;
height: 24px;
fill: currentColor;
}
.kcard-info {
display: flex;
flex-direction: column;
}
.kcard-title {
font-size: 13px;
color: #4b5563;
display: flex;
align-items: center;
gap: 4px;
}
.kcard-title svg {
width: 14px;
height: 14px;
fill: #9ca3af;
}
.kcard-num {
font-size: 28px;
font-weight: bold;
color: #111827;
margin: 4px 0;
}
.kcard-sub {
font-size: 12px;
color: #6b7280;
}
.kcard-chart svg {
width: 80px;
height: 30px;
}
.kcard-chart path.blue {
stroke: #3b82f6;
fill: none;
stroke-width: 2;
}
.kcard-chart path.orange {
stroke: #f97316;
fill: none;
stroke-width: 2;
}
.kcard-chart path.red {
stroke: #ef4444;
fill: none;
stroke-width: 2;
}
/* Table Area */
.table-area {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f3f4f6;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.toolbar-title {
font-weight: 600;
font-size: 14px;
color: #111827;
}
.filter-selects {
display: flex;
gap: 8px;
}
.fselect {
border: 1px solid #e5e7eb;
background: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
color: #4b5563;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.fselect:after {
content: "▾";
color: #9ca3af;
}
/* Custom icon for el-select to match batch action button icon ▾ */
.custom-fselect .el-input__suffix-inner > .el-icon > svg {
display: none !important;
}
.custom-fselect .el-input__suffix-inner > .el-icon::before {
content: "▾";
color: #9ca3af;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.btn-reset {
background: transparent;
border: 1px solid #e5e7eb;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
}
.btn-settings {
background: transparent;
border: 1px solid #e5e7eb;
padding: 6px;
border-radius: 6px;
cursor: pointer;
display: flex;
color: #6b7280;
}
.table-wrap {
width: 100%;
}
.xtable {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 13px;
table-layout: fixed;
}
.xtable th {
background: #fafafa;
padding: 12px 20px;
font-weight: 500;
color: #4b5563;
border-bottom: 1px solid #f3f4f6;
}
.xtable td {
padding: 12px 20px;
border-bottom: 1px solid #f3f4f6;
color: #111827;
}
.xtable tr:hover td {
background: #f9fafb;
}
.xtable tr.selected td {
background: #f0f7ff;
}
.domain-table th,
.domain-table td {
vertical-align: middle;
}
.domain-table th:nth-child(9),
.domain-table td:nth-child(9),
.domain-table th:nth-child(10),
.domain-table td:nth-child(10),
.domain-table th:nth-child(11),
.domain-table td:nth-child(11) {
white-space: nowrap;
}
.domain-table th:nth-child(10) svg {
vertical-align: middle;
}
.domain-table td:nth-child(9),
.domain-table td:nth-child(10) {
overflow: hidden;
text-overflow: ellipsis;
}
.domain-link {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-dot.green {
background: #22c55e;
}
.status-dot.orange {
background: #f97316;
}
.status-dot.red {
background: #ef4444;
}
.risk-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
border: 1px solid transparent;
}
.risk-badge.low {
background: #f0fdf4;
color: #22c55e;
border-color: #bbf7d0;
}
.risk-badge.mid {
background: #fff7ed;
color: #ea580c;
border-color: #fed7aa;
}
.risk-badge.high {
background: #fef2f2;
color: #ef4444;
border-color: #fecaca;
}
.cell-sub {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.cell-sub.green {
color: #22c55e;
}
.action-links a {
color: #2563eb;
text-decoration: none;
margin-right: 12px;
}
.action-links a:hover {
text-decoration: underline;
}
.action-dots {
color: #6b7280;
cursor: pointer;
font-weight: bold;
}
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
}
.tfoot-left {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: #4b5563;
}
.btn-batch {
background: #fff;
border: 1px solid #e5e7eb;
padding: 4px 10px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: #2563eb;
}
.tfoot-right {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: #4b5563;
}
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
min-width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
background: transparent;
color: #4b5563;
}
.page-btn.active {
background: #2563eb;
color: #fff;
}
.page-btn.outline {
border-color: #e5e7eb;
}
.page-size {
border: 1px solid #e5e7eb;
padding: 4px 8px;
border-radius: 6px;
background: #fff;
}
/* Recent Follow Up */
.recent-area {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.recent-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.recent-title {
font-weight: 600;
font-size: 15px;
color: #111827;
}
.recent-link {
color: #2563eb;
font-size: 13px;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recent-item {
display: flex;
align-items: center;
font-size: 13px;
}
.r-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2563eb;
margin-right: 12px;
}
.r-time {
color: #6b7280;
width: 140px;
}
.r-tag {
padding: 2px 6px;
border-radius: 4px;
margin-right: 12px;
font-size: 12px;
}
.r-tag.blue {
color: #2563eb;
background: #eff6ff;
}
.r-tag.orange {
color: #ea580c;
background: #fff7ed;
}
.r-tag.red {
color: #ef4444;
background: #fef2f2;
}
.r-tag.green {
color: #22c55e;
background: #f0fdf4;
}
.r-text {
color: #374151;
flex: 1;
}
.r-action {
color: #2563eb;
text-decoration: none;
}
/* Drawer */
.domain-mode .domain-mode .domain-mode .detail-panel {
background: #fff;
width: 400px !important;
max-width: 400px !important;
border-left: 1px solid #e5e7eb;
box-shadow: -4px 0 15px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
}
.drawer-head {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.drawer-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.drawer-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.drawer-status {
padding: 2px 8px;
border-radius: 12px;
background: #f0fdf4;
color: #22c55e;
font-size: 12px;
border: 1px solid #bbf7d0;
}
.drawer-close {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
}
.drawer-close svg {
width: 20px;
height: 20px;
}
.drawer-tabs {
display: flex;
padding: 0 20px;
border-bottom: 1px solid #e5e7eb;
gap: 24px;
}
.dtab {
padding: 10px 0;
font-size: 14px;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.dtab.active {
color: #2563eb;
border-bottom-color: #2563eb;
font-weight: 500;
}
.drawer-body {
padding: 20px;
flex: 1;
overflow-y: auto;
}
.dgrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px 16px;
margin-bottom: 32px;
}
.ditem {
display: flex;
flex-direction: column;
gap: 6px;
}
.ditem.full {
grid-column: 1 / -1;
}
.dlabel {
font-size: 12px;
color: #6b7280;
}
.dvalue {
font-size: 13px;
color: #111827;
display: flex;
align-items: center;
gap: 6px;
}
.d-recent h4 {
font-size: 14px;
font-weight: 600;
margin: 0 0 16px 0;
}
.dtimeline {
display: flex;
flex-direction: column;
gap: 16px;
}
.dtime-item {
display: flex;
gap: 12px;
}
.dtime-dot-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.dtime-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 4px;
}
.dtime-dot.blue {
background: #2563eb;
}
.dtime-dot.green {
background: #22c55e;
}
.dtime-dot.gray {
background: #9ca3af;
}
.dtime-line {
width: 1px;
flex: 1;
background: #e5e7eb;
margin-top: 4px;
}
.dtime-content {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
padding-bottom: 4px;
}
.dtime-row1 {
display: flex;
gap: 12px;
}
.dtime-date {
color: #6b7280;
}
.dtime-tag {
color: #2563eb;
}
.dtime-tag.green {
color: #22c55e;
}
.dtime-tag.gray {
color: #6b7280;
}
.dtime-text {
color: #374151;
}
.drawer-foot {
padding: 16px 20px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-foot-close {
background: #fff;
border: 1px solid #e5e7eb;
padding: 8px 24px;
border-radius: 6px;
font-size: 13px;
color: #374151;
cursor: pointer;
}
.btn-foot-submit {
background: #2563eb;
border: 1px solid #2563eb;
padding: 8px 24px;
border-radius: 6px;
font-size: 13px;
color: #fff;
cursor: pointer;
}
.domain-mode .th-action {
text-align: center;
}
.domain-mode .td-action {
text-align: center;
}
.domain-mode .action-links {
white-space: nowrap;
}
.domain-mode .td-action {
white-space: nowrap;
}
.domain-mode .td-action a,
.domain-mode .td-action .action-dots {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.domain-mode .action-links a {
margin: 0 6px;
}
.domain-mode .action-dots {
margin: 0 6px;
}
/* =========================================================
UI Refinement (Hover, Interaction, Action Buttons)
========================================================= */
/* Table Container Refinement */
.table-area-refined {
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* Table Row Interactions */
.domain-table-refined {
border-collapse: separate;
border-spacing: 0;
}
.domain-table-refined tbody tr {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.domain-table-refined tbody td {
background: #ffffff;
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.domain-table-refined tbody tr td:first-child {
border-left: 3px solid transparent;
}
/* Hover Effect */
.domain-table-refined tbody tr:hover td {
background: #f8fafc;
}
.domain-table-refined tbody tr:hover td:first-child {
border-left-color: #cbd5e1;
}
/* Selected Effect */
.domain-table-refined tbody tr.selected td {
background: #f0f6ff;
}
.domain-table-refined tbody tr.selected td:first-child {
border-left-color: #3b82f6;
}
/* Action Column Buttons */
.action-cluster {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
}
.action-pill {
height: 28px;
border-radius: 6px;
border: none;
background: transparent;
color: #475569;
font-size: 13px;
font-weight: 500;
padding: 0 8px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
}
.action-pill:hover {
background: #f1f5f9;
color: #0f172a;
}
.action-pill-primary {
color: #2563eb;
}
.action-pill-primary:hover {
background: #eff6ff;
color: #1d4ed8;
}
.action-icon {
width: 28px;
height: 28px;
border-radius: 6px;
border: none;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
}
.action-icon:hover {
background: #f1f5f9;
color: #0f172a;
}
.action-icon svg path {
fill: currentColor !important;
}
/* 消除原来的旧外边距干扰 */
.domain-mode .action-dots {
margin: 0;
}
\ No newline at end of file
:root {
--page-bg: #f5f7fb;
--surface: #ffffff;
--surface-soft: #fafcff;
--surface-muted: #f3f6fb;
--line: #e7edf6;
--line-strong: #d9e2f2;
--text-main: #1f2a44;
--text-sub: #667085;
--text-soft: #98a2b3;
--brand: #2563eb;
--brand-deep: #1d4ed8;
--brand-soft: rgba(37, 99, 235, 0.08);
--green: #22c55e;
--green-soft: rgba(34, 197, 94, 0.12);
--orange: #ff8a00;
--orange-soft: rgba(255, 138, 0, 0.12);
--red: #ef4444;
--red-soft: rgba(239, 68, 68, 0.11);
--purple: #7c4dff;
--purple-soft: rgba(124, 77, 255, 0.12);
--blue-soft: rgba(37, 99, 235, 0.1);
--nav-bg: linear-gradient(180deg, #071224 0%, #0c1730 54%, #0b162d 100%);
--nav-active: rgba(66, 113, 213, 0.28);
--nav-line: rgba(255, 255, 255, 0.08);
--nav-text: rgba(255, 255, 255, 0.92);
--nav-soft: rgba(255, 255, 255, 0.64);
--shadow-card: 0 10px 30px rgba(13, 44, 100, 0.06);
--shadow-soft: 0 2px 6px rgba(13, 44, 100, 0.05);
--radius-xl: 18px;
--radius-lg: 14px;
--radius-md: 12px;
--radius-sm: 10px;
--sidebar-width: 238px;
--sidebar-collapsed-width: 72px;
--detail-width: 338px;
--font-ui: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
min-height: 100%;
overflow: hidden;
background: var(--page-bg);
color: var(--text-main);
font-family: var(--font-ui);
}
body {
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
.workspace {
display: flex;
align-items: stretch;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: var(--page-bg);
}
.workspace.sidebar-collapsed {
align-items: stretch;
}
.shell-nav {
display: flex;
flex-direction: column;
gap: 24px;
min-height: 100vh;
padding: 22px 12px 18px;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
max-width: var(--sidebar-width);
box-sizing: border-box;
flex-shrink: 0;
overflow: hidden;
background: var(--nav-bg);
color: var(--nav-text);
}
.brand-panel {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 12px;
min-height: 40px;
padding: 2px 4px 10px;
box-sizing: border-box;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: linear-gradient(180deg, #2e8fff 0%, #2453eb 100%);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.28);
}
.brand-badge {
display: block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 5px 5px 9px 9px;
position: relative;
}
.brand-badge::before {
content: "";
position: absolute;
top: 2px;
left: 4px;
width: 4px;
height: 7px;
border-right: 2px solid rgba(255, 255, 255, 0.92);
border-bottom: 2px solid rgba(255, 255, 255, 0.92);
transform: rotate(40deg);
}
.brand-copy {
min-width: 0;
}
.brand-copy h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.25;
color: #fff;
}
.nav-settings,
.nav-toggle-button,
.detail-close,
.icon-button,
.pagination-button,
.view-mode-button,
.action-square {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: transparent;
}
.nav-main {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.primary-nav {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
min-width: 0;
}
.nav-link,
.nav-toggle-button {
display: flex;
align-items: center;
width: 100%;
min-height: 48px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 14px;
box-sizing: border-box;
color: var(--nav-text);
background: transparent;
text-align: left;
transition: background 0.2s ease, border-color 0.2s ease;
}
.nav-link {
gap: 12px;
}
.nav-link:hover,
.nav-link.is-active,
.nav-toggle-button:hover {
background: var(--nav-active);
border-color: rgba(122, 163, 255, 0.16);
}
.nav-glyph,
.nav-toggle-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
min-width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.94);
line-height: 1;
text-align: center;
flex-shrink: 0;
}
.nav-glyph {
font-size: 18px;
}
.nav-toggle-button {
justify-content: flex-start;
gap: 6px;
}
.nav-toggle-glyph span {
display: block;
width: 8px;
height: 8px;
border-top: 1.8px solid #fff;
border-left: 1.8px solid #fff;
}
.nav-toggle-glyph span:first-child {
transform: rotate(-45deg) translate(1px, 1px);
}
.nav-toggle-glyph span:last-child {
margin-left: -3px;
transform: rotate(-45deg) translate(1px, 1px);
}
.nav-label,
.nav-toggle-label {
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
.nav-bottom {
display: flex;
flex-direction: column;
gap: 18px;
flex-shrink: 0;
margin-top: auto;
}
.nav-toggle-wrap {
padding-top: 18px;
border-top: 1px solid var(--nav-line);
}
.nav-footer {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 24px;
align-items: center;
gap: 12px;
}
.nav-user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(180deg, #d8e3ff 0%, #eef3ff 100%);
color: #3c4a63;
font-size: 13px;
}
.nav-user-copy p {
margin: 0;
}
.nav-user-name {
font-size: 15px;
font-weight: 700;
color: #fff;
}
.nav-user-role {
margin-top: 3px;
font-size: 12px;
color: var(--nav-soft);
}
.nav-settings {
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
}
.shell-main {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #fff;
}
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 24px 14px;
border-bottom: 1px solid var(--line);
}
.page-title-block {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.page-title-block h2 {
margin: 0;
font-size: 24px;
line-height: 1.2;
font-weight: 700;
color: #101828;
}
.page-title-block p {
margin: 0;
color: var(--text-sub);
font-size: 13px;
}
.header-tools {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.header-search {
display: flex;
align-items: center;
gap: 10px;
min-width: 430px;
height: 44px;
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: #fff;
color: var(--text-soft);
}
.header-search::before {
content: "\1F50D";
font-size: 14px;
}
.header-search input {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--text-main);
}
.header-shortcut {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 24px;
padding: 0 8px;
border-radius: 8px;
background: var(--surface-muted);
color: var(--text-sub);
font-size: 12px;
font-weight: 700;
}
.status-pill,
.status-chip,
.metric-link,
.info-chip,
.small-note {
display: inline-flex;
align-items: center;
}
.status-pill {
gap: 8px;
height: 38px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
font-size: 13px;
color: var(--text-sub);
white-space: nowrap;
}
.status-pill::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
.status-pill.sync::before,
.status-pill.info::before {
display: none;
}
.status-pill strong {
color: var(--brand-deep);
}
.header-button,
.header-button-primary,
.header-button-muted,
.footer-button,
.footer-button-primary,
.filter-button,
.filter-button-primary,
.toolbar-button,
.toolbar-button-primary,
.batch-button,
.batch-button-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.header-button,
.header-button-muted,
.footer-button,
.filter-button,
.toolbar-button,
.batch-button {
border: 1px solid var(--line-strong);
background: #fff;
color: var(--text-main);
}
.header-button-primary,
.footer-button-primary,
.filter-button-primary,
.toolbar-button-primary,
.batch-button-primary {
border: 1px solid var(--brand);
background: linear-gradient(180deg, #3177ff 0%, #2462eb 100%);
color: #fff;
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18);
}
.shell-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
min-height: 0;
overflow-y: auto;
padding: 18px 24px 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.stats-grid.overview {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card {
position: relative;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 86px;
align-items: start;
gap: 16px;
min-height: 140px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(180deg, #fff 0%, #fbfdff 100%);
box-shadow: var(--shadow-soft);
}
.metric-card[data-tone="blue"] {
border-color: rgba(59, 130, 246, 0.24);
background: linear-gradient(180deg, rgba(240, 247, 255, 0.88) 0%, #ffffff 100%);
}
.metric-card[data-tone="green"] {
border-color: rgba(34, 197, 94, 0.22);
background: linear-gradient(180deg, rgba(242, 252, 246, 0.96) 0%, #ffffff 100%);
}
.metric-card[data-tone="orange"] {
border-color: rgba(255, 138, 0, 0.22);
background: linear-gradient(180deg, rgba(255, 247, 239, 0.96) 0%, #ffffff 100%);
}
.metric-card[data-tone="red"] {
border-color: rgba(239, 68, 68, 0.22);
background: linear-gradient(180deg, rgba(255, 243, 243, 0.98) 0%, #ffffff 100%);
}
.metric-card[data-tone="purple"] {
border-color: rgba(124, 77, 255, 0.2);
background: linear-gradient(180deg, rgba(247, 243, 255, 0.98) 0%, #ffffff 100%);
}
.metric-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
font-size: 24px;
background: #eef4ff;
color: var(--brand);
}
.metric-card[data-tone="green"] .metric-icon {
background: #e9f9ef;
color: var(--green);
}
.metric-card[data-tone="orange"] .metric-icon {
background: #fff2e4;
color: var(--orange);
}
.metric-card[data-tone="red"] .metric-icon {
background: #ffeaeb;
color: var(--red);
}
.metric-card[data-tone="purple"] .metric-icon {
background: #f3efff;
color: var(--purple);
}
.metric-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.metric-title {
font-size: 14px;
font-weight: 700;
color: #24324a;
}
.metric-value {
font-size: 20px;
font-weight: 700;
color: #101828;
}
.metric-number {
display: block;
margin-top: 6px;
font-size: 22px;
line-height: 1;
font-weight: 800;
}
.metric-number.large {
font-size: 44px;
color: var(--brand);
}
.metric-card[data-tone="orange"] .metric-number.large {
color: var(--orange);
}
.metric-card[data-tone="green"] .metric-number.large {
color: var(--green);
}
.metric-card[data-tone="red"] .metric-number.large,
.metric-card[data-tone="purple"] .metric-number.large {
color: var(--red);
}
.metric-sub {
font-size: 13px;
color: var(--text-sub);
}
.metric-link {
align-self: end;
justify-self: end;
font-size: 13px;
font-weight: 700;
color: var(--brand);
text-decoration: none;
}
.metric-card svg {
align-self: end;
width: 80px;
height: 34px;
}
.metric-card path {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.metric-card[data-tone="blue"] path {
stroke: #2563eb;
}
.metric-card[data-tone="green"] path {
stroke: #22c55e;
}
.metric-card[data-tone="orange"] path {
stroke: #ff8a00;
}
.metric-card[data-tone="red"] path,
.metric-card[data-tone="purple"] path {
stroke: #ff5d5d;
}
.panel-card,
.table-card,
.preview-card,
.filter-card,
.empty-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow-card);
}
.panel-card,
.filter-card,
.table-card,
.empty-card {
padding: 16px 18px;
}
.card-tabs {
display: flex;
align-items: center;
gap: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.card-tab {
position: relative;
padding: 0 0 10px;
border: 0;
background: transparent;
color: var(--text-sub);
font-size: 16px;
font-weight: 600;
}
.card-tab.is-active {
color: var(--brand);
}
.card-tab.is-active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -13px;
height: 3px;
border-radius: 999px;
background: var(--brand);
}
.filter-row,
.filter-row-split,
.toolbar-row,
.table-footer,
.preview-footer,
.preview-head,
.filter-actions {
display: flex;
align-items: center;
gap: 12px;
}
.filter-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.filter-row {
flex-wrap: wrap;
}
.filter-field,
.filter-search,
.filter-date,
.fake-select,
.fake-input {
display: flex;
align-items: center;
height: 38px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: #fff;
color: var(--text-sub);
}
.filter-field,
.fake-select,
.fake-input {
padding: 0 12px;
}
.filter-field.small {
min-width: 112px;
}
.filter-field.medium {
min-width: 132px;
}
.filter-field.large {
min-width: 196px;
}
.filter-field.xlarge,
.filter-search {
min-width: 240px;
}
.filter-date {
min-width: 286px;
justify-content: space-between;
padding: 0 12px;
}
.filter-search::before {
content: "\1F50D";
margin-left: 12px;
color: var(--text-soft);
}
.filter-search input,
.fake-input input {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--text-main);
}
.filter-search input {
padding: 0 12px 0 10px;
}
.filter-label {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.filter-label span {
font-size: 13px;
font-weight: 600;
color: #344054;
}
.section-head,
.table-head,
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title,
.preview-title,
.detail-section-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1d2939;
}
.section-kicker,
.preview-kicker,
.detail-note {
margin: 0;
color: var(--text-sub);
font-size: 13px;
}
.toolbar-row {
justify-content: space-between;
margin-top: 14px;
}
.toolbar-left,
.toolbar-right,
.footer-left,
.footer-right,
.preview-stats,
.batch-left,
.batch-right {
display: flex;
align-items: center;
gap: 10px;
}
.selected-pill {
display: inline-flex;
align-items: center;
height: 34px;
padding: 0 12px;
border-radius: 10px;
background: rgba(37, 99, 235, 0.1);
color: var(--brand);
font-size: 13px;
font-weight: 700;
}
.action-square,
.view-mode-button,
.icon-button {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: #fff;
color: var(--text-sub);
}
.view-mode-button.is-active {
color: var(--brand);
border-color: rgba(37, 99, 235, 0.22);
background: rgba(37, 99, 235, 0.05);
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin-top: 14px;
font-size: 13px;
}
.data-table thead th {
padding: 12px 14px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #fbfcff;
color: #475467;
font-weight: 700;
text-align: left;
white-space: nowrap;
}
.data-table thead th:first-child {
border-left: 1px solid var(--line);
border-top-left-radius: 12px;
}
.data-table thead th:last-child {
border-right: 1px solid var(--line);
border-top-right-radius: 12px;
}
.data-table tbody td {
padding: 11px 14px;
border-bottom: 1px solid var(--line);
color: #344054;
background: #fff;
vertical-align: middle;
}
.data-table tbody tr td:first-child {
border-left: 1px solid var(--line);
}
.data-table tbody tr td:last-child {
border-right: 1px solid var(--line);
}
.data-table tbody tr:last-child td:first-child {
border-bottom-left-radius: 12px;
}
.data-table tbody tr:last-child td:last-child {
border-bottom-right-radius: 12px;
}
.data-table tbody tr.is-selected td {
background: #f7fbff;
box-shadow: inset 0 1px 0 rgba(37, 99, 235, 0.48), inset 0 -1px 0 rgba(37, 99, 235, 0.48);
}
.data-table tbody tr.is-selected td:first-child {
box-shadow: inset 1px 0 0 rgba(37, 99, 235, 0.48), inset 0 1px 0 rgba(37, 99, 235, 0.48), inset 0 -1px 0 rgba(37, 99, 235, 0.48);
}
.data-table tbody tr.is-selected td:last-child {
box-shadow: inset -1px 0 0 rgba(37, 99, 235, 0.48), inset 0 1px 0 rgba(37, 99, 235, 0.48), inset 0 -1px 0 rgba(37, 99, 235, 0.48);
}
.checkbox-cell {
width: 16px;
height: 16px;
border: 1.6px solid #d0d5dd;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
}
.checkbox-cell.checked {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.checkbox-cell.checked::before {
content: "\2713";
font-size: 11px;
line-height: 1;
}
.platform-cell,
.name-cell,
.account-cell,
.preview-row,
.detail-top-card,
.detail-mini-meta,
.detail-image-strip,
.related-list,
.log-list,
.phone-links {
display: flex;
align-items: center;
gap: 10px;
}
.platform-logo,
.name-avatar,
.preview-avatar,
.detail-main-avatar,
.detail-number-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.platform-logo {
width: 24px;
height: 24px;
border-radius: 8px;
background: rgba(37, 99, 235, 0.08);
color: var(--brand);
font-size: 11px;
font-weight: 700;
}
.name-avatar,
.preview-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(180deg, #ebf4ff 0%, #dbeafe 100%);
color: #2156d7;
font-size: 12px;
font-weight: 700;
}
.platform-copy,
.name-copy,
.preview-copy,
.timeline-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.platform-name,
.row-primary,
.preview-name {
color: #344054;
font-weight: 600;
}
.link-text {
color: var(--brand);
font-weight: 600;
}
.row-secondary,
.preview-sub,
.meta-sub,
.timeline-time,
.tag-inline {
color: var(--text-sub);
font-size: 12px;
}
.stack-date {
display: flex;
flex-direction: column;
gap: 2px;
}
.stack-date .warn {
color: var(--orange);
font-size: 12px;
font-weight: 600;
}
.stack-date .danger {
color: var(--red);
font-size: 12px;
font-weight: 600;
}
.status-chip {
gap: 6px;
height: 24px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.status-chip.success {
color: #16a34a;
background: #ecfdf3;
border-color: #b7efcc;
}
.status-chip.warning {
color: #ea7b00;
background: #fff6ea;
border-color: #ffd7ab;
}
.status-chip.danger {
color: #ef4444;
background: #fff2f3;
border-color: #fecdd3;
}
.status-chip.neutral {
color: #4b6bff;
background: #eef3ff;
border-color: #d8e3ff;
}
.action-link,
.more-link {
color: var(--brand);
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.ellipsis {
color: var(--text-soft);
font-weight: 700;
letter-spacing: 2px;
}
.table-footer {
justify-content: space-between;
margin-top: 14px;
color: var(--text-sub);
font-size: 13px;
}
.pagination {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-button,
.pagination-number,
.page-size {
min-width: 28px;
height: 28px;
padding: 0 8px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #fff;
color: #475467;
font-size: 13px;
}
.pagination-number.is-active {
border-color: var(--brand);
background: var(--brand);
color: #fff;
}
.page-size {
min-width: 92px;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.preview-card {
padding: 16px;
}
.preview-badges,
.related-tags,
.detail-tags,
.summary-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.preview-badge,
.info-chip {
height: 24px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
.preview-badge {
background: #f4f6fb;
color: #475467;
}
.preview-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.preview-row {
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid #eef2f7;
}
.preview-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.preview-footer {
justify-content: flex-end;
margin-top: 14px;
}
.empty-card {
min-height: 420px;
display: grid;
place-items: center;
text-align: center;
}
.empty-card h3 {
margin: 0;
font-size: 22px;
}
.empty-card p {
margin: 8px 0 0;
color: var(--text-sub);
}
.detail-panel {
display: flex;
flex: 0 0 var(--detail-width);
flex-direction: column;
width: var(--detail-width);
min-width: var(--detail-width);
max-width: var(--detail-width);
min-height: 0;
overflow: hidden;
background: #fff;
border-left: 1px solid var(--line);
}
.detail-shell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 18px 18px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.detail-shell-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
min-height: 0;
padding: 14px 18px 18px;
overflow-y: auto;
}
.detail-close {
width: 28px;
height: 28px;
border-radius: 50%;
color: #667085;
}
.detail-close:hover {
background: #f5f7fb;
}
.detail-title {
margin: 0;
font-size: 17px;
font-weight: 700;
color: #111827;
}
.detail-subtabs {
display: flex;
gap: 26px;
border-bottom: 1px solid var(--line);
}
.detail-subtab {
position: relative;
padding: 0 0 12px;
border: 0;
background: transparent;
color: var(--text-sub);
font-size: 15px;
font-weight: 700;
}
.detail-subtab.is-active {
color: var(--brand);
}
.detail-subtab.is-active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
border-radius: 99px;
background: var(--brand);
}
.detail-top-card {
align-items: flex-start;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.detail-main-avatar {
width: 46px;
height: 46px;
border-radius: 14px;
background: #edf4ff;
color: var(--brand);
font-size: 24px;
}
.detail-main-avatar.photo {
overflow: hidden;
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
}
.detail-main-avatar.photo::before {
content: "";
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0.02));
}
.detail-title-wrap {
display: grid;
gap: 6px;
min-width: 0;
}
.detail-primary-line,
.detail-meta-line,
.detail-action-row,
.notes-box {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.detail-primary-name {
font-size: 18px;
font-weight: 700;
color: #111827;
}
.detail-meta-line {
color: var(--text-sub);
font-size: 13px;
}
.detail-group {
display: grid;
gap: 14px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
}
.detail-item {
display: grid;
gap: 4px;
}
.detail-item.full {
grid-column: 1 / -1;
}
.detail-item label {
color: var(--text-sub);
font-size: 12px;
font-weight: 600;
}
.detail-item span,
.detail-item p {
margin: 0;
color: #344054;
font-size: 14px;
line-height: 1.45;
}
.detail-divider {
height: 1px;
background: var(--line);
}
.detail-image-strip {
gap: 10px;
flex-wrap: wrap;
}
.image-thumb,
.image-thumb.more {
width: 68px;
height: 58px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: linear-gradient(180deg, #eff4fb 0%, #e4ebf7 100%);
position: relative;
overflow: hidden;
}
.image-thumb.card {
width: 72px;
height: 64px;
}
.image-thumb::before {
content: "";
position: absolute;
inset: 8px;
border-radius: 8px;
background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(37,99,235,0.18));
}
.image-thumb.more {
display: inline-flex;
align-items: center;
justify-content: center;
color: #344054;
font-weight: 700;
}
.related-list,
.phone-links {
flex-direction: column;
align-items: stretch;
}
.related-row,
.phone-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.info-chip {
border: 1px solid transparent;
}
.info-chip.blue {
background: #eef3ff;
color: #3b5bdb;
}
.info-chip.orange {
background: #fff4e8;
color: #ff8a00;
}
.info-chip.green {
background: #ecfdf3;
color: #16a34a;
}
.info-chip.red {
background: #fff1f2;
color: #ef4444;
}
.log-list {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.timeline-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
gap: 12px;
}
.timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
background: #98a2b3;
}
.note-box {
padding: 12px;
border-radius: 10px;
background: #f6f8fc;
color: #475467;
font-size: 13px;
line-height: 1.55;
}
.detail-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.detail-footer .footer-button,
.detail-footer .footer-button-primary {
flex: 1;
}
.hidden {
display: none !important;
}
.workspace.sidebar-collapsed .shell-nav {
width: var(--sidebar-collapsed-width);
min-width: var(--sidebar-collapsed-width);
max-width: var(--sidebar-collapsed-width);
padding: 20px 8px 18px;
}
.workspace.sidebar-collapsed .brand-panel {
grid-template-columns: 32px 0;
justify-content: center;
gap: 0;
padding-left: 0;
padding-right: 0;
}
.workspace.sidebar-collapsed .brand-copy,
.workspace.sidebar-collapsed .nav-label,
.workspace.sidebar-collapsed .nav-toggle-label,
.workspace.sidebar-collapsed .nav-user-copy,
.workspace.sidebar-collapsed .nav-settings {
display: none;
}
.workspace.sidebar-collapsed .nav-link,
.workspace.sidebar-collapsed .nav-toggle-button {
width: 56px;
height: 48px;
min-height: 48px;
justify-content: center;
padding: 0;
gap: 0;
margin: 0 auto;
border-radius: 12px;
}
.workspace.sidebar-collapsed .primary-nav {
align-items: center;
gap: 10px;
}
.workspace.sidebar-collapsed .nav-toggle-wrap {
padding-top: 16px;
}
.workspace.sidebar-collapsed .nav-toggle-glyph {
margin: 0 auto;
}
.workspace.sidebar-collapsed .nav-toggle-glyph span:first-child {
transform: rotate(135deg) translate(1px, 1px);
}
.workspace.sidebar-collapsed .nav-toggle-glyph span:last-child {
margin-left: -3px;
transform: rotate(135deg) translate(1px, 1px);
}
.workspace.sidebar-collapsed .nav-footer {
grid-template-columns: 40px;
justify-content: center;
}
@media (max-width: 1480px) {
.workspace,
.workspace.sidebar-collapsed {
grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr);
}
.detail-panel {
display: none;
}
.shell-nav {
width: var(--sidebar-collapsed-width);
min-width: var(--sidebar-collapsed-width);
max-width: var(--sidebar-collapsed-width);
padding: 20px 8px 18px;
}
.brand-copy,
.nav-label,
.nav-toggle-label,
.nav-user-copy,
.nav-settings {
display: none;
}
.brand-panel {
grid-template-columns: 32px 0;
justify-content: center;
gap: 0;
padding-left: 0;
padding-right: 0;
}
.primary-nav {
align-items: center;
gap: 10px;
}
.nav-link,
.nav-toggle-button {
width: 56px;
height: 48px;
min-height: 48px;
justify-content: center;
padding: 0;
gap: 0;
margin: 0 auto;
border-radius: 12px;
}
.nav-toggle-glyph span:first-child {
transform: rotate(135deg) translate(1px, 1px);
}
.nav-toggle-glyph span:last-child {
margin-left: -3px;
transform: rotate(135deg) translate(1px, 1px);
}
.nav-footer {
grid-template-columns: 40px;
justify-content: center;
}
}
@media (max-width: 1180px) {
.header-tools {
flex-wrap: wrap;
justify-content: flex-end;
}
.header-search {
min-width: 280px;
}
.stats-grid,
.stats-grid.overview,
.preview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 840px) {
.workspace,
.workspace.sidebar-collapsed {
grid-template-columns: 1fr;
}
.shell-nav {
display: none;
}
.shell-header {
flex-direction: column;
align-items: stretch;
}
.header-search {
min-width: 0;
}
.stats-grid,
.stats-grid.overview,
.preview-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.shell-body {
padding-left: 14px;
padding-right: 14px;
}
.shell-header {
padding-left: 14px;
padding-right: 14px;
}
}
/* UI polish override: push the current implementation closer to the approved mockups. */
:root {
--page-bg: #f4f7fb;
--shadow-card: 0 12px 32px rgba(13, 44, 100, 0.07);
--shadow-soft: 0 4px 14px rgba(13, 44, 100, 0.05);
--radius-xl: 20px;
--radius-lg: 16px;
--sidebar-width: 240px;
--sidebar-collapsed-width: 72px;
--detail-width: 344px;
}
.workspace {
transition: none;
}
.shell-nav {
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
transition: width 0.22s ease, min-width 0.22s ease, max-width 0.22s ease, padding 0.22s ease;
}
.brand-panel {
min-height: 42px;
}
.brand-copy h1 {
font-size: 15px;
line-height: 1.3;
}
.nav-link,
.nav-toggle-button {
min-height: 50px;
}
.nav-link:hover,
.nav-link.is-active,
.nav-toggle-button:hover {
border-color: rgba(122, 163, 255, 0.2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.nav-label,
.nav-toggle-label {
font-size: 14px;
}
.workspace.sidebar-collapsed .nav-link:hover,
.workspace.sidebar-collapsed .nav-link.is-active,
.workspace.sidebar-collapsed .nav-toggle-button:hover {
width: 56px;
height: 48px;
}
.shell-main {
background: linear-gradient(180deg, #fefefe 0%, #f8fbff 100%);
}
.shell-header {
min-height: 76px;
padding: 14px 24px;
background: rgba(255, 255, 255, 0.92);
}
.page-title-block h2 {
font-size: 22px;
}
.header-search {
min-width: 436px;
height: 42px;
padding: 0 13px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.02);
}
.status-pill {
height: 40px;
}
.header-button,
.header-button-primary,
.header-button-muted,
.footer-button,
.footer-button-primary,
.filter-button,
.filter-button-primary,
.toolbar-button,
.toolbar-button-primary,
.batch-button,
.batch-button-primary {
height: 42px;
}
.shell-body {
gap: 16px;
padding: 16px 24px 20px;
}
.metric-card {
grid-template-columns: 68px minmax(0, 1fr) 84px;
gap: 14px;
min-height: 152px;
padding: 18px 20px;
}
.metric-icon {
width: 54px;
height: 54px;
font-size: 23px;
}
.metric-title {
line-height: 1.35;
}
.metric-number.large {
font-size: 41px;
}
.metric-sub {
margin-top: 2px;
}
.metric-link {
margin-bottom: 2px;
}
.panel-card,
.filter-card,
.table-card,
.empty-card {
padding: 16px 16px 14px;
}
.card-tabs {
padding: 2px 4px 12px;
}
.card-tab {
font-size: 15px;
}
.filter-stack {
gap: 12px;
}
.filter-field,
.filter-search,
.filter-date,
.fake-select,
.fake-input {
height: 40px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.02);
}
.toolbar-row {
margin-top: 12px;
}
.selected-pill {
height: 36px;
}
.data-table {
margin-top: 12px;
}
.data-table thead th {
padding: 11px 14px;
}
.data-table tbody td {
padding: 12px 14px;
}
.table-footer {
margin-top: 12px;
}
.preview-card {
padding: 16px 16px 14px;
}
.detail-panel {
box-shadow: -10px 0 28px rgba(15, 23, 42, 0.03);
}
.detail-shell-header {
min-height: 62px;
padding: 16px 18px 10px;
}
.detail-shell-body {
gap: 16px;
padding: 16px 18px 18px;
}
.detail-title {
font-size: 16px;
}
.detail-top-card {
padding-bottom: 12px;
}
.detail-main-avatar {
width: 48px;
height: 48px;
}
.detail-primary-name {
font-size: 17px;
}
.detail-group {
gap: 12px;
}
.detail-grid {
gap: 12px 16px;
}
.detail-item span,
.detail-item p {
font-size: 13px;
}
@media (max-width: 1180px) {
.header-search {
min-width: 320px;
}
}
/* Table toolbar + pagination polish for 2026-06-25 list update */
.toolbar-row {
justify-content: flex-end;
}
.toolbar-right-fixed {
justify-content: flex-end;
flex-wrap: wrap;
margin-left: auto;
}
.table-footer {
align-items: center;
flex-wrap: wrap;
row-gap: 12px;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12px;
margin-left: auto;
}
.page-size-group,
.page-nav {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.page-size-button,
.pagination-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: #fff;
color: #475467;
font-size: 13px;
font-weight: 600;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.page-size-button:hover,
.pagination-button:hover:not(:disabled) {
border-color: rgba(37, 99, 235, 0.28);
color: var(--brand);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.page-size-button.is-active {
border-color: rgba(37, 99, 235, 0.22);
background: rgba(37, 99, 235, 0.08);
color: var(--brand);
}
.pagination-button {
min-width: 32px;
padding: 0 10px;
}
.pagination-button:disabled {
opacity: 0.42;
cursor: not-allowed;
box-shadow: none;
}
.pagination-number {
min-width: 32px;
height: 32px;
padding: 0 10px;
border-radius: 10px;
}
.pagination-total {
color: var(--text-sub);
font-size: 13px;
font-weight: 600;
}
.preview-grid,
.preview-grid-full {
grid-template-columns: minmax(0, 1fr);
}
.preview-card-wide {
width: 100%;
}
.preview-card-wide .preview-list {
margin-top: 16px;
}
@media (max-width: 1180px) {
.preview-grid,
.preview-grid-full {
grid-template-columns: 1fr;
}
}
@media (max-width: 840px) {
.toolbar-right-fixed,
.pagination,
.page-size-group,
.page-nav {
width: 100%;
justify-content: flex-start;
}
}
/* Sidebar image icons + brand logo update */
.brand-mark {
overflow: hidden;
background: rgba(255, 255, 255, 0.12);
}
.brand-logo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.nav-glyph {
font-size: 0;
}
.nav-icon {
display: block;
width: 20px;
height: 20px;
object-fit: contain;
}
/* Deep pagination style update based on 2026-06-25 reference */
.pagination-shell {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 14px;
margin-left: auto;
}
.pagination-main {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.12);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.pagination-pages {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pagination-arrow,
.pagination-page-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 10px;
background: #ffffff;
color: #344054;
font-size: 14px;
font-weight: 600;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, opacity 0.18s ease;
}
.pagination-arrow:hover:not(:disabled),
.pagination-page-button:hover {
background: #f8fafc;
border-color: rgba(15, 23, 42, 0.12);
color: #111827;
}
.pagination-page-button.is-active {
background: #111827;
border-color: #111827;
color: #ffffff;
}
.pagination-arrow:disabled {
opacity: 0.26;
cursor: not-allowed;
}
.pagination-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 32px;
color: #98a2b3;
font-size: 13px;
letter-spacing: 1px;
}
.pagination-jump {
display: inline-flex;
align-items: center;
gap: 10px;
color: #111827;
font-size: 14px;
font-weight: 600;
}
.pagination-jump-label {
color: #111827;
}
.pagination-jump-input {
width: 56px;
height: 40px;
padding: 0 10px;
border: 1px solid rgba(15, 23, 42, 0.16);
border-radius: 8px;
background: #ffffff;
color: #111827;
font-size: 14px;
font-weight: 600;
text-align: center;
outline: none;
box-sizing: border-box;
}
.pagination-jump-input:focus {
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.pagination-jump-input::-webkit-outer-spin-button,
.pagination-jump-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.pagination-size-select-wrap {
position: relative;
display: inline-flex;
align-items: center;
min-width: 136px;
height: 48px;
padding: 6px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.12);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.pagination-size-select {
width: 100%;
height: 100%;
padding: 0 38px 0 14px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 12px;
background: #ffffff;
color: #111827;
font-size: 14px;
font-weight: 600;
outline: none;
appearance: none;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.pagination-size-select:hover {
background: #f8fafc;
border-color: rgba(15, 23, 42, 0.16);
}
.pagination-size-select:focus {
border-color: rgba(59, 130, 246, 0.5);
}
.pagination-size-arrow {
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-50%);
color: #667085;
font-size: 12px;
pointer-events: none;
}
@media (max-width: 960px) {
.pagination-shell {
width: 100%;
justify-content: flex-start;
}
.pagination-main {
order: 1;
}
.pagination-jump {
order: 2;
}
.pagination-size-select-wrap {
order: 3;
}
}
@media (max-width: 840px) {
.pagination-shell,
.pagination-main,
.pagination-jump,
.pagination-size-select-wrap {
width: 100%;
}
.pagination-main {
justify-content: center;
}
.pagination-jump {
justify-content: flex-start;
}
.pagination-size-select-wrap {
min-width: 0;
}
}
/* Right workspace recreation: keep sidebar untouched, only restyle shell-main and detail panel. */
.shell-main {
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 28%),
linear-gradient(180deg, #f6f8fc 0%, #eef3f9 100%);
}
.shell-header {
padding: 28px 28px 20px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 251, 255, 0.88) 100%);
}
.workbench-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 28px;
}
.hero-copy {
display: grid;
gap: 12px;
max-width: 760px;
}
.hero-copy-top {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hero-kicker,
.detail-shell-kicker {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #6980a0;
}
.hero-badge {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.1);
color: #2452cf;
font-size: 12px;
font-weight: 700;
}
.page-title {
margin: 0;
font-size: 31px;
line-height: 1.15;
font-weight: 800;
color: #0f172a;
}
.hero-summary,
.preview-summary,
.table-head-note {
margin: 0;
color: #667085;
font-size: 14px;
line-height: 1.6;
}
.hero-pills {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.header-tools {
display: flex;
flex: 0 0 470px;
flex-direction: column;
align-items: stretch;
gap: 14px;
}
.header-search {
min-width: 0;
width: 100%;
height: 50px;
padding: 0 16px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
}
.header-search::before {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #eef4ff;
color: #2452cf;
font-size: 13px;
}
.header-search input {
font-size: 14px;
}
.header-shortcut {
min-width: 42px;
height: 28px;
border-radius: 10px;
background: #f3f6fb;
}
.header-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.header-button,
.header-button-primary,
.toolbar-button,
.toolbar-button-primary,
.footer-button,
.footer-button-primary {
height: 44px;
padding: 0 18px;
border-radius: 14px;
font-weight: 700;
}
.header-button,
.toolbar-button,
.footer-button {
border-color: rgba(148, 163, 184, 0.26);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
}
.header-button-primary,
.toolbar-button-primary,
.footer-button-primary {
border-color: #2563eb;
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.22);
}
.header-button-suffix {
font-size: 12px;
}
.status-pill {
height: 34px;
border-radius: 999px;
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04);
}
.status-chip {
height: 28px;
padding: 0 10px;
border-radius: 999px;
}
.shell-body {
gap: 20px;
padding: 20px 28px 28px;
background: transparent;
}
.stats-grid,
.stats-grid.overview {
gap: 16px;
}
.metric-card-premium {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 196px;
padding: 20px 22px;
border-radius: 22px;
border: 1px solid rgba(226, 232, 240, 0.9);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.metric-card-premium::after {
content: "";
position: absolute;
right: -30px;
top: -36px;
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.38);
}
.metric-card-premium[data-tone="blue"] {
background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%);
}
.metric-card-premium[data-tone="green"] {
background: linear-gradient(180deg, #edfdf3 0%, #ffffff 100%);
}
.metric-card-premium[data-tone="orange"] {
background: linear-gradient(180deg, #fff5ea 0%, #ffffff 100%);
}
.metric-card-premium[data-tone="red"] {
background: linear-gradient(180deg, #fff0f2 0%, #ffffff 100%);
}
.metric-card-top,
.metric-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.metric-icon-shell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.7);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.metric-icon {
width: auto;
height: auto;
background: transparent;
font-size: 24px;
}
.metric-card-premium[data-tone="green"] .metric-icon {
color: #16a34a;
}
.metric-card-premium[data-tone="orange"] .metric-icon {
color: #f97316;
}
.metric-card-premium[data-tone="red"] .metric-icon {
color: #ef4444;
}
.metric-tag,
.toolbar-pill {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.06);
color: #475467;
font-size: 12px;
font-weight: 700;
}
.metric-copy {
gap: 8px;
}
.metric-title {
font-size: 15px;
color: #24324a;
}
.metric-value-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.metric-value {
margin: 0;
font-size: 42px;
line-height: 0.95;
font-weight: 800;
}
.metric-unit {
padding-bottom: 6px;
color: #667085;
font-size: 13px;
font-weight: 700;
}
.metric-sub {
font-size: 14px;
line-height: 1.6;
}
.metric-foot-note,
.metric-foot-link {
font-size: 13px;
font-weight: 700;
}
.metric-foot-note {
color: #667085;
}
.metric-foot-link {
color: #2563eb;
}
.table-card-shell,
.preview-card-shell,
.empty-card-shell {
border-radius: 24px;
border: 1px solid rgba(226, 232, 240, 0.86);
box-shadow: 0 22px 44px rgba(15, 23, 42, 0.07);
background: rgba(255, 255, 255, 0.96);
}
.table-card-shell,
.preview-card-shell {
overflow: hidden;
}
.table-card-shell {
padding: 0;
}
.table-head-v2 {
padding: 22px 22px 18px;
}
.table-head-copy {
display: grid;
gap: 6px;
}
.table-selection-chip {
display: grid;
gap: 4px;
min-width: 220px;
padding: 12px 14px;
border-radius: 18px;
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
border: 1px solid rgba(37, 99, 235, 0.12);
}
.table-selection-label {
color: #6980a0;
font-size: 12px;
font-weight: 700;
}
.table-selection-chip strong {
color: #0f172a;
font-size: 14px;
}
.toolbar-row-v2 {
margin-top: 0;
padding: 0 22px 18px;
justify-content: space-between;
}
.toolbar-left-v2 {
gap: 12px;
}
.toolbar-note,
.footer-hint {
color: #667085;
font-size: 13px;
}
.table-scroll-shell {
padding: 0 18px;
}
.data-table {
margin-top: 0;
font-size: 13px;
}
.data-table thead th {
background: #f7f9fd;
color: #526177;
}
.data-table tbody tr {
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.data-table tbody tr:hover td {
background: #fafcff;
}
.data-table tbody tr.is-selected td {
background: #f2f7ff;
box-shadow: inset 0 1px 0 rgba(37, 99, 235, 0.4), inset 0 -1px 0 rgba(37, 99, 235, 0.4);
}
.table-footer-v2 {
padding: 18px 22px 22px;
border-top: 1px solid rgba(226, 232, 240, 0.72);
}
.footer-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.footer-total {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.pagination-shell {
gap: 12px;
}
.pagination-main,
.pagination-size-select-wrap {
border-radius: 18px;
border-color: rgba(148, 163, 184, 0.22);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
}
.pagination-page-button.is-active {
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.18);
}
.pagination-jump-input,
.pagination-size-select {
font-weight: 700;
}
.preview-card-shell {
padding: 0;
}
.preview-head-v2 {
padding: 22px 22px 16px;
border-bottom: 1px solid rgba(226, 232, 240, 0.72);
}
.preview-status-wrap {
display: inline-flex;
align-items: center;
}
.preview-list-v2 {
display: grid;
gap: 12px;
padding: 14px 22px 22px;
}
.preview-row-v2 {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(226, 232, 240, 0.8);
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
}
.preview-row-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #eef4ff;
color: #2452cf;
font-size: 13px;
font-weight: 800;
}
.detail-panel {
width: 372px;
min-width: 372px;
max-width: 372px;
border-left: 1px solid rgba(226, 232, 240, 0.9);
background: linear-gradient(180deg, #fbfdff 0%, #f3f7fc 100%);
box-shadow: -18px 0 36px rgba(15, 23, 42, 0.05);
}
.detail-shell-header {
min-height: 82px;
padding: 22px 22px 14px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
}
.detail-shell-title-wrap {
display: grid;
gap: 6px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #0f172a;
}
.detail-shell-body {
padding: 18px 20px 22px;
gap: 18px;
}
.detail-group-shell {
gap: 18px;
}
.detail-top-card-v2 {
align-items: flex-start;
padding: 18px;
border-radius: 22px;
border: 1px solid rgba(37, 99, 235, 0.1);
background: linear-gradient(180deg, #ffffff 0%, #f3f8ff 100%);
}
.detail-main-avatar {
width: 54px;
height: 54px;
border-radius: 18px;
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
color: #fff;
font-size: 24px;
font-weight: 800;
}
.detail-primary-name {
font-size: 18px;
}
.detail-summary-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-section-shell {
padding: 16px;
border-radius: 20px;
border: 1px solid rgba(226, 232, 240, 0.86);
background: rgba(255, 255, 255, 0.92);
}
.detail-section-head {
display: grid;
gap: 4px;
margin-bottom: 14px;
}
.detail-grid {
gap: 12px;
}
.detail-item {
padding: 12px 13px;
border-radius: 16px;
background: #f8fafc;
}
.detail-item label {
color: #6980a0;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.detail-item span,
.detail-item p {
font-size: 13px;
line-height: 1.55;
}
.log-list {
gap: 12px;
}
.timeline-row {
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(226, 232, 240, 0.82);
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.timeline-dot {
width: 10px;
height: 10px;
margin-top: 5px;
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
}
.detail-footer {
margin-top: 2px;
}
.note-box,
.empty-card-shell {
border-radius: 22px;
border: 1px solid rgba(226, 232, 240, 0.82);
background: rgba(255, 255, 255, 0.92);
}
.empty-card-shell {
min-height: 360px;
}
@media (max-width: 1360px) {
.workbench-hero,
.table-head-v2,
.toolbar-row-v2,
.table-footer-v2 {
gap: 16px;
}
.workbench-hero,
.table-head-v2,
.toolbar-row-v2,
.table-footer-v2 {
flex-direction: column;
align-items: stretch;
}
.header-tools {
flex: 1 1 auto;
}
.table-selection-chip {
min-width: 0;
}
}
@media (max-width: 1180px) {
.shell-header,
.shell-body {
padding-left: 18px;
padding-right: 18px;
}
.page-title {
font-size: 28px;
}
.header-search {
min-width: 0;
}
.preview-row-v2 {
grid-template-columns: 32px minmax(0, 1fr);
}
.preview-row-v2 .status-chip {
grid-column: 2;
justify-self: start;
}
}
@media (max-width: 840px) {
.shell-header,
.shell-body {
padding-left: 14px;
padding-right: 14px;
}
.page-title {
font-size: 24px;
}
.metric-card-premium,
.table-selection-chip,
.detail-section-shell,
.preview-row-v2 {
border-radius: 18px;
}
}
/* Professional admin dashboard visual override */
.shell-main { background: linear-gradient(180deg, #f7f9fc 0%, #eff4f9 100%); }
.shell-header { padding: 28px 28px 20px; border-bottom: 1px solid rgba(15, 23, 42, 0.06); background: rgba(255, 255, 255, 0.92); }
.workspace-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.heading-copy { display: grid; gap: 12px; max-width: 760px; }
.heading-caption-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.heading-caption { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #6b7a90; }
.heading-badge { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border-radius: 999px; background: #eef4ff; color: #2554cf; font-size: 12px; font-weight: 700; }
.page-title { margin: 0; font-size: 32px; line-height: 1.1; font-weight: 800; color: #0f172a; }
.page-summary { margin: 0; color: #667085; font-size: 14px; line-height: 1.7; }
.admin-summary-pills { display: flex; flex-wrap: wrap; gap: 10px; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border-radius: 999px; border: 1px solid rgba(148, 163, 184, 0.18); background: #fff; box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04); font-size: 13px; color: #475467; }
.status-pill strong { color: #0f172a; }
.status-pill.blue { background: #eef5ff; color: #2554cf; }
.status-pill.green { background: #edfdf3; color: #177245; }
.status-pill.orange { background: #fff6ea; color: #c76a00; }
.status-pill.red { background: #fff1f3; color: #d92d20; }
.header-tools { display: flex; flex: 0 0 460px; flex-direction: column; gap: 14px; }
.header-search { display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; height: 52px; padding: 0 16px; border: 1px solid rgba(148, 163, 184, 0.24); border-radius: 16px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05); }
.header-search::before { content: none; }
.header-search-icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: #eef4ff; color: #2554cf; font-size: 14px; }
.header-search input { font-size: 14px; }
.header-actions { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
.header-button, .header-button-primary, .toolbar-button, .footer-button, .footer-button-primary, .preview-link { height: 42px; padding: 0 16px; border-radius: 14px; font-weight: 700; }
.header-button, .toolbar-button, .footer-button, .preview-link { border: 1px solid rgba(148, 163, 184, 0.2); background: #fff; box-shadow: 0 8px 22px rgba(15, 23, 42, 0.05); }
.header-button-primary, .footer-button-primary { border: 1px solid #2563eb; background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); color: #fff; box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24); }
.shell-body { gap: 20px; padding: 20px 28px 28px; background: transparent; }
.stats-grid { gap: 16px; }
.metric-card-premium { position: relative; display: flex; flex-direction: column; gap: 16px; min-height: 190px; padding: 20px 22px; border-radius: 22px; border: 1px solid rgba(226, 232, 240, 0.92); box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06); overflow: hidden; }
.metric-card-premium[data-tone="blue"] { background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%); }
.metric-card-premium[data-tone="orange"] { background: linear-gradient(180deg, #fff5ea 0%, #ffffff 100%); }
.metric-card-premium[data-tone="red"] { background: linear-gradient(180deg, #fff0f2 0%, #ffffff 100%); }
.metric-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.metric-icon-shell { display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 18px; background: rgba(255, 255, 255, 0.78); }
.metric-icon { font-size: 24px; }
.metric-trend { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border-radius: 999px; background: rgba(15, 23, 42, 0.06); color: #475467; font-size: 12px; font-weight: 700; }
.metric-title { margin: 0; font-size: 15px; color: #24324a; }
.metric-value { margin: 0; font-size: 42px; line-height: 0.95; font-weight: 800; }
.metric-sub { margin: 0; font-size: 14px; line-height: 1.6; color: #667085; }
.metric-line span { display: block; width: 96px; height: 3px; border-radius: 999px; background: rgba(37, 99, 235, 0.25); }
.table-card-shell, .preview-card-shell, .empty-card-shell { border-radius: 24px; border: 1px solid rgba(226, 232, 240, 0.88); background: rgba(255, 255, 255, 0.97); box-shadow: 0 22px 44px rgba(15, 23, 42, 0.07); overflow: hidden; }
.table-head-v3 { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 22px 18px; }
.table-head-copy { display: grid; gap: 6px; }
.section-kicker { margin: 0; color: #6980a0; font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.section-title { margin: 0; font-size: 22px; color: #0f172a; }
.table-head-note { margin: 0; color: #667085; font-size: 14px; line-height: 1.6; }
.table-selection-chip { display: grid; gap: 4px; min-width: 220px; padding: 12px 14px; border-radius: 18px; background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%); border: 1px solid rgba(37, 99, 235, 0.12); }
.table-selection-label { color: #6980a0; font-size: 12px; font-weight: 700; }
.filter-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 22px 18px; }
.filter-group { display: flex; flex-wrap: wrap; gap: 10px; }
.filter-chip { display: inline-flex; align-items: center; gap: 10px; height: 40px; padding: 0 14px; border: 1px solid rgba(148, 163, 184, 0.24); border-radius: 12px; background: #fff; color: #344054; }
.filter-caret { color: #98a2b3; font-size: 12px; }
.filter-actions { display: flex; gap: 10px; }
.table-scroll-shell { padding: 0 18px; }
.data-table { margin-top: 0; font-size: 13px; }
.data-table thead th { background: #f7f9fd; color: #526177; }
.data-table tbody tr { cursor: pointer; transition: transform 0.18s ease, box-shadow 0.18s ease; }
.data-table tbody tr:hover td { background: #fafcff; }
.data-table tbody tr.is-selected td { background: #f2f7ff; box-shadow: inset 0 1px 0 rgba(37, 99, 235, 0.35), inset 0 -1px 0 rgba(37, 99, 235, 0.35); }
.table-check { display: inline-flex; width: 16px; height: 16px; border-radius: 5px; border: 1px solid #cbd5e1; background: #fff; }
.table-check.is-active { border-color: #2563eb; background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); box-shadow: inset 0 0 0 3px #fff; }
.row-link { color: #2452cf; font-weight: 700; }
.row-secondary { color: #98a2b3; font-size: 12px; }
.tag-chip { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 24px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.tag-chip.success { background: #edfdf3; color: #177245; }
.tag-chip.warning { background: #fff6ea; color: #c76a00; }
.tag-chip.danger { background: #fff1f3; color: #d92d20; }
.action-links { display: flex; gap: 8px; }
.action-links button { padding: 0; border: 0; background: transparent; color: #2452cf; font-weight: 700; }
.table-footer-v3 { padding: 18px 22px 22px; border-top: 1px solid rgba(226, 232, 240, 0.72); }
.footer-left { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.footer-total { color: #0f172a; font-size: 14px; font-weight: 700; }
.footer-hint { color: #667085; font-size: 13px; }
.preview-head-v3 { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 22px 16px; border-bottom: 1px solid rgba(226, 232, 240, 0.72); }
.preview-list-v3 { display: grid; gap: 12px; padding: 14px 22px 22px; }
.preview-row-v3 { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 14px 16px; border-radius: 18px; border: 1px solid rgba(226, 232, 240, 0.8); background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%); }
.preview-row-marker { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #eef4ff; color: #2452cf; font-size: 13px; font-weight: 800; }
.preview-copy-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.preview-tag { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.preview-tag.success { background: #edfdf3; color: #177245; }
.preview-tag.warning { background: #fff6ea; color: #c76a00; }
.preview-tag.danger { background: #fff1f3; color: #d92d20; }
.detail-panel { width: 388px; min-width: 388px; max-width: 388px; border-left: 1px solid rgba(226, 232, 240, 0.9); background: linear-gradient(180deg, #fbfdff 0%, #f3f7fc 100%); box-shadow: -18px 0 36px rgba(15, 23, 42, 0.05); }
.detail-shell-header { min-height: 82px; padding: 22px 22px 14px; background: rgba(255, 255, 255, 0.72); backdrop-filter: blur(12px); }
.detail-title { margin: 0; font-size: 20px; font-weight: 800; color: #0f172a; }
.detail-shell-body { padding: 18px 20px 22px; gap: 18px; }
.detail-tab-bar { display: flex; gap: 8px; }
.detail-tab { height: 34px; padding: 0 12px; border: 0; border-radius: 999px; background: #eef2f6; color: #667085; font-weight: 700; }
.detail-tab.is-active { background: #eef4ff; color: #2452cf; }
.detail-top-card-v3 { display: flex; align-items: flex-start; gap: 14px; padding: 18px; border-radius: 22px; border: 1px solid rgba(37, 99, 235, 0.1); background: linear-gradient(180deg, #ffffff 0%, #f3f8ff 100%); }
.detail-main-avatar { display: inline-flex; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 18px; background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%); color: #fff; font-size: 24px; font-weight: 800; }
.detail-primary-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.detail-primary-name { font-size: 18px; font-weight: 800; color: #0f172a; }
.detail-meta-line { color: #667085; font-size: 12px; }
.detail-summary-badges { display: flex; flex-wrap: wrap; gap: 8px; }
.info-chip { display: inline-flex; align-items: center; height: 26px; padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.info-chip.blue { background: #eef4ff; color: #2452cf; }
.info-chip.neutral { background: #f2f4f7; color: #667085; }
.detail-section-shell { padding: 16px; border-radius: 20px; border: 1px solid rgba(226, 232, 240, 0.86); background: rgba(255, 255, 255, 0.92); }
.detail-section-head { display: grid; gap: 4px; margin-bottom: 14px; }
.detail-section-title { margin: 0; font-size: 16px; color: #0f172a; }
.detail-note { color: #667085; font-size: 12px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.detail-item { display: grid; gap: 6px; padding: 12px 13px; border-radius: 16px; background: #f8fafc; }
.detail-item.full { grid-column: 1 / -1; }
.detail-item label { color: #6980a0; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
.detail-item span { font-size: 13px; line-height: 1.55; }
.log-list { display: grid; gap: 12px; }
.timeline-row { display: flex; gap: 12px; padding: 14px; border-radius: 16px; border: 1px solid rgba(226, 232, 240, 0.82); background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); }
.timeline-dot { width: 10px; height: 10px; margin-top: 5px; border-radius: 50%; background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); }
.timeline-copy { display: grid; gap: 4px; }
.timeline-time { color: #98a2b3; font-size: 12px; }
.detail-footer { display: flex; gap: 10px; }
.detail-footer > * { flex: 1; }
@media (max-width: 1360px) { .workspace-heading, .table-head-v3, .filter-toolbar, .preview-head-v3 { flex-direction: column; align-items: stretch; } .header-tools { flex: 1 1 auto; } .table-selection-chip { min-width: 0; } }
@media (max-width: 1180px) { .shell-header, .shell-body { padding-left: 18px; padding-right: 18px; } .page-title { font-size: 28px; } .preview-row-v3 { grid-template-columns: 32px minmax(0, 1fr); } .preview-link { grid-column: 2; justify-self: start; } }
@media (max-width: 840px) { .shell-header, .shell-body { padding-left: 14px; padding-right: 14px; } .page-title { font-size: 24px; } .detail-grid { grid-template-columns: 1fr; } }
@media (min-width: 1280px) {
.workspace:not(.sidebar-collapsed) .shell-nav { width: var(--sidebar-width); min-width: var(--sidebar-width); max-width: var(--sidebar-width); }
.workspace:not(.sidebar-collapsed) .brand-copy,
.workspace:not(.sidebar-collapsed) .nav-label,
.workspace:not(.sidebar-collapsed) .nav-toggle-label,
.workspace:not(.sidebar-collapsed) .nav-user-copy,
.workspace:not(.sidebar-collapsed) .nav-settings { display: block; }
.workspace:not(.sidebar-collapsed) .nav-link,
.workspace:not(.sidebar-collapsed) .nav-toggle-button { justify-content: flex-start; gap: 12px; padding: 0 14px; }
}
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