Files
TalkingQ_Dashboard/index.html
“yuanmengy” a225272d1e data&device ready
2026-06-05 17:11:56 +08:00

2169 lines
96 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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)),
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);
const close = () => {
overlay.classList.remove('open');
setTimeout(() => overlay.remove(), 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>