2201 lines
99 KiB
HTML
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">×</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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[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>
|