feat: 新增 Anyrouter/Cubence/YesCode 架构及余额监控增强

- 新增三个 provider 架构:Anyrouter、Cubence、YesCode
- 前端新增对应的认证配置模板
- 余额监控增强:支持窗口限额显示(进度条+倒计时)、签到状态、错误信息展示
- 架构基类新增 prepare_verify_config 异步预处理方法
- API 返回 ops_architecture_id 字段用于前端展示
This commit is contained in:
fawney19
2026-01-19 17:18:03 +08:00
parent 3d88dfd98a
commit 6bc9cdc69d
25 changed files with 2057 additions and 20 deletions

View File

@@ -311,6 +311,7 @@ export interface ProviderWithEndpointsSummary {
api_formats: string[]
endpoint_health_details: EndpointHealthDetail[]
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
created_at: string
updated_at: string
}

View File

@@ -83,7 +83,11 @@ export interface BalanceInfo {
total_available: number | null
expires_at: string | null
currency: string
extra: Record<string, any>
extra: Record<string, any> & {
// Anyrouter 签到信息
checkin_success?: boolean | null // true=成功, false=失败, null=已签到/跳过
checkin_message?: string
}
}
/** 签到信息 */

View File

@@ -0,0 +1,84 @@
/**
* Anyrouter 认证模板
*
* 适用于 Anyrouter 中转站:
* - 使用 Cookie 认证session
* - 自动处理 acw_sc__v2 反爬 Cookie后端处理
* - quota 单位是 1/500000 美元
*/
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
export const anyrouterTemplate: AuthTemplate = {
id: 'anyrouter',
name: 'Anyrouter',
description: '适用于 Anyrouter 中转站,使用 Cookie 认证',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: '站点地址',
type: 'text',
placeholder: providerWebsite || 'https://anyrouter.top',
required: !providerWebsite,
helpText: '通常为 https://anyrouter.top',
},
{
key: 'session_cookie',
label: 'Cookie',
type: 'password',
placeholder: 'session=MTc2ODc4...; acw_sc__v2=...',
required: true,
sensitive: true,
helpText: '从浏览器开发者工具复制完整 Cookie或仅填写 session 值',
},
],
},
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'anyrouter',
base_url: baseUrl,
connector: {
auth_type: 'cookie',
config: {},
credentials: {
session_cookie: formData.session_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
return {
base_url: config?.base_url || '',
session_cookie: config?.connector?.credentials?.session_cookie || '',
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.session_cookie?.trim()) {
return '请填写 Session Cookie'
}
return null
},
formatQuota(quota: number): string {
// Anyrouter 的 quota 单位是 1/500000 美元
const usd = quota / 500000
if (usd >= 1) {
return `$${usd.toFixed(2)}`
}
return `$${usd.toFixed(4)}`
},
}

View File

@@ -0,0 +1,157 @@
/**
* Cubence 认证模板
*
* 适用于 Cubence 中转站:
* - 使用 Cookie 认证token JWT
* - 余额单位直接是美元
* - 支持窗口限额查询5小时/每周)
*/
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
/**
* 格式化窗口限额显示(百分比格式)
* 单位是美元(原始值除以 1000000
*/
function formatWindowLimit(limit: {
limit?: number
used?: number
remaining?: number
resets_at?: number
}): { text: string; percent: number } {
if (!limit || limit.remaining === undefined || limit.limit === undefined || limit.limit === 0) {
return { text: '-', percent: 0 }
}
// 计算剩余百分比
const percent = Math.round((limit.remaining / limit.limit) * 100)
return { text: `${percent}%`, percent }
}
/**
* 格式化窗口限额详细信息(用于 tooltip
*/
function formatWindowLimitDetail(limit: {
limit?: number
used?: number
remaining?: number
resets_at?: number
}): string {
if (!limit || limit.remaining === undefined || limit.limit === undefined) {
return ''
}
// 转换为美元(原始值除以 1000000
const remaining = (limit.remaining / 1000000).toFixed(2)
const total = (limit.limit / 1000000).toFixed(2)
return `$${remaining} / $${total}`
}
export const cubenceTemplate: AuthTemplate = {
id: 'cubence',
name: 'Cubence',
description: '适用于 Cubence 中转站,使用 Cookie 认证',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: '站点地址',
type: 'text',
placeholder: providerWebsite || 'https://cubence.com',
required: !providerWebsite,
helpText: '通常为 https://cubence.com',
},
{
key: 'token_cookie',
label: 'Token Cookie',
type: 'password',
placeholder: 'token=eyJhbGciOiJI...',
required: true,
sensitive: true,
helpText: '从浏览器开发者工具复制 Cookie 中的 token 值JWT 格式)',
},
],
},
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'cubence',
base_url: baseUrl,
connector: {
auth_type: 'cookie',
config: {},
credentials: {
token_cookie: formData.token_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
return {
base_url: config?.base_url || '',
token_cookie: config?.connector?.credentials?.token_cookie || '',
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.token_cookie?.trim()) {
return '请填写 Token Cookie'
}
return null
},
formatQuota(quota: number): string {
// Cubence 的余额单位直接是美元
if (quota >= 1) {
return `$${quota.toFixed(2)}`
}
return `$${quota.toFixed(4)}`
},
formatBalanceExtra(extra: Record<string, any>): BalanceExtraItem[] {
const items: BalanceExtraItem[] = []
// 5小时窗口限额
if (extra.five_hour_limit) {
const limit = extra.five_hour_limit
const detail = formatWindowLimitDetail(limit)
const { text, percent } = formatWindowLimit(limit)
items.push({
label: '5h',
value: text,
percent,
resetsAt: limit.resets_at,
tooltip: detail || undefined,
})
}
// 每周窗口限额
if (extra.weekly_limit) {
const limit = extra.weekly_limit
const detail = formatWindowLimitDetail(limit)
const { text, percent } = formatWindowLimit(limit)
items.push({
label: '周',
value: text,
percent,
resetsAt: limit.resets_at,
tooltip: detail || undefined,
})
}
return items
},
}

