Files
2026-06-10 17:31:01 +08:00

2201 lines
99 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TalkingQ - AI 玩具智能运营平台</title>
<link rel="stylesheet" href="styles/main.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="logo">
<span class="logo-text">TalkingQ</span>
</div>
<button class="sidebar-toggle" id="sidebarToggle" type="button" aria-label="收起侧边栏" aria-expanded="true" title="收起侧边栏">
<i data-lucide="chevron-left"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">数据概览</span>
<a href="#" class="nav-item active" data-page="dashboard">
<i data-lucide="layout-dashboard"></i>
<span>数据仪表盘</span>
</a>
</div>
<!-- 设备管理 - 按公司分类 -->
<div class="nav-section" id="clientCompaniesNav">
<span class="nav-section-title">设备管理</span>
<div id="companyListNav"></div>
</div>
<div class="nav-section">
<span class="nav-section-title">应用配置</span>
<a href="#" class="nav-item nav-parent" id="appManageToggle" data-nav-toggle="app-management" aria-expanded="true">
<i data-lucide="layout-grid"></i>
<span>应用管理</span>
<i data-lucide="chevron-down" class="nav-chevron"></i>
</a>
<div class="nav-submenu open" data-nav-group="app-management">
<a href="#" class="nav-item nav-item-sub" data-page="agent-manage-multimodal">
<i data-lucide="monitor-smartphone"></i>
<span>多模态交互应用</span>
</a>
<a href="#" class="nav-item nav-item-sub" data-page="agent-manage-voice">
<i data-lucide="mic"></i>
<span>语音交互应用</span>
</a>
</div>
<a href="#" class="nav-item" data-page="hotword-library">
<i data-lucide="book-open-text"></i>
<span>热词库管理</span>
</a>
<a href="#" class="nav-item" data-page="knowledge-base">
<i data-lucide="database"></i>
<span>知识库管理</span>
</a>
</div>
<div class="nav-section">
<span class="nav-section-title">会话分析</span>
<a href="#" class="nav-item" data-page="session">
<i data-lucide="messages-square"></i>
<span>会话详情</span>
</a>
<a href="#" class="nav-item" data-page="badcase">
<i data-lucide="flag"></i>
<span>Bad Case 管理</span>
</a>
</div>
<div class="nav-section">
<span class="nav-section-title">系统管理</span>
<a href="#" class="nav-item" data-page="rbac">
<i data-lucide="shield-check"></i>
<span>权限管理 (RBAC)</span>
</a>
<a href="#" class="nav-item" data-page="privacy">
<i data-lucide="lock"></i>
<span>隐私保护 (PII)</span>
</a>
<a href="#" class="nav-item" data-page="settings">
<i data-lucide="settings"></i>
<span>系统设置</span>
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">A</div>
<div class="user-details">
<span class="user-name">Admin</span>
<span class="user-role">超级管理员</span>
</div>
<button class="logout-btn" title="退出登录" id="logoutBtn">
<i data-lucide="log-out"></i>
</button>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content" id="mainContent">
<!-- Top Bar -->
<header class="topbar">
<div class="topbar-left">
<button class="menu-btn" id="menuBtn">
<i data-lucide="menu"></i>
</button>
<div class="breadcrumb" id="breadcrumb">数据仪表盘</div>
</div>
<div class="topbar-right">
<div class="topbar-time" id="topbarTime"></div>
<div class="notif-dropdown-wrapper" id="notificationCenter">
<button class="topbar-btn" id="notifBtn" title="通知">
<i data-lucide="bell"></i>
<span class="notif-dot"></span>
</button>
<div class="notif-dropdown" id="notifDropdown">
<div class="notif-header">
<span class="notif-title">通知中心</span>
<span class="notif-count">全部已读</span>
</div>
<div class="notif-list">
<div class="notif-empty">通知加载中...</div>
</div>
<div class="notif-footer">
<span class="notif-footer-muted">暂无通知</span>
</div>
</div>
</div>
<!-- 公司选择器 -->
<div class="company-selector" id="companySelector">
<i data-lucide="building-2"></i>
<select id="currentCompanySelect" onchange="window.switchCompany(this.value)">
<option value="all">全部公司</option>
</select>
<button type="button" class="company-manage-btn" id="companyManageBtn">
<i data-lucide="settings-2"></i>
<span>公司管理</span>
</button>
<i data-lucide="chevron-down"></i>
</div>
</div>
</header>
<!-- Page Container -->
<div class="page-container" id="pageContainer">
<!-- Pages injected by JS -->
</div>
</main>
<script>
// ===== Inline API stubs =====
const API = {
BASE_URL: '/api/v1',
auth: {
login: (credentials) => Promise.resolve({
success: true,
token: 'mock',
user: (() => {
const matchedUser = window.__lookupRbacUserByLoginName ? window.__lookupRbacUserByLoginName(credentials?.username) : null;
const loginRole = window.__normalizeRole ? window.__normalizeRole(matchedUser?.role || credentials?.role || 'admin') : (matchedUser?.role || credentials?.role || 'admin');
const companyId = loginRole === 'client'
? (matchedUser?.companyId || matchedUser?.allowedCompanies?.[0] || 'company_a')
: 'all';
return {
username: credentials?.username,
role: loginRole,
companyId,
allowedCompanies: loginRole === 'client' ? [companyId] : ['all'],
roleName: loginRole === 'client'
? `${window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : companyId}-客户`
: loginRole === 'admin' ? '超级管理员' : '运营人员',
};
})(),
}),
logout: () => ({ success: true }),
me: () => Promise.resolve({
success: true,
data: JSON.parse(sessionStorage.getItem('talkingq_user') || 'null'),
}),
},
rbac: {
createUser: (d) => ({ success: true }),
updateUser: (id, d) => ({ success: true }),
getCurrentPermissions: (role) => Promise.resolve({
success: true,
data: window.__getRoleConfig ? window.__getRoleConfig(role) : null,
}),
},
tenant: {
getCompanies: () => Promise.resolve({ success: true, data: window.companies || [] }),
getMyCompany: () => Promise.resolve({
success: true,
data: window.__getCompanyById ? window.__getCompanyById(window.__getCurrentCompanyId?.()) : null,
}),
getScope: () => Promise.resolve({
success: true,
data: window.__getTenantScopeParams ? window.__getTenantScopeParams() : {},
}),
},
tenants: {
listCompanies: () => Promise.resolve({ success: true, data: window.companies || [] }),
createCompany: (data) => Promise.resolve({ success: true, data }),
updateCompany: (companyId, data) => Promise.resolve({ success: true, data: { companyId, ...data } }),
deleteCompany: (companyId) => Promise.resolve({ success: true, data: { companyId } }),
deleteCompanyWithTransfer: (companyId, data) => Promise.resolve({ success: true, data: { companyId, ...data } }),
getMyCompany: () => Promise.resolve({
success: true,
data: window.__getCompanyById ? window.__getCompanyById(window.__getCurrentCompanyId?.()) : null,
}),
getMyScope: () => Promise.resolve({
success: true,
data: window.__getTenantScopeParams ? window.__getTenantScopeParams() : {},
}),
switchCompany: (companyId) => Promise.resolve({ success: true, data: { companyId } }),
},
agentDev: {
saveConfig: (id, config) => new Promise(r => setTimeout(r, 800)),
deployAgent: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 1200)),
previewVoice: (provider, voice, text) => new Promise(r => setTimeout(r, 600)),
testRAGQuery: (agentId, query) => new Promise(r => setTimeout(r, 1000)),
createKnowledgeBase: (data) => new Promise(r => setTimeout(r, 600)),
listASRModels: (params) => Promise.resolve({ success: true, data: window.__asrModelRegistry || [], params }),
createASRModel: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 300)),
updateASRModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 300)),
deleteASRModel: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
validateASRModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 600)),
testASRModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 500)),
setASRModelEnabled: (id, enabled) => new Promise(r => setTimeout(() => r({ success: true, data: { id, enabled } }), 250)),
listTTSModels: (params) => Promise.resolve({ success: true, data: window.__ttsModelRegistry || [], params }),
createTTSModel: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 300)),
updateTTSModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 300)),
deleteTTSModel: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
validateTTSModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 700)),
setTTSModelEnabled: (id, enabled) => new Promise(r => setTimeout(() => r({ success: true, data: { id, enabled } }), 250)),
listTextModels: (params) => Promise.resolve({ success: true, data: window.__textModelRegistry || [], params }),
createTextModel: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 300)),
updateTextModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 300)),
deleteTextModel: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
validateTextModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 700)),
setTextModelEnabled: (id, enabled) => new Promise(r => setTimeout(() => r({ success: true, data: { id, enabled } }), 250)),
listVariables: (agentId, params) => Promise.resolve({ success: true, data: window.__agentVariables || [], params: { agentId, ...params } }),
createVariable: (agentId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, ...data } }), 300)),
updateVariable: (agentId, variableId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, variableId, ...data } }), 300)),
deleteVariable: (agentId, variableId) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, variableId } }), 250)),
injectVariableToPrompt: (agentId, variableId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, variableId, ...data } }), 250)),
getVariableState: (agentId, sessionId, params) => Promise.resolve({ success: true, data: {}, params: { agentId, sessionId, ...params } }),
updateVariableState: (agentId, sessionId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, sessionId, ...data } }), 300)),
getHotwordGroups: (params) => Promise.resolve({ success: true, data: window.__agentHotwordGroups || [], params }),
createHotwordGroup: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 500)),
updateHotwordGroup: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 500)),
deleteHotwordGroup: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
bindHotwordGroups: (agentId, groupIds) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, groupIds } }), 300)),
},
agents: {
create: (data) => new Promise(r => setTimeout(r, 500)),
delete: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id, retentionDays: 7 } }), 300)),
listRecycleBin: (params) => Promise.resolve({ success: true, data: [], params }),
restore: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
purge: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
cleanupRecycleBin: () => Promise.resolve({ success: true, data: { cleaned: true } }),
},
prompts: {
create: (data) => new Promise(r => setTimeout(r, 500)),
},
devices: {
create: (data) => new Promise(r => setTimeout(r, 500)),
batchCreate: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 700)),
validateBatch: (data) => new Promise(r => setTimeout(() => r({ success: true, data: { valid: true, ...data } }), 300)),
delete: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
list: (params) => new Promise(r => setTimeout(() => r({ success: true, data: [], params }), 200)),
export: (params) => new Promise(r => setTimeout(() => r({ success: true, data: null, params }), 300)),
},
ota: {
list: (params) => new Promise(r => setTimeout(() => r({ success: true, data: [], params }), 200)),
create: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 500)),
cancel: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
resume: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
getProgress: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 250)),
uploadFirmware: (file, meta) => new Promise(r => setTimeout(() => r({ success: true, data: { fileName: file?.name, ...meta } }), 700)),
deleteFirmware: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
},
sessions: {
list: (params) => new Promise(r => setTimeout(() => r({ success: true, data: [], params }), 200)),
},
badCases: {
review: (id, data) => new Promise(r => setTimeout(r, 500)),
},
notifications: {
list: (params) => Promise.resolve({ success: true, data: [], params }),
get: (id) => Promise.resolve({ success: true, data: { id } }),
getCatalog: () => Promise.resolve({ success: true, data: window.TQ_NOTIFICATION_EVENT_CATALOG || [] }),
create: (data) => Promise.resolve({ success: true, data }),
markRead: (id) => Promise.resolve({ success: true, data: { id } }),
markAllRead: (params) => Promise.resolve({ success: true, data: params || {} }),
archive: (id) => Promise.resolve({ success: true, data: { id } }),
},
settings: {
createApiKey: (data) => new Promise(r => setTimeout(r, 500)),
getNotifications: () => Promise.resolve({ success: true, data: (window.loadSettings ? window.loadSettings().notificationRules : {}) || {} }),
updateNotifications: (data) => Promise.resolve({ success: true, data }),
}
};
</script>
<script>
// ===== Inline Login Page =====
(function() {
const pageRenderers = {};
window.registerPage = function(id, renderer) {
pageRenderers[id] = renderer;
};
const TOPBAR_COMPANY_SELECTOR_HIDDEN_PAGES = new Set([
// 应用配置
'agent-dev',
'agent-manage',
'agent-manage-multimodal',
'agent-manage-voice',
'agent-config',
'hotword-library',
'knowledge-base',
'kb-create',
'kb-detail',
// 系统管理
'rbac',
'privacy',
'settings',
]);
const TOPBAR_COMPANY_MANAGE_HIDDEN_PAGES = new Set([
'session',
'badcase',
]);
window.__shouldHideCompanySelector = function(role, pageId = window.__currentPage) {
const normalizedRole = window.__normalizeRole ? window.__normalizeRole(role) : (role || 'admin');
return normalizedRole === 'client' || TOPBAR_COMPANY_SELECTOR_HIDDEN_PAGES.has(pageId);
};
window.__syncCompanySelectorVisibility = function(role, pageId = window.__currentPage) {
const companySelectorEl = document.getElementById('companySelector');
const companyManageBtn = document.getElementById('companyManageBtn');
if (!companySelectorEl) return;
const shouldHideSelector = window.__shouldHideCompanySelector(role, pageId);
companySelectorEl.style.display = shouldHideSelector ? 'none' : '';
if (companyManageBtn) {
companyManageBtn.style.display = shouldHideSelector || TOPBAR_COMPANY_MANAGE_HIDDEN_PAGES.has(pageId) ? 'none' : '';
}
};
window.__syncCompanySelectorValue = function(companyId = window.currentCompany) {
const select = document.getElementById('currentCompanySelect');
if (!select) return;
const value = companyId || 'all';
if (Array.from(select.options).some(option => option.value === value)) {
select.value = value;
}
};
window.navigateTo = function(pageId) {
const routeId = String(pageId || 'dashboard');
const basePageId = routeId.split('?')[0];
const container = document.getElementById('pageContainer');
container?.classList.toggle('agent-config-page-container', basePageId === 'agent-config');
document.body.classList.toggle('agent-workbench-mode', basePageId === 'agent-config');
if (basePageId !== 'login' && !window.checkAuth()) {
window.navigateTo('login');
return;
}
if (basePageId !== 'login' && window.__canAccessPage) {
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
if (!window.__canAccessPage(basePageId, role)) {
Toast.warning('当前角色无权访问该页面');
window.navigateTo(window.__getDefaultPage ? window.__getDefaultPage(role) : 'dashboard');
return;
}
}
if (basePageId === 'agent-manage') {
window.navigateTo('agent-manage-multimodal');
return;
}
window.__currentRoute = routeId;
window.__currentPage = basePageId;
if (basePageId === 'login') {
document.body.classList.add('login-mode');
if (pageRenderers[basePageId]) {
container.innerHTML = '<div class="page active" id="page-' + basePageId + '"></div>';
pageRenderers[basePageId](document.getElementById('page-' + basePageId), { route: routeId });
}
return;
}
document.body.classList.remove('login-mode');
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
window.__syncCompanySelectorVisibility?.(role, basePageId);
window.__syncCompanySelectorValue?.();
window.__syncNotificationCenterForRole?.(role, basePageId);
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active', el.dataset.page === basePageId);
});
document.querySelectorAll('.nav-parent[data-nav-toggle]').forEach(parent => {
parent.classList.remove('active');
});
const activeSubmenu = document.querySelector(`.nav-item[data-page="${basePageId}"]`)?.closest('.nav-submenu[data-nav-group]');
if (activeSubmenu) {
activeSubmenu.classList.add('open');
const parent = document.querySelector(`.nav-parent[data-nav-toggle="${activeSubmenu.dataset.navGroup}"]`);
parent?.classList.add('active');
parent?.setAttribute('aria-expanded', 'true');
}
// 移除所有公司导航项的 active 状态(除非当前页面是设备或OTA)
if (basePageId !== 'device' && basePageId !== 'ota') {
document.querySelectorAll('.company-nav-item').forEach(el => {
el.classList.remove('active');
});
document.querySelectorAll('.submenu-item.active').forEach(el => {
el.classList.remove('active');
});
// 关闭所有展开的子菜单
document.querySelectorAll('.company-submenu.open').forEach(el => {
el.classList.remove('open');
});
} else {
window.__syncCompanyNavState?.(basePageId);
}
const breadcrumb = document.getElementById('breadcrumb');
if (breadcrumb) breadcrumb.textContent = pageNames[basePageId] || basePageId;
if (pageRenderers[basePageId]) {
container.innerHTML = '<div class="page active" id="page-' + basePageId + '"></div>';
pageRenderers[basePageId](document.getElementById('page-' + basePageId), { route: routeId });
} else {
container.innerHTML = '<div class="page active"><div class="empty-state"><p style="font-size:16px;font-weight:600;margin-bottom:8px">功能开发中</p><p>该页面正在建设中,敬请期待</p></div></div>';
}
};
const pageNames = {
'login': '登录',
'dashboard': '数据仪表盘',
'agent-dev': '对话应用配置',
'agent-manage': '应用管理',
'agent-manage-multimodal': '多模态交互应用',
'agent-manage-voice': '语音交互应用',
'agent-config': '应用配置',
'hotword-library': '热词库管理',
'knowledge-base': '知识库管理',
'kb-create': '创建知识库',
'kb-detail': '知识库详情',
'device': '设备管理',
'ota': 'OTA 升级',
'session': '会话详情',
'badcase': 'Bad Case 管理',
'rbac': '权限管理 (RBAC)',
'privacy': '隐私保护 (PII)',
'settings': '系统设置',
};
const ROLE_POLICIES = {
admin: {
roleName: '管理员',
backendRole: 'Admin',
defaultPage: 'dashboard',
defaultCompany: 'all',
allowedCompanies: 'all',
allowedPages: '*',
},
worker: {
roleName: '员工',
backendRole: 'Worker',
defaultPage: 'dashboard',
defaultCompany: 'all',
allowedCompanies: 'all',
allowedPages: [
'dashboard',
'device',
'ota',
'agent-dev',
'agent-manage',
'agent-manage-multimodal',
'agent-manage-voice',
'agent-config',
'hotword-library',
'knowledge-base',
'kb-create',
'kb-detail',
'session',
'badcase',
'privacy',
'settings',
],
},
client: {
roleName: '客户',
backendRole: 'Client',
defaultPage: 'dashboard',
defaultCompany: 'company_a',
allowedCompanies: ['company_a'],
allowedPages: ['dashboard', 'device', 'session'],
},
};
window.__normalizeRole = function(role) {
if (!role) return 'admin';
const value = String(role).toLowerCase();
if (value === 'admin' || value === 'administrator') return 'admin';
if (value === 'worker' || value === 'employee' || value === 'staff') return 'worker';
if (value === 'client' || value === 'customer') return 'client';
return 'admin';
};
window.__getRoleConfig = function(role) {
return ROLE_POLICIES[window.__normalizeRole(role)] || ROLE_POLICIES.admin;
};
window.__canAccessPage = function(pageId, role) {
const basePageId = String(pageId || '').split('?')[0];
if (!basePageId || basePageId === 'login') return true;
const policy = window.__getRoleConfig(role);
return policy.allowedPages === '*' || policy.allowedPages.includes(basePageId);
};
window.__getDefaultPage = function(role) {
return window.__getRoleConfig(role).defaultPage;
};
window.copyText = async function(text, successMessage = '已复制到剪贴板') {
try {
if (!navigator.clipboard) throw new Error('Clipboard API unavailable');
await navigator.clipboard.writeText(text);
Toast.success(successMessage, 1500);
} catch (error) {
Toast.warning('当前浏览器不允许自动复制,请手动复制');
}
};
// Toast
window.Toast = {
show: function(message, type, duration = 3000) {
const container = document.querySelector('.toast-container') || (() => {
const c = document.createElement('div');
c.className = 'toast-container';
document.body.appendChild(c);
return c;
})();
const toast = document.createElement('div');
toast.className = 'toast ' + (type || 'success');
toast.innerHTML = '<span>' + message + '</span>';
toast.style.cssText = 'display:flex;align-items:center;gap:10px;padding:12px 16px;background:#fff;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.12);border-left:4px solid #52c41a;min-width:280px;margin-bottom:8px;';
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s';
setTimeout(() => toast.remove(), 300);
}, duration);
},
success: function(m, duration) { this.show(m, 'success', duration); },
error: function(m, duration) { this.show(m, 'error', duration); },
warning: function(m, duration) { this.show(m, 'warning', duration); },
info: function(m, duration) { this.show(m, 'info', duration); }
};
// Modal
window.createModal = function({ title, content, onConfirm, confirmText, cancelText, size = '', confirmClass = '', showCancel = true }) {
const sizeClass = size ? ` ${String(size).startsWith('modal-') ? size : `modal-${size}`}` : '';
const confirmBtnClass = confirmClass ? ` ${confirmClass}` : '';
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `<div class="modal${sizeClass}"><div class="modal-header"><span class="modal-title">${title}</span><button class="modal-close">&times;</button></div><div class="modal-body">${content}</div><div class="modal-footer">${showCancel ? `<button class="btn btn-outline cancel-btn">${cancelText || '取消'}</button>` : ''}${onConfirm ? `<button class="btn btn-primary confirm-btn${confirmBtnClass}">${confirmText || '确认'}</button>` : ''}</div></div>`;
document.body.appendChild(overlay);
document.body.classList.add('modal-open');
const close = () => {
overlay.classList.remove('open');
setTimeout(() => {
overlay.remove();
if (!document.querySelector('.modal-overlay')) {
document.body.classList.remove('modal-open');
}
}, 200);
};
overlay.querySelector('.modal-close').addEventListener('click', close);
overlay.querySelector('.cancel-btn')?.addEventListener('click', close);
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
if (onConfirm) {
overlay.querySelector('.confirm-btn').addEventListener('click', async () => {
const confirmBtn = overlay.querySelector('.confirm-btn');
confirmBtn.disabled = true;
try {
const result = await onConfirm();
if (result === false) return;
close();
} finally {
if (document.body.contains(overlay)) confirmBtn.disabled = false;
}
});
}
requestAnimationFrame(() => overlay.classList.add('open'));
lucide.createIcons({ elements: [overlay] });
return { overlay, close };
};
// Auth
window.checkAuth = function() {
return sessionStorage.getItem('talkingq_logged_in') === 'true';
};
window.doLogout = function() {
sessionStorage.removeItem('talkingq_user');
sessionStorage.removeItem('talkingq_logged_in');
Toast.info('已退出登录');
setTimeout(() => navigateTo('login'), 300);
};
// Login page renderer
registerPage('login', function(container) {
container.innerHTML = `
<div class="login-center-wrapper">
<div class="login-center-content">
<div class="login-brand">
<h2 class="brand-title">
<a href="http://www.talkingq.com" target="_blank" rel="noopener noreferrer" aria-label="点击打开公司官网主页" data-tooltip="点击打开公司官网主页" data-official-site-link>TalkingQ</a>
</h2>
<p class="brand-slogan">重新定义玩具的未来</p>
<p class="brand-slogan-en">SHAPING THE FUTURE OF TOYS</p>
</div>
<div class="login-form-card">
<div class="login-form-header">
<h1 class="login-title">欢迎回来</h1>
<p class="login-subtitle">AI 玩具智能运营平台</p>
</div>
<!-- 角色选择 -->
<div class="role-selector" id="roleSelector">
<div class="role-option selected" data-role="admin">
<div class="role-icon">
<i data-lucide="shield-check" style="width:22px;height:22px"></i>
</div>
<div class="role-info">
<div class="role-name">管理员</div>
</div>
</div>
<div class="role-option" data-role="worker">
<div class="role-icon">
<i data-lucide="users" style="width:22px;height:22px"></i>
</div>
<div class="role-info">
<div class="role-name">员工</div>
</div>
</div>
<div class="role-option" data-role="client">
<div class="role-icon">
<i data-lucide="building-2" style="width:22px;height:22px"></i>
</div>
<div class="role-info">
<div class="role-name">客户</div>
</div>
</div>
</div>
<form class="login-form" id="loginForm">
<div class="form-group">
<label class="form-label">用户名 / 邮箱</label>
<div class="input-wrapper">
<i data-lucide="user" class="input-icon"></i>
<input type="text" class="form-control with-icon" id="loginUsername" placeholder="请输入用户名或邮箱" autocomplete="username">
</div>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<div class="input-wrapper">
<i data-lucide="lock" class="input-icon"></i>
<input type="password" class="form-control with-icon" id="loginPassword" placeholder="请输入密码" autocomplete="current-password">
<button type="button" class="password-toggle" id="passwordToggle" title="显示密码" aria-label="显示密码">
<i data-lucide="eye-off"></i>
</button>
</div>
</div>
<div class="form-row" style="align-items:center;margin-bottom:24px">
<label class="checkbox-label">
<input type="checkbox" id="rememberMe">
<span>记住登录状态</span>
</label>
<a href="#" class="link" onclick="Toast.info('请联系管理员重置密码');return false;">忘记密码?</a>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block" id="loginBtn">
<span class="btn-text">登 录</span>
</button>
</form>
<div class="login-footer">
<p>还没有账号?<a href="#" class="link" onclick="Toast.info('请联系管理员创建账号');return false;">申请试用</a></p>
</div>
</div>
</div>
</div>
`;
lucide.createIcons({ elements: [container] });
const officialSiteLink = container.querySelector('[data-official-site-link]');
officialSiteLink?.addEventListener('mouseenter', () => {
if (!officialSiteLink.classList.contains('tooltip-suppressed')) {
officialSiteLink.classList.add('tooltip-visible');
}
});
officialSiteLink?.addEventListener('click', () => {
officialSiteLink.classList.remove('tooltip-visible');
officialSiteLink.classList.add('tooltip-suppressed');
});
officialSiteLink?.addEventListener('mouseleave', () => {
officialSiteLink.classList.remove('tooltip-visible');
officialSiteLink.classList.remove('tooltip-suppressed');
});
// 角色选择
let selectedRole = 'admin';
container.querySelectorAll('.role-option').forEach(option => {
option.addEventListener('click', () => {
container.querySelectorAll('.role-option').forEach(o => o.classList.remove('selected'));
option.classList.add('selected');
selectedRole = option.dataset.role;
});
});
// Login handlers
const form = document.getElementById('loginForm');
const passwordToggle = document.getElementById('passwordToggle');
const passwordInput = document.getElementById('loginPassword');
passwordToggle?.addEventListener('click', () => {
const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password';
const iconName = isPassword ? 'eye' : 'eye-off';
const label = isPassword ? '隐藏密码' : '显示密码';
passwordToggle.innerHTML = `<i data-lucide="${iconName}"></i>`;
passwordToggle.setAttribute('title', label);
passwordToggle.setAttribute('aria-label', label);
lucide.createIcons({ elements: [passwordToggle] });
});
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('loginUsername')?.value.trim();
const password = document.getElementById('loginPassword')?.value;
if (!username) { Toast.warning('请输入用户名或邮箱'); return; }
if (!password) { Toast.warning('请输入密码'); return; }
let loginResult;
try {
loginResult = await API.auth.login({ username, password, role: selectedRole });
} catch (error) {
Toast.error('登录失败,请检查用户名和密码');
return;
}
if (!loginResult?.success) {
Toast.error('登录失败,请检查用户名和密码');
return;
}
// 后端接入时用 API.auth.login 返回的角色、租户和权限替换这里
const loginRole = window.__normalizeRole ? window.__normalizeRole(loginResult.user?.role || selectedRole) : selectedRole;
const roleConfig = window.__getRoleConfig ? window.__getRoleConfig(loginRole) : { roleName: '管理员', backendRole: 'Admin', defaultCompany: 'all', allowedCompanies: 'all' };
const allowedCompanies = Array.isArray(loginResult.user?.allowedCompanies) && loginResult.user.allowedCompanies.length
? loginResult.user.allowedCompanies
: (roleConfig.allowedCompanies === 'all' ? ['all'] : roleConfig.allowedCompanies);
const userData = {
username,
role: loginRole,
roleName: loginResult.user?.roleName || roleConfig.roleName,
backendRole: roleConfig.backendRole,
companyId: loginResult.user?.companyId || roleConfig.defaultCompany,
allowedCompanies,
};
sessionStorage.setItem('talkingq_user', JSON.stringify(userData));
sessionStorage.setItem('talkingq_logged_in', 'true');
// 根据角色跳转到不同首页
const roleTargets = {
admin: 'dashboard',
client: 'dashboard',
worker: 'dashboard'
};
const target = roleTargets[loginRole] || 'dashboard';
Toast.success('登录成功!');
setTimeout(() => {
// 更新侧边栏
window.__updateSidebarForRole && window.__updateSidebarForRole(loginRole);
navigateTo(target);
}, 500);
});
});
// Dashboard renderer
registerPage('dashboard', function(container) {
container.innerHTML = '<div style="padding:40px;text-align:center"><h1>数据仪表盘</h1><p>欢迎使用 TalkingQ 平台</p></div>';
});
// ===== Company/Tenant Management =====
const COMPANY_COLOR_PALETTE = ['#722ed1', '#52c41a', '#fa8c16', '#13c2c2', '#1677ff', '#eb2f96', '#fa541c'];
const COMPANY_SEED = [
{ id: 'company_a', name: '智慧星科技', color: '#722ed1', deviceCount: 3200, latestVersion: 'v2.3.1' },
{ id: 'company_b', name: '未来玩具厂', color: '#52c41a', deviceCount: 1500, latestVersion: 'v2.0.2' },
{ id: 'company_c', name: '童趣电子', color: '#fa8c16', deviceCount: 800, latestVersion: 'v1.8.2' },
];
function cloneCompanyList(list) {
return list.map(item => ({ ...item }));
}
function normalizeCompanyRecord(company, index = 0) {
return {
id: company.id,
name: company.name,
color: company.color || COMPANY_COLOR_PALETTE[index % COMPANY_COLOR_PALETTE.length],
deviceCount: Number.isFinite(company.deviceCount) ? company.deviceCount : 0,
latestVersion: company.latestVersion || 'v1.0.0',
};
}
function loadCompanies() {
try {
localStorage.removeItem('talkingq_companies_v1');
} catch (error) {
console.warn('Failed to clear legacy company cache', error);
}
return cloneCompanyList(COMPANY_SEED);
}
function saveCompanies() {
// Company changes are intentionally kept in memory for the demo.
}
function getCompanyList() {
return Array.isArray(window.companies) ? window.companies : [];
}
function getCompanyById(companyId) {
return getCompanyList().find(item => item.id === companyId) || null;
}
function getCompanyDeviceCount(companyId) {
const company = getCompanyById(companyId);
return company?.deviceCount || 0;
}
function getNextCompanyColor() {
const usedColors = new Set(getCompanyList().map(company => company.color).filter(Boolean));
return COMPANY_COLOR_PALETTE.find(color => !usedColors.has(color)) || COMPANY_COLOR_PALETTE[getCompanyList().length % COMPANY_COLOR_PALETTE.length];
}
function generateCompanyId(name) {
const base = String(name || 'company')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'company';
let candidate = `company_${base}`;
let suffix = 1;
while (getCompanyList().some(company => company.id === candidate)) {
suffix += 1;
candidate = `company_${base}_${suffix}`;
}
return candidate;
}
window.companies = loadCompanies();
const companies = window.companies;
// 当前选中的公司,默认为"全部公司"
window.currentCompany = 'all';
// 当前用户信息
window.currentUser = {
role: 'Admin', // Admin | Worker | Client
allowedCompanies: ['all'], // Client 用户只能访问特定公司
};
window.__getCompanyById = function(companyId) {
return getCompanyById(companyId) || getCompanyList()[0] || null;
};
window.__getCompanyNameById = function(companyId) {
return window.__getCompanyById?.(companyId)?.name || String(companyId || '未知公司');
};
window.__getCompanyCountById = function(companyId) {
return window.__getCompanyById?.(companyId)?.deviceCount || 0;
};
window.__syncCompanyDeviceCounts = function() {
const deviceSource = typeof window.__getAllDevices === 'function' ? window.__getAllDevices() : [];
const counts = deviceSource.reduce((map, device) => {
if (!device?.companyId) return map;
map[device.companyId] = (map[device.companyId] || 0) + 1;
return map;
}, {});
getCompanyList().forEach(company => {
if (company.id !== 'all') {
company.deviceCount = counts[company.id] || 0;
}
});
saveCompanies();
};
window.__createCompany = function(input) {
const name = String(input?.name || '').trim();
if (!name) throw new Error('公司名称不能为空');
if (getCompanyList().some(company => company.name === name)) throw new Error('公司名称已存在');
const record = normalizeCompanyRecord({
id: input?.id || generateCompanyId(name),
name,
color: input?.color || getNextCompanyColor(),
deviceCount: 0,
latestVersion: input?.latestVersion || 'v1.0.0',
}, getCompanyList().length);
window.companies.push(record);
saveCompanies();
return record;
};
window.__updateCompany = function(companyId, patch) {
const company = getCompanyList().find(item => item.id === companyId);
if (!company) return null;
if (typeof patch?.name === 'string') {
const nextName = patch.name.trim();
if (!nextName) throw new Error('公司名称不能为空');
if (getCompanyList().some(item => item.id !== companyId && item.name === nextName)) {
throw new Error('公司名称已存在');
}
company.name = nextName;
}
if (patch?.color) company.color = patch.color;
if (patch?.latestVersion) company.latestVersion = patch.latestVersion;
saveCompanies();
return company;
};
window.__moveDevicesToCompany = function(sourceCompanyId, targetCompanyId) {
if (!sourceCompanyId || !targetCompanyId || sourceCompanyId === targetCompanyId) return 0;
if (typeof window.__reassignCompanyDevices === 'function') {
const movedCount = window.__reassignCompanyDevices(sourceCompanyId, targetCompanyId);
window.__syncCompanyDeviceCounts();
return movedCount;
}
return 0;
};
window.__removeCompany = function(companyId) {
const index = getCompanyList().findIndex(company => company.id === companyId);
if (index < 0) return false;
window.companies.splice(index, 1);
saveCompanies();
if (window.currentCompany === companyId) {
window.currentCompany = getCompanyList()[0]?.id || 'all';
}
return true;
};
function resetTenantMockData() {
window.companies.splice(0, window.companies.length, ...cloneCompanyList(COMPANY_SEED));
window.currentCompany = 'all';
saveCompanies();
if (typeof window.__resetDeviceMockData === 'function') {
window.__resetDeviceMockData();
} else {
localStorage.removeItem('talkingq_added_devices_v1');
localStorage.removeItem('talkingq_removed_device_ids_v1');
localStorage.removeItem('talkingq_device_company_overrides_v1');
}
window.__syncCompanyDeviceCounts?.();
window.__refreshCompanyNavigation?.();
}
window.__resetTenantMockData = resetTenantMockData;
window.__refreshCompanyNavigation = function() {
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
window.__syncCompanyDeviceCounts?.();
initCompanyNav(role);
const currentPage = window.__currentPage;
if (currentPage) navigateTo(currentPage);
};
window.__getCurrentCompanyId = function() {
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
const config = window.__getRoleConfig ? window.__getRoleConfig(role) : null;
const availableCompanyIds = getCompanyList().map(company => company.id);
const normalizedCurrent = window.currentCompany && window.currentCompany !== 'all' && !availableCompanyIds.includes(window.currentCompany)
? availableCompanyIds[0] || 'all'
: window.currentCompany;
if (normalizedCurrent !== window.currentCompany) {
window.currentCompany = normalizedCurrent;
}
if (config?.allowedCompanies !== 'all') {
return config.allowedCompanies.includes(normalizedCurrent)
? normalizedCurrent
: config.defaultCompany;
}
return normalizedCurrent || 'all';
};
window.__getAllowedCompanyIds = function(role) {
const config = window.__getRoleConfig ? window.__getRoleConfig(role) : null;
if (!config || config.allowedCompanies === 'all') {
return getCompanyList().map(c => c.id);
}
return config.allowedCompanies;
};
window.__getTenantScopeParams = function() {
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
const companyId = window.__getCurrentCompanyId();
if (role === 'client') return { companyId };
return companyId && companyId !== 'all' ? { companyId } : {};
};
function openCompanyEditor(company, onSaved) {
const isEdit = Boolean(company);
const title = isEdit ? '编辑公司' : '添加公司';
const confirmText = isEdit ? '保存' : '创建';
const safeName = isEdit ? escapeHtml(company.name) : '';
createModal({
title,
content: `
<div class="form-group">
<label class="form-label">公司名称 <span class="required">*</span></label>
<input type="text" class="form-control" id="companyNameInput" value="${safeName}" maxlength="40" placeholder="例如:星球智能科技">
</div>
<div class="form-group">
<label class="form-label">颜色标识</label>
<div class="company-color-pills" id="companyColorPills">
${COMPANY_COLOR_PALETTE.map(color => `
<button type="button" class="company-color-pill ${(!isEdit && color === getNextCompanyColor()) || (isEdit && company.color === color) ? 'selected' : ''}" data-color="${color}" style="--pill-color:${color}">
<span></span>
</button>
`).join('')}
</div>
</div>
`,
confirmText,
onConfirm: async () => {
const name = document.getElementById('companyNameInput')?.value.trim();
const selectedColor = document.querySelector('#companyColorPills .company-color-pill.selected')?.dataset.color || getNextCompanyColor();
if (!name) {
Toast.warning('请输入公司名称');
return false;
}
try {
if (isEdit) {
await API.tenants.updateCompany?.(company.id, { name, color: selectedColor });
window.__updateCompany(company.id, { name, color: selectedColor });
} else {
await API.tenants.createCompany?.({ name, color: selectedColor });
const created = window.__createCompany({ name, color: selectedColor });
window.currentCompany = created.id;
}
window.__refreshCompanyNavigation?.();
if (typeof onSaved === 'function') onSaved();
Toast.success(isEdit ? '公司信息已更新' : '公司已创建');
return true;
} catch (error) {
Toast.warning(error?.message || '操作失败');
return false;
}
},
cancelText: '取消',
});
setTimeout(() => {
const pills = document.querySelectorAll('#companyColorPills .company-color-pill');
pills.forEach(pill => {
pill.addEventListener('click', () => {
pills.forEach(item => item.classList.remove('selected'));
pill.classList.add('selected');
});
});
}, 0);
}
function openCompanyDeleteConfirm(company, onDeleted) {
const companyId = company.id;
const deviceCount = typeof window.__getAllDevices === 'function'
? window.__getAllDevices().filter(device => device.companyId === companyId).length
: getCompanyDeviceCount(companyId);
const otherCompanies = getCompanyList().filter(item => item.id !== companyId);
if (otherCompanies.length === 0) {
Toast.warning('至少保留一个公司后才能删除');
return;
}
const targetCompanyId = otherCompanies[0].id;
createModal({
title: '删除公司',
confirmText: '确认删除',
confirmClass: 'btn-danger',
content: `
<div class="company-delete-body">
<p class="company-delete-title">确认删除「${escapeHtml(company.name)}」?</p>
<p class="company-delete-desc">删除后公司将从顶部选择器和侧边栏中移除。</p>
${deviceCount > 0 ? `
<div class="form-group">
<label class="form-label">转移 ${deviceCount} 台设备到</label>
<select class="form-control" id="companyTransferTarget">
${otherCompanies.map(item => `<option value="${escapeHtml(item.id)}" ${item.id === targetCompanyId ? 'selected' : ''}>${escapeHtml(item.name)}</option>`).join('')}
</select>
</div>
` : ''}
</div>
`,
onConfirm: async () => {
const targetSelect = document.getElementById('companyTransferTarget');
const targetId = deviceCount > 0 ? targetSelect?.value : targetCompanyId;
if (deviceCount > 0 && !targetId) {
Toast.warning('请选择设备转移目标公司');
return false;
}
try {
if (deviceCount > 0 && API.tenants.deleteCompanyWithTransfer) {
await API.tenants.deleteCompanyWithTransfer(companyId, { transferToCompanyId: targetId });
} else {
await API.tenants.deleteCompany?.(companyId);
}
} catch (error) {
Toast.warning('公司删除失败,请稍后重试');
return false;
}
if (deviceCount > 0) {
window.__moveDevicesToCompany(companyId, targetId);
}
window.__removeCompany(companyId);
if (window.currentCompany === companyId) {
window.currentCompany = targetId || 'all';
}
window.__refreshCompanyNavigation?.();
if (typeof onDeleted === 'function') onDeleted();
Toast.success('公司已删除');
return true;
},
cancelText: '取消',
});
}
window.openCompanyManagerModal = function() {
const renderCompanyManagerContent = () => {
const companiesList = getCompanyList();
const allDevices = typeof window.__getAllDevices === 'function' ? window.__getAllDevices() : [];
const totalDevices = allDevices.length;
return `
<div class="company-manager">
<div class="company-manager-toolbar">
<div class="company-manager-summary">
共 ${companiesList.length} 家公司 · ${totalDevices} 台设备
</div>
<div class="company-manager-toolbar-actions">
<button type="button" class="btn btn-primary btn-sm" id="addCompanyBtn">
<i data-lucide="plus"></i>
添加公司
</button>
</div>
</div>
<div class="company-manager-list">
${companiesList.length === 0 ? '<div class="company-manager-empty">暂无公司,请先创建公司</div>' : companiesList.map(company => {
const deviceCount = allDevices.filter(device => device.companyId === company.id).length;
return `
<div class="company-manager-item" data-company-id="${escapeHtml(company.id)}">
<div class="company-manager-info">
<span class="company-manager-dot" style="background:${company.color || '#52c41a'}"></span>
<div>
<div class="company-manager-name">${escapeHtml(company.name)}</div>
<div class="company-manager-meta">ID ${escapeHtml(company.id)} · ${deviceCount} 台设备 · 最新固件 ${escapeHtml(company.latestVersion || '-')}</div>
</div>
</div>
<div class="company-manager-actions">
<button type="button" class="btn btn-outline btn-sm" data-action="edit-company" data-company-id="${escapeHtml(company.id)}">
<i data-lucide="pencil"></i>
编辑
</button>
<button type="button" class="btn btn-outline btn-sm danger" data-action="delete-company" data-company-id="${escapeHtml(company.id)}">
<i data-lucide="trash-2"></i>
删除
</button>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
};
let refreshCompanyManager = () => {};
const bindCompanyManagerActions = (overlay) => {
overlay.querySelector('#addCompanyBtn')?.addEventListener('click', () => openCompanyEditor(null, refreshCompanyManager));
overlay.querySelectorAll('[data-action="edit-company"]').forEach(button => {
button.addEventListener('click', () => {
const company = getCompanyById(button.dataset.companyId);
if (company) openCompanyEditor(company, refreshCompanyManager);
});
});
overlay.querySelectorAll('[data-action="delete-company"]').forEach(button => {
button.addEventListener('click', () => {
const company = getCompanyById(button.dataset.companyId);
if (company) openCompanyDeleteConfirm(company, refreshCompanyManager);
});
});
};
const modal = createModal({
title: '公司管理',
size: 'xl',
content: renderCompanyManagerContent(),
showCancel: false,
confirmText: '关闭',
onConfirm: () => true,
});
const overlay = modal.overlay;
refreshCompanyManager = () => {
const body = overlay.querySelector('.modal-body');
if (!body || !document.body.contains(overlay)) return;
body.innerHTML = renderCompanyManagerContent();
bindCompanyManagerActions(overlay);
lucide.createIcons({ elements: [overlay] });
};
bindCompanyManagerActions(overlay);
lucide.createIcons({ elements: [overlay] });
};
const NOTIFICATION_EVENT_CATALOG = [
{
type: 'device.offline_rate_exceeded',
category: '设备健康',
name: '设备离线率异常',
severity: 'critical',
trigger: '公司设备离线率连续 10 分钟超过阈值',
ruleSummary: '离线率 > 5%,且连续 10 分钟未恢复',
window: '10 分钟连续检测',
cooldown: '30 分钟内同公司同规则只通知 1 次',
contentFields: ['公司名称', '当前离线率', '影响设备数', '最近检测时间', '建议处理动作'],
defaultThreshold: 5,
thresholdUnit: '%',
defaultChannels: ['in_app', 'webhook'],
actionPage: 'dashboard',
},
{
type: 'device.error_spike',
category: '设备健康',
name: '设备异常数量突增',
severity: 'warning',
trigger: '异常设备数在 30 分钟内超过阈值',
ruleSummary: '30 分钟内新增异常设备 >= 20 台',
window: '30 分钟滚动窗口',
cooldown: '60 分钟内同公司同异常类型只通知 1 次',
contentFields: ['公司名称', '异常设备数', '主要异常类型', '影响范围', '建议处理动作'],
defaultThreshold: 20,
thresholdUnit: '台',
defaultChannels: ['in_app'],
actionPage: 'device',
},
{
type: 'ota.task_failed',
category: 'OTA 升级',
name: 'OTA 任务失败',
severity: 'warning',
trigger: 'OTA 任务状态变为失败',
ruleSummary: '任务失败即通知',
thresholdLabel: '发生即通知',
window: '任务状态变更时',
cooldown: '同一 OTA 任务只通知 1 次',
contentFields: ['公司名称', '任务名称', '目标版本', '失败阶段', '失败原因摘要'],
defaultChannels: ['in_app', 'webhook'],
actionPage: 'ota',
},
{
type: 'ota.failure_rate_exceeded',
category: 'OTA 升级',
name: 'OTA 失败率超过阈值',
severity: 'warning',
trigger: 'OTA 升级失败率超过阈值',
ruleSummary: '失败率 > 3%,且任务已执行设备数 >= 50 台',
window: '任务执行中实时检测',
cooldown: '同一 OTA 任务每 30 分钟最多通知 1 次',
contentFields: ['公司名称', '任务名称', '目标版本', '成功/失败设备数', '失败率', '失败原因摘要'],
defaultThreshold: 3,
thresholdUnit: '%',
defaultChannels: ['in_app', 'webhook'],
actionPage: 'ota',
},
{
type: 'ai.badcase_rate_exceeded',
category: 'AI 质量',
name: '问题对话率超过阈值',
severity: 'warning',
trigger: 'Bad Case 占比在统计周期内超过阈值',
ruleSummary: '问题对话率 > 5%,且样本会话数 >= 100',
window: '24 小时滚动窗口',
cooldown: '同公司同应用 12 小时内只通知 1 次',
contentFields: ['公司名称', '应用名称', '问题对话率', '主要问题类型', '样例会话入口'],
defaultThreshold: 5,
thresholdUnit: '%',
defaultChannels: ['in_app'],
actionPage: 'badcase',
},
{
type: 'ai.safety_critical',
category: 'AI 安全',
name: '高风险内容安全事件',
severity: 'critical',
trigger: '检测到儿童安全、危险指导、隐私泄露等高风险回复',
ruleSummary: '命中高风险安全标签 >= 1 条',
thresholdLabel: '1 条即通知',
window: '实时检测',
cooldown: '同会话只通知 1 次',
contentFields: ['公司名称', '应用名称', '设备 ID', '风险类型', '会话片段入口'],
defaultChannels: ['in_app', 'webhook'],
actionPage: 'badcase',
},
{
type: 'agent.publish_result',
category: '应用发布',
name: '应用发布失败或上线完成',
severity: 'info',
trigger: '发布为异步任务,结果需要通知发起人',
ruleSummary: '发布失败必通知;发布成功只通知发起人',
thresholdLabel: '任务完成即通知',
window: '发布任务结束时',
cooldown: '同一发布任务只通知 1 次',
contentFields: ['应用名称', '版本', '发布状态', '失败原因', '发布时间'],
defaultChannels: ['in_app'],
actionPage: 'agent-manage-multimodal',
},
{
type: 'agent.lifecycle',
category: '应用管理',
name: '应用删除 / 恢复 / 回收站清理',
severity: 'warning',
trigger: '交互应用被移入回收站、从回收站恢复、彻底删除或到期自动清理',
ruleSummary: '应用生命周期关键操作即通知',
thresholdLabel: '发生即通知',
window: '实时触发',
cooldown: '不合并,逐条通知',
contentFields: ['应用名称', '所属公司', '操作类型', '操作者', '保留到期时间'],
defaultChannels: ['in_app'],
actionPage: 'agent-manage',
},
{
type: 'kb.index_failed',
category: '知识库',
name: '知识库解析或索引失败',
severity: 'warning',
trigger: '文件解析、切片、向量化或索引构建失败',
ruleSummary: '失败文件数 >= 1,或索引任务状态为失败',
thresholdLabel: '1 个失败即通知',
window: '知识库任务结束时',
cooldown: '同一知识库任务只通知 1 次',
contentFields: ['知识库名称', '失败文件数', '失败阶段', '错误摘要', '重试入口'],
defaultChannels: ['in_app'],
actionPage: 'knowledge-base',
},
{
type: 'security.risk_event',
category: '安全权限',
name: '敏感安全事件',
severity: 'critical',
trigger: '异常登录、管理员变更、API Key 创建/撤销、权限异常变更',
ruleSummary: '命中安全审计规则即通知',
thresholdLabel: '发生即通知',
window: '实时检测',
cooldown: '不合并,逐条通知',
contentFields: ['事件类型', '操作者', '影响对象', 'IP/设备', '发生时间'],
defaultChannels: ['in_app', 'webhook'],
actionPage: 'rbac',
},
];
window.TQ_NOTIFICATION_EVENT_CATALOG = NOTIFICATION_EVENT_CATALOG;
const notificationReadIds = new Set();
const NOTIFICATION_SEVERITY_META = {
critical: { label: '高危', className: 'critical', icon: 'alert-triangle', iconBg: '#fff2f0', iconColor: '#ff4d4f' },
warning: { label: '预警', className: 'warning', icon: 'alert-triangle', iconBg: '#fff7e6', iconColor: '#fa8c16' },
info: { label: '提醒', className: 'info', icon: 'info', iconBg: '#e6f7ff', iconColor: '#1890ff' },
success: { label: '完成', className: 'success', icon: 'check-circle', iconBg: '#f6ffed', iconColor: '#52c41a' },
};
function getNotificationEventMeta(type) {
return NOTIFICATION_EVENT_CATALOG.find(item => item.type === type) || {};
}
function getNotificationSettingsSnapshot() {
if (window.__talkingqSettings?.notificationRules) {
return window.__talkingqSettings.notificationRules;
}
try {
localStorage.removeItem('talkingq_settings');
} catch (error) {
console.warn('Failed to clear legacy settings cache', error);
}
return {};
}
function isNotificationTypeEnabled(type) {
const rules = getNotificationSettingsSnapshot();
const rule = rules[type];
return rule ? rule.enabled !== false : true;
}
function getReadNotificationIds() {
return new Set(notificationReadIds);
}
function saveReadNotificationIds(readIds) {
notificationReadIds.clear();
readIds.forEach(id => notificationReadIds.add(id));
}
function markNotificationReadLocally(id) {
notificationReadIds.add(id);
}
const notificationState = {};
function getNotificationKey(role) {
const companyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : 'all';
return `${role}:${role === 'client' ? companyId : 'all'}`;
}
function getNotificationSeeds(role) {
const buildNotification = (eventType, data) => {
const eventMeta = getNotificationEventMeta(eventType);
const severityMeta = NOTIFICATION_SEVERITY_META[data.severity || eventMeta.severity || 'info'] || NOTIFICATION_SEVERITY_META.info;
return {
eventType,
category: eventMeta.category || '系统通知',
severity: data.severity || eventMeta.severity || 'info',
severityLabel: severityMeta.label,
icon: data.icon || severityMeta.icon,
iconBg: data.iconBg || severityMeta.iconBg,
iconColor: data.iconColor || severityMeta.iconColor,
actionPage: data.actionPage || eventMeta.actionPage || '',
actionText: data.actionText || '去处理',
...data,
};
};
if (role === 'client') {
const company = window.getCurrentCompany ? window.getCurrentCompany() : { id: 'company_a', name: '当前公司' };
return [
buildNotification('device.offline_rate_exceeded', {
id: `${company.id}-offline-rate`,
title: `${company.name} 设备离线率超过 5%`,
time: '5 分钟前',
detail: `${company.name} 最近 30 分钟内设备离线率达到 5.8%,影响 38 台设备。建议先查看设备心跳、网络状态和最近固件变更。`,
objectName: company.name,
metric: '离线率 5.8%',
read: false,
actionPage: 'device',
actionText: '查看设备',
}),
buildNotification('ai.badcase_rate_exceeded', {
id: `${company.id}-badcase-rate`,
title: `${company.name} 问题对话率超过阈值`,
time: '42 分钟前',
detail: `${company.name} 今日问题对话率达到 6.4%,主要集中在“内容偏离”和“识别误差”。建议查看样例会话并调整应用配置。`,
objectName: company.name,
metric: '问题对话率 6.4%',
read: false,
actionPage: 'session',
actionText: '查看会话',
}),
].filter(item => isNotificationTypeEnabled(item.eventType));
}
const companyAName = window.__getCompanyNameById ? window.__getCompanyNameById('company_a') : '智慧星科技';
const companyBName = window.__getCompanyNameById ? window.__getCompanyNameById('company_b') : '未来玩具厂';
const companyCName = window.__getCompanyNameById ? window.__getCompanyNameById('company_c') : '童趣电子';
return [
buildNotification('device.offline_rate_exceeded', {
id: 'company-a-offline-rate',
title: `${companyAName} 设备离线率超过 5%`,
time: '5 分钟前',
detail: `${companyAName} 最近 30 分钟内设备离线率达到 5.8%,影响 126 台设备。建议运营人员检查设备心跳、网络波动和最近 OTA 变更。`,
objectName: companyAName,
metric: '离线率 5.8%',
read: false,
actionPage: 'device',
actionText: '查看设备',
}),
buildNotification('ota.failure_rate_exceeded', {
id: 'company-b-ota-failed',
title: `${companyBName} OTA v2.1.0 失败率超过阈值`,
time: '1 小时前',
detail: `${companyBName} OTA v2.1.0 升级任务失败率为 4.6%,超过 3% 阈值。失败设备可进入 OTA 升级页面查看详情并重试。`,
objectName: 'OTA v2.1.0',
metric: '失败率 4.6%',
read: false,
actionPage: 'ota',
actionText: '查看 OTA',
}),
buildNotification('ai.safety_critical', {
id: 'company-c-safety-critical',
title: `${companyCName} 出现高风险内容安全事件`,
time: '2 小时前',
detail: `${companyCName} 的“科学百科探索者”检测到 1 条高风险回复,风险类型为危险指导。建议立即查看 Bad Case 并处理。`,
objectName: '科学百科探索者',
metric: '高风险 1 条',
read: false,
actionPage: 'badcase',
actionText: '查看 Bad Case',
}),
buildNotification('kb.index_failed', {
id: 'kb-index-failed',
title: '故事素材库 V2 索引失败',
time: '昨天',
detail: '故事素材库 V2 有 3 个文件在向量化阶段失败。建议进入知识库查看失败文件并重新解析。',
objectName: '故事素材库 V2',
metric: '失败文件 3 个',
read: false,
actionPage: 'knowledge-base',
actionText: '查看知识库',
}),
buildNotification('security.risk_event', {
id: 'security-api-key-created',
title: '管理员创建了新的 API Key',
time: '昨天',
detail: '管理员 Admin 创建了新的阿里云百炼 API Key。该类操作会进入审计日志,并建议确认是否为预期变更。',
objectName: 'API Key',
metric: '安全审计',
read: true,
actionPage: 'settings',
actionText: '查看设置',
}),
].filter(item => isNotificationTypeEnabled(item.eventType));
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
}
function getNotificationsForRole(role) {
const key = getNotificationKey(role);
if (!notificationState[key]) {
const readIds = getReadNotificationIds();
notificationState[key] = getNotificationSeeds(role).map(item => ({
...item,
read: readIds.has(item.id) || item.read,
}));
}
return notificationState[key];
}
window.__addRuntimeNotification = async function(data = {}) {
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
const normalizedRole = window.__normalizeRole ? window.__normalizeRole(role) : role;
if (normalizedRole === 'client') return;
const eventType = data.eventType || 'agent.lifecycle';
if (!isNotificationTypeEnabled(eventType)) return;
const eventMeta = getNotificationEventMeta(eventType);
const severity = data.severity || eventMeta.severity || 'info';
const severityMeta = NOTIFICATION_SEVERITY_META[severity] || NOTIFICATION_SEVERITY_META.info;
const notification = {
id: data.id || `${eventType}-${Date.now()}`,
eventType,
category: data.category || eventMeta.category || '系统通知',
title: data.title || eventMeta.name || '新的系统通知',
time: data.time || '刚刚',
detail: data.detail || '',
objectName: data.objectName || '-',
metric: data.metric || '-',
severity,
severityLabel: severityMeta.label,
icon: data.icon || severityMeta.icon,
iconBg: data.iconBg || severityMeta.iconBg,
iconColor: data.iconColor || severityMeta.iconColor,
read: false,
actionPage: data.actionPage || eventMeta.actionPage || '',
actionText: data.actionText || '去查看',
};
const notifications = getNotificationsForRole(normalizedRole);
notifications.unshift(notification);
try {
await API.notifications.create(notification);
} catch (error) {
console.warn('Runtime notification create failed in mock mode', error);
}
updateNotificationsForRole(normalizedRole);
};
function renderNotificationFooter(role, unreadCount, totalCount) {
const footer = document.querySelector('.notif-footer');
if (!footer) return;
if (totalCount === 0) {
footer.innerHTML = '<span class="notif-footer-muted">暂无通知</span>';
return;
}
footer.innerHTML = `
<button type="button" class="notif-footer-action" id="openNotificationSettings">
通知设置
</button>
<button type="button" class="notif-footer-action" id="markAllNotificationsRead" ${unreadCount === 0 ? 'disabled' : ''}>
全部标为已读
</button>
`;
footer.querySelector('#openNotificationSettings')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
document.getElementById('notifDropdown')?.classList.remove('open');
navigateTo('settings');
});
footer.querySelector('#markAllNotificationsRead')?.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const notifications = getNotificationsForRole(role);
notifications.forEach(item => { item.read = true; });
const readIds = getReadNotificationIds();
notifications.forEach(item => readIds.add(item.id));
saveReadNotificationIds(readIds);
await API.notifications.markAllRead(window.__getTenantScopeParams ? window.__getTenantScopeParams() : {});
updateNotificationsForRole(role);
Toast.success('全部通知已读');
});
}
function syncNotificationBadge(unreadCount) {
const count = document.querySelector('.notif-count');
const dot = document.querySelector('.notif-dot');
if (count) count.textContent = unreadCount > 0 ? `${unreadCount} 条未读` : '全部已读';
if (dot) dot.style.display = unreadCount > 0 ? '' : 'none';
}
window.__syncNotificationCenterForRole = function(role, pageId = window.__currentPage) {
const normalizedRole = window.__normalizeRole ? window.__normalizeRole(role) : (role || 'admin');
const notificationCenter = document.getElementById('notificationCenter');
const dropdown = document.getElementById('notifDropdown');
const shouldHide = normalizedRole === 'client' || pageId === 'login';
if (notificationCenter) {
notificationCenter.style.display = shouldHide ? 'none' : '';
}
if (shouldHide) {
dropdown?.classList.remove('open');
}
};
function openNotificationDetail(role, notificationId) {
const notifications = getNotificationsForRole(role);
const notification = notifications.find(item => item.id === notificationId);
if (!notification) return;
const wasUnread = !notification.read;
document.getElementById('notifDropdown')?.classList.remove('open');
createModal({
title: '通知详情',
content: `
<div style="display:flex;gap:12px;align-items:flex-start">
<div class="notif-icon" style="background:${notification.iconBg};color:${notification.iconColor}">
<i data-lucide="${notification.icon}" style="width:16px;height:16px"></i>
</div>
<div style="flex:1;min-width:0">
<div class="notif-detail-tags">
<span class="notif-tag">${escapeHtml(notification.category)}</span>
<span class="notif-severity ${escapeHtml(notification.severity)}">${escapeHtml(notification.severityLabel)}</span>
</div>
<div style="font-size:15px;font-weight:600;margin-bottom:6px">${escapeHtml(notification.title)}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:14px">${escapeHtml(notification.time)}</div>
<div style="font-size:13px;line-height:1.7;color:var(--text-secondary)">${escapeHtml(notification.detail)}</div>
<div class="notif-detail-meta">
<div><span>通知类型</span><strong>${escapeHtml(notification.eventType)}</strong></div>
<div><span>影响对象</span><strong>${escapeHtml(notification.objectName || '-')}</strong></div>
<div><span>关键指标</span><strong>${escapeHtml(notification.metric || '-')}</strong></div>
</div>
</div>
</div>
`,
confirmText: notification.actionPage ? notification.actionText : (notification.read ? '确认' : '确认并标为已读'),
onConfirm: async () => {
notification.read = true;
markNotificationReadLocally(notification.id);
await API.notifications.markRead(notification.id);
updateNotificationsForRole(role);
if (wasUnread) Toast.success('通知已标记为已读', 1200);
if (notification.actionPage) navigateTo(notification.actionPage);
},
});
lucide.createIcons();
}
function updateNotificationsForRole(role) {
const list = document.querySelector('.notif-list');
if (!list) return;
const normalizedRole = window.__normalizeRole ? window.__normalizeRole(role) : role;
window.__syncNotificationCenterForRole?.(normalizedRole, window.__currentPage);
if (normalizedRole === 'client') {
syncNotificationBadge(0);
renderNotificationFooter(normalizedRole, 0, 0);
list.innerHTML = '<div class="notif-empty">客户侧不显示通知中心</div>';
return;
}
const notifications = getNotificationsForRole(normalizedRole);
const unreadCount = notifications.filter(item => !item.read).length;
syncNotificationBadge(unreadCount);
renderNotificationFooter(normalizedRole, unreadCount, notifications.length);
list.innerHTML = notifications.length === 0
? '<div class="notif-empty">暂无通知</div>'
: notifications.map(item => `
<button type="button" class="notif-item ${item.read ? '' : 'unread'}" data-notification-id="${item.id}">
<div class="notif-icon" style="background:${item.iconBg};color:${item.iconColor}">
<i data-lucide="${item.icon}" style="width:14px;height:14px"></i>
</div>
<div class="notif-content">
<div class="notif-item-tags">
<span>${escapeHtml(item.category)}</span>
<span class="${escapeHtml(item.severity)}">${escapeHtml(item.severityLabel)}</span>
</div>
<div class="notif-text">${escapeHtml(item.title)}</div>
<div class="notif-time">${escapeHtml(item.time)}${item.read ? ' · 已读' : ''}</div>
</div>
</button>
`).join('');
list.querySelectorAll('.notif-item[data-notification-id]').forEach(item => {
item.addEventListener('click', () => openNotificationDetail(normalizedRole, item.dataset.notificationId));
});
lucide.createIcons({ elements: [list] });
}
window.__refreshNotifications = function() {
Object.keys(notificationState).forEach(key => delete notificationState[key]);
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
window.__syncNotificationCenterForRole?.(role, window.__currentPage);
if ((window.__normalizeRole ? window.__normalizeRole(role) : role) === 'client') return;
updateNotificationsForRole(role);
};
window.__syncCompanyNavState = function(pageId = window.__currentPage) {
const navContainer = document.getElementById('companyListNav');
if (!navContainer) return;
const isCompanyScopedPage = pageId === 'device' || pageId === 'ota';
const companyItems = Array.from(navContainer.querySelectorAll('.company-nav-item'));
navContainer.querySelectorAll('.submenu-item.active').forEach(item => item.classList.remove('active'));
if (!isCompanyScopedPage) return;
const companyId = window.currentCompany || 'all';
const activeCompany = companyItems.find(item => item.dataset.company === companyId);
companyItems.forEach(item => item.classList.toggle('active', item === activeCompany));
if (!activeCompany) return;
const submenu = activeCompany.querySelector('.company-submenu');
if (!document.getElementById('sidebar')?.classList.contains('collapsed')) {
submenu?.classList.add('open');
}
submenu?.querySelectorAll('.submenu-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === pageId);
});
};
// 初始化公司导航
function initCompanyNav(role) {
const navContainer = document.getElementById('companyListNav');
const select = document.getElementById('currentCompanySelect');
if (!navContainer || !select) return;
// 获取当前角色
const rawRole = role || (window.currentUser.role === 'Admin' ? 'admin' : window.currentUser.role === 'Worker' ? 'worker' : 'client');
const currentRole = window.__normalizeRole ? window.__normalizeRole(rawRole) : rawRole;
const isClient = currentRole === 'client';
// 客户只保留一个设备入口,不出现任何公司切换控件
if (isClient) {
const roleConfig = window.__getRoleConfig ? window.__getRoleConfig(currentRole) : null;
const companyId = roleConfig?.allowedCompanies?.[0] || window.currentUser.allowedCompanies?.[0] || 'company_a';
const company = companies.find(c => c.id === companyId) || companies.find(c => c.id === 'company_a');
window.currentCompany = company.id;
select.innerHTML = `<option value="${company.id}" selected>${company.name}</option>`;
navContainer.innerHTML = `
<a href="#" class="nav-item ${window.__currentPage === 'device' ? 'active' : ''}" data-page="device" data-company="${company.id}">
<i data-lucide="tablet-smartphone"></i>
<span>设备管理</span>
</a>
`;
navContainer.querySelector('.nav-item')?.addEventListener('click', (e) => {
e.preventDefault();
window.currentCompany = company.id;
navigateTo('device');
});
lucide.createIcons({ elements: [navContainer] });
return;
}
// 管理员和员工可查看全部公司及全部设备/OTA入口
const allowedCompanies = companies;
navContainer.innerHTML = allowedCompanies.map(company => `
<div class="company-nav-item ${window.currentCompany === company.id ? 'active' : ''}" data-company="${company.id}" data-short="${company.name.slice(0, 1)}">
<i data-lucide="server" style="width:14px;height:14px"></i>
<span class="company-name">${company.name}</span>
${company.deviceCount > 0 ? `<span class="company-badge">${company.deviceCount}</span>` : ''}
<div class="company-submenu">
<a href="#" class="submenu-item" data-page="device" data-company="${company.id}">
<i data-lucide="tablet-smartphone"></i> 设备管理
</a>
${!isClient ? `<a href="#" class="submenu-item" data-page="ota" data-company="${company.id}">
<i data-lucide="refresh-cw"></i> OTA 升级
</a>` : ''}
</div>
</div>
`).join('');
// 渲染下拉选择器(客户不需要,已隐藏)
select.innerHTML = `
<option value="all" ${window.currentCompany === 'all' ? 'selected' : ''}>全部公司</option>
${allowedCompanies.map(c =>
`<option value="${c.id}" ${window.currentCompany === c.id ? 'selected' : ''}>${c.name}</option>`
).join('')}
`;
window.__syncCompanySelectorValue?.();
// 绑定点击事件
navContainer.querySelectorAll('.company-nav-item').forEach(item => {
item.addEventListener('click', (e) => {
// 如果点击的是子菜单项,不触发展开/收起
if (e.target.closest('.company-submenu')) return;
const companyId = item.dataset.company;
const submenu = item.querySelector('.company-submenu');
const wasSidebarCollapsed = document.getElementById('sidebar')?.classList.contains('collapsed');
if (wasSidebarCollapsed) {
window.__setSidebarCollapsed?.(false);
}
// 手风琴效果:关闭其他已展开的子菜单
navContainer.querySelectorAll('.company-submenu.open').forEach(sm => {
if (sm !== submenu) sm.classList.remove('open');
});
// 切换当前子菜单展开/收起
if (submenu) {
submenu.classList.toggle('open', wasSidebarCollapsed ? true : !submenu.classList.contains('open'));
}
// 更新选中状态
if (companyId !== 'all') {
window.currentCompany = companyId;
navContainer.querySelectorAll('.company-nav-item').forEach(ci => ci.classList.remove('active'));
item.classList.add('active');
select.value = companyId;
}
});
});
// 绑定子菜单点击
navContainer.querySelectorAll('.submenu-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const page = item.dataset.page;
const companyId = item.dataset.company;
// 切换到对应页面
window.currentCompany = companyId;
select.value = companyId;
// 更新侧边栏选中状态
navContainer.querySelectorAll('.company-nav-item').forEach(ci => ci.classList.remove('active'));
item.closest('.company-nav-item').classList.add('active');
navContainer.querySelectorAll('.submenu-item.active').forEach(si => si.classList.remove('active'));
item.classList.add('active');
// 导航到页面
navigateTo(page);
// 更新面包屑
const breadcrumb = document.getElementById('breadcrumb');
const companyName = companies.find(c => c.id === companyId)?.name || '';
const pageName = { device: '设备管理', ota: 'OTA 升级' }[page] || page;
if (breadcrumb) breadcrumb.textContent = `${companyName} - ${pageName}`;
});
});
lucide.createIcons({ elements: [navContainer] });
window.__syncCompanyNavState?.(window.__currentPage);
}
// 切换公司
window.switchCompany = function(companyId) {
const role = window.__getUserRole ? window.__getUserRole() : 'admin';
const allowedCompanyIds = window.__getAllowedCompanyIds ? window.__getAllowedCompanyIds(role) : ['all'];
if (companyId !== 'all' && !window.__getCompanyById?.(companyId)) {
Toast.warning('公司不存在或已被删除');
return;
}
if (role === 'client') {
companyId = allowedCompanyIds[0] || 'company_a';
} else if (companyId !== 'all' && !allowedCompanyIds.includes('all') && !allowedCompanyIds.includes(companyId)) {
Toast.warning('当前角色无权访问该公司');
return;
}
window.currentCompany = companyId;
window.__dashboardAppScope = null;
const navContainer = document.getElementById('companyListNav');
window.__syncCompanySelectorValue?.(companyId);
// 更新侧边栏选中状态
navContainer?.querySelectorAll('.company-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.company === companyId);
});
// 如果当前页面需要公司上下文,刷新页面
const currentPage = window.__currentPage || document.querySelector('.nav-item.active')?.dataset.page;
window.__syncCompanyNavState?.(currentPage);
if (['dashboard', 'device', 'ota', 'session'].includes(currentPage)) {
navigateTo(currentPage);
}
// 触发公司切换事件
window.dispatchEvent(new CustomEvent('companyChanged', { detail: { companyId } }));
};
// 获取当前公司信息
window.getCurrentCompany = function() {
const companyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : window.currentCompany;
if (companyId === 'all') {
return { id: 'all', name: '全部公司', deviceCount: getCompanyList().reduce((sum, company) => sum + (company.deviceCount || 0), 0) };
}
return window.__getCompanyById?.(companyId) || { id: companyId || 'all', name: companyId || '全部公司' };
};
// 获取当前公司的设备数据(过滤用)
window.getCompanyDevices = function(allDevices) {
const companyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : window.currentCompany;
if (companyId === 'all') return allDevices;
return allDevices.filter(d => d.companyId === companyId);
};
// 获取当前公司的固件版本
window.getCompanyFirmwareVersions = function(allVersions) {
const companyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : window.currentCompany;
if (companyId === 'all') return allVersions;
return allVersions.filter(v => v.companyId === companyId || !v.companyId);
};
// 退出登录
window.doLogout = function() {
sessionStorage.removeItem('talkingq_user');
sessionStorage.removeItem('talkingq_logged_in');
Toast.success('已退出登录');
navigateTo('login');
};
// 根据角色更新侧边栏
window.__updateSidebarForRole = function(role) {
const normalizedRole = window.__normalizeRole ? window.__normalizeRole(role) : (role || 'admin');
const roleConfig = window.__getRoleConfig ? window.__getRoleConfig(normalizedRole) : { roleName: '管理员', backendRole: 'Admin', defaultCompany: 'all', allowedCompanies: 'all' };
const sidebar = document.getElementById('sidebar');
const userName = sidebar?.querySelector('.user-name');
const userRole = sidebar?.querySelector('.user-role');
const userAvatar = sidebar?.querySelector('.user-avatar');
// 获取用户信息
const userStr = sessionStorage.getItem('talkingq_user');
const userData = userStr ? JSON.parse(userStr) : { username: 'User', role: normalizedRole };
const currentCompanyId = userData.companyId || roleConfig.defaultCompany || 'all';
const currentAllowedCompanies = Array.isArray(userData.allowedCompanies) && userData.allowedCompanies.length
? userData.allowedCompanies
: (roleConfig.allowedCompanies === 'all' ? ['all'] : roleConfig.allowedCompanies);
// 更新用户信息显示
if (userName) userName.textContent = userData.username;
if (userRole) userRole.textContent = userData.roleName || roleConfig.roleName;
if (userAvatar) userAvatar.textContent = userData.username?.charAt(0).toUpperCase() || 'A';
// ======= 权限规则 =======
// admin:全部可见
// worker(员工):仪表盘 + 设备管理(全部)+ 应用配置(全部)+ 会话分析(全部)+ 系统管理中的隐私保护和系统设置
// client(客户):仪表盘 + 会话详情(会话分析) + 设备管理(仅设备管理,无OTA),且只看自己公司
const sidebar_el = document.getElementById('sidebar');
window.currentUser.role = roleConfig.backendRole;
window.currentUser.allowedCompanies = currentAllowedCompanies;
window.currentCompany = normalizedRole === 'client' ? currentCompanyId : (currentCompanyId || roleConfig.defaultCompany);
// 重新初始化公司导航(客户会被渲染为单一设备入口)
initCompanyNav(normalizedRole);
sidebar_el?.querySelectorAll('.nav-item[data-page]').forEach(item => {
item.style.display = window.__canAccessPage(item.dataset.page, normalizedRole) ? '' : 'none';
});
sidebar_el?.querySelectorAll('.nav-section').forEach(section => {
const isDeviceSection = section.id === 'clientCompaniesNav';
const hasVisibleNavItem = Array.from(section.querySelectorAll('.nav-item[data-page]'))
.some(item => item.style.display !== 'none');
section.style.display = (isDeviceSection && window.__canAccessPage('device', normalizedRole)) || hasVisibleNavItem ? '' : 'none';
});
// 顶部公司选择器:客户隐藏;管理员和员工可切换全部公司/单公司视图
window.__syncCompanySelectorVisibility?.(normalizedRole, window.__currentPage);
window.__syncNotificationCenterForRole?.(normalizedRole, window.__currentPage);
if (normalizedRole !== 'client') {
updateNotificationsForRole(normalizedRole);
}
const currentPage = window.__currentPage;
if (currentPage && currentPage !== 'login' && !window.__canAccessPage(currentPage, normalizedRole)) {
navigateTo(roleConfig.defaultPage);
}
};
// 获取当前用户角色
window.__getUserRole = function() {
const userStr = sessionStorage.getItem('talkingq_user');
if (!userStr) return 'admin';
try {
const userData = JSON.parse(userStr);
return window.__normalizeRole ? window.__normalizeRole(userData.role) : (userData.role || 'admin');
} catch (e) {
return 'admin';
}
};
// Init
document.addEventListener('DOMContentLoaded', () => {
// Sidebar toggle
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebarToggle');
const setSidebarCollapsed = (collapsed) => {
sidebar?.classList.toggle('collapsed', collapsed);
sidebar?.querySelectorAll('.company-submenu.open').forEach(el => el.classList.remove('open'));
if (sidebarToggle) {
sidebarToggle.setAttribute('aria-label', collapsed ? '展开侧边栏' : '收起侧边栏');
sidebarToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
sidebarToggle.setAttribute('title', collapsed ? '展开侧边栏' : '收起侧边栏');
}
};
window.__setSidebarCollapsed = setSidebarCollapsed;
sidebarToggle?.addEventListener('click', () => {
setSidebarCollapsed(!sidebar?.classList.contains('collapsed'));
});
document.getElementById('menuBtn')?.addEventListener('click', () => {
setSidebarCollapsed(!sidebar?.classList.contains('collapsed'));
});
// Logout
document.getElementById('logoutBtn')?.addEventListener('click', () => {
window.doLogout();
});
// Nav click
document.querySelectorAll('.nav-item[data-page]').forEach(el => {
el.addEventListener('click', e => {
e.preventDefault();
navigateTo(el.dataset.page);
});
});
document.querySelectorAll('.nav-parent[data-nav-toggle]').forEach(el => {
el.addEventListener('click', e => {
e.preventDefault();
const submenu = document.querySelector(`.nav-submenu[data-nav-group="${el.dataset.navToggle}"]`);
if (sidebar?.classList.contains('collapsed')) {
setSidebarCollapsed(false);
submenu?.classList.add('open');
el.setAttribute('aria-expanded', 'true');
return;
}
const isOpen = submenu?.classList.toggle('open');
el.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
});
// 初始化公司导航
initCompanyNav(window.__getUserRole ? window.__getUserRole() : 'admin');
window.__syncCompanyDeviceCounts?.();
document.getElementById('companyManageBtn')?.addEventListener('click', () => {
if (window.__getUserRole && window.__getUserRole() === 'client') {
Toast.warning('当前角色无权管理公司');
return;
}
window.openCompanyManagerModal?.();
});
// 通知下拉菜单
const notifBtn = document.getElementById('notifBtn');
const notifDropdown = document.getElementById('notifDropdown');
if (notifBtn && notifDropdown) {
notifBtn.addEventListener('click', (e) => {
e.stopPropagation();
notifDropdown.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!notifDropdown.contains(e.target) && e.target !== notifBtn) {
notifDropdown.classList.remove('open');
}
});
}
try {
localStorage.removeItem('talkingq_theme');
document.documentElement.removeAttribute('data-theme');
} catch (error) {
console.warn('Failed to clear legacy theme preference', error);
}
// Clock
function updateClock() {
const el = document.getElementById('topbarTime');
if (!el) return;
el.textContent = new Date().toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
}
updateClock();
setInterval(updateClock, 1000);
// Check auth
const loggedIn = sessionStorage.getItem('talkingq_logged_in');
const targetPage = loggedIn === 'true' ? 'dashboard' : 'login';
setTimeout(() => {
// 如果已登录,根据角色更新侧边栏
if (loggedIn === 'true') {
window.__updateSidebarForRole && window.__updateSidebarForRole(window.__getUserRole());
}
navigateTo(targetPage);
}, 50);
});
})();
</script>
<!-- Load other page scripts -->
<script src="scripts/dashboard.js"></script>
<script src="scripts/hotword-library.js"></script>
<script src="scripts/agent-dev.js"></script>
<script src="scripts/agent-manage.js"></script>
<script src="scripts/knowledge-base.js"></script>
<script src="scripts/kb-create.js"></script>
<script src="scripts/kb-detail.js"></script>
<script src="scripts/device.js"></script>
<script src="scripts/ota.js"></script>
<script src="scripts/session.js"></script>
<script src="scripts/badcase.js"></script>
<script src="scripts/rbac.js"></script>
<script src="scripts/privacy.js"></script>
<script src="scripts/settings.js"></script>
</body>
</html>