Merge branch 'main' into main

This commit is contained in:
Wesley Liddick
2025-07-28 09:01:23 +08:00
committed by GitHub
5 changed files with 589 additions and 39 deletions

View File

@@ -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;

View File

@@ -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() {
@@ -757,6 +773,20 @@ const app = createApp({
minute: '2-digit' 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 是否已过期 // 检查 API Key 是否已过期
isApiKeyExpired(expiresAt) { isApiKeyExpired(expiresAt) {
@@ -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';
} }
} }
}); });

View File

@@ -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>

View File

@@ -27,7 +27,14 @@ const app = createApp({
// 分时间段的统计数据 // 分时间段的统计数据
dailyStats: null, dailyStats: null,
monthlyStats: null monthlyStats: null,
// OEM设置
oemSettings: {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: ''
}
}; };
}, },
@@ -407,6 +414,60 @@ const app = createApp({
this.apiId = null; this.apiId = null;
}, },
// 加载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) {
@@ -566,8 +627,11 @@ const app = createApp({
mounted() { mounted() {
// 页面加载完成后的初始化 // 页面加载完成后的初始化
console.log('User Stats Page loaded'); console.log('User Stats Page loaded');
// 加载OEM设置
this.loadOemSettings();
// 检查 URL 参数 // 检查 URL 参数是否有预填的 API Key用于开发测试
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const presetApiId = urlParams.get('apiId'); const presetApiId = urlParams.get('apiId');
const presetApiKey = urlParams.get('apiKey'); const presetApiKey = urlParams.get('apiKey');

View File

@@ -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,41 +65,45 @@
<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-gray-800 leading-tight" style="text-shadow: 0 1px 2px rgba(0,0,0,0.1);">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-gray-600 text-sm leading-tight font-medium">使用统计查询</p> <p class="text-gray-600 text-sm leading-tight mt-0.5">API Key 使用统计</p>
</div> </div>
</div> </div>
<div v-if="showAdminButton" 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>
</div> </div>
</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>