mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: 添加 OEM 设置功能并统一 API 统计页面样式
- 添加 OEM 设置管理功能,支持自定义网站名称和图标 - 支持图标文件上传和 Base64 编码存储 - 实现动态更新网站标题和 favicon - 统一 API 统计页面与管理页面的样式和布局 - 修复文本颜色显示问题,提升可读性 - 优化错误处理和默认值回退机制 - 移除测试文件和冗余代码 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2227,4 +2227,91 @@ function compareVersions(current, latest) {
|
|||||||
return currentV.patch - latestV.patch;
|
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;
|
module.exports = router;
|
||||||
214
web/admin/app.js
214
web/admin/app.js
@@ -1,3 +1,4 @@
|
|||||||
|
/* global Vue, Chart, ElementPlus, ElementPlusLocaleZhCn, FileReader, document, localStorage, location, navigator, window */
|
||||||
const { createApp } = Vue;
|
const { createApp } = Vue;
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
@@ -24,7 +25,8 @@ const app = createApp({
|
|||||||
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||||
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
||||||
{ key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
|
{ 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, // 是否显示发布说明
|
showReleaseNotes: false, // 是否显示发布说明
|
||||||
autoCheckInterval: null, // 自动检查定时器
|
autoCheckInterval: null, // 自动检查定时器
|
||||||
noUpdateMessage: false // 显示"已是最新版"提醒
|
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();
|
this.loadCurrentTabData();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果在仪表盘,等待Chart.js加载后初始化图表
|
// 如果在仪表盘,等待Chart.js加载后初始化图表
|
||||||
if (this.activeTab === 'dashboard') {
|
if (this.activeTab === 'dashboard') {
|
||||||
this.waitForChartJS().then(() => {
|
this.waitForChartJS().then(() => {
|
||||||
@@ -456,6 +469,9 @@ const app = createApp({
|
|||||||
} else {
|
} else {
|
||||||
console.log('No auth token found, user needs to login');
|
console.log('No auth token found, user needs to login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 始终加载OEM设置,无论登录状态
|
||||||
|
this.loadOemSettings();
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -758,6 +774,20 @@ const app = createApp({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
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 是否已过期
|
// 检查 API Key 是否已过期
|
||||||
isApiKeyExpired(expiresAt) {
|
isApiKeyExpired(expiresAt) {
|
||||||
if (!expiresAt) return false;
|
if (!expiresAt) return false;
|
||||||
@@ -1488,6 +1518,12 @@ const app = createApp({
|
|||||||
case 'tutorial':
|
case 'tutorial':
|
||||||
// 教程页面不需要加载数据
|
// 教程页面不需要加载数据
|
||||||
break;
|
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', '重置成功');
|
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,10 +39,15 @@
|
|||||||
<div v-if="!isLoggedIn" class="flex items-center justify-center min-h-screen p-6">
|
<div v-if="!isLoggedIn" class="flex items-center justify-center min-h-screen p-6">
|
||||||
<div class="glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl">
|
<div class="glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl">
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm">
|
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden">
|
||||||
<i class="fas fa-cloud text-3xl text-gray-700"></i>
|
<img v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
alt="Logo"
|
||||||
|
class="w-12 h-12 object-contain"
|
||||||
|
@error="(e) => e.target.style.display = 'none'">
|
||||||
|
<i v-else class="fas fa-cloud text-3xl text-gray-700"></i>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2 header-title">Claude Relay Service</h1>
|
<h1 class="text-3xl font-bold text-white mb-2 header-title">{{ oemSettings.siteName || 'Claude Relay Service' }}</h1>
|
||||||
<p class="text-white/80 text-lg">管理后台</p>
|
<p class="text-white/80 text-lg">管理后台</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,12 +97,17 @@
|
|||||||
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl" style="z-index: 10; position: relative;">
|
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl" style="z-index: 10; position: relative;">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0">
|
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
|
||||||
<i class="fas fa-cloud text-xl text-gray-700"></i>
|
<img v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
alt="Logo"
|
||||||
|
class="w-8 h-8 object-contain"
|
||||||
|
@error="(e) => e.target.style.display = 'none'">
|
||||||
|
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-center min-h-[48px]">
|
<div class="flex flex-col justify-center min-h-[48px]">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-white header-title leading-tight">Claude Relay Service</h1>
|
<h1 class="text-2xl font-bold text-white header-title leading-tight">{{ oemSettings.siteName || 'Claude Relay Service' }}</h1>
|
||||||
<!-- 版本信息 -->
|
<!-- 版本信息 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
|
<span class="text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
|
||||||
@@ -202,7 +212,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<div class="glass-strong rounded-3xl p-6 shadow-xl" style="z-index: 1;">
|
<div class="glass-strong rounded-3xl p-6 shadow-xl" style="z-index: 1; min-height: calc(100vh - 240px);">
|
||||||
<!-- 标签栏 -->
|
<!-- 标签栏 -->
|
||||||
<div class="flex flex-wrap gap-2 mb-8 bg-white/10 rounded-2xl p-2 backdrop-blur-sm">
|
<div class="flex flex-wrap gap-2 mb-8 bg-white/10 rounded-2xl p-2 backdrop-blur-sm">
|
||||||
<button
|
<button
|
||||||
@@ -2001,6 +2011,138 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 其他设置页面 -->
|
||||||
|
<div v-if="activeTab === 'settings'" class="tab-content">
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 mb-2">其他设置</h3>
|
||||||
|
<p class="text-gray-600">自定义网站名称和图标</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="oemSettingsLoading" class="text-center py-12">
|
||||||
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500">正在加载设置...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-container">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<tbody class="divide-y divide-gray-200/50">
|
||||||
|
<!-- 网站名称 -->
|
||||||
|
<tr class="table-row">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||||
|
<i class="fas fa-font text-white text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-gray-900">网站名称</div>
|
||||||
|
<div class="text-xs text-gray-500">品牌标识</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<input
|
||||||
|
v-model="oemSettings.siteName"
|
||||||
|
type="text"
|
||||||
|
class="form-input w-full max-w-md"
|
||||||
|
placeholder="TokenDance"
|
||||||
|
maxlength="100"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">将显示在浏览器标题和页面头部</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- 网站图标 -->
|
||||||
|
<tr class="table-row">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3">
|
||||||
|
<i class="fas fa-image text-white text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-gray-900">网站图标</div>
|
||||||
|
<div class="text-xs text-gray-500">Favicon</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- 图标预览 -->
|
||||||
|
<div v-if="oemSettings.siteIconData || oemSettings.siteIcon" class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<img
|
||||||
|
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
alt="图标预览"
|
||||||
|
class="w-8 h-8"
|
||||||
|
@error="handleIconError"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-600">当前图标</span>
|
||||||
|
<button
|
||||||
|
@click="removeIcon"
|
||||||
|
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash mr-1"></i>删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 文件上传 -->
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="iconFileInput"
|
||||||
|
@change="handleIconUpload"
|
||||||
|
accept=".ico,.png,.jpg,.jpeg,.svg"
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="$refs.iconFileInput.click()"
|
||||||
|
class="btn btn-success px-4 py-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-upload mr-2"></i>
|
||||||
|
上传图标
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式,最大 350KB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-6" colspan="2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="saveOemSettings"
|
||||||
|
:disabled="oemSettingsSaving"
|
||||||
|
class="btn btn-primary px-6 py-3"
|
||||||
|
>
|
||||||
|
<div v-if="oemSettingsSaving" class="loading-spinner mr-2"></div>
|
||||||
|
<i v-else class="fas fa-save mr-2"></i>
|
||||||
|
{{ oemSettingsSaving ? '保存中...' : '保存设置' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="resetOemSettings"
|
||||||
|
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
|
||||||
|
>
|
||||||
|
<i class="fas fa-undo mr-2"></i>
|
||||||
|
重置为默认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
|
||||||
|
<i class="fas fa-clock mr-1"></i>
|
||||||
|
最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ const app = createApp({
|
|||||||
|
|
||||||
// 分时间段的统计数据
|
// 分时间段的统计数据
|
||||||
dailyStats: null,
|
dailyStats: null,
|
||||||
monthlyStats: null
|
monthlyStats: null,
|
||||||
|
|
||||||
|
// OEM设置
|
||||||
|
oemSettings: {
|
||||||
|
siteName: 'Claude Relay Service',
|
||||||
|
siteIcon: '',
|
||||||
|
siteIconData: ''
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -375,6 +382,60 @@ const app = createApp({
|
|||||||
this.statsPeriod = 'daily'; // 重置为默认值
|
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() {
|
async refreshData() {
|
||||||
if (this.statsData && this.apiKey) {
|
if (this.statsData && this.apiKey) {
|
||||||
@@ -475,6 +536,9 @@ const app = createApp({
|
|||||||
// 页面加载完成后的初始化
|
// 页面加载完成后的初始化
|
||||||
console.log('User Stats Page loaded');
|
console.log('User Stats Page loaded');
|
||||||
|
|
||||||
|
// 加载OEM设置
|
||||||
|
this.loadOemSettings();
|
||||||
|
|
||||||
// 检查 URL 参数是否有预填的 API Key(用于开发测试)
|
// 检查 URL 参数是否有预填的 API Key(用于开发测试)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const presetApiKey = urlParams.get('apiKey');
|
const presetApiKey = urlParams.get('apiKey');
|
||||||
|
|||||||
@@ -3,13 +3,56 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Claude Relay Service - API Key 统计</title>
|
<title>API Key 统计</title>
|
||||||
|
|
||||||
<!-- 🎨 样式 -->
|
<!-- 🎨 样式 -->
|
||||||
<link rel="stylesheet" href="/apiStats/style.css">
|
<link rel="stylesheet" href="/apiStats/style.css">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[v-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调整间距使其与管理页面一致 */
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 与管理页面一致的按钮样式 */
|
||||||
|
.glass-button {
|
||||||
|
background: var(--glass-color, rgba(255, 255, 255, 0.1));
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调整卡片样式 */
|
||||||
|
.card {
|
||||||
|
background: var(--surface-color, rgba(255, 255, 255, 0.95));
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- 🔧 Vue 3 -->
|
<!-- 🔧 Vue 3 -->
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||||
|
|
||||||
@@ -22,25 +65,29 @@
|
|||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/timezone.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/timezone.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/utc.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.9/plugin/utc.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen text-white">
|
<body class="min-h-screen">
|
||||||
<div id="app">
|
<div id="app" v-cloak class="min-h-screen p-6">
|
||||||
<!-- 🎯 顶部导航 -->
|
<!-- 🎯 顶部导航 -->
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0">
|
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
|
||||||
<i class="fas fa-cloud text-xl text-gray-700"></i>
|
<img v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
alt="Logo"
|
||||||
|
class="w-8 h-8 object-contain"
|
||||||
|
@error="(e) => e.target.style.display = 'none'">
|
||||||
|
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-center min-h-[48px]">
|
<div class="flex flex-col justify-center min-h-[48px]">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-white header-title leading-tight">Claude Relay Service</h1>
|
<h1 class="text-2xl font-bold text-white header-title leading-tight">{{ oemSettings.siteName || 'Claude Relay Service' }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-white/80 text-sm leading-tight">API Key 使用统计</p>
|
<p class="text-gray-600 text-sm leading-tight mt-0.5">API Key 使用统计</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/web" class="glass-button rounded-xl px-4 py-2 text-white hover:bg-white/20 transition-colors flex items-center gap-2">
|
<a href="/web" class="glass-button rounded-xl px-4 py-2 text-gray-700 hover:bg-white/20 transition-colors flex items-center gap-2">
|
||||||
<i class="fas fa-cog text-sm"></i>
|
<i class="fas fa-cog text-sm"></i>
|
||||||
<span class="text-sm font-medium">管理后台</span>
|
<span class="text-sm font-medium">管理后台</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -48,15 +95,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🔑 API Key 输入区域 -->
|
<!-- 🔑 API Key 输入区域 -->
|
||||||
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||||
<!-- 📊 标题区域 -->
|
<!-- 📊 标题区域 -->
|
||||||
<div class="wide-card-title text-center mb-6">
|
<div class="wide-card-title text-center mb-6">
|
||||||
<h2 class="text-2xl font-bold mb-2">
|
<h2 class="text-2xl font-bold mb-2">
|
||||||
<i class="fas fa-chart-line mr-3"></i>
|
<i class="fas fa-chart-line mr-3"></i>
|
||||||
使用统计查询
|
使用统计查询
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-base">查询您的 API Key 使用情况和统计数据</p>
|
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🔍 输入区域 -->
|
<!-- 🔍 输入区域 -->
|
||||||
@@ -100,18 +147,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ❌ 错误提示 -->
|
<!-- ❌ 错误提示 -->
|
||||||
<div v-if="error" class="max-w-2xl mx-auto mb-8">
|
<div v-if="error" class="mb-8">
|
||||||
<div class="bg-red-500/20 border border-red-500/30 rounded-lg p-4 text-red-200">
|
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 📊 统计数据展示区域 -->
|
<!-- 📊 统计数据展示区域 -->
|
||||||
<div v-if="statsData" class="fade-in">
|
<div v-if="statsData" class="fade-in">
|
||||||
<!-- 主要内容卡片 -->
|
<!-- 主要内容卡片 -->
|
||||||
<div class="glass-strong rounded-3xl p-6">
|
<div class="glass-strong rounded-3xl p-6 shadow-xl">
|
||||||
<!-- 📅 时间范围选择器 -->
|
<!-- 📅 时间范围选择器 -->
|
||||||
<div class="mb-6 pb-6 border-b border-gray-200">
|
<div class="mb-6 pb-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||||
@@ -200,19 +247,19 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="stat-card text-center">
|
<div class="stat-card text-center">
|
||||||
<div class="text-2xl font-bold text-green-600">{{ formatNumber(currentPeriodData.requests) }}</div>
|
<div class="text-3xl font-bold text-green-600">{{ formatNumber(currentPeriodData.requests) }}</div>
|
||||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</div>
|
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card text-center">
|
<div class="stat-card text-center">
|
||||||
<div class="text-2xl font-bold text-blue-600">{{ formatNumber(currentPeriodData.allTokens) }}</div>
|
<div class="text-3xl font-bold text-blue-600">{{ formatNumber(currentPeriodData.allTokens) }}</div>
|
||||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数</div>
|
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card text-center">
|
<div class="stat-card text-center">
|
||||||
<div class="text-2xl font-bold text-purple-600">{{ currentPeriodData.formattedCost || '$0.000000' }}</div>
|
<div class="text-3xl font-bold text-purple-600">{{ currentPeriodData.formattedCost || '$0.000000' }}</div>
|
||||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</div>
|
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card text-center">
|
<div class="stat-card text-center">
|
||||||
<div class="text-2xl font-bold text-yellow-600">{{ formatNumber(currentPeriodData.inputTokens) }}</div>
|
<div class="text-3xl font-bold text-yellow-600">{{ formatNumber(currentPeriodData.inputTokens) }}</div>
|
||||||
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token</div>
|
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user