From 5cfa3cc72fa993c079feafcd098188ab782e9534 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 1 Sep 2025 11:29:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E7=9A=84=E8=B4=A6=E6=88=B7=E8=B4=B9=E7=94=A8=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E5=92=8C=E6=97=B6=E5=8C=BA=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现基于模型使用量的精确每日费用计算 - 添加 dateHelper 工具支持时区转换 - 移除未使用的 webhook 配置代码 - 清理环境变量和配置文件中的 webhook 相关设置 - 优化前端费用显示,使用后端精确计算的数据 - 添加 DEBUG_HTTP_TRAFFIC 调试选项支持 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 7 +- .gitignore | 4 + README.md | 66 ------ config/config.example.js | 10 - src/app.js | 11 + src/models/redis.js | 54 ++++- src/routes/webhook.js | 95 ++++++-- src/services/claudeAccountService.js | 9 +- src/services/claudeConsoleAccountService.js | 3 +- src/services/pricingService.js | 4 +- src/services/webhookService.js | 7 +- src/utils/costCalculator.js | 2 +- src/utils/dateHelper.js | 100 +++++++++ src/utils/webhookNotifier.js | 3 +- web/admin-spa/src/views/AccountsView.vue | 17 +- web/admin-spa/src/views/SettingsView.vue | 235 ++++++++++++++++++-- 16 files changed, 476 insertions(+), 151 deletions(-) create mode 100644 src/utils/dateHelper.js diff --git a/.env.example b/.env.example index bdf204cf..8fb9648e 100644 --- a/.env.example +++ b/.env.example @@ -55,14 +55,9 @@ WEB_LOGO_URL=/assets/logo.png # 🛠️ 开发配置 DEBUG=false +DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境) ENABLE_CORS=true TRUST_PROXY=true # 🔒 客户端限制(可选) # ALLOW_CUSTOM_CLIENTS=false - -# 📢 Webhook 通知配置 -WEBHOOK_ENABLED=true -WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify -WEBHOOK_TIMEOUT=10000 -WEBHOOK_RETRIES=3 \ No newline at end of file diff --git a/.gitignore b/.gitignore index fbe70338..10594f73 100644 --- a/.gitignore +++ b/.gitignore @@ -216,6 +216,10 @@ local/ debug.log error.log access.log +http-debug*.log +logs/http-debug-*.log + +src/middleware/debugInterceptor.js # Session files sessions/ diff --git a/README.md b/README.md index 8e6cfd5a..53f58c95 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,6 @@ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= -# Webhook通知配置(可选) -WEBHOOK_ENABLED=true -WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key -WEBHOOK_TIMEOUT=10000 -WEBHOOK_RETRIES=3 ``` **编辑 `config/config.js` 文件:** @@ -518,67 +513,6 @@ http://你的服务器:3000/openai/claude/v1/ --- -## 📢 Webhook 通知功能 - -### 功能说明 - -当系统检测到账号异常时,会自动发送 webhook 通知,支持企业微信、钉钉、Slack 等平台。 - -### 通知触发场景 - -- **Claude OAuth 账户**: token 过期或未授权时 -- **Claude Console 账户**: 系统检测到账户被封锁时 -- **Gemini 账户**: token 刷新失败时 -- **手动禁用账户**: 管理员手动禁用账户时 - -### 配置方法 - -**1. 环境变量配置** - -```bash -# 启用 webhook 通知 -WEBHOOK_ENABLED=true - -# 企业微信 webhook 地址(替换为你的实际地址) -WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key - -# 多个地址用逗号分隔 -WEBHOOK_URLS=https://webhook1.com,https://webhook2.com - -# 请求超时时间(毫秒,默认10秒) -WEBHOOK_TIMEOUT=10000 - -# 重试次数(默认3次) -WEBHOOK_RETRIES=3 -``` - -**2. 企业微信设置** - -1. 在企业微信群中添加「群机器人」 -2. 获取 webhook 地址:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx` -3. 将地址配置到 `WEBHOOK_URLS` 环境变量 - -### 通知内容格式 - -系统会发送结构化的通知消息: - -``` -账户名称 账号异常,异常代码 ERROR_CODE -平台:claude-oauth -时间:2025-08-14 17:30:00 -原因:Token expired -``` - -### 测试 Webhook - -可以通过管理后台测试 webhook 连通性: - -1. 登录管理后台:`http://你的服务器:3000/web` -2. 访问:`/admin/webhook/test` -3. 发送测试通知确认配置正确 - ---- - ## 🔧 日常维护 ### 服务管理 diff --git a/config/config.example.js b/config/config.example.js index ec3ff3d2..9bab4b9f 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -127,16 +127,6 @@ const config = { allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true' }, - // 📢 Webhook通知配置 - webhook: { - enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 - urls: process.env.WEBHOOK_URLS - ? process.env.WEBHOOK_URLS.split(',').map((url) => url.trim()) - : [], - timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时 - retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次 - }, - // 🛠️ 开发配置 development: { debug: process.env.DEBUG === 'true', diff --git a/src/app.js b/src/app.js index a1f8020b..25550664 100644 --- a/src/app.js +++ b/src/app.js @@ -133,6 +133,17 @@ class Application { // 📝 请求日志(使用自定义logger而不是morgan) this.app.use(requestLogger) + // 🐛 HTTP调试拦截器(仅在启用调试时生效) + if (process.env.DEBUG_HTTP_TRAFFIC === 'true') { + try { + const { debugInterceptor } = require('./middleware/debugInterceptor') + this.app.use(debugInterceptor) + logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log') + } catch (error) { + logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message) + } + } + // 🔧 基础中间件 this.app.use( express.json({ diff --git a/src/models/redis.js b/src/models/redis.js index b65cc8d1..1515ce3c 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -733,6 +733,52 @@ class RedisClient { logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`) } + // 💰 计算账户的每日费用(基于模型使用) + async getAccountDailyCost(accountId) { + const CostCalculator = require('../utils/costCalculator') + const today = getDateStringInTimezone() + + // 获取账户今日所有模型的使用数据 + const pattern = `account_usage:model:daily:${accountId}:*:${today}` + const modelKeys = await this.client.keys(pattern) + + if (!modelKeys || modelKeys.length === 0) { + return 0 + } + + let totalCost = 0 + + for (const key of modelKeys) { + // 从key中解析模型名称 + // 格式:account_usage:model:daily:{accountId}:{model}:{date} + const parts = key.split(':') + const model = parts[4] // 模型名在第5个位置(索引4) + + // 获取该模型的使用数据 + const modelUsage = await this.client.hgetall(key) + + if (modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) { + const usage = { + input_tokens: parseInt(modelUsage.inputTokens || 0), + output_tokens: parseInt(modelUsage.outputTokens || 0), + cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0), + cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0) + } + + // 使用CostCalculator计算费用 + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total + + logger.debug( + `💰 Account ${accountId} daily cost for model ${model}: $${costResult.costs.total}` + ) + } + } + + logger.debug(`💰 Account ${accountId} total daily cost: $${totalCost}`) + return totalCost + } + // 📊 获取账户使用统计 async getAccountUsageStats(accountId) { const accountKey = `account_usage:${accountId}` @@ -792,10 +838,16 @@ class RedisClient { const dailyData = handleAccountData(daily) const monthlyData = handleAccountData(monthly) + // 获取每日费用(基于模型使用) + const dailyCost = await this.getAccountDailyCost(accountId) + return { accountId, total: totalData, - daily: dailyData, + daily: { + ...dailyData, + cost: dailyCost + }, monthly: monthlyData, averages: { rpm: Math.round(avgRPM * 100) / 100, diff --git a/src/routes/webhook.js b/src/routes/webhook.js index 3f31802a..f4c70b41 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -4,6 +4,7 @@ const logger = require('../utils/logger') const webhookService = require('../services/webhookService') const webhookConfigService = require('../services/webhookConfigService') const { authenticateAdmin } = require('../middleware/auth') +const { getISOStringWithTimezone } = require('../utils/dateHelper') // 获取webhook配置 router.get('/config', authenticateAdmin, async (req, res) => { @@ -114,27 +115,62 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => { // 测试Webhook连通性 router.post('/test', authenticateAdmin, async (req, res) => { try { - const { url, type = 'custom', secret, enableSign } = req.body + const { + url, + type = 'custom', + secret, + enableSign, + deviceKey, + serverUrl, + level, + sound, + group + } = req.body - if (!url) { - return res.status(400).json({ - error: 'Missing webhook URL', - message: '请提供webhook URL' - }) + // Bark平台特殊处理 + if (type === 'bark') { + if (!deviceKey) { + return res.status(400).json({ + error: 'Missing device key', + message: '请提供Bark设备密钥' + }) + } + + // 验证服务器URL(如果提供) + if (serverUrl) { + try { + new URL(serverUrl) + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid server URL format', + message: '请提供有效的Bark服务器URL' + }) + } + } + + logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`) + } else { + // 其他平台验证URL + if (!url) { + return res.status(400).json({ + error: 'Missing webhook URL', + message: '请提供webhook URL' + }) + } + + // 验证URL格式 + try { + new URL(url) + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid URL format', + message: '请提供有效的webhook URL' + }) + } + + logger.info(`🧪 测试webhook: ${type} - ${url}`) } - // 验证URL格式 - try { - new URL(url) - } catch (urlError) { - return res.status(400).json({ - error: 'Invalid URL format', - message: '请提供有效的webhook URL' - }) - } - - logger.info(`🧪 测试webhook: ${type} - ${url}`) - // 创建临时平台配置 const platform = { type, @@ -145,21 +181,34 @@ router.post('/test', authenticateAdmin, async (req, res) => { timeout: 10000 } + // 添加Bark特有字段 + if (type === 'bark') { + platform.deviceKey = deviceKey + platform.serverUrl = serverUrl + platform.level = level + platform.sound = sound + platform.group = group + } + const result = await webhookService.testWebhook(platform) if (result.success) { - logger.info(`✅ Webhook测试成功: ${url}`) + const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url + logger.info(`✅ Webhook测试成功: ${identifier}`) res.json({ success: true, message: 'Webhook测试成功', - url + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined }) } else { - logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`) + const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url + logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`) res.status(400).json({ success: false, message: 'Webhook测试失败', - url, + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined, error: result.error }) } @@ -218,7 +267,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => { errorCode, reason, message, - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) } const result = await webhookService.sendNotification(type, testData) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index a850511d..bb579bf5 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -15,6 +15,7 @@ const { } = require('../utils/tokenRefreshLogger') const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') +const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') class ClaudeAccountService { constructor() { @@ -1121,8 +1122,8 @@ class ClaudeAccountService { platform: 'claude-oauth', status: 'error', errorCode: 'CLAUDE_OAUTH_RATE_LIMITED', - reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`, - timestamp: new Date().toISOString() + reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`, + timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { logger.error('Failed to send rate limit webhook notification:', webhookError) @@ -1322,7 +1323,7 @@ class ClaudeAccountService { status: 'resumed', errorCode: 'CLAUDE_5H_LIMIT_RESUMED', reason: '进入新的5小时窗口,已自动恢复调度', - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { logger.error('Failed to send webhook notification:', webhookError) @@ -1985,7 +1986,7 @@ class ClaudeAccountService { status: 'warning', errorCode: 'CLAUDE_5H_LIMIT_WARNING', reason: '5小时使用量接近限制,已自动停止调度', - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { logger.error('Failed to send webhook notification:', webhookError) diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 7bde2c29..28be976d 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -369,6 +369,7 @@ class ClaudeConsoleAccountService { // 发送Webhook通知 try { const webhookNotifier = require('../utils/webhookNotifier') + const { getISOStringWithTimezone } = require('../utils/dateHelper') await webhookNotifier.sendAccountAnomalyNotification({ accountId, accountName: account.name || 'Claude Console Account', @@ -376,7 +377,7 @@ class ClaudeConsoleAccountService { status: 'error', errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`, - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) }) } catch (webhookError) { logger.error('Failed to send rate limit webhook notification:', webhookError) diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 606465d5..43ce2d1a 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -308,7 +308,9 @@ class PricingService { // 确保价格对象包含缓存价格 ensureCachePricing(pricing) { - if (!pricing) return pricing + if (!pricing) { + return pricing + } // 如果缺少缓存价格,根据输入价格计算(缓存创建价格通常是输入价格的1.25倍,缓存读取是0.1倍) if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) { diff --git a/src/services/webhookService.js b/src/services/webhookService.js index cffa3b03..c026ead6 100644 --- a/src/services/webhookService.js +++ b/src/services/webhookService.js @@ -2,6 +2,7 @@ const axios = require('axios') const crypto = require('crypto') const logger = require('../utils/logger') const webhookConfigService = require('./webhookConfigService') +const { getISOStringWithTimezone } = require('../utils/dateHelper') class WebhookService { constructor() { @@ -206,7 +207,7 @@ class WebhookService { const payload = { type, service: 'claude-relay-service', - timestamp: new Date().toISOString(), + timestamp: getISOStringWithTimezone(new Date()), data } @@ -357,7 +358,7 @@ class WebhookService { title, color, fields, - timestamp: new Date().toISOString(), + timestamp: getISOStringWithTimezone(new Date()), footer: { text: 'Claude Relay Service' } @@ -580,7 +581,7 @@ class WebhookService { try { const testData = { message: 'Claude Relay Service webhook测试', - timestamp: new Date().toISOString() + timestamp: getISOStringWithTimezone(new Date()) } await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index e623abaa..9eef07d2 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -31,7 +31,7 @@ const MODEL_PRICING = { cacheWrite: 18.75, cacheRead: 1.5 }, - + // Claude Opus 4.1 (新模型) 'claude-opus-4-1-20250805': { input: 15.0, diff --git a/src/utils/dateHelper.js b/src/utils/dateHelper.js new file mode 100644 index 00000000..7a8a333c --- /dev/null +++ b/src/utils/dateHelper.js @@ -0,0 +1,100 @@ +const config = require('../../config/config') + +/** + * 格式化日期时间为指定时区的本地时间字符串 + * @param {Date|number} date - Date对象或时间戳(秒或毫秒) + * @param {boolean} includeTimezone - 是否在输出中包含时区信息 + * @returns {string} 格式化后的时间字符串 + */ +function formatDateWithTimezone(date, includeTimezone = true) { + // 处理不同类型的输入 + let dateObj + if (typeof date === 'number') { + // 判断是秒还是毫秒时间戳 + // Unix时间戳(秒)通常小于 10^10,毫秒时间戳通常大于 10^12 + if (date < 10000000000) { + dateObj = new Date(date * 1000) // 秒转毫秒 + } else { + dateObj = new Date(date) // 已经是毫秒 + } + } else if (date instanceof Date) { + dateObj = date + } else { + dateObj = new Date(date) + } + + // 获取配置的时区偏移(小时) + const timezoneOffset = config.system.timezoneOffset || 8 // 默认 UTC+8 + + // 计算本地时间 + const offsetMs = timezoneOffset * 3600000 // 转换为毫秒 + const localTime = new Date(dateObj.getTime() + offsetMs) + + // 格式化为 YYYY-MM-DD HH:mm:ss + const year = localTime.getUTCFullYear() + const month = String(localTime.getUTCMonth() + 1).padStart(2, '0') + const day = String(localTime.getUTCDate()).padStart(2, '0') + const hours = String(localTime.getUTCHours()).padStart(2, '0') + const minutes = String(localTime.getUTCMinutes()).padStart(2, '0') + const seconds = String(localTime.getUTCSeconds()).padStart(2, '0') + + let formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + + // 添加时区信息 + if (includeTimezone) { + const sign = timezoneOffset >= 0 ? '+' : '' + formattedDate += ` (UTC${sign}${timezoneOffset})` + } + + return formattedDate +} + +/** + * 获取指定时区的ISO格式时间字符串 + * @param {Date|number} date - Date对象或时间戳 + * @returns {string} ISO格式的时间字符串 + */ +function getISOStringWithTimezone(date) { + // 先获取本地格式的时间(不含时区后缀) + const localTimeStr = formatDateWithTimezone(date, false) + + // 获取时区偏移 + const timezoneOffset = config.system.timezoneOffset || 8 + + // 构建ISO格式,添加时区偏移 + const sign = timezoneOffset >= 0 ? '+' : '-' + const absOffset = Math.abs(timezoneOffset) + const offsetHours = String(Math.floor(absOffset)).padStart(2, '0') + const offsetMinutes = String(Math.round((absOffset % 1) * 60)).padStart(2, '0') + + // 将空格替换为T,并添加时区 + return `${localTimeStr.replace(' ', 'T')}${sign}${offsetHours}:${offsetMinutes}` +} + +/** + * 计算时间差并格式化为人类可读的字符串 + * @param {number} seconds - 秒数 + * @returns {string} 格式化的时间差字符串 + */ +function formatDuration(seconds) { + if (seconds < 60) { + return `${seconds}秒` + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60) + return `${minutes}分钟` + } else if (seconds < 86400) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时` + } else { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + return hours > 0 ? `${days}天${hours}小时` : `${days}天` + } +} + +module.exports = { + formatDateWithTimezone, + getISOStringWithTimezone, + formatDuration +} diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index 59c15147..e5002c29 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -1,5 +1,6 @@ const logger = require('./logger') const webhookService = require('../services/webhookService') +const { getISOStringWithTimezone } = require('./dateHelper') class WebhookNotifier { constructor() { @@ -28,7 +29,7 @@ class WebhookNotifier { errorCode: notification.errorCode || this._getErrorCode(notification.platform, notification.status), reason: notification.reason, - timestamp: notification.timestamp || new Date().toISOString() + timestamp: notification.timestamp || getISOStringWithTimezone(new Date()) }) } catch (error) { logger.error('Failed to send account anomaly notification:', error) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 683ab91f..a9e49d05 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1726,20 +1726,17 @@ const formatCost = (cost) => { return cost.toFixed(2) } -// 计算每日费用(估算,基于平均模型价格) +// 计算每日费用(使用后端返回的精确费用数据) const calculateDailyCost = (account) => { if (!account.usage || !account.usage.daily) return '0.0000' - const dailyTokens = account.usage.daily.allTokens || 0 - if (dailyTokens === 0) return '0.0000' + // 如果后端已经返回了计算好的费用,直接使用 + if (account.usage.daily.cost !== undefined) { + return formatCost(account.usage.daily.cost) + } - // 使用平均价格估算(基于Claude 3.5 Sonnet的价格) - // 输入: $3/1M tokens, 输出: $15/1M tokens - // 假设平均比例为 输入:输出 = 3:1 - const avgPricePerMillion = 3 * 0.75 + 15 * 0.25 // 加权平均价格 - const cost = (dailyTokens / 1000000) * avgPricePerMillion - - return formatCost(cost) + // 如果后端没有返回费用(旧版本),返回0 + return '0.0000' } // 切换调度状态 diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index 91b16fa1..15801feb 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -479,6 +479,7 @@ +
@@ -508,8 +509,8 @@ />
- -
+ +
+ +
+ +
+ + +

+ 在Bark App中查看您的推送密钥 +

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+

1. 在iPhone上安装Bark App

+

2. 打开App获取您的设备密钥

+

3. 将密钥粘贴到上方输入框

+
+
+
+