mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加精确的账户费用计算和时区支持
- 实现基于模型使用量的精确每日费用计算 - 添加 dateHelper 工具支持时区转换 - 移除未使用的 webhook 配置代码 - 清理环境变量和配置文件中的 webhook 相关设置 - 优化前端费用显示,使用后端精确计算的数据 - 添加 DEBUG_HTTP_TRAFFIC 调试选项支持 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -55,14 +55,9 @@ WEB_LOGO_URL=/assets/logo.png
|
|||||||
|
|
||||||
# 🛠️ 开发配置
|
# 🛠️ 开发配置
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境)
|
||||||
ENABLE_CORS=true
|
ENABLE_CORS=true
|
||||||
TRUST_PROXY=true
|
TRUST_PROXY=true
|
||||||
|
|
||||||
# 🔒 客户端限制(可选)
|
# 🔒 客户端限制(可选)
|
||||||
# ALLOW_CUSTOM_CLIENTS=false
|
# 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
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -216,6 +216,10 @@ local/
|
|||||||
debug.log
|
debug.log
|
||||||
error.log
|
error.log
|
||||||
access.log
|
access.log
|
||||||
|
http-debug*.log
|
||||||
|
logs/http-debug-*.log
|
||||||
|
|
||||||
|
src/middleware/debugInterceptor.js
|
||||||
|
|
||||||
# Session files
|
# Session files
|
||||||
sessions/
|
sessions/
|
||||||
|
|||||||
66
README.md
66
README.md
@@ -250,11 +250,6 @@ REDIS_HOST=localhost
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
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` 文件:**
|
**编辑 `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. 发送测试通知确认配置正确
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 日常维护
|
## 🔧 日常维护
|
||||||
|
|
||||||
### 服务管理
|
### 服务管理
|
||||||
|
|||||||
@@ -127,16 +127,6 @@ const config = {
|
|||||||
allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true'
|
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: {
|
development: {
|
||||||
debug: process.env.DEBUG === 'true',
|
debug: process.env.DEBUG === 'true',
|
||||||
|
|||||||
11
src/app.js
11
src/app.js
@@ -133,6 +133,17 @@ class Application {
|
|||||||
// 📝 请求日志(使用自定义logger而不是morgan)
|
// 📝 请求日志(使用自定义logger而不是morgan)
|
||||||
this.app.use(requestLogger)
|
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(
|
this.app.use(
|
||||||
express.json({
|
express.json({
|
||||||
|
|||||||
@@ -733,6 +733,52 @@ class RedisClient {
|
|||||||
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
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) {
|
async getAccountUsageStats(accountId) {
|
||||||
const accountKey = `account_usage:${accountId}`
|
const accountKey = `account_usage:${accountId}`
|
||||||
@@ -792,10 +838,16 @@ class RedisClient {
|
|||||||
const dailyData = handleAccountData(daily)
|
const dailyData = handleAccountData(daily)
|
||||||
const monthlyData = handleAccountData(monthly)
|
const monthlyData = handleAccountData(monthly)
|
||||||
|
|
||||||
|
// 获取每日费用(基于模型使用)
|
||||||
|
const dailyCost = await this.getAccountDailyCost(accountId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
total: totalData,
|
total: totalData,
|
||||||
daily: dailyData,
|
daily: {
|
||||||
|
...dailyData,
|
||||||
|
cost: dailyCost
|
||||||
|
},
|
||||||
monthly: monthlyData,
|
monthly: monthlyData,
|
||||||
averages: {
|
averages: {
|
||||||
rpm: Math.round(avgRPM * 100) / 100,
|
rpm: Math.round(avgRPM * 100) / 100,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const logger = require('../utils/logger')
|
|||||||
const webhookService = require('../services/webhookService')
|
const webhookService = require('../services/webhookService')
|
||||||
const webhookConfigService = require('../services/webhookConfigService')
|
const webhookConfigService = require('../services/webhookConfigService')
|
||||||
const { authenticateAdmin } = require('../middleware/auth')
|
const { authenticateAdmin } = require('../middleware/auth')
|
||||||
|
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||||
|
|
||||||
// 获取webhook配置
|
// 获取webhook配置
|
||||||
router.get('/config', authenticateAdmin, async (req, res) => {
|
router.get('/config', authenticateAdmin, async (req, res) => {
|
||||||
@@ -114,27 +115,62 @@ router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
|
|||||||
// 测试Webhook连通性
|
// 测试Webhook连通性
|
||||||
router.post('/test', authenticateAdmin, async (req, res) => {
|
router.post('/test', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { url, type = 'custom', secret, enableSign } = req.body
|
const {
|
||||||
|
url,
|
||||||
|
type = 'custom',
|
||||||
|
secret,
|
||||||
|
enableSign,
|
||||||
|
deviceKey,
|
||||||
|
serverUrl,
|
||||||
|
level,
|
||||||
|
sound,
|
||||||
|
group
|
||||||
|
} = req.body
|
||||||
|
|
||||||
if (!url) {
|
// Bark平台特殊处理
|
||||||
return res.status(400).json({
|
if (type === 'bark') {
|
||||||
error: 'Missing webhook URL',
|
if (!deviceKey) {
|
||||||
message: '请提供webhook URL'
|
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 = {
|
const platform = {
|
||||||
type,
|
type,
|
||||||
@@ -145,21 +181,34 @@ router.post('/test', authenticateAdmin, async (req, res) => {
|
|||||||
timeout: 10000
|
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)
|
const result = await webhookService.testWebhook(platform)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info(`✅ Webhook测试成功: ${url}`)
|
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
|
||||||
|
logger.info(`✅ Webhook测试成功: ${identifier}`)
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Webhook测试成功',
|
message: 'Webhook测试成功',
|
||||||
url
|
url: type === 'bark' ? undefined : url,
|
||||||
|
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
|
||||||
})
|
})
|
||||||
} else {
|
} 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({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Webhook测试失败',
|
message: 'Webhook测试失败',
|
||||||
url,
|
url: type === 'bark' ? undefined : url,
|
||||||
|
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined,
|
||||||
error: result.error
|
error: result.error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -218,7 +267,7 @@ router.post('/test-notification', authenticateAdmin, async (req, res) => {
|
|||||||
errorCode,
|
errorCode,
|
||||||
reason,
|
reason,
|
||||||
message,
|
message,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: getISOStringWithTimezone(new Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await webhookService.sendNotification(type, testData)
|
const result = await webhookService.sendNotification(type, testData)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const {
|
|||||||
} = require('../utils/tokenRefreshLogger')
|
} = require('../utils/tokenRefreshLogger')
|
||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||||
|
|
||||||
class ClaudeAccountService {
|
class ClaudeAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -1121,8 +1122,8 @@ class ClaudeAccountService {
|
|||||||
platform: 'claude-oauth',
|
platform: 'claude-oauth',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
|
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'}`,
|
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: getISOStringWithTimezone(new Date())
|
||||||
})
|
})
|
||||||
} catch (webhookError) {
|
} catch (webhookError) {
|
||||||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||||||
@@ -1322,7 +1323,7 @@ class ClaudeAccountService {
|
|||||||
status: 'resumed',
|
status: 'resumed',
|
||||||
errorCode: 'CLAUDE_5H_LIMIT_RESUMED',
|
errorCode: 'CLAUDE_5H_LIMIT_RESUMED',
|
||||||
reason: '进入新的5小时窗口,已自动恢复调度',
|
reason: '进入新的5小时窗口,已自动恢复调度',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: getISOStringWithTimezone(new Date())
|
||||||
})
|
})
|
||||||
} catch (webhookError) {
|
} catch (webhookError) {
|
||||||
logger.error('Failed to send webhook notification:', webhookError)
|
logger.error('Failed to send webhook notification:', webhookError)
|
||||||
@@ -1985,7 +1986,7 @@ class ClaudeAccountService {
|
|||||||
status: 'warning',
|
status: 'warning',
|
||||||
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
|
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
|
||||||
reason: '5小时使用量接近限制,已自动停止调度',
|
reason: '5小时使用量接近限制,已自动停止调度',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: getISOStringWithTimezone(new Date())
|
||||||
})
|
})
|
||||||
} catch (webhookError) {
|
} catch (webhookError) {
|
||||||
logger.error('Failed to send webhook notification:', webhookError)
|
logger.error('Failed to send webhook notification:', webhookError)
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ class ClaudeConsoleAccountService {
|
|||||||
// 发送Webhook通知
|
// 发送Webhook通知
|
||||||
try {
|
try {
|
||||||
const webhookNotifier = require('../utils/webhookNotifier')
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||||
await webhookNotifier.sendAccountAnomalyNotification({
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
accountId,
|
accountId,
|
||||||
accountName: account.name || 'Claude Console Account',
|
accountName: account.name || 'Claude Console Account',
|
||||||
@@ -376,7 +377,7 @@ class ClaudeConsoleAccountService {
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
|
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
|
||||||
reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
|
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) {
|
} catch (webhookError) {
|
||||||
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
logger.error('Failed to send rate limit webhook notification:', webhookError)
|
||||||
|
|||||||
@@ -308,7 +308,9 @@ class PricingService {
|
|||||||
|
|
||||||
// 确保价格对象包含缓存价格
|
// 确保价格对象包含缓存价格
|
||||||
ensureCachePricing(pricing) {
|
ensureCachePricing(pricing) {
|
||||||
if (!pricing) return pricing
|
if (!pricing) {
|
||||||
|
return pricing
|
||||||
|
}
|
||||||
|
|
||||||
// 如果缺少缓存价格,根据输入价格计算(缓存创建价格通常是输入价格的1.25倍,缓存读取是0.1倍)
|
// 如果缺少缓存价格,根据输入价格计算(缓存创建价格通常是输入价格的1.25倍,缓存读取是0.1倍)
|
||||||
if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) {
|
if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const axios = require('axios')
|
|||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const webhookConfigService = require('./webhookConfigService')
|
const webhookConfigService = require('./webhookConfigService')
|
||||||
|
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||||
|
|
||||||
class WebhookService {
|
class WebhookService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -206,7 +207,7 @@ class WebhookService {
|
|||||||
const payload = {
|
const payload = {
|
||||||
type,
|
type,
|
||||||
service: 'claude-relay-service',
|
service: 'claude-relay-service',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: getISOStringWithTimezone(new Date()),
|
||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +358,7 @@ class WebhookService {
|
|||||||
title,
|
title,
|
||||||
color,
|
color,
|
||||||
fields,
|
fields,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: getISOStringWithTimezone(new Date()),
|
||||||
footer: {
|
footer: {
|
||||||
text: 'Claude Relay Service'
|
text: 'Claude Relay Service'
|
||||||
}
|
}
|
||||||
@@ -580,7 +581,7 @@ class WebhookService {
|
|||||||
try {
|
try {
|
||||||
const testData = {
|
const testData = {
|
||||||
message: 'Claude Relay Service webhook测试',
|
message: 'Claude Relay Service webhook测试',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: getISOStringWithTimezone(new Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })
|
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })
|
||||||
|
|||||||
100
src/utils/dateHelper.js
Normal file
100
src/utils/dateHelper.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const logger = require('./logger')
|
const logger = require('./logger')
|
||||||
const webhookService = require('../services/webhookService')
|
const webhookService = require('../services/webhookService')
|
||||||
|
const { getISOStringWithTimezone } = require('./dateHelper')
|
||||||
|
|
||||||
class WebhookNotifier {
|
class WebhookNotifier {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -28,7 +29,7 @@ class WebhookNotifier {
|
|||||||
errorCode:
|
errorCode:
|
||||||
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
|
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
|
||||||
reason: notification.reason,
|
reason: notification.reason,
|
||||||
timestamp: notification.timestamp || new Date().toISOString()
|
timestamp: notification.timestamp || getISOStringWithTimezone(new Date())
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to send account anomaly notification:', error)
|
logger.error('Failed to send account anomaly notification:', error)
|
||||||
|
|||||||
@@ -1726,20 +1726,17 @@ const formatCost = (cost) => {
|
|||||||
return cost.toFixed(2)
|
return cost.toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算每日费用(估算,基于平均模型价格)
|
// 计算每日费用(使用后端返回的精确费用数据)
|
||||||
const calculateDailyCost = (account) => {
|
const calculateDailyCost = (account) => {
|
||||||
if (!account.usage || !account.usage.daily) return '0.0000'
|
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的价格)
|
// 如果后端没有返回费用(旧版本),返回0
|
||||||
// 输入: $3/1M tokens, 输出: $15/1M tokens
|
return '0.0000'
|
||||||
// 假设平均比例为 输入:输出 = 3:1
|
|
||||||
const avgPricePerMillion = 3 * 0.75 + 15 * 0.25 // 加权平均价格
|
|
||||||
const cost = (dailyTokens / 1000000) * avgPricePerMillion
|
|
||||||
|
|
||||||
return formatCost(cost)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换调度状态
|
// 切换调度状态
|
||||||
|
|||||||
@@ -479,6 +479,7 @@
|
|||||||
<option value="feishu">🟦 飞书</option>
|
<option value="feishu">🟦 飞书</option>
|
||||||
<option value="slack">🟣 Slack</option>
|
<option value="slack">🟣 Slack</option>
|
||||||
<option value="discord">🟪 Discord</option>
|
<option value="discord">🟪 Discord</option>
|
||||||
|
<option value="bark">🔔 Bark</option>
|
||||||
<option value="custom">⚙️ 自定义</option>
|
<option value="custom">⚙️ 自定义</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
@@ -508,8 +509,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Webhook URL -->
|
<!-- Webhook URL (非Bark平台) -->
|
||||||
<div>
|
<div v-if="platformForm.type !== 'bark'">
|
||||||
<label
|
<label
|
||||||
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
@@ -548,6 +549,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bark 平台特有字段 -->
|
||||||
|
<div v-if="platformForm.type === 'bark'" class="space-y-5">
|
||||||
|
<!-- 设备密钥 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key mr-2 text-gray-400"></i>
|
||||||
|
设备密钥 (Device Key)
|
||||||
|
<span class="ml-1 text-xs text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.deviceKey"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 font-mono text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="例如:aBcDeFgHiJkLmNoPqRsTuVwX"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
在Bark App中查看您的推送密钥
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 服务器URL(可选) -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-server mr-2 text-gray-400"></i>
|
||||||
|
服务器地址
|
||||||
|
<span class="ml-2 text-xs text-gray-500">(可选)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.serverUrl"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 font-mono text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="默认: https://api.day.app/push"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通知级别 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-flag mr-2 text-gray-400"></i>
|
||||||
|
通知级别
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="platformForm.level"
|
||||||
|
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">自动(根据通知类型)</option>
|
||||||
|
<option value="passive">被动</option>
|
||||||
|
<option value="active">默认</option>
|
||||||
|
<option value="timeSensitive">时效性</option>
|
||||||
|
<option value="critical">紧急</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通知声音 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-volume-up mr-2 text-gray-400"></i>
|
||||||
|
通知声音
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="platformForm.sound"
|
||||||
|
class="w-full appearance-none rounded-xl border border-gray-300 bg-white px-4 py-3 pr-10 text-gray-900 shadow-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">自动(根据通知类型)</option>
|
||||||
|
<option value="default">默认</option>
|
||||||
|
<option value="alarm">警报</option>
|
||||||
|
<option value="bell">铃声</option>
|
||||||
|
<option value="birdsong">鸟鸣</option>
|
||||||
|
<option value="electronic">电子音</option>
|
||||||
|
<option value="glass">玻璃</option>
|
||||||
|
<option value="horn">喇叭</option>
|
||||||
|
<option value="silence">静音</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分组 -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-folder mr-2 text-gray-400"></i>
|
||||||
|
通知分组
|
||||||
|
<span class="ml-2 text-xs text-gray-500">(可选)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="platformForm.group"
|
||||||
|
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
placeholder="默认: claude-relay"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示信息 -->
|
||||||
|
<div class="mt-2 flex items-start rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||||
|
<i class="fas fa-info-circle mr-2 mt-0.5 text-blue-600 dark:text-blue-400"></i>
|
||||||
|
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<p>1. 在iPhone上安装Bark App</p>
|
||||||
|
<p>2. 打开App获取您的设备密钥</p>
|
||||||
|
<p>3. 将密钥粘贴到上方输入框</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 签名设置(钉钉/飞书) -->
|
<!-- 签名设置(钉钉/飞书) -->
|
||||||
<div
|
<div
|
||||||
v-if="platformForm.type === 'dingtalk' || platformForm.type === 'feishu'"
|
v-if="platformForm.type === 'dingtalk' || platformForm.type === 'feishu'"
|
||||||
@@ -633,7 +746,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="group flex items-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all hover:from-blue-700 hover:to-indigo-700 hover:shadow-lg disabled:cursor-not-allowed disabled:from-gray-400 disabled:to-gray-500"
|
class="group flex items-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all hover:from-blue-700 hover:to-indigo-700 hover:shadow-lg disabled:cursor-not-allowed disabled:from-gray-400 disabled:to-gray-500"
|
||||||
:disabled="!platformForm.url || savingPlatform"
|
:disabled="!isPlatformFormValid || savingPlatform"
|
||||||
@click="savePlatform"
|
@click="savePlatform"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
@@ -652,7 +765,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
@@ -721,6 +834,44 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听平台类型变化,重置验证状态
|
||||||
|
const platformTypeWatcher = watch(
|
||||||
|
() => platformForm.value.type,
|
||||||
|
(newType) => {
|
||||||
|
// 切换平台类型时重置验证状态
|
||||||
|
urlError.value = false
|
||||||
|
urlValid.value = false
|
||||||
|
|
||||||
|
// 如果不是编辑模式,清空相关字段
|
||||||
|
if (!editingPlatform.value) {
|
||||||
|
if (newType === 'bark') {
|
||||||
|
// 切换到Bark时,清空URL相关字段
|
||||||
|
platformForm.value.url = ''
|
||||||
|
platformForm.value.enableSign = false
|
||||||
|
platformForm.value.secret = ''
|
||||||
|
} else {
|
||||||
|
// 切换到其他平台时,清空Bark相关字段
|
||||||
|
platformForm.value.deviceKey = ''
|
||||||
|
platformForm.value.serverUrl = ''
|
||||||
|
platformForm.value.level = ''
|
||||||
|
platformForm.value.sound = ''
|
||||||
|
platformForm.value.group = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算属性:判断平台表单是否有效
|
||||||
|
const isPlatformFormValid = computed(() => {
|
||||||
|
if (platformForm.value.type === 'bark') {
|
||||||
|
// Bark平台需要deviceKey
|
||||||
|
return !!platformForm.value.deviceKey
|
||||||
|
} else {
|
||||||
|
// 其他平台需要URL且URL格式正确
|
||||||
|
return !!platformForm.value.url && !urlError.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 页面加载时获取设置
|
// 页面加载时获取设置
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -747,6 +898,9 @@ onBeforeUnmount(() => {
|
|||||||
if (sectionWatcher) {
|
if (sectionWatcher) {
|
||||||
sectionWatcher()
|
sectionWatcher()
|
||||||
}
|
}
|
||||||
|
if (platformTypeWatcher) {
|
||||||
|
platformTypeWatcher()
|
||||||
|
}
|
||||||
|
|
||||||
// 安全关闭模态框
|
// 安全关闭模态框
|
||||||
if (showAddPlatformModal.value) {
|
if (showAddPlatformModal.value) {
|
||||||
@@ -795,6 +949,13 @@ const saveWebhookConfig = async () => {
|
|||||||
|
|
||||||
// 验证 URL
|
// 验证 URL
|
||||||
const validateUrl = () => {
|
const validateUrl = () => {
|
||||||
|
// Bark平台不需要验证URL
|
||||||
|
if (platformForm.value.type === 'bark') {
|
||||||
|
urlError.value = false
|
||||||
|
urlValid.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const url = platformForm.value.url
|
const url = platformForm.value.url
|
||||||
if (!url) {
|
if (!url) {
|
||||||
urlError.value = false
|
urlError.value = false
|
||||||
@@ -925,18 +1086,26 @@ const testPlatform = async (platform) => {
|
|||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const testData = {
|
||||||
'/admin/webhook/test',
|
type: platform.type,
|
||||||
{
|
secret: platform.secret,
|
||||||
url: platform.url,
|
enableSign: platform.enableSign
|
||||||
type: platform.type,
|
}
|
||||||
secret: platform.secret,
|
|
||||||
enableSign: platform.enableSign
|
// 根据平台类型添加不同字段
|
||||||
},
|
if (platform.type === 'bark') {
|
||||||
{
|
testData.deviceKey = platform.deviceKey
|
||||||
signal: abortController.value.signal
|
testData.serverUrl = platform.serverUrl
|
||||||
}
|
testData.level = platform.level
|
||||||
)
|
testData.sound = platform.sound
|
||||||
|
testData.group = platform.group
|
||||||
|
} else {
|
||||||
|
testData.url = platform.url
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.post('/admin/webhook/test', testData, {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
showToast('测试成功,webhook连接正常', 'success')
|
showToast('测试成功,webhook连接正常', 'success')
|
||||||
}
|
}
|
||||||
@@ -952,14 +1121,23 @@ const testPlatform = async (platform) => {
|
|||||||
const testPlatformForm = async () => {
|
const testPlatformForm = async () => {
|
||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
|
|
||||||
if (!platformForm.value.url) {
|
// Bark平台验证
|
||||||
showToast('请先输入Webhook URL', 'error')
|
if (platformForm.value.type === 'bark') {
|
||||||
return
|
if (!platformForm.value.deviceKey) {
|
||||||
}
|
showToast('请先输入Bark设备密钥', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他平台验证URL
|
||||||
|
if (!platformForm.value.url) {
|
||||||
|
showToast('请先输入Webhook URL', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (urlError.value) {
|
if (urlError.value) {
|
||||||
showToast('请输入有效的Webhook URL', 'error')
|
showToast('请输入有效的Webhook URL', 'error')
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testingConnection.value = true
|
testingConnection.value = true
|
||||||
@@ -1020,7 +1198,13 @@ const closePlatformModal = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
enableSign: false,
|
enableSign: false,
|
||||||
secret: ''
|
secret: '',
|
||||||
|
// Bark特有字段
|
||||||
|
deviceKey: '',
|
||||||
|
serverUrl: '',
|
||||||
|
level: '',
|
||||||
|
sound: '',
|
||||||
|
group: ''
|
||||||
}
|
}
|
||||||
urlError.value = false
|
urlError.value = false
|
||||||
urlValid.value = false
|
urlValid.value = false
|
||||||
@@ -1037,6 +1221,7 @@ const getPlatformName = (type) => {
|
|||||||
feishu: '飞书',
|
feishu: '飞书',
|
||||||
slack: 'Slack',
|
slack: 'Slack',
|
||||||
discord: 'Discord',
|
discord: 'Discord',
|
||||||
|
bark: 'Bark',
|
||||||
custom: '自定义'
|
custom: '自定义'
|
||||||
}
|
}
|
||||||
return names[type] || type
|
return names[type] || type
|
||||||
@@ -1049,6 +1234,7 @@ const getPlatformIcon = (type) => {
|
|||||||
feishu: 'fas fa-dove text-blue-600',
|
feishu: 'fas fa-dove text-blue-600',
|
||||||
slack: 'fab fa-slack text-purple-600',
|
slack: 'fab fa-slack text-purple-600',
|
||||||
discord: 'fab fa-discord text-indigo-600',
|
discord: 'fab fa-discord text-indigo-600',
|
||||||
|
bark: 'fas fa-bell text-orange-500',
|
||||||
custom: 'fas fa-webhook text-gray-600'
|
custom: 'fas fa-webhook text-gray-600'
|
||||||
}
|
}
|
||||||
return icons[type] || 'fas fa-bell'
|
return icons[type] || 'fas fa-bell'
|
||||||
@@ -1061,6 +1247,7 @@ const getWebhookHint = (type) => {
|
|||||||
feishu: '请在飞书群机器人设置中获取Webhook地址',
|
feishu: '请在飞书群机器人设置中获取Webhook地址',
|
||||||
slack: '请在Slack应用的Incoming Webhooks中获取地址',
|
slack: '请在Slack应用的Incoming Webhooks中获取地址',
|
||||||
discord: '请在Discord服务器的集成设置中创建Webhook',
|
discord: '请在Discord服务器的集成设置中创建Webhook',
|
||||||
|
bark: '请在Bark App中查看您的设备密钥',
|
||||||
custom: '请输入完整的Webhook接收地址'
|
custom: '请输入完整的Webhook接收地址'
|
||||||
}
|
}
|
||||||
return hints[type] || ''
|
return hints[type] || ''
|
||||||
|
|||||||
Reference in New Issue
Block a user