911 lines
39 KiB
JavaScript
911 lines
39 KiB
JavaScript
/**
|
||
* TalkingQ Platform - Settings Page
|
||
*/
|
||
|
||
const SETTINGS_API_KEY_PROVIDERS = [
|
||
{
|
||
id: 'openai',
|
||
name: 'OpenAI API Key',
|
||
provider: 'OpenAI',
|
||
placeholder: 'sk-...',
|
||
description: '用于 OpenAI 文本、多模态和实时模型调用',
|
||
},
|
||
{
|
||
id: 'ali',
|
||
name: '阿里云百炼 API Key',
|
||
provider: 'Alibaba Cloud Bailian',
|
||
placeholder: 'sk-...',
|
||
description: '用于通义千问、百炼应用和阿里云模型服务',
|
||
},
|
||
{
|
||
id: 'gemini',
|
||
name: 'Google Gemini API Key',
|
||
provider: 'Google Gemini',
|
||
placeholder: 'AIza...',
|
||
description: '用于 Gemini 文本与多模态模型调用',
|
||
},
|
||
];
|
||
|
||
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 getDefaultSettings() {
|
||
return {
|
||
apiKeys: {
|
||
openai: '',
|
||
ali: 'sk-talkingq-demo-bailian-20260609',
|
||
gemini: '',
|
||
},
|
||
apiKeyRecords: [
|
||
{
|
||
id: 'ali',
|
||
name: '百炼运行密钥',
|
||
providerId: 'ali',
|
||
provider: 'Alibaba Cloud Bailian',
|
||
value: 'sk-talkingq-demo-bailian-20260609',
|
||
status: 'available',
|
||
createdAt: '2026-06-09',
|
||
lastTestedAt: '2026-06-09',
|
||
},
|
||
],
|
||
platformName: 'TalkingQ',
|
||
refreshInterval: 300,
|
||
defaultModel: 'qwen-turbo',
|
||
enableDebug: false,
|
||
notificationDelivery: {
|
||
emails: 'admin@talkingq.com',
|
||
webhookUrl: 'https://hooks.talkingq.com/demo/alerts',
|
||
},
|
||
notificationRules: {},
|
||
};
|
||
}
|
||
|
||
function normalizeSettings(settings = {}) {
|
||
const defaults = getDefaultSettings();
|
||
const legacyKeys = {
|
||
...defaults.apiKeys,
|
||
...(settings.apiKeys || {}),
|
||
};
|
||
const apiKeyRecords = Array.isArray(settings.apiKeyRecords)
|
||
? settings.apiKeyRecords.map(record => ({ ...record }))
|
||
: SETTINGS_API_KEY_PROVIDERS
|
||
.filter(provider => String(legacyKeys[provider.id] || '').trim())
|
||
.map(provider => ({
|
||
id: provider.id,
|
||
name: provider.name.replace(' API Key', ''),
|
||
providerId: provider.id,
|
||
provider: provider.provider,
|
||
value: legacyKeys[provider.id],
|
||
status: 'configured',
|
||
createdAt: '2026-06-09',
|
||
lastTestedAt: '',
|
||
}));
|
||
const apiKeys = apiKeyRecords.reduce((result, record) => {
|
||
if (record.providerId && record.value) result[record.providerId] = record.value;
|
||
return result;
|
||
}, { openai: '', ali: '', gemini: '' });
|
||
return {
|
||
...defaults,
|
||
...settings,
|
||
apiKeys,
|
||
apiKeyRecords,
|
||
notificationDelivery: {
|
||
...defaults.notificationDelivery,
|
||
...(settings.notificationDelivery || {}),
|
||
},
|
||
notificationRules: {
|
||
...getDefaultNotificationRules(settings),
|
||
...(settings.notificationRules || {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
function loadSettings() {
|
||
if (window.__talkingqSettings) {
|
||
return normalizeSettings(JSON.parse(JSON.stringify(window.__talkingqSettings)));
|
||
}
|
||
try {
|
||
localStorage.removeItem('talkingq_settings');
|
||
} catch (error) {
|
||
console.warn('Failed to clear legacy settings cache', error);
|
||
}
|
||
return normalizeSettings();
|
||
}
|
||
|
||
function saveSettings(settings) {
|
||
window.__talkingqSettings = JSON.parse(JSON.stringify(normalizeSettings(settings || {})));
|
||
}
|
||
|
||
function getApiKey(provider) {
|
||
const settings = loadSettings();
|
||
return settings.apiKeyRecords?.find(record => record.providerId === provider)?.value
|
||
|| settings.apiKeys?.[provider]
|
||
|| '';
|
||
}
|
||
|
||
function escapeSettingsHtml(value) {
|
||
return String(value ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function getApiKeyStatusMeta(status) {
|
||
if (status === 'available') return { label: '连接可用', badge: 'badge-success' };
|
||
if (status === 'failed') return { label: '连接失败', badge: 'badge-danger' };
|
||
if (status === 'testing') return { label: '测试中', badge: 'badge-warning' };
|
||
return { label: '已保存', badge: 'badge-info' };
|
||
}
|
||
|
||
function maskApiKey(value) {
|
||
const key = String(value || '');
|
||
if (!key) return '-';
|
||
if (key.length <= 8) return `${key.slice(0, 2)}****`;
|
||
return `${key.slice(0, 5)}****${key.slice(-4)}`;
|
||
}
|
||
|
||
function getSettingsDateText() {
|
||
const now = new Date();
|
||
const pad = value => String(value).padStart(2, '0');
|
||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||
}
|
||
|
||
function isValidEmailList(value) {
|
||
const emails = String(value || '')
|
||
.split(/[,,;\s]+/)
|
||
.map(item => item.trim())
|
||
.filter(Boolean);
|
||
return emails.length > 0 && emails.every(email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
|
||
}
|
||
|
||
function isValidWebhookUrl(value) {
|
||
return /^https?:\/\/[^\s]+$/i.test(String(value || '').trim());
|
||
}
|
||
|
||
async function callSettingsApi(method, ...args) {
|
||
try {
|
||
if (typeof API !== 'undefined' && API.settings?.[method]) {
|
||
return await API.settings[method](...args);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`Mock settings API ${method} failed:`, error);
|
||
throw error;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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="${escapeSettingsHtml(item.type)}">
|
||
<div class="notification-rule-main">
|
||
<div class="notification-rule-head">
|
||
<div>
|
||
<div class="notification-rule-title">${escapeSettingsHtml(item.name)}</div>
|
||
<div class="notification-rule-desc">${escapeSettingsHtml(item.trigger)}</div>
|
||
</div>
|
||
<span class="notification-rule-severity ${escapeSettingsHtml(item.severity)}">${getNotificationSeverityLabel(item.severity)}</span>
|
||
</div>
|
||
<div class="notification-rule-meta">
|
||
<span>规则:${escapeSettingsHtml(item.ruleSummary || thresholdText)}</span>
|
||
<span>窗口:${escapeSettingsHtml(item.window || '实时')}</span>
|
||
<span>冷却:${escapeSettingsHtml(item.cooldown || '无')}</span>
|
||
</div>
|
||
<div class="notification-rule-content">
|
||
通知内容:${item.contentFields.map(escapeSettingsHtml).join('、')}
|
||
</div>
|
||
</div>
|
||
<div class="notification-rule-controls">
|
||
<label class="notification-switch">
|
||
<input type="checkbox" class="notification-enabled-input" id="notif_${key}_enabled" ${rule.enabled !== false ? 'checked' : ''}>
|
||
<span>启用</span>
|
||
</label>
|
||
${hasThreshold ? `
|
||
<label class="notification-threshold">
|
||
<span>阈值</span>
|
||
<input type="number" class="form-control notification-threshold-input" id="notif_${key}_threshold" value="${escapeSettingsHtml(rule.threshold || item.defaultThreshold)}" min="1" ${item.thresholdUnit === '%' ? 'max="100"' : ''} style="width:84px">
|
||
<em>${escapeSettingsHtml(item.thresholdUnit || '')}</em>
|
||
</label>
|
||
` : `
|
||
<div class="notification-threshold readonly">
|
||
<span>阈值</span>
|
||
<strong>${escapeSettingsHtml(thresholdText)}</strong>
|
||
</div>
|
||
`}
|
||
<div class="notification-channels">
|
||
<label><input type="checkbox" id="notif_${key}_channel_in_app" checked disabled> 站内</label>
|
||
<label><input type="checkbox" class="notification-channel-input" id="notif_${key}_channel_email" ${channels.has('email') ? 'checked' : ''}> 邮件</label>
|
||
<label><input type="checkbox" class="notification-channel-input" id="notif_${key}_channel_webhook" ${channels.has('webhook') ? 'checked' : ''}> Webhook</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
registerPage('settings', function(container) {
|
||
let settings = loadSettings();
|
||
|
||
function renderPage() {
|
||
const notificationRules = getNotificationRules(settings);
|
||
const enabledRuleCount = Object.values(notificationRules).filter(rule => rule.enabled !== false).length;
|
||
const availableKeyCount = settings.apiKeyRecords.filter(record => record.status === 'available').length;
|
||
container.innerHTML = `
|
||
<div class="page-header settings-page-header">
|
||
<div>
|
||
<div class="page-title">系统设置</div>
|
||
<div class="page-desc">统一管理平台运行参数、API 密钥和消息通知规则</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-layout">
|
||
<aside class="settings-sidebar">
|
||
<div class="settings-sidebar-title">设置导航</div>
|
||
<nav class="settings-sidebar-nav" aria-label="系统设置导航">
|
||
<button class="settings-nav-item active" data-settings-target="settings-api">
|
||
<span class="settings-nav-icon"><i data-lucide="key-round"></i></span>
|
||
<span>
|
||
<strong>API 密钥</strong>
|
||
<small>${settings.apiKeyRecords.length} 个已保存</small>
|
||
</span>
|
||
</button>
|
||
<button class="settings-nav-item" data-settings-target="settings-platform">
|
||
<span class="settings-nav-icon"><i data-lucide="sliders-horizontal"></i></span>
|
||
<span>
|
||
<strong>平台配置</strong>
|
||
<small>基础运行参数</small>
|
||
</span>
|
||
</button>
|
||
<button class="settings-nav-item" data-settings-target="settings-delivery">
|
||
<span class="settings-nav-icon"><i data-lucide="send"></i></span>
|
||
<span>
|
||
<strong>通知渠道</strong>
|
||
<small>邮箱与 Webhook</small>
|
||
</span>
|
||
</button>
|
||
<button class="settings-nav-item" data-settings-target="settings-notifications">
|
||
<span class="settings-nav-icon"><i data-lucide="bell-ring"></i></span>
|
||
<span>
|
||
<strong>通知规则</strong>
|
||
<small>${enabledRuleCount} 项已启用</small>
|
||
</span>
|
||
</button>
|
||
</nav>
|
||
<div class="settings-sidebar-status">
|
||
<div class="settings-status-line">
|
||
<span class="settings-status-dot"></span>
|
||
<span>系统配置正常</span>
|
||
</div>
|
||
<small>${availableKeyCount} 个 API Key 连接可用</small>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="settings-content">
|
||
<section class="settings-section-card settings-section-wide" id="settings-api">
|
||
<div class="settings-section-header">
|
||
<div class="settings-section-heading">
|
||
<span class="settings-section-icon"><i data-lucide="key-round"></i></span>
|
||
<div>
|
||
<h2>API 密钥配置</h2>
|
||
<p>统一维护智能体运行时可选择的 API Key</p>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary" id="createApiKeyBtn"><i data-lucide="plus"></i> 创建 API Key</button>
|
||
</div>
|
||
<div class="settings-section-body">
|
||
${settings.apiKeyRecords.length ? `
|
||
<div class="table-wrapper settings-api-table">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>密钥名称</th>
|
||
<th>Provider</th>
|
||
<th>API Key</th>
|
||
<th>状态</th>
|
||
<th>创建时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${settings.apiKeyRecords.map(record => {
|
||
const status = getApiKeyStatusMeta(record.status);
|
||
return `
|
||
<tr>
|
||
<td>
|
||
<div class="settings-key-name">
|
||
<span class="settings-key-avatar"><i data-lucide="key"></i></span>
|
||
<strong>${escapeSettingsHtml(record.name)}</strong>
|
||
</div>
|
||
</td>
|
||
<td class="settings-muted-cell">${escapeSettingsHtml(record.provider)}</td>
|
||
<td><code class="settings-key-code">${escapeSettingsHtml(maskApiKey(record.value))}</code></td>
|
||
<td><span class="badge ${status.badge}">${status.label}</span></td>
|
||
<td class="settings-muted-cell">${escapeSettingsHtml(record.createdAt || '-')}</td>
|
||
<td>
|
||
<div class="settings-table-actions">
|
||
<button class="settings-icon-btn" data-api-key-action="copy" data-key-id="${escapeSettingsHtml(record.id)}" data-tooltip="复制密钥" aria-label="复制密钥"><i data-lucide="copy"></i></button>
|
||
<button class="btn btn-outline btn-sm" data-api-key-action="test" data-key-id="${escapeSettingsHtml(record.id)}"><i data-lucide="plug-zap"></i> 测试</button>
|
||
<button class="settings-icon-btn danger" data-api-key-action="delete" data-key-id="${escapeSettingsHtml(record.id)}" data-tooltip="删除密钥" aria-label="删除密钥"><i data-lucide="trash-2"></i></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
` : `
|
||
<div class="settings-empty-state">
|
||
<span><i data-lucide="key-round"></i></span>
|
||
<strong>暂无已保存的 API Key</strong>
|
||
<p>创建并保存后,密钥会直接显示在这里。</p>
|
||
<button class="btn btn-primary btn-sm" id="createApiKeyEmptyBtn"><i data-lucide="plus"></i> 创建 API Key</button>
|
||
</div>
|
||
`}
|
||
<div class="settings-security-note">
|
||
<i data-lucide="shield-check"></i>
|
||
<span>密钥列表仅展示脱敏信息。建议按环境分别创建,并定期测试和轮换。</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="settings-two-column">
|
||
<section class="settings-section-card" id="settings-platform">
|
||
<div class="settings-section-header">
|
||
<div class="settings-section-heading">
|
||
<span class="settings-section-icon"><i data-lucide="sliders-horizontal"></i></span>
|
||
<div>
|
||
<h2>平台配置</h2>
|
||
<p>系统展示和默认运行参数</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section-body">
|
||
<div class="form-group">
|
||
<label class="form-label">平台名称<span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="platformName" value="${escapeSettingsHtml(settings.platformName || 'TalkingQ')}" placeholder="平台名称" maxlength="40">
|
||
</div>
|
||
<div class="form-group">
|
||
<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 ? 'selected' : ''}>5 分钟</option>
|
||
<option value="600" ${settings.refreshInterval == 600 ? 'selected' : ''}>10 分钟</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<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>
|
||
<label class="settings-checkbox-row">
|
||
<span>
|
||
<strong>调试模式</strong>
|
||
<small>在控制台显示更详细的运行日志</small>
|
||
</span>
|
||
<span class="toggle-switch">
|
||
<input type="checkbox" id="enableDebug" ${settings.enableDebug ? 'checked' : ''}>
|
||
<span class="toggle-track"></span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
<div class="settings-section-footer">
|
||
<button class="btn btn-primary" id="savePlatformBtn"><i data-lucide="save"></i> 保存配置</button>
|
||
<button class="btn btn-ghost" id="resetPlatformBtn"><i data-lucide="rotate-ccw"></i> 恢复默认</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section-card" id="settings-delivery">
|
||
<div class="settings-section-header">
|
||
<div class="settings-section-heading">
|
||
<span class="settings-section-icon"><i data-lucide="send"></i></span>
|
||
<div>
|
||
<h2>通知渠道</h2>
|
||
<p>配置消息的实际接收地址</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section-body">
|
||
<div class="form-group">
|
||
<label class="form-label">通知邮箱</label>
|
||
<input type="text" class="form-control" id="notificationEmails" value="${escapeSettingsHtml(settings.notificationDelivery?.emails || '')}" placeholder="admin@talkingq.com">
|
||
<div class="form-hint">多个邮箱使用逗号分隔</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Webhook URL</label>
|
||
<input type="url" class="form-control" id="notificationWebhookUrl" value="${escapeSettingsHtml(settings.notificationDelivery?.webhookUrl || '')}" placeholder="https://your-webhook.com/alerts">
|
||
<div class="form-hint">用于向企业内部系统推送告警消息</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section-footer">
|
||
<button class="btn btn-outline" id="testEmailBtn"><i data-lucide="mail-check"></i> 测试邮件</button>
|
||
<button class="btn btn-outline" id="testWebhookBtn"><i data-lucide="send"></i> 测试 Webhook</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="settings-section-card settings-section-wide" id="settings-notifications">
|
||
<div class="settings-section-header">
|
||
<div class="settings-section-heading">
|
||
<span class="settings-section-icon"><i data-lucide="bell-ring"></i></span>
|
||
<div>
|
||
<h2>通知规则</h2>
|
||
<p>只通知需要关注或处理的异常、异步任务和关键安全操作</p>
|
||
</div>
|
||
</div>
|
||
<span class="settings-section-count">${enabledRuleCount} 项已启用</span>
|
||
</div>
|
||
<div class="settings-section-body">
|
||
<div class="notification-principles">
|
||
<div><span class="settings-principle-icon"><i data-lucide="triangle-alert"></i></span><span><strong>异常告警</strong><small>设备、AI 安全、OTA 失败</small></span></div>
|
||
<div><span class="settings-principle-icon"><i data-lucide="timer"></i></span><span><strong>异步结果</strong><small>应用发布和知识库任务</small></span></div>
|
||
<div><span class="settings-principle-icon"><i data-lucide="shield-check"></i></span><span><strong>安全权限</strong><small>登录、管理员和权限变更</small></span></div>
|
||
</div>
|
||
<div class="notification-rule-list">${renderNotificationRuleRows(notificationRules)}</div>
|
||
</div>
|
||
<div class="settings-section-footer">
|
||
<button class="btn btn-primary" id="saveNotificationsBtn"><i data-lucide="save"></i> 保存通知设置</button>
|
||
<button class="btn btn-ghost" id="resetNotificationsBtn"><i data-lucide="rotate-ccw"></i> 恢复默认</button>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
`;
|
||
|
||
bindEvents();
|
||
syncAllNotificationRows();
|
||
lucide.createIcons({ elements: [container] });
|
||
}
|
||
|
||
function bindEvents() {
|
||
container.querySelector('#createApiKeyBtn')?.addEventListener('click', openCreateApiKeyModal);
|
||
container.querySelector('#createApiKeyEmptyBtn')?.addEventListener('click', openCreateApiKeyModal);
|
||
container.querySelectorAll('[data-settings-target]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const section = container.querySelector(`#${button.dataset.settingsTarget}`);
|
||
if (!section) return;
|
||
container.querySelectorAll('.settings-nav-item').forEach(item => item.classList.toggle('active', item === button));
|
||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
});
|
||
container.querySelectorAll('[data-api-key-action]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const keyId = button.dataset.keyId;
|
||
if (button.dataset.apiKeyAction === 'copy') copySavedApiKey(keyId);
|
||
if (button.dataset.apiKeyAction === 'test') testSavedApiKey(keyId, button);
|
||
if (button.dataset.apiKeyAction === 'delete') deleteSavedApiKey(keyId);
|
||
});
|
||
});
|
||
container.querySelector('#savePlatformBtn')?.addEventListener('click', savePlatformSettingsFromForm);
|
||
container.querySelector('#resetPlatformBtn')?.addEventListener('click', resetPlatformSettings);
|
||
container.querySelector('#testEmailBtn')?.addEventListener('click', event => testNotificationEmail(event.currentTarget));
|
||
container.querySelector('#testWebhookBtn')?.addEventListener('click', event => testNotificationWebhook(event.currentTarget));
|
||
container.querySelector('#saveNotificationsBtn')?.addEventListener('click', saveNotificationSettingsFromForm);
|
||
container.querySelector('#resetNotificationsBtn')?.addEventListener('click', resetNotificationSettings);
|
||
|
||
container.querySelectorAll('.notification-enabled-input').forEach(input => {
|
||
input.addEventListener('change', () => syncNotificationRow(input.closest('.notification-rule-row')));
|
||
});
|
||
}
|
||
|
||
function syncNotificationRow(row) {
|
||
if (!row) return;
|
||
const enabled = row.querySelector('.notification-enabled-input')?.checked !== false;
|
||
row.style.opacity = enabled ? '1' : '.58';
|
||
row.querySelectorAll('.notification-threshold-input, .notification-channel-input').forEach(input => {
|
||
input.disabled = !enabled;
|
||
});
|
||
}
|
||
|
||
function syncAllNotificationRows() {
|
||
container.querySelectorAll('.notification-rule-row').forEach(syncNotificationRow);
|
||
}
|
||
|
||
function persistApiKeyRecords(records) {
|
||
settings = normalizeSettings({ ...settings, apiKeyRecords: records });
|
||
saveSettings(settings);
|
||
window.dispatchEvent(new CustomEvent('settingsChanged', { detail: { section: 'apiKeys' } }));
|
||
}
|
||
|
||
function openCreateApiKeyModal() {
|
||
const modal = createModal({
|
||
title: '创建 API Key',
|
||
content: `
|
||
<div class="form-group">
|
||
<label class="form-label">密钥名称<span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="newApiKeyName" placeholder="例如:生产环境百炼密钥" maxlength="50">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Provider<span class="required">*</span></label>
|
||
<select class="form-control" id="newApiKeyProvider">
|
||
${SETTINGS_API_KEY_PROVIDERS.map(provider => `<option value="${provider.id}">${escapeSettingsHtml(provider.provider)}</option>`).join('')}
|
||
<option value="custom">其他 Provider</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="customApiProviderGroup" style="display:none">
|
||
<label class="form-label">Provider 名称<span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="customApiProviderName" placeholder="请输入 Provider 名称">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">API Key<span class="required">*</span></label>
|
||
<div class="input-wrapper">
|
||
<input type="password" class="form-control" id="newApiKeyValue" placeholder="请输入 API Key" autocomplete="new-password">
|
||
<button type="button" class="password-toggle" id="newApiKeyVisibility" title="显示密钥"><i data-lucide="eye-off"></i></button>
|
||
</div>
|
||
</div>
|
||
<div class="api-note">
|
||
<i data-lucide="info"></i>
|
||
<span>保存后该密钥会直接显示在列表中,并出现在智能体运行 API Key 下拉选择里。</span>
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
const name = modal.overlay.querySelector('#newApiKeyName')?.value.trim();
|
||
const providerSelection = modal.overlay.querySelector('#newApiKeyProvider')?.value || '';
|
||
const customProvider = modal.overlay.querySelector('#customApiProviderName')?.value.trim();
|
||
const value = modal.overlay.querySelector('#newApiKeyValue')?.value.trim();
|
||
if (!name || !providerSelection || !value) {
|
||
Toast.warning('请填写密钥名称、Provider 和 API Key');
|
||
return false;
|
||
}
|
||
if (providerSelection === 'custom' && !customProvider) {
|
||
Toast.warning('请填写 Provider 名称');
|
||
return false;
|
||
}
|
||
if (value.length < 8) {
|
||
Toast.warning('API Key 长度不能少于 8 位');
|
||
return false;
|
||
}
|
||
if (settings.apiKeyRecords.some(record => record.name === name)) {
|
||
Toast.warning('密钥名称已存在');
|
||
return false;
|
||
}
|
||
|
||
const providerConfig = SETTINGS_API_KEY_PROVIDERS.find(provider => provider.id === providerSelection);
|
||
const record = {
|
||
id: `key-${Date.now()}`,
|
||
name,
|
||
providerId: providerConfig?.id || `custom-${Date.now()}`,
|
||
provider: providerConfig?.provider || customProvider,
|
||
value,
|
||
status: 'configured',
|
||
createdAt: getSettingsDateText(),
|
||
lastTestedAt: '',
|
||
};
|
||
persistApiKeyRecords([record, ...settings.apiKeyRecords]);
|
||
try {
|
||
await callSettingsApi('createApiKey', {
|
||
name: record.name,
|
||
provider: record.provider,
|
||
providerId: record.providerId,
|
||
apiKey: record.value,
|
||
});
|
||
} catch (error) {
|
||
console.warn('Mock create API key failed:', error);
|
||
}
|
||
Toast.success('API Key 已创建并保存');
|
||
renderPage();
|
||
},
|
||
confirmText: '创建并保存',
|
||
});
|
||
|
||
const providerSelect = modal.overlay.querySelector('#newApiKeyProvider');
|
||
const customGroup = modal.overlay.querySelector('#customApiProviderGroup');
|
||
providerSelect?.addEventListener('change', () => {
|
||
if (customGroup) customGroup.style.display = providerSelect.value === 'custom' ? '' : 'none';
|
||
});
|
||
modal.overlay.querySelector('#newApiKeyVisibility')?.addEventListener('click', event => {
|
||
const input = modal.overlay.querySelector('#newApiKeyValue');
|
||
if (!input) return;
|
||
const show = input.type === 'password';
|
||
input.type = show ? 'text' : 'password';
|
||
event.currentTarget.innerHTML = `<i data-lucide="${show ? 'eye' : 'eye-off'}"></i>`;
|
||
lucide.createIcons({ elements: [event.currentTarget] });
|
||
});
|
||
}
|
||
|
||
async function copySavedApiKey(keyId) {
|
||
const record = settings.apiKeyRecords.find(item => item.id === keyId);
|
||
if (!record) return;
|
||
try {
|
||
await navigator.clipboard.writeText(record.value);
|
||
Toast.success('API Key 已复制');
|
||
} catch (error) {
|
||
Toast.warning('当前浏览器不允许自动复制');
|
||
}
|
||
}
|
||
|
||
async function testSavedApiKey(keyId, button) {
|
||
const record = settings.apiKeyRecords.find(item => item.id === keyId);
|
||
if (!record) return;
|
||
button.disabled = true;
|
||
button.innerHTML = '<i data-lucide="loader-2" class="spin"></i> 测试中';
|
||
lucide.createIcons({ elements: [button] });
|
||
await new Promise(resolve => setTimeout(resolve, 650));
|
||
const ok = record.value.length >= 8 && !/(invalid|error|fail)/i.test(record.value);
|
||
const nextRecords = settings.apiKeyRecords.map(item => item.id === keyId
|
||
? { ...item, status: ok ? 'available' : 'failed', lastTestedAt: getSettingsDateText() }
|
||
: item);
|
||
persistApiKeyRecords(nextRecords);
|
||
Toast[ok ? 'success' : 'error'](ok ? 'API Key 连接可用' : 'API Key 连接失败');
|
||
renderPage();
|
||
}
|
||
|
||
function deleteSavedApiKey(keyId) {
|
||
const record = settings.apiKeyRecords.find(item => item.id === keyId);
|
||
if (!record) return;
|
||
createModal({
|
||
title: '删除 API Key',
|
||
content: `
|
||
<div style="text-align:center;padding:18px 0">
|
||
<i data-lucide="alert-triangle" style="width:44px;height:44px;color:var(--warning);margin-bottom:12px"></i>
|
||
<p style="font-size:15px;font-weight:600;margin-bottom:8px">确认删除「${escapeSettingsHtml(record.name)}」?</p>
|
||
<p style="font-size:13px;color:var(--text-muted)">删除后智能体运行页将无法再选择该密钥。</p>
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
persistApiKeyRecords(settings.apiKeyRecords.filter(item => item.id !== keyId));
|
||
try {
|
||
await callSettingsApi('revokeApiKey', keyId);
|
||
} catch (error) {
|
||
console.warn('Mock revoke API key failed:', error);
|
||
}
|
||
Toast.success('API Key 已删除');
|
||
renderPage();
|
||
},
|
||
confirmText: '确认删除',
|
||
});
|
||
lucide.createIcons();
|
||
}
|
||
|
||
function savePlatformSettingsFromForm() {
|
||
const platformName = container.querySelector('#platformName')?.value.trim();
|
||
if (!platformName) {
|
||
Toast.warning('请填写平台名称');
|
||
return;
|
||
}
|
||
settings = normalizeSettings({
|
||
...settings,
|
||
platformName,
|
||
refreshInterval: Number(container.querySelector('#refreshInterval')?.value || 300),
|
||
defaultModel: container.querySelector('#defaultModel')?.value || 'qwen-turbo',
|
||
enableDebug: Boolean(container.querySelector('#enableDebug')?.checked),
|
||
});
|
||
saveSettings(settings);
|
||
window.dispatchEvent(new CustomEvent('settingsChanged', { detail: { section: 'platform' } }));
|
||
Toast.success('平台配置已保存');
|
||
renderPage();
|
||
}
|
||
|
||
function resetPlatformSettings() {
|
||
createModal({
|
||
title: '恢复平台默认配置',
|
||
content: '<p style="font-size:14px">确认恢复平台名称、刷新间隔、默认模型和调试模式的默认值?</p>',
|
||
onConfirm: () => {
|
||
const defaults = getDefaultSettings();
|
||
settings = normalizeSettings({
|
||
...settings,
|
||
platformName: defaults.platformName,
|
||
refreshInterval: defaults.refreshInterval,
|
||
defaultModel: defaults.defaultModel,
|
||
enableDebug: defaults.enableDebug,
|
||
});
|
||
saveSettings(settings);
|
||
Toast.success('平台配置已恢复默认');
|
||
renderPage();
|
||
},
|
||
confirmText: '确认恢复',
|
||
});
|
||
}
|
||
|
||
async function runLoadingButton(button, loadingText, task) {
|
||
const original = button.innerHTML;
|
||
button.disabled = true;
|
||
button.innerHTML = `<i data-lucide="loader-2" class="spin"></i> ${loadingText}`;
|
||
lucide.createIcons({ elements: [button] });
|
||
try {
|
||
await task();
|
||
} finally {
|
||
if (document.body.contains(button)) {
|
||
button.disabled = false;
|
||
button.innerHTML = original;
|
||
lucide.createIcons({ elements: [button] });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function testNotificationEmail(button) {
|
||
const emails = container.querySelector('#notificationEmails')?.value.trim() || '';
|
||
if (!isValidEmailList(emails)) {
|
||
Toast.warning('请输入有效的通知邮箱,多个邮箱可用逗号分隔');
|
||
return;
|
||
}
|
||
await runLoadingButton(button, '发送中...', async () => {
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
Toast.success('测试邮件已发送');
|
||
});
|
||
}
|
||
|
||
async function testNotificationWebhook(button) {
|
||
const url = container.querySelector('#notificationWebhookUrl')?.value.trim() || '';
|
||
if (!isValidWebhookUrl(url)) {
|
||
Toast.warning('请输入以 http:// 或 https:// 开头的 Webhook URL');
|
||
return;
|
||
}
|
||
await runLoadingButton(button, '测试中...', async () => {
|
||
try {
|
||
await callSettingsApi('testWebhook', url);
|
||
} catch (error) {
|
||
Toast.warning('后端测试接口暂不可用,已完成前端格式校验');
|
||
return;
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 400));
|
||
Toast.success('Webhook 测试请求已发送');
|
||
});
|
||
}
|
||
|
||
function collectNotificationForm() {
|
||
const rules = {};
|
||
let error = '';
|
||
getNotificationCatalog().forEach(item => {
|
||
const key = getNotificationInputKey(item.type);
|
||
const enabled = Boolean(container.querySelector(`#notif_${key}_enabled`)?.checked);
|
||
const channels = ['in_app'];
|
||
if (container.querySelector(`#notif_${key}_channel_email`)?.checked) channels.push('email');
|
||
if (container.querySelector(`#notif_${key}_channel_webhook`)?.checked) channels.push('webhook');
|
||
let threshold;
|
||
if (item.defaultThreshold !== undefined) {
|
||
threshold = Number(container.querySelector(`#notif_${key}_threshold`)?.value);
|
||
if (enabled && (!Number.isFinite(threshold) || threshold < 1 || (item.thresholdUnit === '%' && threshold > 100))) {
|
||
error = `${item.name} 的阈值设置不正确`;
|
||
}
|
||
}
|
||
rules[item.type] = {
|
||
enabled,
|
||
threshold,
|
||
thresholdUnit: item.thresholdUnit || undefined,
|
||
thresholdLabel: item.thresholdLabel || undefined,
|
||
window: item.window || undefined,
|
||
cooldown: item.cooldown || undefined,
|
||
channels,
|
||
};
|
||
});
|
||
return { rules, error };
|
||
}
|
||
|
||
async function saveNotificationSettingsFromForm() {
|
||
const emails = container.querySelector('#notificationEmails')?.value.trim() || '';
|
||
const webhookUrl = container.querySelector('#notificationWebhookUrl')?.value.trim() || '';
|
||
const { rules, error } = collectNotificationForm();
|
||
if (error) {
|
||
Toast.warning(error);
|
||
return;
|
||
}
|
||
const enabledRules = Object.values(rules).filter(rule => rule.enabled);
|
||
const needsEmail = enabledRules.some(rule => rule.channels.includes('email'));
|
||
const needsWebhook = enabledRules.some(rule => rule.channels.includes('webhook'));
|
||
if (needsEmail && !isValidEmailList(emails)) {
|
||
Toast.warning('已有规则启用邮件通知,请先填写有效通知邮箱');
|
||
return;
|
||
}
|
||
if (needsWebhook && !isValidWebhookUrl(webhookUrl)) {
|
||
Toast.warning('已有规则启用 Webhook,请先填写有效 Webhook URL');
|
||
return;
|
||
}
|
||
|
||
settings.notificationRules = rules;
|
||
settings.notificationDelivery = { emails, webhookUrl };
|
||
settings.notifyBadcase = rules['ai.badcase_rate_exceeded']?.enabled !== false;
|
||
settings.badcaseThreshold = rules['ai.badcase_rate_exceeded']?.threshold || 5;
|
||
settings.notifyOffline = rules['device.offline_rate_exceeded']?.enabled !== false;
|
||
settings.notifyOTA = rules['ota.task_failed']?.enabled !== false;
|
||
saveSettings(settings);
|
||
|
||
try {
|
||
await callSettingsApi('updateNotifications', {
|
||
rules,
|
||
delivery: settings.notificationDelivery,
|
||
tenantScope: window.__getTenantScopeParams ? window.__getTenantScopeParams() : {},
|
||
});
|
||
} catch (error) {
|
||
Toast.warning('通知设置已保存在前端,后端接口接入后会自动同步');
|
||
renderPage();
|
||
return;
|
||
}
|
||
window.__refreshNotifications?.();
|
||
Toast.success('通知设置已保存');
|
||
renderPage();
|
||
}
|
||
|
||
function resetNotificationSettings() {
|
||
createModal({
|
||
title: '恢复默认通知设置',
|
||
content: '<p style="font-size:14px">确认恢复全部通知规则阈值和默认通知渠道?通知邮箱和 Webhook 地址会保留。</p>',
|
||
onConfirm: () => {
|
||
settings.notificationRules = getDefaultNotificationRules({});
|
||
saveSettings(settings);
|
||
Toast.success('通知规则已恢复默认');
|
||
renderPage();
|
||
},
|
||
confirmText: '确认恢复',
|
||
});
|
||
}
|
||
|
||
renderPage();
|
||
});
|