mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
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:
334
web/admin-spa/src/stores/accounts.js
Normal file
334
web/admin-spa/src/stores/accounts.js
Normal 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
|
||||
}
|
||||
})
|
||||
192
web/admin-spa/src/stores/apiKeys.js
Normal file
192
web/admin-spa/src/stores/apiKeys.js
Normal 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
|
||||
}
|
||||
})
|
||||
343
web/admin-spa/src/stores/apistats.js
Normal file
343
web/admin-spa/src/stores/apistats.js
Normal 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
|
||||
}
|
||||
})
|
||||
130
web/admin-spa/src/stores/auth.js
Normal file
130
web/admin-spa/src/stores/auth.js
Normal 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
|
||||
}
|
||||
})
|
||||
41
web/admin-spa/src/stores/clients.js
Normal file
41
web/admin-spa/src/stores/clients.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
450
web/admin-spa/src/stores/dashboard.js
Normal file
450
web/admin-spa/src/stores/dashboard.js
Normal 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
|
||||
}
|
||||
})
|
||||
151
web/admin-spa/src/stores/settings.js
Normal file
151
web/admin-spa/src/stores/settings.js
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user