Files
TalkingQ_Dashboard/scripts/settings.js
“yuanmengy” 69785c214f first commit
2026-06-10 17:31:01 +08:00

911 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();
});