From 9193d64d2a768b0c963c04596935ece11cf9dc8e Mon Sep 17 00:00:00 2001 From: Edric Li Date: Sun, 27 Jul 2025 23:20:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20OEM=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=8A=9F=E8=83=BD=E5=B9=B6=E7=BB=9F=E4=B8=80=20API=20?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=A1=B5=E9=9D=A2=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 OEM 设置管理功能,支持自定义网站名称和图标 - 支持图标文件上传和 Base64 编码存储 - 实现动态更新网站标题和 favicon - 统一 API 统计页面与管理页面的样式和布局 - 修复文本颜色显示问题,提升可读性 - 优化错误处理和默认值回退机制 - 移除测试文件和冗余代码 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/admin.js | 87 ++++++++++++++++ web/admin/app.js | 214 +++++++++++++++++++++++++++++++++++++++- web/admin/index.html | 156 +++++++++++++++++++++++++++-- web/apiStats/app.js | 66 ++++++++++++- web/apiStats/index.html | 101 ++++++++++++++----- 5 files changed, 587 insertions(+), 37 deletions(-) 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 @@
-
- +
+ Logo +
-

Claude Relay Service

+

{{ oemSettings.siteName || 'Claude Relay Service' }}

管理后台

@@ -92,12 +97,17 @@
-
- +
+ Logo +
-

Claude Relay Service

+

{{ oemSettings.siteName || 'Claude Relay Service' }}

v{{ versionInfo.current || '...' }} @@ -202,7 +212,7 @@
-
+
+ + +
+
+
+
+

其他设置

+

自定义网站名称和图标

+
+
+ +
+
+

正在加载设置...

+
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
网站名称
+
品牌标识
+
+
+
+ +

将显示在浏览器标题和页面头部

+
+
+
+ +
+
+
网站图标
+
Favicon
+
+
+
+
+ +
+ 图标预览 + 当前图标 + +
+ +
+ + + 支持 .ico, .png, .jpg, .svg 格式,最大 350KB +
+
+
+
+
+ + + +
+ +
+ + 最后更新:{{ formatDateTime(oemSettings.updatedAt) }} +
+
+
+
+
diff --git a/web/apiStats/app.js b/web/apiStats/app.js index b61de143..4d982c97 100644 --- a/web/apiStats/app.js +++ b/web/apiStats/app.js @@ -25,7 +25,14 @@ const app = createApp({ // 分时间段的统计数据 dailyStats: null, - monthlyStats: null + monthlyStats: null, + + // OEM设置 + oemSettings: { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '' + } }; }, @@ -375,6 +382,60 @@ const app = createApp({ this.statsPeriod = 'daily'; // 重置为默认值 }, + // 加载OEM设置 + async loadOemSettings() { + try { + const response = await fetch('/admin/oem-settings', { + headers: { + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const result = await response.json(); + if (result && result.success && result.data) { + this.oemSettings = { ...this.oemSettings, ...result.data }; + + // 应用设置到页面 + this.applyOemSettings(); + } + } + } catch (error) { + console.error('Error loading OEM settings:', error); + // 静默失败,使用默认值 + } + }, + + // 应用OEM设置 + applyOemSettings() { + // 更新网站标题 + document.title = `API Key 统计 - ${this.oemSettings.siteName}`; + + // 应用网站图标 + 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); + } + }, + // 🔄 刷新数据 async refreshData() { if (this.statsData && this.apiKey) { @@ -475,6 +536,9 @@ const app = createApp({ // 页面加载完成后的初始化 console.log('User Stats Page loaded'); + // 加载OEM设置 + this.loadOemSettings(); + // 检查 URL 参数是否有预填的 API Key(用于开发测试) const urlParams = new URLSearchParams(window.location.search); const presetApiKey = urlParams.get('apiKey'); diff --git a/web/apiStats/index.html b/web/apiStats/index.html index c238effc..0030289a 100644 --- a/web/apiStats/index.html +++ b/web/apiStats/index.html @@ -3,13 +3,56 @@ - Claude Relay Service - API Key 统计 + API Key 统计 + + @@ -22,41 +65,45 @@ - -
+ +
-
-
- +
+ Logo +
-

Claude Relay Service

+

{{ oemSettings.siteName || 'Claude Relay Service' }}

-

API Key 使用统计

+

API Key 使用统计

- - -
+ + +

使用统计查询

-

查询您的 API Key 使用情况和统计数据

+

查询您的 API Key 使用情况和统计数据

@@ -100,18 +147,18 @@
- -
-
- - {{ error }} -
+ +
+
+ + {{ error }}
+
- -
- -
+ +
+ +
@@ -200,19 +247,19 @@
-
{{ formatNumber(currentPeriodData.requests) }}
+
{{ formatNumber(currentPeriodData.requests) }}
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
-
{{ formatNumber(currentPeriodData.allTokens) }}
+
{{ formatNumber(currentPeriodData.allTokens) }}
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
-
{{ currentPeriodData.formattedCost || '$0.000000' }}
+
{{ currentPeriodData.formattedCost || '$0.000000' }}
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
-
{{ formatNumber(currentPeriodData.inputTokens) }}
+
{{ formatNumber(currentPeriodData.inputTokens) }}
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token