View File

@@ -22,12 +22,15 @@
*/
import type { AuthTemplate, AuthTemplateRegistry } from './types'
import { anyrouterTemplate } from './anyrouter'
import { cubenceTemplate } from './cubence'
import { newApiTemplate } from './new-api'
import { yescodeTemplate } from './yescode'
// ==================== 模板注册 ====================
// 在这里添加新模板
const templates: AuthTemplate[] = [newApiTemplate]
const templates: AuthTemplate[] = [newApiTemplate, anyrouterTemplate, cubenceTemplate, yescodeTemplate]
// ==================== 注册表实现 ====================
@@ -63,4 +66,7 @@ export const authTemplateRegistry: AuthTemplateRegistry = {
// ==================== 导出 ====================
export * from './types'
export { anyrouterTemplate } from './anyrouter'
export { cubenceTemplate } from './cubence'
export { newApiTemplate } from './new-api'
export { yescodeTemplate } from './yescode'

View File

@@ -103,6 +103,29 @@ export interface AuthTemplate {
* @param quota quota 值
*/
formatQuota?(quota: number): string
/**
* 格式化余额 extra 信息(如窗口限额等)
* 返回一个数组,每个元素包含 label 和 value
* @param extra 余额 extra 字段
*/
formatBalanceExtra?(extra: Record<string, any>): BalanceExtraItem[]
}
/**
* 余额附加信息项
*/
export interface BalanceExtraItem {
/** 显示标签 */
label: string
/** 显示值 */
value: string
/** 百分比数值 (0-100),用于进度条显示 */
percent?: number
/** 重置时间戳Unix 秒),用于倒计时显示 */
resetsAt?: number
/** 可选的提示文本 */
tooltip?: string
}
/**

View File

@@ -0,0 +1,135 @@
/**
* YesCode 认证模板
*
* 适用于 YesCode 中转站:
* - 使用 Cookie 认证yescode_auth JWT + yescode_csrf
* - 余额单位直接是美元
* - subscription_balance: 套餐每日额度
* - weekly_limit: 周限额(用完后套餐额度也无法使用)
*/
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
/**
* 格式化限额显示(百分比格式)
*/
function formatLimitPercent(remaining: number, limit: number): { text: string; percent: number } {
if (!limit || limit === 0) {
return { text: '-', percent: 0 }
}
const percent = Math.round((remaining / limit) * 100)
return { text: `${percent}%`, percent }
}
export const yescodeTemplate: AuthTemplate = {
id: 'yescode',
name: 'YesCode',
description: '适用于 YesCode 中转站,使用 Cookie 认证',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: '站点地址',
type: 'text',
placeholder: providerWebsite || 'https://co.yes.vg',
required: !providerWebsite,
helpText: '通常为 https://co.yes.vg',
},
{
key: 'auth_cookie',
label: 'Auth Cookie',
type: 'password',
placeholder: 'yescode_auth=eyJhbGciOiJI...; yescode_csrf=...',
required: true,
sensitive: true,
helpText: '从浏览器开发者工具复制 Cookie包含 yescode_auth 和 yescode_csrf',
},
],
},
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'yescode',
base_url: baseUrl,
connector: {
auth_type: 'cookie',
config: {},
credentials: {
auth_cookie: formData.auth_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
return {
base_url: config?.base_url || '',
auth_cookie: config?.connector?.credentials?.auth_cookie || '',
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.auth_cookie?.trim()) {
return '请填写 Auth Cookie'
}
return null
},
formatQuota(quota: number): string {
// YesCode 的余额单位直接是美元
if (quota >= 1) {
return `$${quota.toFixed(2)}`
}
return `$${quota.toFixed(4)}`
},
formatBalanceExtra(extra: Record<string, any>): BalanceExtraItem[] {
const items: BalanceExtraItem[] = []
const weeklyLimit = extra.weekly_limit
const weeklySpent = extra.weekly_spent
const dailyLimit = extra.daily_limit
const dailySpent = extra.daily_spent
const dailyResetsAt = extra.daily_resets_at
const weeklyResetsAt = extra.weekly_resets_at
// 天限
if (dailyLimit !== undefined && dailyLimit > 0 && dailySpent !== undefined) {
const dailyRemaining = Math.max(0, dailyLimit - dailySpent)
const { text, percent } = formatLimitPercent(dailyRemaining, dailyLimit)
items.push({
label: '天',
value: text,
percent,
resetsAt: dailyResetsAt,
tooltip: `$${dailyRemaining.toFixed(2)} / $${(dailyLimit as number).toFixed(2)}`,
})
}
// 周限
if (weeklyLimit !== undefined && weeklyLimit > 0 && weeklySpent !== undefined) {
const weeklyRemaining = Math.max(0, weeklyLimit - weeklySpent)
const { text, percent } = formatLimitPercent(weeklyRemaining, weeklyLimit)
items.push({
label: '周',
value: text,
percent,
resetsAt: weeklyResetsAt,
tooltip: `$${weeklyRemaining.toFixed(2)} / $${(weeklyLimit as number).toFixed(2)}`,
})
}
return items
},
}

