diff --git a/src/routes/admin.js b/src/routes/admin.js index 53bb13f4..8c55fa87 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2227,4 +2227,91 @@ function compareVersions(current, latest) { return currentV.patch - latestV.patch; } +// 🎨 OEM设置管理 + +// 获取OEM设置(公开接口,用于显示) +router.get('/oem-settings', async (req, res) => { + try { + const client = redis.getClient(); + const oemSettings = await client.get('oem:settings'); + + // 默认设置 + const defaultSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', // Base64编码的图标数据 + updatedAt: new Date().toISOString() + }; + + let settings = defaultSettings; + if (oemSettings) { + try { + settings = { ...defaultSettings, ...JSON.parse(oemSettings) }; + } catch (err) { + logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message); + } + } + + res.json({ + success: true, + data: settings + }); + } catch (error) { + logger.error('❌ Failed to get OEM settings:', error); + res.status(500).json({ error: 'Failed to get OEM settings', message: error.message }); + } +}); + +// 更新OEM设置 +router.put('/oem-settings', authenticateAdmin, async (req, res) => { + try { + const { siteName, siteIcon, siteIconData } = req.body; + + // 验证输入 + if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { + return res.status(400).json({ error: 'Site name is required' }); + } + + if (siteName.length > 100) { + return res.status(400).json({ error: 'Site name must be less than 100 characters' }); + } + + // 验证图标数据大小(如果是base64) + if (siteIconData && siteIconData.length > 500000) { // 约375KB + return res.status(400).json({ error: 'Icon file must be less than 350KB' }); + } + + // 验证图标URL(如果提供) + if (siteIcon && !siteIconData) { + // 简单验证URL格式 + try { + new URL(siteIcon); + } catch (err) { + return res.status(400).json({ error: 'Invalid icon URL format' }); + } + } + + const settings = { + siteName: siteName.trim(), + siteIcon: (siteIcon || '').trim(), + siteIconData: (siteIconData || '').trim(), // Base64数据 + updatedAt: new Date().toISOString() + }; + + const client = redis.getClient(); + await client.set('oem:settings', JSON.stringify(settings)); + + logger.info(`✅ OEM settings updated: ${siteName}`); + + res.json({ + success: true, + message: 'OEM settings updated successfully', + data: settings + }); + } catch (error) { + logger.error('❌ Failed to update OEM settings:', error); + res.status(500).json({ error: 'Failed to update OEM settings', message: error.message }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/web/admin/app.js b/web/admin/app.js index 76ae9994..ff580da9 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -1,3 +1,4 @@ +/* global Vue, Chart, ElementPlus, ElementPlusLocaleZhCn, FileReader, document, localStorage, location, navigator, window */ const { createApp } = Vue; const app = createApp({ @@ -24,7 +25,8 @@ const app = createApp({ { key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' }, { key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' }, { key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' }, - { key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' } + { key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' }, + { key: 'settings', name: '其他设置', icon: 'fas fa-cogs' } ], // 教程系统选择 @@ -298,7 +300,17 @@ const app = createApp({ showReleaseNotes: false, // 是否显示发布说明 autoCheckInterval: null, // 自动检查定时器 noUpdateMessage: false // 显示"已是最新版"提醒 - } + }, + + // OEM设置相关 + oemSettings: { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', // Base64图标数据 + updatedAt: null + }, + oemSettingsLoading: false, + oemSettingsSaving: false } }, @@ -445,6 +457,7 @@ const app = createApp({ // 根据当前活跃标签页加载数据 this.loadCurrentTabData(); }); + // 如果在仪表盘,等待Chart.js加载后初始化图表 if (this.activeTab === 'dashboard') { this.waitForChartJS().then(() => { @@ -456,6 +469,9 @@ const app = createApp({ } else { console.log('No auth token found, user needs to login'); } + + // 始终加载OEM设置,无论登录状态 + this.loadOemSettings(); }, beforeUnmount() { @@ -757,6 +773,20 @@ const app = createApp({ minute: '2-digit' }); }, + + // 格式化日期时间 + formatDateTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }, // 检查 API Key 是否已过期 isApiKeyExpired(expiresAt) { @@ -1488,6 +1518,12 @@ const app = createApp({ case 'tutorial': // 教程页面不需要加载数据 break; + case 'settings': + // OEM 设置已在 mounted 时加载,避免重复加载 + if (!this.oemSettings.siteName && !this.oemSettings.siteIcon && !this.oemSettings.siteIconData) { + this.loadOemSettings(); + } + break; } }, @@ -3965,6 +4001,180 @@ const app = createApp({ }); this.showToast('已重置筛选条件并刷新数据', 'info', '重置成功'); + }, + + // OEM设置相关方法 + async loadOemSettings() { + this.oemSettingsLoading = true; + try { + const result = await this.apiRequest('/admin/oem-settings'); + if (result && result.success) { + this.oemSettings = { ...this.oemSettings, ...result.data }; + + // 应用设置到页面 + this.applyOemSettings(); + } else { + // 如果请求失败但不是因为认证问题,使用默认值 + console.warn('Failed to load OEM settings, using defaults'); + this.applyOemSettings(); + } + } catch (error) { + console.error('Error loading OEM settings:', error); + // 加载失败时也应用默认值,确保页面正常显示 + this.applyOemSettings(); + } finally { + this.oemSettingsLoading = false; + } + }, + + async saveOemSettings() { + // 验证输入 + if (!this.oemSettings.siteName || this.oemSettings.siteName.trim() === '') { + this.showToast('网站名称不能为空', 'error', '验证失败'); + return; + } + + if (this.oemSettings.siteName.length > 100) { + this.showToast('网站名称不能超过100个字符', 'error', '验证失败'); + return; + } + + this.oemSettingsSaving = true; + try { + const result = await this.apiRequest('/admin/oem-settings', { + method: 'PUT', + body: JSON.stringify({ + siteName: this.oemSettings.siteName.trim(), + siteIcon: this.oemSettings.siteIcon.trim(), + siteIconData: this.oemSettings.siteIconData.trim() + }) + }); + + if (result && result.success) { + this.oemSettings = { ...this.oemSettings, ...result.data }; + this.showToast('OEM设置保存成功', 'success', '保存成功'); + + // 应用设置到页面 + this.applyOemSettings(); + } else { + this.showToast(result?.message || '保存失败', 'error', '保存失败'); + } + } catch (error) { + console.error('Error saving OEM settings:', error); + this.showToast('保存OEM设置失败', 'error', '保存失败'); + } finally { + this.oemSettingsSaving = false; + } + }, + + applyOemSettings() { + // 更新网站标题 + document.title = `${this.oemSettings.siteName} - 管理后台`; + + // 更新页面中的所有网站名称 + const titleElements = document.querySelectorAll('.header-title'); + titleElements.forEach(el => { + el.textContent = this.oemSettings.siteName; + }); + + // 应用自定义CSS + this.applyCustomCss(); + + // 应用网站图标 + this.applyFavicon(); + }, + + applyCustomCss() { + // 移除之前的自定义CSS + const existingStyle = document.getElementById('custom-oem-css'); + if (existingStyle) { + existingStyle.remove(); + } + }, + + applyFavicon() { + const iconData = this.oemSettings.siteIconData || this.oemSettings.siteIcon; + if (iconData && iconData.trim()) { + // 移除现有的favicon + const existingFavicons = document.querySelectorAll('link[rel*="icon"]'); + existingFavicons.forEach(link => link.remove()); + + // 添加新的favicon + const link = document.createElement('link'); + link.rel = 'icon'; + + // 根据数据类型设置适当的type + if (iconData.startsWith('data:')) { + // Base64数据 + link.href = iconData; + } else { + // URL + link.type = 'image/x-icon'; + link.href = iconData; + } + + document.head.appendChild(link); + } + }, + + resetOemSettings() { + this.oemSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + updatedAt: null + }; + }, + + // 处理图标文件上传 + async handleIconUpload(event) { + const file = event.target.files[0]; + if (!file) return; + + // 验证文件大小 + if (file.size > 350 * 1024) { // 350KB + this.showToast('图标文件大小不能超过350KB', 'error', '文件太大'); + return; + } + + // 验证文件类型 + const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/svg+xml']; + if (!allowedTypes.includes(file.type) && !file.name.endsWith('.ico')) { + this.showToast('请选择有效的图标文件格式 (.ico, .png, .jpg, .svg)', 'error', '格式错误'); + return; + } + + try { + // 读取文件为Base64 + const reader = new FileReader(); + reader.onload = (e) => { + this.oemSettings.siteIconData = e.target.result; + this.oemSettings.siteIcon = ''; // 清空URL + this.showToast('图标上传成功', 'success', '上传成功'); + }; + reader.onerror = () => { + this.showToast('图标文件读取失败', 'error', '读取失败'); + }; + reader.readAsDataURL(file); + } catch (error) { + console.error('Icon upload error:', error); + this.showToast('图标上传过程中出现错误', 'error', '上传失败'); + } + }, + + // 移除图标 + removeIcon() { + this.oemSettings.siteIcon = ''; + this.oemSettings.siteIconData = ''; + if (this.$refs.iconFileInput) { + this.$refs.iconFileInput.value = ''; + } + }, + + // 处理图标加载错误 + handleIconError(event) { + console.error('Icon load error'); + event.target.style.display = 'none'; } } }); diff --git a/web/admin/index.html b/web/admin/index.html index 046a37cb..7a4a6cd4 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -39,10 +39,15 @@
管理后台
自定义网站名称和图标
+正在加载设置...
+|
+
+
+
+
+
+
+
+ 网站名称
+ 品牌标识
+ |
+
+
+ 将显示在浏览器标题和页面头部 + |
+
|
+
+
+
+
+
+
+
+ 网站图标
+ Favicon
+ |
+
+
+
+
+
+
+
+
+
+
+ 支持 .ico, .png, .jpg, .svg 格式,最大 350KB
+
+ |
+
|
+
+
+
+
+
+
+
+
+
+
+ 最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
+
+ |
+ |
使用统计查询
+API Key 使用统计
查询您的 API Key 使用情况和统计数据
+查询您的 API Key 使用情况和统计数据