407 lines
17 KiB
JavaScript
407 lines
17 KiB
JavaScript
/**
|
||
* 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] || '';
|
||
}
|