View File

@@ -175,7 +175,7 @@ import {
} from '../auth-templates'
// 敏感字段列表(用于验证和加载配置时的特殊处理)
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'cookie_string', 'cookies'] as const
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookies'] as const
const props = defineProps<{
open: boolean
@@ -427,11 +427,11 @@ function loadFromConfig(config: any) {
hasExistingConfig.value = true
// 目前只支持 new_api 模板
selectedTemplateId.value = 'new_api'
// 使用模板解析配置
// 根据已保存的 architecture_id 选择对应模板,不存在则回退到 new_api
const architectureId = config.architecture_id || 'new_api'
selectedTemplateId.value = authTemplateRegistry.get(architectureId) ? architectureId : 'new_api'
const template = authTemplateRegistry.get(selectedTemplateId.value)
if (template) {
const parsedData = template.parseConfig(config)

View File

@@ -101,7 +101,7 @@
<TableHead class="w-[180px] h-11 font-medium text-foreground/80">
提供商信息
</TableHead>
<TableHead class="w-[140px] h-11 font-medium text-foreground/80 text-center">
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">
余额监控
</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">
@@ -142,15 +142,73 @@
</a>
</div>
</TableCell>
<TableCell class="py-3.5 text-center">
<TableCell class="py-3.5">
<!-- 显示从上游 API 查询的余额 -->
<div
v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-xs"
class="flex items-center gap-2 text-xs"
>
<span class="font-semibold text-foreground/90">
<!-- 余额文字 -->
<span class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums">
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
</span>
<!-- 窗口限额 + 签到状态 -->
<div
v-if="getProviderBalanceExtra(provider.id, provider.ops_architecture_id).length > 0 || getProviderCheckin(provider.id)"
class="text-muted-foreground/70 space-y-0.5"
>
<!-- 限额进度条 + 倒计时每行一个 -->
<template
v-for="item in getProviderBalanceExtra(provider.id, provider.ops_architecture_id)"
:key="item.label"
>
<div
:title="item.tooltip"
:class="['flex items-center gap-1', item.tooltip ? 'cursor-help' : '']"
>
<span class="text-[10px] text-muted-foreground/60 w-4">{{ item.label }}</span>
<div class="w-12 h-1.5 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full"
:class="[
item.percent !== undefined && item.percent >= 50 ? 'bg-green-500' :
item.percent !== undefined && item.percent >= 20 ? 'bg-amber-500' : 'bg-red-500'
]"
:style="{ width: `${item.percent ?? 0}%` }"
/>
</div>
<span class="text-[10px] text-muted-foreground/50 w-7 text-right tabular-nums">{{ item.value }}</span>
<span
v-if="item.resetsAt"
class="text-[10px] text-muted-foreground/40 w-14 text-right tabular-nums"
>{{ formatResetCountdown(item.resetsAt) }}</span>
</div>
</template>
<!-- 签到状态 -->
<div
v-if="getProviderCheckin(provider.id)"
class="flex items-center gap-1.5"
>
<span
v-if="getProviderCheckin(provider.id)?.success === true"
class="text-[10px] text-muted-foreground/60"
:title="getProviderCheckin(provider.id)?.message"
>已签到</span>
<span
v-else-if="getProviderCheckin(provider.id)?.success === false"
class="text-[10px] text-destructive/70"
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
</div>
</div>
</div>
<!-- 余额查询失败时显示错误 -->
<div
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
class="text-xs text-destructive/80"
:title="getProviderBalanceError(provider.id)?.message"
>
{{ getProviderBalanceError(provider.id)?.message }}
</div>
<!-- 显示本地配置的月度配额 -->
<div
@@ -371,6 +429,25 @@
class="text-muted-foreground"
>
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
<!-- 签到状态显示 -->
<span
v-if="getProviderCheckin(provider.id)?.success === true"
class="ml-1 text-muted-foreground"
:title="getProviderCheckin(provider.id)?.message"
>已签到</span>
<span
v-else-if="getProviderCheckin(provider.id)?.success === false"
class="ml-1 text-destructive/70"
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
</span>
<!-- 余额查询失败时显示错误 -->
<span
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
class="text-destructive/80"
:title="getProviderBalanceError(provider.id)?.message"
>
{{ getProviderBalanceError(provider.id)?.message }}
</span>
<!-- 本地配额 -->
<span
@@ -459,7 +536,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import {
Plus,
Search,
@@ -496,6 +573,7 @@ import {
import { adminApi } from '@/api/admin'
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
import { formatBillingType } from '@/utils/format'
import { authTemplateRegistry, type BalanceExtraItem } from '@/features/providers/auth-templates'
const { error: showError, success: showSuccess } = useToast()
const { confirmDanger } = useConfirm()
@@ -658,6 +736,46 @@ function getProviderBalance(providerId: string): { available: number | null; cur
}
}
// 获取 provider 余额查询的错误状态
function getProviderBalanceError(providerId: string): { status: string; message: string } | null {
const result = balanceCache.value[providerId]
if (!result) {
return null
}
// 认证失败或过期
if (result.status === 'auth_failed' || result.status === 'auth_expired') {
return {
status: result.status,
message: result.message || '认证失败'
}
}
// 其他错误
if (result.status !== 'success') {
return {
status: result.status,
message: result.message || '查询失败'
}
}
return null
}
// 获取 provider 的签到信息(从 extra 字段)
function getProviderCheckin(providerId: string): { success: boolean | null; message: string } | null {
const result = balanceCache.value[providerId]
if (!result || result.status !== 'success' || !result.data) {
return null
}
const data = result.data as Record<string, any>
const extra = data.extra
if (!extra || extra.checkin_success === undefined) {
return null
}
return {
success: extra.checkin_success,
message: extra.checkin_message || ''
}
}
// 格式化余额显示
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
if (!balance || balance.available == null) {
@@ -667,6 +785,48 @@ function formatBalanceDisplay(balance: { available: number | null; currency: str
return `${symbol}${balance.available.toFixed(2)}`
}
// 格式化重置倒计时(从 Unix 时间戳)
function formatResetCountdown(resetsAt: number): string {
// 依赖 tickCounter 触发响应式更新
void tickCounter.value
const now = Date.now() / 1000
const diff = resetsAt - now
if (diff <= 0) return '即将重置'
const totalHours = Math.floor(diff / 3600)
const minutes = Math.floor((diff % 3600) / 60)
const seconds = Math.floor(diff % 60)
const pad = (n: number) => n.toString().padStart(2, '0')
if (totalHours > 0) {
return `${totalHours}:${pad(minutes)}:${pad(seconds)}`
}
return `${minutes}:${pad(seconds)}`
}
// 获取 provider 余额的额外信息(如窗口限额)
function getProviderBalanceExtra(providerId: string, architectureId?: string): BalanceExtraItem[] {
if (!architectureId) return []
const result = balanceCache.value[providerId]
if (!result || result.status !== 'success' || !result.data) {
return []
}
const data = result.data as Record<string, any>
const extra = data.extra
if (!extra) return []
// 获取对应的模板
const template = authTemplateRegistry.get(architectureId)
if (!template?.formatBalanceExtra) return []
return template.formatBalanceExtra(extra)
}
// 格式化官网显示
function formatWebsiteDisplay(url: string): string {
@@ -844,8 +1004,22 @@ async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
}
}
// 用于触发倒计时更新的响应式计数器
const tickCounter = ref(0)
let tickInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
loadProviders()
loadPriorityMode()
// 每秒更新一次倒计时
tickInterval = setInterval(() => {
tickCounter.value++
}, 1000)
})
onUnmounted(() => {
if (tickInterval) {
clearInterval(tickInterval)
}
})
</script>