feat: 全新的Vue3管理后台(admin-spa)和路由重构

🎨 新增功能:
- 使用Vue3 + Vite构建的全新管理后台界面
- 支持Tab切换的API统计页面(统计查询/使用教程)
- 优雅的胶囊式Tab切换设计
- 同步了PR #106的会话窗口管理功能
- 完整的响应式设计和骨架屏加载状态

🔧 路由调整:
- 新版管理后台部署在 /admin-next/ 路径
- 将根路径 / 重定向到 /admin-next/api-stats
- 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由
- 将 /apiStats 页面路由重定向到新版,保留API端点

🗑️ 清理工作:
- 删除旧版 web/admin/ 静态文件
- 删除旧版 web/apiStats/ 静态文件
- 清理相关的文件服务代码

🐛 修复问题:
- 修复重定向循环问题
- 修复环境变量配置
- 修复路由404错误
- 优化构建配置

🚀 生成方式:使用 Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-29 12:40:51 +08:00
parent c98de2aca5
commit 414856f152
70 changed files with 18748 additions and 10314 deletions

View File

@@ -0,0 +1,334 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/config/api'
export const useAccountsStore = defineStore('accounts', () => {
// 状态
const claudeAccounts = ref([])
const geminiAccounts = ref([])
const loading = ref(false)
const error = ref(null)
const sortBy = ref('')
const sortOrder = ref('asc')
// Actions
// 获取Claude账户列表
const fetchClaudeAccounts = async () => {
loading.value = true
error.value = null
try {
const response = await apiClient.get('/admin/claude-accounts')
if (response.success) {
claudeAccounts.value = response.data || []
} else {
throw new Error(response.message || '获取Claude账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 获取Gemini账户列表
const fetchGeminiAccounts = async () => {
loading.value = true
error.value = null
try {
const response = await apiClient.get('/admin/gemini-accounts')
if (response.success) {
geminiAccounts.value = response.data || []
} else {
throw new Error(response.message || '获取Gemini账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 获取所有账户
const fetchAllAccounts = async () => {
loading.value = true
error.value = null
try {
await Promise.all([
fetchClaudeAccounts(),
fetchGeminiAccounts()
])
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 创建Claude账户
const createClaudeAccount = async (data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.post('/admin/claude-accounts', data)
if (response.success) {
await fetchClaudeAccounts()
return response.data
} else {
throw new Error(response.message || '创建Claude账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 创建Gemini账户
const createGeminiAccount = async (data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.post('/admin/gemini-accounts', data)
if (response.success) {
await fetchGeminiAccounts()
return response.data
} else {
throw new Error(response.message || '创建Gemini账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 更新Claude账户
const updateClaudeAccount = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.put(`/admin/claude-accounts/${id}`, data)
if (response.success) {
await fetchClaudeAccounts()
return response
} else {
throw new Error(response.message || '更新Claude账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 更新Gemini账户
const updateGeminiAccount = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.put(`/admin/gemini-accounts/${id}`, data)
if (response.success) {
await fetchGeminiAccounts()
return response
} else {
throw new Error(response.message || '更新Gemini账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 切换账户状态
const toggleAccount = async (platform, id) => {
loading.value = true
error.value = null
try {
const endpoint = platform === 'claude'
? `/admin/claude-accounts/${id}/toggle`
: `/admin/gemini-accounts/${id}/toggle`
const response = await apiClient.put(endpoint)
if (response.success) {
if (platform === 'claude') {
await fetchClaudeAccounts()
} else {
await fetchGeminiAccounts()
}
return response
} else {
throw new Error(response.message || '切换状态失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 删除账户
const deleteAccount = async (platform, id) => {
loading.value = true
error.value = null
try {
const endpoint = platform === 'claude'
? `/admin/claude-accounts/${id}`
: `/admin/gemini-accounts/${id}`
const response = await apiClient.delete(endpoint)
if (response.success) {
if (platform === 'claude') {
await fetchClaudeAccounts()
} else {
await fetchGeminiAccounts()
}
return response
} else {
throw new Error(response.message || '删除失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 刷新Claude Token
const refreshClaudeToken = async (id) => {
loading.value = true
error.value = null
try {
const response = await apiClient.post(`/admin/claude-accounts/${id}/refresh`)
if (response.success) {
await fetchClaudeAccounts()
return response
} else {
throw new Error(response.message || 'Token刷新失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 生成Claude OAuth URL
const generateClaudeAuthUrl = async (proxyConfig) => {
try {
const response = await apiClient.post('/admin/claude-accounts/generate-auth-url', proxyConfig)
if (response.success) {
return response.data.authUrl // 返回authUrl字符串而不是整个对象
} else {
throw new Error(response.message || '生成授权URL失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// 交换Claude OAuth Code
const exchangeClaudeCode = async (data) => {
try {
const response = await apiClient.post('/admin/claude-accounts/exchange-code', data)
if (response.success) {
return response.data
} else {
throw new Error(response.message || '交换授权码失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// 生成Gemini OAuth URL
const generateGeminiAuthUrl = async (proxyConfig) => {
try {
const response = await apiClient.post('/admin/gemini-accounts/generate-auth-url', proxyConfig)
if (response.success) {
return response.data.authUrl // 返回authUrl字符串而不是整个对象
} else {
throw new Error(response.message || '生成授权URL失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// 交换Gemini OAuth Code
const exchangeGeminiCode = async (data) => {
try {
const response = await apiClient.post('/admin/gemini-accounts/exchange-code', data)
if (response.success) {
return response.data
} else {
throw new Error(response.message || '交换授权码失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// 排序账户
const sortAccounts = (field) => {
if (sortBy.value === field) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = field
sortOrder.value = 'asc'
}
}
// 重置store
const reset = () => {
claudeAccounts.value = []
geminiAccounts.value = []
loading.value = false
error.value = null
sortBy.value = ''
sortOrder.value = 'asc'
}
return {
// State
claudeAccounts,
geminiAccounts,
loading,
error,
sortBy,
sortOrder,
// Actions
fetchClaudeAccounts,
fetchGeminiAccounts,
fetchAllAccounts,
createClaudeAccount,
createGeminiAccount,
updateClaudeAccount,
updateGeminiAccount,
toggleAccount,
deleteAccount,
refreshClaudeToken,
generateClaudeAuthUrl,
exchangeClaudeCode,
generateGeminiAuthUrl,
exchangeGeminiCode,
sortAccounts,
reset
}
})

View File

@@ -0,0 +1,192 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/config/api'
export const useApiKeysStore = defineStore('apiKeys', () => {
// 状态
const apiKeys = ref([])
const loading = ref(false)
const error = ref(null)
const statsTimeRange = ref('all')
const sortBy = ref('')
const sortOrder = ref('asc')
// Actions
// 获取API Keys列表
const fetchApiKeys = async () => {
loading.value = true
error.value = null
try {
const response = await apiClient.get('/admin/api-keys')
if (response.success) {
apiKeys.value = response.data || []
} else {
throw new Error(response.message || '获取API Keys失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 创建API Key
const createApiKey = async (data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.post('/admin/api-keys', data)
if (response.success) {
await fetchApiKeys()
return response.data
} else {
throw new Error(response.message || '创建API Key失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 更新API Key
const updateApiKey = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.put(`/admin/api-keys/${id}`, data)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '更新API Key失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 切换API Key状态
const toggleApiKey = async (id) => {
loading.value = true
error.value = null
try {
const response = await apiClient.put(`/admin/api-keys/${id}/toggle`)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '切换状态失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 续期API Key
const renewApiKey = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.put(`/admin/api-keys/${id}/renew`, data)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '续期失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 删除API Key
const deleteApiKey = async (id) => {
loading.value = true
error.value = null
try {
const response = await apiClient.delete(`/admin/api-keys/${id}`)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '删除失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 获取API Key统计
const fetchApiKeyStats = async (id, timeRange = 'all') => {
try {
const response = await apiClient.get(`/admin/api-keys/${id}/stats`, {
params: { timeRange }
})
if (response.success) {
return response.stats
} else {
throw new Error(response.message || '获取统计失败')
}
} catch (err) {
console.error('获取API Key统计失败:', err)
return null
}
}
// 排序API Keys
const sortApiKeys = (field) => {
if (sortBy.value === field) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = field
sortOrder.value = 'asc'
}
}
// 重置store
const reset = () => {
apiKeys.value = []
loading.value = false
error.value = null
statsTimeRange.value = 'all'
sortBy.value = ''
sortOrder.value = 'asc'
}
return {
// State
apiKeys,
loading,
error,
statsTimeRange,
sortBy,
sortOrder,
// Actions
fetchApiKeys,
createApiKey,
updateApiKey,
toggleApiKey,
renewApiKey,
deleteApiKey,
fetchApiKeyStats,
sortApiKeys,
reset
}
})

View File

@@ -0,0 +1,343 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiStatsClient } from '@/config/apiStats'
export const useApiStatsStore = defineStore('apistats', () => {
// 状态
const apiKey = ref('')
const apiId = ref(null)
const loading = ref(false)
const modelStatsLoading = ref(false)
const oemLoading = ref(true)
const error = ref('')
const statsPeriod = ref('daily')
const statsData = ref(null)
const modelStats = ref([])
const dailyStats = ref(null)
const monthlyStats = ref(null)
const oemSettings = ref({
siteName: '',
siteIcon: '',
siteIconData: ''
})
// 计算属性
const currentPeriodData = computed(() => {
const defaultData = {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
}
if (statsPeriod.value === 'daily') {
return dailyStats.value || defaultData
} else {
return monthlyStats.value || defaultData
}
})
const usagePercentages = computed(() => {
if (!statsData.value || !currentPeriodData.value) {
return {
tokenUsage: 0,
costUsage: 0,
requestUsage: 0
}
}
const current = currentPeriodData.value
const limits = statsData.value.limits
return {
tokenUsage: limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0,
costUsage: limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0,
requestUsage: limits.rateLimitRequests > 0 ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) : 0
}
})
// Actions
// 查询统计数据
async function queryStats() {
if (!apiKey.value.trim()) {
error.value = '请输入 API Key'
return
}
loading.value = true
error.value = ''
statsData.value = null
modelStats.value = []
apiId.value = null
try {
// 获取 API Key ID
const idResult = await apiStatsClient.getKeyId(apiKey.value)
if (idResult.success) {
apiId.value = idResult.data.id
// 使用 apiId 查询统计数据
const statsResult = await apiStatsClient.getUserStats(apiId.value)
if (statsResult.success) {
statsData.value = statsResult.data
// 同时加载今日和本月的统计数据
await loadAllPeriodStats()
// 清除错误信息
error.value = ''
// 更新 URL
updateURL()
} else {
throw new Error(statsResult.message || '查询失败')
}
} else {
throw new Error(idResult.message || '获取 API Key ID 失败')
}
} catch (err) {
console.error('Query stats error:', err)
error.value = err.message || '查询统计数据失败,请检查您的 API Key 是否正确'
statsData.value = null
modelStats.value = []
apiId.value = null
} finally {
loading.value = false
}
}
// 加载所有时间段的统计数据
async function loadAllPeriodStats() {
if (!apiId.value) return
// 并行加载今日和本月的数据
await Promise.all([
loadPeriodStats('daily'),
loadPeriodStats('monthly')
])
// 加载当前选择时间段的模型统计
await loadModelStats(statsPeriod.value)
}
// 加载指定时间段的统计数据
async function loadPeriodStats(period) {
try {
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
if (result.success) {
// 计算汇总数据
const modelData = result.data || []
const summary = {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
cost: 0,
formattedCost: '$0.000000'
}
modelData.forEach(model => {
summary.requests += model.requests || 0
summary.inputTokens += model.inputTokens || 0
summary.outputTokens += model.outputTokens || 0
summary.cacheCreateTokens += model.cacheCreateTokens || 0
summary.cacheReadTokens += model.cacheReadTokens || 0
summary.allTokens += model.allTokens || 0
summary.cost += model.costs?.total || 0
})
summary.formattedCost = formatCost(summary.cost)
// 存储到对应的时间段数据
if (period === 'daily') {
dailyStats.value = summary
} else {
monthlyStats.value = summary
}
} else {
console.warn(`Failed to load ${period} stats:`, result.message)
}
} catch (err) {
console.error(`Load ${period} stats error:`, err)
}
}
// 加载模型统计数据
async function loadModelStats(period = 'daily') {
if (!apiId.value) return
modelStatsLoading.value = true
try {
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
if (result.success) {
modelStats.value = result.data || []
} else {
throw new Error(result.message || '加载模型统计失败')
}
} catch (err) {
console.error('Load model stats error:', err)
modelStats.value = []
} finally {
modelStatsLoading.value = false
}
}
// 切换时间范围
async function switchPeriod(period) {
if (statsPeriod.value === period || modelStatsLoading.value) {
return
}
statsPeriod.value = period
// 如果对应时间段的数据还没有加载,则加载它
if ((period === 'daily' && !dailyStats.value) ||
(period === 'monthly' && !monthlyStats.value)) {
await loadPeriodStats(period)
}
// 加载对应的模型统计
await loadModelStats(period)
}
// 使用 apiId 直接加载数据
async function loadStatsWithApiId() {
if (!apiId.value) return
loading.value = true
error.value = ''
statsData.value = null
modelStats.value = []
try {
const result = await apiStatsClient.getUserStats(apiId.value)
if (result.success) {
statsData.value = result.data
// 同时加载今日和本月的统计数据
await loadAllPeriodStats()
// 清除错误信息
error.value = ''
} else {
throw new Error(result.message || '查询失败')
}
} catch (err) {
console.error('Load stats with apiId error:', err)
error.value = err.message || '查询统计数据失败'
statsData.value = null
modelStats.value = []
} finally {
loading.value = false
}
}
// 加载 OEM 设置
async function loadOemSettings() {
oemLoading.value = true
try {
const result = await apiStatsClient.getOemSettings()
if (result && result.success && result.data) {
oemSettings.value = { ...oemSettings.value, ...result.data }
}
} catch (err) {
console.error('Error loading OEM settings:', err)
// 失败时使用默认值
oemSettings.value = {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: ''
}
} finally {
oemLoading.value = false
}
}
// 工具函数
// 格式化费用
function formatCost(cost) {
if (typeof cost !== 'number' || cost === 0) {
return '$0.000000'
}
// 根据数值大小选择精度
if (cost >= 1) {
return '$' + cost.toFixed(2)
} else if (cost >= 0.01) {
return '$' + cost.toFixed(4)
} else {
return '$' + cost.toFixed(6)
}
}
// 更新 URL
function updateURL() {
if (apiId.value) {
const url = new URL(window.location)
url.searchParams.set('apiId', apiId.value)
window.history.pushState({}, '', url)
}
}
// 清除数据
function clearData() {
statsData.value = null
modelStats.value = []
dailyStats.value = null
monthlyStats.value = null
error.value = ''
statsPeriod.value = 'daily'
apiId.value = null
}
// 重置
function reset() {
apiKey.value = ''
clearData()
}
return {
// State
apiKey,
apiId,
loading,
modelStatsLoading,
oemLoading,
error,
statsPeriod,
statsData,
modelStats,
dailyStats,
monthlyStats,
oemSettings,
// Computed
currentPeriodData,
usagePercentages,
// Actions
queryStats,
loadAllPeriodStats,
loadPeriodStats,
loadModelStats,
switchPeriod,
loadStatsWithApiId,
loadOemSettings,
clearData,
reset
}
})

View File

@@ -0,0 +1,130 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import router from '@/router'
import { apiClient } from '@/config/api'
export const useAuthStore = defineStore('auth', () => {
// 状态
const isLoggedIn = ref(false)
const authToken = ref(localStorage.getItem('authToken') || '')
const username = ref('')
const loginError = ref('')
const loginLoading = ref(false)
const oemSettings = ref({
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
faviconData: ''
})
const oemLoading = ref(true)
// 计算属性
const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value)
const token = computed(() => authToken.value)
const user = computed(() => ({ username: username.value }))
// 方法
async function login(credentials) {
loginLoading.value = true
loginError.value = ''
try {
const result = await apiClient.post('/web/auth/login', credentials)
if (result.success) {
authToken.value = result.token
username.value = credentials.username
isLoggedIn.value = true
localStorage.setItem('authToken', result.token)
await router.push('/dashboard')
} else {
loginError.value = result.message || '登录失败'
}
} catch (error) {
loginError.value = error.message || '登录失败,请检查用户名和密码'
} finally {
loginLoading.value = false
}
}
function logout() {
isLoggedIn.value = false
authToken.value = ''
username.value = ''
localStorage.removeItem('authToken')
router.push('/login')
}
function checkAuth() {
if (authToken.value) {
isLoggedIn.value = true
// 验证token有效性
verifyToken()
}
}
async function verifyToken() {
try {
// 使用 dashboard 端点来验证 token
// 如果 token 无效,会抛出错误
const result = await apiClient.get('/admin/dashboard')
if (!result.success) {
logout()
}
} catch (error) {
// token 无效,需要重新登录
logout()
}
}
async function loadOemSettings() {
oemLoading.value = true
try {
const result = await apiClient.get('/admin/oem-settings')
if (result.success && result.data) {
oemSettings.value = { ...oemSettings.value, ...result.data }
// 设置favicon
if (result.data.faviconData) {
const link = document.querySelector("link[rel*='icon']") || document.createElement('link')
link.type = 'image/x-icon'
link.rel = 'shortcut icon'
link.href = result.data.faviconData
document.getElementsByTagName('head')[0].appendChild(link)
}
// 设置页面标题
if (result.data.siteName) {
document.title = `${result.data.siteName} - 管理后台`
}
}
} catch (error) {
console.error('加载OEM设置失败:', error)
} finally {
oemLoading.value = false
}
}
return {
// 状态
isLoggedIn,
authToken,
username,
loginError,
loginLoading,
oemSettings,
oemLoading,
// 计算属性
isAuthenticated,
token,
user,
// 方法
login,
logout,
checkAuth,
loadOemSettings
}
})

View File

@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { apiClient } from '@/config/api'
export const useClientsStore = defineStore('clients', {
state: () => ({
supportedClients: [],
loading: false,
error: null
}),
actions: {
async loadSupportedClients() {
if (this.supportedClients.length > 0) {
// 如果已经加载过,不重复加载
return this.supportedClients
}
this.loading = true
this.error = null
try {
const response = await apiClient.get('/admin/supported-clients')
if (response.success) {
this.supportedClients = response.data || []
} else {
this.error = response.message || '加载支持的客户端失败'
console.error('Failed to load supported clients:', this.error)
}
return this.supportedClients
} catch (error) {
this.error = error.message || '加载支持的客户端失败'
console.error('Error loading supported clients:', error)
return []
} finally {
this.loading = false
}
}
}
})

View File

@@ -0,0 +1,450 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
export const useDashboardStore = defineStore('dashboard', () => {
// 状态
const loading = ref(false)
const dashboardData = ref({
totalApiKeys: 0,
activeApiKeys: 0,
totalAccounts: 0,
activeAccounts: 0,
rateLimitedAccounts: 0,
todayRequests: 0,
totalRequests: 0,
todayTokens: 0,
todayInputTokens: 0,
todayOutputTokens: 0,
totalTokens: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreateTokens: 0,
totalCacheReadTokens: 0,
todayCacheCreateTokens: 0,
todayCacheReadTokens: 0,
systemRPM: 0,
systemTPM: 0,
systemStatus: '正常',
uptime: 0
})
const costsData = ref({
todayCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } },
totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }
})
const modelStats = ref([])
const trendData = ref([])
const dashboardModelStats = ref([])
const apiKeysTrendData = ref({
data: [],
topApiKeys: [],
totalApiKeys: 0
})
// 日期筛选
const dateFilter = ref({
type: 'preset', // preset 或 custom
preset: '7days', // today, 7days, 30days
customStart: '',
customEnd: '',
customRange: null,
presetOptions: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
})
// 趋势图粒度
const trendGranularity = ref('day') // 'day' 或 'hour'
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
// 默认时间
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
// 计算属性
const formattedUptime = computed(() => {
const seconds = dashboardData.value.uptime
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}${hours}小时`
} else if (hours > 0) {
return `${hours}小时 ${minutes}分钟`
} else {
return `${minutes}分钟`
}
})
// 方法
async function loadDashboardData() {
loading.value = true
try {
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
apiClient.get('/admin/dashboard'),
apiClient.get('/admin/usage-costs?period=today'),
apiClient.get('/admin/usage-costs?period=all')
])
if (dashboardResponse.success) {
const overview = dashboardResponse.data.overview || {}
const recentActivity = dashboardResponse.data.recentActivity || {}
const systemAverages = dashboardResponse.data.systemAverages || {}
const systemHealth = dashboardResponse.data.systemHealth || {}
dashboardData.value = {
totalApiKeys: overview.totalApiKeys || 0,
activeApiKeys: overview.activeApiKeys || 0,
totalAccounts: overview.totalClaudeAccounts || 0,
activeAccounts: overview.activeClaudeAccounts || 0,
rateLimitedAccounts: overview.rateLimitedClaudeAccounts || 0,
todayRequests: recentActivity.requestsToday || 0,
totalRequests: overview.totalRequestsUsed || 0,
todayTokens: recentActivity.tokensToday || 0,
todayInputTokens: recentActivity.inputTokensToday || 0,
todayOutputTokens: recentActivity.outputTokensToday || 0,
totalTokens: overview.totalTokensUsed || 0,
totalInputTokens: overview.totalInputTokensUsed || 0,
totalOutputTokens: overview.totalOutputTokensUsed || 0,
totalCacheCreateTokens: overview.totalCacheCreateTokensUsed || 0,
totalCacheReadTokens: overview.totalCacheReadTokensUsed || 0,
todayCacheCreateTokens: recentActivity.cacheCreateTokensToday || 0,
todayCacheReadTokens: recentActivity.cacheReadTokensToday || 0,
systemRPM: systemAverages.rpm || 0,
systemTPM: systemAverages.tpm || 0,
systemStatus: systemHealth.redisConnected ? '正常' : '异常',
uptime: systemHealth.uptime || 0
}
}
// 更新费用数据
if (todayCostsResponse.success && totalCostsResponse.success) {
costsData.value = {
todayCosts: todayCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } },
totalCosts: totalCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } }
}
}
} catch (error) {
console.error('加载仪表板数据失败:', error)
} finally {
loading.value = false
}
}
async function loadUsageTrend(days = 7, granularity = 'day') {
try {
let url = '/admin/usage-trend?'
if (granularity === 'hour') {
// 小时粒度,传递开始和结束时间
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
}
} else {
// 天粒度,传递天数
url += `granularity=day&days=${days}`
}
const response = await apiClient.get(url)
if (response.success) {
trendData.value = response.data
}
} catch (error) {
console.error('加载使用趋势失败:', error)
}
}
async function loadModelStats(period = 'daily') {
try {
const response = await apiClient.get(`/admin/model-stats?period=${period}`)
if (response.success) {
dashboardModelStats.value = response.data
}
} catch (error) {
console.error('加载模型统计失败:', error)
}
}
async function loadApiKeysTrend(metric = 'requests') {
try {
let url = '/admin/api-keys-usage-trend?'
if (trendGranularity.value === 'hour') {
// 小时粒度,传递开始和结束时间
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
}
} else {
// 天粒度,传递天数
const days = dateFilter.value.type === 'preset'
? (dateFilter.value.preset === 'today' ? 1 : dateFilter.value.preset === '7days' ? 7 : 30)
: calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
url += `granularity=day&days=${days}`
}
url += `&metric=${metric}`
const response = await apiClient.get(url)
if (response.success) {
apiKeysTrendData.value = {
data: response.data || [],
topApiKeys: response.topApiKeys || [],
totalApiKeys: response.totalApiKeys || 0
}
}
} catch (error) {
console.error('加载API Keys趋势失败:', error)
}
}
// 日期筛选相关方法
function setDateFilterPreset(preset) {
dateFilter.value.type = 'preset'
dateFilter.value.preset = preset
// 根据预设计算并设置具体的日期范围
const option = dateFilter.value.presetOptions.find(opt => opt.value === preset)
if (option) {
const now = new Date()
let startDate, endDate
if (trendGranularity.value === 'hour') {
// 小时粒度的预设
switch (preset) {
case 'last24h':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endDate = now
break
case 'yesterday':
startDate = new Date(now)
startDate.setDate(now.getDate() - 1)
startDate.setHours(0, 0, 0, 0)
endDate = new Date(startDate)
endDate.setHours(23, 59, 59, 999)
break
case 'dayBefore':
startDate = new Date(now)
startDate.setDate(now.getDate() - 2)
startDate.setHours(0, 0, 0, 0)
endDate = new Date(startDate)
endDate.setHours(23, 59, 59, 999)
break
}
} else {
// 天粒度的预设
startDate = new Date(now)
endDate = new Date(now)
if (preset === 'today') {
// 今日:从凌晨开始
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
} else {
// 其他预设:按天数计算
startDate.setDate(now.getDate() - (option.days - 1))
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
}
}
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
// 设置 customRange 为 Element Plus 需要的格式
const formatDate = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
dateFilter.value.customRange = [
formatDate(startDate),
formatDate(endDate)
]
}
// 触发数据刷新
refreshChartsData()
}
function onCustomDateRangeChange(value) {
if (value && value.length === 2) {
dateFilter.value.type = 'custom'
dateFilter.value.preset = '' // 清除预设选择
dateFilter.value.customRange = value
dateFilter.value.customStart = value[0].split(' ')[0]
dateFilter.value.customEnd = value[1].split(' ')[0]
// 检查日期范围限制
const start = new Date(value[0])
const end = new Date(value[1])
if (trendGranularity.value === 'hour') {
// 小时粒度:限制 24 小时
const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) {
showToast('小时粒度下日期范围不能超过24小时', 'warning')
return
}
} else {
// 天粒度:限制 31 天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 31) {
showToast('日期范围不能超过 31 天', 'warning')
return
}
}
// 触发数据刷新
refreshChartsData()
} else if (value === null) {
// 清空时恢复默认
setDateFilterPreset(trendGranularity.value === 'hour' ? '7days' : '7days')
}
}
function setTrendGranularity(granularity) {
trendGranularity.value = granularity
// 根据粒度更新预设选项
if (granularity === 'hour') {
dateFilter.value.presetOptions = [
{ value: 'last24h', label: '近24小时', hours: 24 },
{ value: 'yesterday', label: '昨天', hours: 24 },
{ value: 'dayBefore', label: '前天', hours: 24 }
]
// 检查当前自定义日期范围是否超过24小时
if (dateFilter.value.type === 'custom' && dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
const start = new Date(dateFilter.value.customRange[0])
const end = new Date(dateFilter.value.customRange[1])
const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) {
showToast('小时粒度下日期范围不能超过24小时已切换到近24小时', 'warning')
setDateFilterPreset('last24h')
return
}
}
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
setDateFilterPreset('last24h')
return
}
} else {
// 天粒度
dateFilter.value.presetOptions = [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
setDateFilterPreset('7days')
return
}
}
// 触发数据刷新
refreshChartsData()
}
async function refreshChartsData() {
// 根据当前筛选条件刷新数据
let days
let modelPeriod = 'monthly'
if (dateFilter.value.type === 'preset') {
const option = dateFilter.value.presetOptions.find(opt => opt.value === dateFilter.value.preset)
if (trendGranularity.value === 'hour') {
// 小时粒度
days = 1 // 小时粒度默认查看1天的数据
modelPeriod = 'daily' // 小时粒度使用日统计
} else {
// 天粒度
days = option ? option.days : 7
// 设置模型统计期间
if (dateFilter.value.preset === 'today') {
modelPeriod = 'daily'
} else {
modelPeriod = 'monthly'
}
}
} else {
// 自定义日期范围
if (trendGranularity.value === 'hour') {
// 小时粒度下的自定义范围,计算小时数
const start = new Date(dateFilter.value.customRange[0])
const end = new Date(dateFilter.value.customRange[1])
const hoursDiff = Math.ceil((end - start) / (1000 * 60 * 60))
days = Math.ceil(hoursDiff / 24) || 1
} else {
days = calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
}
modelPeriod = 'daily' // 自定义范围使用日统计
}
await Promise.all([
loadUsageTrend(days, trendGranularity.value),
loadModelStats(modelPeriod),
loadApiKeysTrend(apiKeysTrendMetric.value)
])
}
function calculateDaysBetween(start, end) {
if (!start || !end) return 7
const startDate = new Date(start)
const endDate = new Date(end)
const diffTime = Math.abs(endDate - startDate)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays || 7
}
function disabledDate(date) {
return date > new Date()
}
return {
// 状态
loading,
dashboardData,
costsData,
modelStats,
trendData,
dashboardModelStats,
apiKeysTrendData,
dateFilter,
trendGranularity,
apiKeysTrendMetric,
defaultTime,
// 计算属性
formattedUptime,
// 方法
loadDashboardData,
loadUsageTrend,
loadModelStats,
loadApiKeysTrend,
setDateFilterPreset,
onCustomDateRangeChange,
setTrendGranularity,
refreshChartsData,
disabledDate
}
})

View File

@@ -0,0 +1,151 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/config/api'
export const useSettingsStore = defineStore('settings', () => {
// 状态
const oemSettings = ref({
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
updatedAt: null
})
const loading = ref(false)
const saving = ref(false)
// 移除自定义API请求方法使用统一的apiClient
// Actions
const loadOemSettings = async () => {
loading.value = true
try {
const result = await apiClient.get('/admin/oem-settings')
if (result && result.success) {
oemSettings.value = { ...oemSettings.value, ...result.data }
// 应用设置到页面
applyOemSettings()
}
return result
} catch (error) {
console.error('Failed to load OEM settings:', error)
throw error
} finally {
loading.value = false
}
}
const saveOemSettings = async (settings) => {
saving.value = true
try {
const result = await apiClient.put('/admin/oem-settings', settings)
if (result && result.success) {
oemSettings.value = { ...oemSettings.value, ...result.data }
// 应用设置到页面
applyOemSettings()
}
return result
} catch (error) {
console.error('Failed to save OEM settings:', error)
throw error
} finally {
saving.value = false
}
}
const resetOemSettings = async () => {
const defaultSettings = {
siteName: 'Claude Relay Service',
siteIcon: '',
siteIconData: '',
updatedAt: null
}
oemSettings.value = { ...defaultSettings }
return await saveOemSettings(defaultSettings)
}
// 应用OEM设置到页面
const applyOemSettings = () => {
// 更新页面标题
if (oemSettings.value.siteName) {
document.title = `${oemSettings.value.siteName} - 管理后台`
}
// 更新favicon
if (oemSettings.value.siteIconData || oemSettings.value.siteIcon) {
const favicon = document.querySelector('link[rel="icon"]') || document.createElement('link')
favicon.rel = 'icon'
favicon.href = oemSettings.value.siteIconData || oemSettings.value.siteIcon
if (!document.querySelector('link[rel="icon"]')) {
document.head.appendChild(favicon)
}
}
}
// 格式化日期时间
const formatDateTime = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 验证文件上传
const validateIconFile = (file) => {
const errors = []
// 检查文件大小 (350KB)
if (file.size > 350 * 1024) {
errors.push('图标文件大小不能超过 350KB')
}
// 检查文件类型
const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) {
errors.push('不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件')
}
return {
isValid: errors.length === 0,
errors
}
}
// 将文件转换为Base64
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
return {
// State
oemSettings,
loading,
saving,
// Actions
loadOemSettings,
saveOemSettings,
resetOemSettings,
applyOemSettings,
formatDateTime,
validateIconFile,
fileToBase64
}
})