data&device ready

This commit is contained in:
“yuanmengy”
2026-06-05 17:11:56 +08:00
commit a225272d1e
29 changed files with 49446 additions and 0 deletions

406
scripts/settings.js Normal file
View File

@@ -0,0 +1,406 @@
/**
* TalkingQ Platform - Settings Page
*/
function getNotificationCatalog() {
return window.TQ_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'],
},
];
}
function getNotificationInputKey(type) {
return String(type).replace(/[^a-zA-Z0-9]/g, '_');
}
function getNotificationSeverityLabel(severity) {
return {
critical: '高危',
warning: '预警',
info: '提醒',
success: '完成',
}[severity] || '提醒';
}
function getDefaultNotificationRules(settings = {}) {
const legacyBadcaseEnabled = settings.notifyBadcase !== false;
const legacyOfflineEnabled = settings.notifyOffline !== false;
const legacyOTAEnabled = settings.notifyOTA !== false;
return getNotificationCatalog().reduce((rules, item) => {
let enabled = item.defaultEnabled !== false;
if (item.type === 'device.offline_rate_exceeded') enabled = legacyOfflineEnabled;
if (item.type === 'ota.task_failed') enabled = legacyOTAEnabled;
if (item.type === 'ota.failure_rate_exceeded') enabled = legacyOTAEnabled;
if (item.type === 'ai.badcase_rate_exceeded') enabled = legacyBadcaseEnabled;
rules[item.type] = {
enabled,
threshold: item.type === 'ai.badcase_rate_exceeded'
? (settings.badcaseThreshold || item.defaultThreshold || '')
: (item.defaultThreshold || ''),
channels: item.defaultChannels || ['in_app'],
};
return rules;
}, {});
}
function getNotificationRules(settings = {}) {
const defaults = getDefaultNotificationRules(settings);
const saved = settings.notificationRules || {};
return Object.fromEntries(Object.entries(defaults).map(([type, rule]) => [
type,
{
...rule,
...(saved[type] || {}),
channels: saved[type]?.channels || rule.channels,
},
]));
}
function renderNotificationRuleRows(rules) {
return getNotificationCatalog().map(item => {
const key = getNotificationInputKey(item.type);
const rule = rules[item.type] || {};
const channels = new Set(rule.channels || item.defaultChannels || ['in_app']);
const hasThreshold = item.defaultThreshold !== undefined;
const thresholdText = item.thresholdLabel || (hasThreshold ? `${rule.threshold || item.defaultThreshold}${item.thresholdUnit || ''}` : '发生即通知');
return `
<div class="notification-rule-row" data-rule-type="${item.type}">
<div class="notification-rule-main">
<div class="notification-rule-head">
<div>
<div class="notification-rule-title">${item.name}</div>
<div class="notification-rule-desc">${item.trigger}</div>
</div>
<span class="notification-rule-severity ${item.severity}">${getNotificationSeverityLabel(item.severity)}</span>
</div>
<div class="notification-rule-meta">
<span>规则:${item.ruleSummary || thresholdText}</span>
<span>窗口:${item.window || '实时'}</span>
<span>冷却:${item.cooldown || '无'}</span>
</div>
<div class="notification-rule-content">
通知内容:${item.contentFields.join('、')}
</div>
</div>
<div class="notification-rule-controls">
<label class="notification-switch">
<input type="checkbox" id="notif_${key}_enabled" ${rule.enabled !== false ? 'checked' : ''}>
<span>启用</span>
</label>
${hasThreshold ? `
<label class="notification-threshold">
<span>阈值</span>
<input type="number" class="form-control" id="notif_${key}_threshold" value="${rule.threshold || item.defaultThreshold}" min="1" style="width:84px">
<em>${item.thresholdUnit || ''}</em>
</label>
` : ''}
${!hasThreshold ? `
<div class="notification-threshold readonly">
<span>阈值</span>
<strong>${thresholdText}</strong>
</div>
` : ''}
<div class="notification-channels">
<label><input type="checkbox" id="notif_${key}_channel_in_app" checked disabled> 站内</label>
<label><input type="checkbox" id="notif_${key}_channel_email" ${channels.has('email') ? 'checked' : ''}> 邮件</label>
<label><input type="checkbox" id="notif_${key}_channel_webhook" ${channels.has('webhook') ? 'checked' : ''}> Webhook</label>
</div>
</div>
</div>
`;
}).join('');
}
registerPage('settings', function(container) {
// Load saved settings
const settings = loadSettings();
const notificationRules = getNotificationRules(settings);
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">系统设置</div>
<div class="page-desc">配置平台参数、API 密钥和通知设置</div>
</div>
</div>
<div style="max-width:800px">
<!-- API Key Section -->
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<div>
<div class="card-title">API 密钥配置</div>
<div class="card-subtitle">配置 AI 模型服务的 API 密钥</div>
</div>
</div>
<div style="padding:16px">
<div class="form-group">
<label class="form-label">OpenAI API Key</label>
<div class="input-wrapper">
<input type="password" class="form-control" id="apiKeyOpenai" value="${settings.apiKeys?.openai || ''}" placeholder="sk-...">
<button type="button" class="password-toggle" onclick="toggleApiKeyVisibility('apiKeyOpenai', this)">
<i data-lucide="eye-off"></i>
</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">用于 GPT-4o、GPT-4o mini 等模型调用</div>
</div>
<div class="form-group" style="margin-top:16px">
<label class="form-label">阿里云百炼 API Key</label>
<div class="input-wrapper">
<input type="password" class="form-control" id="apiKeyAli" value="${settings.apiKeys?.ali || ''}" placeholder="sk-...">
<button type="button" class="password-toggle" onclick="toggleApiKeyVisibility('apiKeyAli', this)">
<i data-lucide="eye-off"></i>
</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">用于通义千问等阿里云大模型服务</div>
</div>
<div class="form-group" style="margin-top:16px">
<label class="form-label">Google Gemini API Key</label>
<div class="input-wrapper">
<input type="password" class="form-control" id="apiKeyGemini" value="${settings.apiKeys?.gemini || ''}" placeholder="AIza...">
<button type="button" class="password-toggle" onclick="toggleApiKeyVisibility('apiKeyGemini', this)">
<i data-lucide="eye-off"></i>
</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">用于 Gemini 1.5 Flash 等模型</div>
</div>
<div style="display:flex;gap:12px;margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
<button class="btn btn-primary" onclick="saveApiKeys()">
<i data-lucide="save"></i> 保存密钥
</button>
<button class="btn btn-outline" onclick="testApiConnection(this)">
<i data-lucide="check-circle"></i> 测试连接
</button>
</div>
</div>
</div>
<!-- Platform Settings -->
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<div>
<div class="card-title">平台配置</div>
<div class="card-subtitle">配置系统级参数</div>
</div>
</div>
<div style="padding:16px">
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">平台名称</label>
<input type="text" class="form-control" id="platformName" value="${settings.platformName || 'TalkingQ'}" placeholder="平台名称">
</div>
<div class="form-group" style="flex:1">
<label class="form-label">数据刷新间隔</label>
<select class="form-control" id="refreshInterval">
<option value="30" ${settings.refreshInterval == 30 ? 'selected' : ''}>30 秒</option>
<option value="60" ${settings.refreshInterval == 60 ? 'selected' : ''}>1 分钟</option>
<option value="300" ${settings.refreshInterval == 300 || !settings.refreshInterval ? 'selected' : ''}>5 分钟</option>
<option value="600" ${settings.refreshInterval == 600 ? 'selected' : ''}>10 分钟</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top:16px">
<label class="form-label">默认 AI 模型</label>
<select class="form-control" id="defaultModel">
<option value="gpt-4o-mini" ${settings.defaultModel == 'gpt-4o-mini' ? 'selected' : ''}>GPT-4o mini推荐 · 快速)</option>
<option value="gpt-4o" ${settings.defaultModel == 'gpt-4o' ? 'selected' : ''}>GPT-4o高能力</option>
<option value="qwen-turbo" ${settings.defaultModel == 'qwen-turbo' ? 'selected' : ''}>通义千问-Turbo</option>
<option value="gemini-1.5-flash" ${settings.defaultModel == 'gemini-1.5-flash' ? 'selected' : ''}>Gemini 1.5 Flash</option>
<option value="claude-3-haiku" ${settings.defaultModel == 'claude-3-haiku' ? 'selected' : ''}>Claude 3 Haiku</option>
</select>
</div>
<div class="form-group" style="margin-top:16px">
<label class="checkbox-label">
<input type="checkbox" id="enableDebug" ${settings.enableDebug ? 'checked' : ''}>
<span class="checkmark"></span>
<span>开启调试模式(显示详细日志)</span>
</label>
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
<button class="btn btn-primary" onclick="savePlatformSettings()">
<i data-lucide="save"></i> 保存配置
</button>
</div>
</div>
</div>
<!-- Notification Settings -->
<div class="card">
<div class="card-header">
<div>
<div class="card-title">通知设置</div>
<div class="card-subtitle">只通知需要关注或处理的事件,包含异常、异步任务和关键生命周期操作</div>
</div>
</div>
<div style="padding:16px">
<div class="notification-principles">
<div>
<strong>异常告警</strong>
<span>设备、AI 安全、OTA 失败等需要处理的异常。</span>
</div>
<div>
<strong>异步结果</strong>
<span>应用发布、知识库索引等耗时任务的失败或完成结果。</span>
</div>
<div>
<strong>安全权限</strong>
<span>异常登录、管理员变更、API Key 创建/撤销、权限异常变更。</span>
</div>
</div>
<div class="notification-rule-list">
${renderNotificationRuleRows(notificationRules)}
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
<button class="btn btn-primary" onclick="saveNotificationSettings()">
<i data-lucide="save"></i> 保存设置
</button>
</div>
</div>
</div>
</div>
`;
lucide.createIcons({ elements: [container] });
});
// Load settings from in-memory demo state
function loadSettings() {
if (window.__talkingqSettings) {
return JSON.parse(JSON.stringify(window.__talkingqSettings));
}
try {
localStorage.removeItem('talkingq_settings');
} catch (e) {
console.warn('Failed to clear legacy settings cache', e);
}
return {};
}
// Save settings to in-memory demo state
function saveSettings(settings) {
window.__talkingqSettings = JSON.parse(JSON.stringify(settings || {}));
}
// Toggle API key visibility
function toggleApiKeyVisibility(inputId, btn) {
const input = document.getElementById(inputId);
if (input) {
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
btn.innerHTML = `<i data-lucide="${isPassword ? 'eye' : 'eye-off'}"></i>`;
btn.setAttribute('title', isPassword ? '隐藏密钥' : '显示密钥');
lucide.createIcons({ elements: [btn] });
}
}
async function testApiConnection(btn) {
const keys = ['apiKeyOpenai', 'apiKeyAli', 'apiKeyGemini']
.map(id => document.getElementById(id)?.value.trim())
.filter(Boolean);
if (keys.length === 0) {
Toast.warning('请先填写至少一个 API Key');
return;
}
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> 测试中...';
lucide.createIcons({ elements: [btn] });
await new Promise(resolve => setTimeout(resolve, 800));
btn.disabled = false;
btn.innerHTML = original;
lucide.createIcons({ elements: [btn] });
Toast.success('API 连接测试通过');
}
// Save API Keys
function saveApiKeys() {
const settings = loadSettings();
settings.apiKeys = {
openai: document.getElementById('apiKeyOpenai')?.value || '',
ali: document.getElementById('apiKeyAli')?.value || '',
gemini: document.getElementById('apiKeyGemini')?.value || ''
};
saveSettings(settings);
Toast.success('API 密钥已保存');
}
// Save Platform Settings
function savePlatformSettings() {
const settings = loadSettings();
settings.platformName = document.getElementById('platformName')?.value || 'TalkingQ';
settings.refreshInterval = parseInt(document.getElementById('refreshInterval')?.value || '300');
settings.defaultModel = document.getElementById('defaultModel')?.value || 'gpt-4o-mini';
settings.enableDebug = document.getElementById('enableDebug')?.checked || false;
saveSettings(settings);
Toast.success('平台配置已保存');
}
// Save Notification Settings
async function saveNotificationSettings() {
const settings = loadSettings();
settings.notificationRules = {};
getNotificationCatalog().forEach(item => {
const key = getNotificationInputKey(item.type);
const channels = ['in_app'];
if (document.getElementById(`notif_${key}_channel_email`)?.checked) channels.push('email');
if (document.getElementById(`notif_${key}_channel_webhook`)?.checked) channels.push('webhook');
settings.notificationRules[item.type] = {
enabled: document.getElementById(`notif_${key}_enabled`)?.checked || false,
threshold: item.defaultThreshold !== undefined
? parseInt(document.getElementById(`notif_${key}_threshold`)?.value || item.defaultThreshold, 10)
: undefined,
thresholdUnit: item.thresholdUnit || undefined,
thresholdLabel: item.thresholdLabel || undefined,
window: item.window || undefined,
cooldown: item.cooldown || undefined,
channels,
};
});
settings.notifyBadcase = settings.notificationRules['ai.badcase_rate_exceeded']?.enabled !== false;
settings.badcaseThreshold = settings.notificationRules['ai.badcase_rate_exceeded']?.threshold || 5;
settings.notifyOffline = settings.notificationRules['device.offline_rate_exceeded']?.enabled !== false;
settings.notifyOTA = settings.notificationRules['ota.task_failed']?.enabled !== false;
saveSettings(settings);
try {
await API.settings.updateNotifications({
rules: settings.notificationRules,
tenantScope: window.__getTenantScopeParams ? window.__getTenantScopeParams() : {},
});
} catch (error) {
Toast.warning('通知设置已保存在前端,后端接口接入后会同步保存');
return;
}
window.__refreshNotifications?.();
Toast.success('通知设置已保存');
}
// Get API Key helper
function getApiKey(provider) {
const settings = loadSettings();
return settings.apiKeys?.[provider] || '';
}