refactor(provider-ops): 重构认证配置为 schema-driven 模式

后端架构类通过 get_credentials_schema() 返回带 x-* 扩展字段的 JSON Schema,
前端根据 schema 动态渲染表单、构建请求、验证和格式化显示。
新增架构只需后端一个文件,前端零改动。

主要变更:
- 删除前端手写模板文件(anyrouter.ts, cubence.ts, nekocode.ts, new-api.ts, yescode.ts)
- 新增 schema-utils.ts 实现 schema 到表单的转换、请求构建、验证和格式化
- 新增 field-hooks.ts 支持 schema 声明式字段联动钩子
- 后端 base.py 提供 parse_verify_response 默认实现,减少子类重复代码
- 各架构余额查询逻辑复用 balance.py 中的通用函数
- 删除废弃的 one_api.py 架构,新增架构 hidden 属性
- API 返回 credentials_schema 供前端消费
This commit is contained in:
fawney19
2026-02-13 16:41:30 +08:00
parent 7e68882872
commit d6b39babf5
30 changed files with 1233 additions and 1835 deletions

View File

@@ -1,90 +0,0 @@
/**
* Anyrouter 认证模板
*
* 适用于 Anyrouter 中转站:
* - 使用 Cookie 认证session
* - 自动处理 acw_sc__v2 反爬 Cookie后端处理
* - quota 单位是 1/500000 美元
*/
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
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 值',
},
],
},
PROXY_FIELD_GROUP,
]
},
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: {
...buildProxyConfig(formData),
},
credentials: {
session_cookie: formData.session_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
session_cookie: config?.connector?.credentials?.session_cookie || '',
...proxyData,
}
},
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

@@ -1,163 +0,0 @@
/**
* Cubence 认证模板
*
* 适用于 Cubence 中转站:
* - 使用 Cookie 认证token JWT
* - 余额单位直接是美元
* - 支持窗口限额查询5小时/每周)
*/
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
/**
* 格式化窗口限额显示(百分比格式)
* 单位是美元(原始值除以 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 格式)',
},
],
},
PROXY_FIELD_GROUP,
]
},
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: {
...buildProxyConfig(formData),
},
credentials: {
token_cookie: formData.token_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
token_cookie: config?.connector?.credentials?.token_cookie || '',
...proxyData,
}
},
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

@@ -0,0 +1,109 @@
/**
* 字段钩子注册表
*
* 处理 schema 中 x-field-hooks 定义的客户端逻辑,
* 如从 Cookie 解析 user_id 等。
*/
/**
* 钩子函数签名
* @param value 触发字段的值
* @returns 目标字段的值,返回 null 表示不填充
*/
type FieldHookFn = (value: string) => string | null
/**
* 钩子注册表
*/
const hooks: Record<string, FieldHookFn> = {
parse_new_api_user_id: parseNewApiUserId,
}
/**
* 执行字段钩子
* @param action 钩子 action 名称
* @param value 触发字段的值
* @returns 目标字段的值,未找到钩子返回 null
*/
export function executeFieldHook(action: string, value: string): string | null {
const fn = hooks[action]
if (!fn) return null
return fn(value)
}
// ==================== 钩子实现 ====================
/**
* 从 New API session cookie 解析用户 ID
*
* New API 的 session cookie 格式:
* base64(timestamp|gob_base64|signature)
*
* gob 数据中包含 id 和 username 字段
*
* 支持两种输入:
* 1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
* 2. 仅 session 值: "MTc2ODc4..."
*/
function parseNewApiUserId(cookie: string): string | null {
try {
// 提取 session 值
let sessionValue = cookie.trim()
if (sessionValue.includes('session=')) {
const match = sessionValue.match(/session=([^;]+)/)
if (match) {
sessionValue = match[1]
}
}
// URL-safe base64 解码
const padding = 4 - (sessionValue.length % 4)
if (padding !== 4) {
sessionValue += '='.repeat(padding)
}
const standardBase64 = sessionValue.replace(/-/g, '+').replace(/_/g, '/')
const decoded = atob(standardBase64)
// 分割: timestamp|gob_base64|signature
const parts = decoded.split('|')
if (parts.length < 2) return null
// 解码 gob 数据(第二部分)
let gobB64 = parts[1]
const gobPadding = 4 - (gobB64.length % 4)
if (gobPadding !== 4) {
gobB64 += '='.repeat(gobPadding)
}
const gobStandardB64 = gobB64.replace(/-/g, '+').replace(/_/g, '/')
const gobData = atob(gobStandardB64)
// 解析 gob 编码的 id 字段
const idIntPattern = '\x02id\x03int'
const idIdx = gobData.indexOf(idIntPattern)
if (idIdx === -1) return null
// 跳过 "\x02id\x03int" (7字节) 和类型标记 (2字节)
const valueStart = idIdx + 7 + 2
if (valueStart >= gobData.length) return null
const firstByte = gobData.charCodeAt(valueStart)
if (firstByte !== 0) return null
// 下一个字节是长度标记
const marker = gobData.charCodeAt(valueStart + 1)
if (marker < 0x80) return null
const length = 256 - marker
if (valueStart + 2 + length > gobData.length) return null
// 读取 length 字节,大端序转整数
let val = 0
for (let i = 0; i < length; i++) {
val = (val << 8) | gobData.charCodeAt(valueStart + 2 + i)
}
// gob zigzag 解码:正整数用 2*n 编码
return (val >> 1).toString()
} catch {
return null
}
}

View File

@@ -1,74 +1,11 @@
/**
* 提供商认证模板注册表
* 提供商认证模板
*
* 集中管理所有认证模板。
*
* ## 添加新模板的步骤
*
* 1. 在 `auth-templates/` 目录下创建新的模板文件(如 `my-api.ts`
* 2. 实现 `AuthTemplate` 接口
* 3. 在本文件中导入并注册到 `templates` 数组
*
* ## 模板需要实现的内容
*
* - `id`: 模板唯一标识(对应后端的 architecture_id
* - `name`: 显示名称
* - `description`: 描述文本
* - `getFields()`: 返回表单字段定义
* - `buildRequest()`: 构建后端 API 请求
* - `parseConfig()`: 从已有配置解析表单数据
* - `validate()`: 验证表单数据
* - `formatQuota()`: (可选)格式化 quota 显示
* Schema-Driven 模式:后端返回 JSON Schema含 x-* 扩展字段),
* 前端根据 schema 动态渲染表单、构建请求、格式化显示。
* 新增架构只需后端一个文件,前端零改动。
*/
import type { AuthTemplate, AuthTemplateRegistry } from './types'
import { anyrouterTemplate } from './anyrouter'
import { cubenceTemplate } from './cubence'
import { nekocodeTemplate } from './nekocode'
import { newApiTemplate } from './new-api'
import { yescodeTemplate } from './yescode'
// ==================== 模板注册 ====================
// 在这里添加新模板
const templates: AuthTemplate[] = [newApiTemplate, anyrouterTemplate, cubenceTemplate, nekocodeTemplate, yescodeTemplate]
// ==================== 注册表实现 ====================
const templateMap = new Map<string, AuthTemplate>()
// 初始化 Map
templates.forEach((template) => {
templateMap.set(template.id, template)
})
/**
* 认证模板注册表
*/
export const authTemplateRegistry: AuthTemplateRegistry = {
getAll(): AuthTemplate[] {
return templates
},
get(id: string): AuthTemplate | undefined {
return templateMap.get(id)
},
getDefault(): AuthTemplate {
return templates[0]
},
register(template: AuthTemplate): void {
templates.push(template)
templateMap.set(template.id, template)
},
}
// ==================== 导出 ====================
export * from './types'
export { anyrouterTemplate } from './anyrouter'
export { cubenceTemplate } from './cubence'
export { nekocodeTemplate } from './nekocode'
export { newApiTemplate } from './new-api'
export { yescodeTemplate } from './yescode'
export * from './schema-utils'
export { executeFieldHook } from './field-hooks'

View File

@@ -1,148 +0,0 @@
/**
* NekoCode 认证模板
*
* 适用于 NekoCode 中转站:
* - 使用 Cookie 认证session
* - 显示余额、每日配额、订阅状态
*/
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
export const nekocodeTemplate: AuthTemplate = {
id: 'nekocode',
name: 'NekoCode',
description: '适用于 NekoCode 中转站,使用 Cookie 认证',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: '站点地址',
type: 'text',
placeholder: providerWebsite || 'https://nekocode.ai',
required: !providerWebsite,
helpText: '通常为 https://nekocode.ai',
},
{
key: 'session_cookie',
label: 'Cookie',
type: 'password',
placeholder: 'session=MTc2OTYx...',
required: true,
sensitive: true,
helpText: '从浏览器开发者工具复制 session Cookie 值',
},
],
},
PROXY_FIELD_GROUP,
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'nekocode',
base_url: baseUrl,
connector: {
auth_type: 'cookie',
config: {
...buildProxyConfig(formData),
},
credentials: {
session_cookie: formData.session_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
session_cookie: config?.connector?.credentials?.session_cookie || '',
...proxyData,
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.session_cookie?.trim()) {
return '请填写 Session Cookie'
}
return null
},
formatQuota(quota: number): string {
// NekoCode 的余额单位是美元
if (quota >= 1) {
return `$${quota.toFixed(2)}`
}
return `$${quota.toFixed(4)}`
},
formatBalanceExtra(extra: Record<string, any>): BalanceExtraItem[] {
const items: BalanceExtraItem[] = []
// 每日配额(天卡)- 显示进度条和倒计时
if (extra.daily_quota_limit !== undefined && extra.daily_remaining_quota !== undefined) {
const limit = Number(extra.daily_quota_limit)
const remaining = Number(extra.daily_remaining_quota)
const percent = limit > 0 ? Math.round((remaining / limit) * 100) : 0
// 计算刷新时间戳
let resetsAt: number | undefined
if (extra.effective_start_date) {
try {
// effective_start_date 是订阅开始时间,每日配额在每天的这个时间刷新
const startDate = new Date(extra.effective_start_date)
const now = new Date()
// 找到下一个刷新时间点(今天或明天的同一时间)
const todayReset = new Date(now)
todayReset.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds(), 0)
if (todayReset <= now) {
// 已过今天的刷新时间,设为明天
todayReset.setDate(todayReset.getDate() + 1)
}
resetsAt = Math.floor(todayReset.getTime() / 1000)
} catch {
// 忽略解析错误
}
}
items.push({
label: '天',
value: `${percent}%`,
percent,
resetsAt,
})
}
// 套餐到期时间 - 显示倒计时
if (extra.effective_end_date) {
try {
const endDate = new Date(extra.effective_end_date)
const now = new Date()
const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
const resetsAt = Math.floor(endDate.getTime() / 1000)
const percent = Math.min(100, Math.max(0, Math.round((daysLeft / 30) * 100)))
items.push({
label: '月',
value: `${percent}%`,
percent,
resetsAt,
})
} catch {
// 忽略解析错误
}
}
return items
},
}

View File

@@ -1,253 +0,0 @@
/**
* New API 认证模板
*
* 适用于 New API 风格的中转站:
* - 使用 Bearer Token 认证
* - 需要 New-Api-User Header 传递用户 ID
* - quota 单位通常是 1/500000 美元
*/
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
export const newApiTemplate: AuthTemplate = {
id: 'new_api',
name: 'New API',
description: '适用于 New API 风格的中转站,使用 Bearer Token + New-Api-User Header',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: '站点地址',
type: 'text',
placeholder: providerWebsite
? `${providerWebsite}`
: '请填写站点地址',
required: !providerWebsite,
},
],
},
{
fields: [
{
key: 'cookie',
label: 'Cookie',
type: 'text',
placeholder: '',
required: false,
sensitive: true,
helpText: '填写 Cookie 后支持自动签到',
},
],
},
{
layout: 'inline',
fields: [
{
key: 'api_key',
label: '访问令牌 (API Key)',
type: 'password',
placeholder: '',
required: false,
sensitive: true,
flex: 3,
},
{
key: 'user_id',
label: '用户 ID',
type: 'text',
placeholder: '',
required: false,
flex: 1,
},
],
},
PROXY_FIELD_GROUP,
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'new_api',
base_url: baseUrl,
connector: {
auth_type: 'api_key',
config: {
auth_method: 'bearer',
...buildProxyConfig(formData),
},
credentials: {
// 敏感字段始终发送(空字符串会触发后端合并已保存的值)
api_key: formData.api_key?.trim() || '',
user_id: formData.user_id?.trim() || '',
cookie: formData.cookie?.trim() || '',
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
api_key: config?.connector?.credentials?.api_key || '',
user_id: config?.connector?.credentials?.user_id || '',
cookie: config?.connector?.credentials?.cookie || '',
...proxyData,
}
},
validate(formData: Record<string, any>): string | null {
const hasApiKey = !!formData.api_key?.trim()
const hasCookie = !!formData.cookie?.trim()
const hasUserId = !!formData.user_id?.trim()
// api_key 和 cookie 至少需要一个
if (!hasApiKey && !hasCookie) {
return '访问令牌和 Cookie 至少需要填写一个'
}
// 使用 api_key 时必须提供 user_id使用 cookie 时 user_id 可选
if (hasApiKey && !hasCookie && !hasUserId) {
return '使用访问令牌时,用户 ID 不能为空'
}
return null
},
formatQuota(quota: number): string {
// New API 的 quota 单位是 1/500000 美元
const usd = quota / 500000
if (usd >= 1) {
return `$${usd.toFixed(2)}`
}
return `$${usd.toFixed(4)}`
},
onFieldChange(fieldKey: string, value: any, formData: Record<string, any>): void {
// 当 cookie 变化且 user_id 为空时,尝试从 cookie 解析 user_id
if (fieldKey === 'cookie' && value && !formData.user_id?.trim()) {
const result = parseSessionCookie(value)
if (result.userId) {
formData.user_id = result.userId
}
}
},
}
interface SessionCookieResult {
userId: string | null
username: string | null
}
/**
* 从 Cookie 字符串中解析用户 ID 和用户名
*
* New API 的 session cookie 格式:
* base64(timestamp|gob_base64|signature)
*
* gob 数据中包含 id 和 username 字段
*
* 支持两种输入:
* 1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
* 2. 仅 session 值: "MTc2ODc4..."
*/
function parseSessionCookie(cookie: string): SessionCookieResult {
const result: SessionCookieResult = { userId: null, username: null }
try {
// 提取 session 值
let sessionValue = cookie.trim()
if (sessionValue.includes('session=')) {
const match = sessionValue.match(/session=([^;]+)/)
if (match) {
sessionValue = match[1]
}
}
// URL-safe base64 解码
// 补充 padding
const padding = 4 - (sessionValue.length % 4)
if (padding !== 4) {
sessionValue += '='.repeat(padding)
}
// 将 URL-safe base64 转为标准 base64
const standardBase64 = sessionValue.replace(/-/g, '+').replace(/_/g, '/')
const decoded = atob(standardBase64)
// 分割: timestamp|gob_base64|signature
const parts = decoded.split('|')
if (parts.length < 2) {
return result
}
// 解码 gob 数据(第二部分)
let gobB64 = parts[1]
const gobPadding = 4 - (gobB64.length % 4)
if (gobPadding !== 4) {
gobB64 += '='.repeat(gobPadding)
}
const gobStandardB64 = gobB64.replace(/-/g, '+').replace(/_/g, '/')
const gobData = atob(gobStandardB64)
// 解析 gob 编码的 id 字段
// 查找 "\x02id\x03int" 模式,后面跟着 gob 编码的整数
const idIntPattern = '\x02id\x03int'
const idIdx = gobData.indexOf(idIntPattern)
if (idIdx !== -1) {
// 跳过 "\x02id\x03int" (7字节) 和类型标记 (2字节)
const valueStart = idIdx + 7 + 2
if (valueStart < gobData.length) {
// 读取第一个字节,检查是否是 00正数标记
const firstByte = gobData.charCodeAt(valueStart)
if (firstByte === 0) {
// 下一个字节是长度标记
const marker = gobData.charCodeAt(valueStart + 1)
if (marker >= 0x80) {
// 负的表示长度: 256 - marker = 字节数
const length = 256 - marker
if (valueStart + 2 + length <= gobData.length) {
// 读取 length 字节,大端序转整数
let val = 0
for (let i = 0; i < length; i++) {
val = (val << 8) | gobData.charCodeAt(valueStart + 2 + i)
}
// gob zigzag 解码:正整数用 2*n 编码
result.userId = (val >> 1).toString()
}
}
}
}
}
// 解析 gob 编码的 username 字段
// 查找 "\x08username\x06string" 模式,后面跟着长度和字符串值
const usernamePattern = '\x08username\x06string'
const usernameIdx = gobData.indexOf(usernamePattern)
if (usernameIdx !== -1) {
// 跳过 pattern (16字节) 和类型标记 (3字节)
const lengthPos = usernameIdx + usernamePattern.length + 3
if (lengthPos < gobData.length) {
const lengthByte = gobData.charCodeAt(lengthPos)
const valueStart = lengthPos + 1
// 长度 < 128 表示直接长度编码
if (lengthByte < 128 && valueStart + lengthByte <= gobData.length) {
result.username = gobData.substring(valueStart, valueStart + lengthByte)
}
}
}
return result
} catch {
return result
}
}

View File

@@ -0,0 +1,508 @@
/**
* Schema-Driven 工具函数
*
* 根据后端 JSON Schema含 x-* 扩展字段)动态生成表单、构建请求、解析配置和格式化显示。
* 替代原有的手写模板文件new-api.ts, anyrouter.ts 等)。
*/
import type { ConnectorAuthType, SaveConfigRequest } from '@/api/providerOps'
import type {
AuthTemplateField,
AuthTemplateFieldGroup,
BalanceExtraItem,
} from './types'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
import { executeFieldHook } from './field-hooks'
// ==================== 类型定义 ====================
/** 后端 credentials_schema 的类型 */
export interface CredentialsSchema {
type: 'object'
properties: Record<string, SchemaProperty>
required?: string[]
'x-field-groups'?: SchemaFieldGroup[]
'x-auth-type'?: string
'x-auth-method'?: string
'x-validation'?: SchemaValidation[]
'x-quota-divisor'?: number | null
'x-currency'?: string
'x-default-base-url'?: string
'x-balance-extra-format'?: BalanceExtraFormat[]
'x-field-hooks'?: Record<string, { action: string; target: string }>
}
interface SchemaProperty {
type: string
title?: string
description?: string
'x-sensitive'?: boolean
'x-input-type'?: string
'x-default-value'?: string
'x-help'?: string
}
interface SchemaFieldGroup {
fields: string[]
layout?: 'inline' | 'vertical'
'x-flex'?: Record<string, number>
'x-help'?: string
}
interface SchemaValidation {
type: 'required' | 'any_required' | 'conditional_required'
fields?: string[]
message: string
/** conditional_required: 当此字段有值时 */
if?: string
/** conditional_required: 除非此字段有值 */
unless?: string
/** conditional_required: 则这些字段必填 */
then?: string[]
}
interface BalanceExtraFormat {
label: string
type: 'window_limit' | 'daily_quota' | 'weekly_spent' | 'monthly_expiry'
/** window_limit: extra 中的字段名 */
source?: string
/** window_limit: 单位除数 */
unit_divisor?: number
/** daily_quota / weekly_spent: limit 字段名 */
source_limit?: string
/** daily_quota: remaining 字段名 */
source_remaining?: string
/** daily_quota: 每日重置基准时间字段名(计算下次重置时间) */
source_start_date?: string
/** weekly_spent: spent 字段名 */
source_spent?: string
/** weekly_spent: resets_at 字段名 */
source_resets_at?: string
/** monthly_expiry: 到期日期字段名 */
source_end_date?: string
}
// ==================== Schema -> 表单字段 ====================
/**
* 从 schema 生成表单字段分组
*/
export function schemaToFieldGroups(
schema: CredentialsSchema,
providerWebsite?: string,
): AuthTemplateFieldGroup[] {
const groups: AuthTemplateFieldGroup[] = []
const fieldGroups = schema['x-field-groups']
const properties = schema.properties
const defaultBaseUrl = schema['x-default-base-url']
if (fieldGroups && fieldGroups.length > 0) {
for (const group of fieldGroups) {
const fields: AuthTemplateField[] = []
for (const fieldKey of group.fields) {
const prop = properties[fieldKey]
if (!prop) continue
fields.push(propertyToField(fieldKey, prop, schema, providerWebsite, defaultBaseUrl, group))
}
if (fields.length === 0) continue
const result: AuthTemplateFieldGroup = { fields }
if (group.layout === 'inline') {
result.layout = 'inline'
}
groups.push(result)
}
} else {
// 没有分组定义,按 properties 顺序逐个展示
for (const [key, prop] of Object.entries(properties)) {
groups.push({
fields: [propertyToField(key, prop, schema, providerWebsite, defaultBaseUrl)],
})
}
}
// 追加代理配置
groups.push(PROXY_FIELD_GROUP)
return groups
}
function propertyToField(
key: string,
prop: SchemaProperty,
schema: CredentialsSchema,
providerWebsite?: string,
defaultBaseUrl?: string,
group?: SchemaFieldGroup,
): AuthTemplateField {
const isSensitive = prop['x-sensitive'] === true
const inputType = prop['x-input-type']
let fieldType: AuthTemplateField['type'] = 'text'
if (inputType === 'password' || isSensitive) {
fieldType = 'password'
}
// base_url 特殊处理 placeholder
let placeholder = ''
if (key === 'base_url') {
placeholder = providerWebsite || defaultBaseUrl || ''
}
// 是否必填
const isRequired = schema.required?.includes(key) ?? false
// flex 值
let flex: number | undefined
if (group?.['x-flex']?.[key]) {
flex = group['x-flex'][key]
}
// helpText
let helpText: string | undefined
if (prop['x-help']) {
helpText = prop['x-help']
} else if (group?.['x-help'] && group.fields.length === 1 && group.fields[0] === key) {
helpText = group['x-help']
}
const field: AuthTemplateField = {
key,
label: prop.title || key,
type: fieldType,
placeholder,
required: key === 'base_url' ? !providerWebsite && !defaultBaseUrl : isRequired,
sensitive: isSensitive,
}
if (flex) field.flex = flex
if (helpText) field.helpText = helpText
if (prop['x-default-value']) field.defaultValue = prop['x-default-value']
return field
}
// ==================== 构建请求 ====================
/**
* 从 schema 和表单数据构建 SaveConfigRequest
*/
export function buildRequestFromSchema(
schema: CredentialsSchema,
architectureId: string,
formData: Record<string, any>,
providerWebsite?: string,
): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || schema['x-default-base-url'] || ''
const authType = schema['x-auth-type'] || 'api_key'
// 构建 credentials除 base_url 和代理字段外的所有 schema 属性
const credentials: Record<string, any> = {}
for (const key of Object.keys(schema.properties)) {
if (key === 'base_url') continue
const v = formData[key]
credentials[key] = typeof v === 'string' ? v.trim() : v ?? ''
}
return {
architecture_id: architectureId,
base_url: baseUrl,
connector: {
auth_type: authType as ConnectorAuthType,
config: {
...(schema['x-auth-method'] ? { auth_method: schema['x-auth-method'] } : {}),
...buildProxyConfig(formData),
},
credentials,
},
actions: {},
schedule: {},
}
}
// ==================== 解析配置 ====================
/**
* 从已有配置解析表单数据
*/
export function parseConfigFromSchema(
schema: CredentialsSchema,
config: any,
): Record<string, any> {
const proxyData = parseProxyConfig(config?.connector?.config)
const result: Record<string, any> = {
base_url: config?.base_url || '',
...proxyData,
}
// 从 credentials 中提取各 schema 属性
for (const key of Object.keys(schema.properties)) {
if (key === 'base_url') continue
result[key] = config?.connector?.credentials?.[key] || ''
}
return result
}
// ==================== 验证 ====================
/**
* 根据 schema 验证表单数据
* @returns 错误消息,无错误返回 null
*/
export function validateFromSchema(
schema: CredentialsSchema,
formData: Record<string, any>,
): string | null {
const validations = schema['x-validation']
if (!validations) return null
for (const rule of validations) {
switch (rule.type) {
case 'required': {
if (!rule.fields) break
for (const field of rule.fields) {
if (!formData[field]?.trim?.()) {
return rule.message
}
}
break
}
case 'any_required': {
if (!rule.fields) break
const hasAny = rule.fields.some((f) => !!formData[f]?.trim?.())
if (!hasAny) {
return rule.message
}
break
}
case 'conditional_required': {
const ifField = rule.if
const unlessField = rule.unless
const thenFields = rule.then
if (!ifField || !thenFields) break
const ifHasValue = !!formData[ifField]?.trim?.()
const unlessHasValue = unlessField ? !!formData[unlessField]?.trim?.() : false
if (ifHasValue && !unlessHasValue) {
for (const field of thenFields) {
if (!formData[field]?.trim?.()) {
return rule.message
}
}
}
break
}
}
}
return null
}
// ==================== Quota 格式化 ====================
/**
* 根据 schema 格式化 quota 显示
*/
export function formatQuotaFromSchema(
schema: CredentialsSchema,
quota: number,
): string {
const divisor = schema['x-quota-divisor']
const currency = schema['x-currency'] || 'USD'
let value = quota
if (divisor) {
value = quota / divisor
}
const symbol = currency === 'USD' ? '$' : currency
if (value >= 1) {
return `${symbol}${value.toFixed(2)}`
}
return `${symbol}${value.toFixed(4)}`
}
// ==================== Balance Extra 格式化 ====================
/**
* 根据 schema 格式化余额附加信息
*/
export function formatBalanceExtraFromSchema(
schema: CredentialsSchema,
extra: Record<string, any>,
): BalanceExtraItem[] {
const formats = schema['x-balance-extra-format']
if (!formats) return []
const items: BalanceExtraItem[] = []
for (const fmt of formats) {
switch (fmt.type) {
case 'window_limit': {
const item = formatWindowLimitItem(extra, fmt)
if (item) items.push(item)
break
}
case 'daily_quota': {
const item = formatDailyQuotaItem(extra, fmt)
if (item) items.push(item)
break
}
case 'weekly_spent': {
const item = formatWeeklySpentItem(extra, fmt)
if (item) items.push(item)
break
}
case 'monthly_expiry': {
const item = formatMonthlyExpiryItem(extra, fmt)
if (item) items.push(item)
break
}
}
}
return items
}
function formatWindowLimitItem(
extra: Record<string, any>,
fmt: BalanceExtraFormat,
): BalanceExtraItem | null {
if (!fmt.source) return null
const limit = extra[fmt.source]
if (!limit || limit.remaining === undefined || limit.limit === undefined || limit.limit === 0) {
return null
}
const percent = Math.round((limit.remaining / limit.limit) * 100)
const divisor = fmt.unit_divisor || 1
const remaining = (limit.remaining / divisor).toFixed(2)
const total = (limit.limit / divisor).toFixed(2)
return {
label: fmt.label,
value: `${percent}%`,
percent,
resetsAt: limit.resets_at,
tooltip: `$${remaining} / $${total}`,
}
}
function formatDailyQuotaItem(
extra: Record<string, any>,
fmt: BalanceExtraFormat,
): BalanceExtraItem | null {
const limitKey = fmt.source_limit || 'daily_quota_limit'
const remainingKey = fmt.source_remaining || 'daily_remaining_quota'
const limit = Number(extra[limitKey])
const remaining = Number(extra[remainingKey])
if (extra[limitKey] === undefined || extra[remainingKey] === undefined) return null
if (!limit) return null
const percent = Math.round((remaining / limit) * 100)
// 从 source_start_date 计算下次重置时间
let resetsAt: number | undefined
const startDateKey = fmt.source_start_date
if (startDateKey && extra[startDateKey]) {
try {
const startDate = new Date(extra[startDateKey])
const now = new Date()
const todayReset = new Date(now)
todayReset.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds(), 0)
if (todayReset <= now) {
todayReset.setDate(todayReset.getDate() + 1)
}
resetsAt = Math.floor(todayReset.getTime() / 1000)
} catch {
// ignore
}
}
return {
label: fmt.label,
value: `${percent}%`,
percent,
resetsAt,
}
}
function formatMonthlyExpiryItem(
extra: Record<string, any>,
fmt: BalanceExtraFormat,
): BalanceExtraItem | null {
const endDateKey = fmt.source_end_date || 'effective_end_date'
if (!extra[endDateKey]) return null
try {
const endDate = new Date(extra[endDateKey])
const now = new Date()
const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
const resetsAt = Math.floor(endDate.getTime() / 1000)
const percent = Math.min(100, Math.max(0, Math.round((daysLeft / 30) * 100)))
return {
label: fmt.label,
value: `${percent}%`,
percent,
resetsAt,
}
} catch {
return null
}
}
function formatWeeklySpentItem(
extra: Record<string, any>,
fmt: BalanceExtraFormat,
): BalanceExtraItem | null {
const limitKey = fmt.source_limit || 'weekly_limit'
const spentKey = fmt.source_spent || 'weekly_spent'
const resetsAtKey = fmt.source_resets_at || 'weekly_resets_at'
const limit = extra[limitKey]
const spent = extra[spentKey]
if (limit === undefined || limit <= 0 || spent === undefined) return null
const remaining = Math.max(0, limit - spent)
const percent = Math.round((remaining / limit) * 100)
return {
label: fmt.label,
value: `${percent}%`,
percent,
resetsAt: extra[resetsAtKey],
tooltip: `$${remaining.toFixed(2)} / $${(limit as number).toFixed(2)}`,
}
}
// ==================== Field Hooks ====================
/**
* 处理字段变化时的钩子逻辑
*/
export function handleSchemaFieldChange(
schema: CredentialsSchema,
fieldKey: string,
value: any,
formData: Record<string, any>,
): void {
const hooks = schema['x-field-hooks']
if (!hooks) return
const hook = hooks[fieldKey]
if (!hook) return
// 目标字段为空时才填充
if (formData[hook.target]?.trim?.()) return
const result = executeFieldHook(hook.action, value)
if (result) {
formData[hook.target] = result
}
}

View File

@@ -1,14 +1,7 @@
/**
* 提供商认证模板类型定义
*
* 认证模板定义了:
* - 需要收集的表单字段
* - 如何构建后端请求
* - 如何解析已有配置
*/
import type { SaveConfigRequest } from '@/api/providerOps'
/**
* 表单字段类型
*/
@@ -60,78 +53,6 @@ export interface AuthTemplateFieldGroup {
layout?: 'vertical' | 'inline'
}
/**
* 验证结果数据
*/
export interface VerifyResultData {
username?: string
display_name?: string
email?: string
quota?: number
used_quota?: number
request_count?: number
extra?: Record<string, any>
}
/**
* 认证模板接口
*/
export interface AuthTemplate {
/** 模板 ID对应后端 architecture_id */
id: string
/** 显示名称 */
name: string
/** 描述 */
description: string
/**
* 获取表单字段定义
* @param providerWebsite 提供商官网(用于设置默认 base_url
*/
getFields(providerWebsite?: string): AuthTemplateFieldGroup[]
/**
* 构建保存请求
* @param formData 表单数据
* @param providerWebsite 提供商官网
*/
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest
/**
* 从已有配置解析表单数据
* @param config 已有配置
*/
parseConfig(config: any): Record<string, any>
/**
* 验证表单数据
* @param formData 表单数据
* @returns 错误消息,无错误返回 null
*/
validate(formData: Record<string, any>): string | null
/**
* 格式化验证结果中的 quota 显示
* @param quota quota 值
*/
formatQuota?(quota: number): string
/**
* 格式化余额 extra 信息(如窗口限额等)
* 返回一个数组,每个元素包含 label 和 value
* @param extra 余额 extra 字段
*/
formatBalanceExtra?(extra: Record<string, any>): BalanceExtraItem[]
/**
* 字段值变化时的回调,可用于联动填充其他字段
* @param fieldKey 变化的字段 key
* @param value 新值
* @param formData 当前表单数据(可修改)
*/
onFieldChange?(fieldKey: string, value: any, formData: Record<string, any>): void
}
/**
* 余额附加信息项
*/
@@ -148,20 +69,6 @@ export interface BalanceExtraItem {
tooltip?: string
}
/**
* 认证模板注册表类型
*/
export interface AuthTemplateRegistry {
/** 获取所有模板 */
getAll(): AuthTemplate[]
/** 根据 ID 获取模板 */
get(id: string): AuthTemplate | undefined
/** 获取默认模板 */
getDefault(): AuthTemplate
/** 注册模板 */
register(template: AuthTemplate): void
}
// ==================== 通用字段定义 ====================
/**
@@ -188,30 +95,6 @@ export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
toggleKey: 'proxy_enabled',
}
// 兼容旧代码的字段导出
export const PROXY_URL_FIELD: AuthTemplateField = {
key: 'proxy_url',
label: '代理地址',
type: 'text',
placeholder: 'http://proxy:port 或 socks5://',
required: false,
}
export const PROXY_USERNAME_FIELD: AuthTemplateField = {
key: 'proxy_username',
label: '用户名',
type: 'text',
placeholder: '可选',
required: false,
}
export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
key: 'proxy_password',
label: '密码',
type: 'password',
placeholder: '可选',
required: false,
sensitive: true,
}
/**
* 构建代理配置(仅代理节点模式)
*
@@ -253,6 +136,3 @@ export function parseProxyConfig(config: any): Record<string, any> {
proxy_node_id: '',
}
}
// 兼容旧的导出
export const PROXY_FIELD: AuthTemplateField = PROXY_URL_FIELD

View File

@@ -1,141 +0,0 @@
/**
* 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'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
/**
* 格式化限额显示(百分比格式)
*/
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',
},
],
},
PROXY_FIELD_GROUP,
]
},
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: {
...buildProxyConfig(formData),
},
credentials: {
auth_cookie: formData.auth_cookie,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
auth_cookie: config?.connector?.credentials?.auth_cookie || '',
...proxyData,
}
},
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

@@ -29,26 +29,26 @@
<div class="space-y-2">
<Label>认证模板</Label>
<Select
v-model="selectedTemplateId"
@update:model-value="handleTemplateChange"
v-model="selectedArchitectureId"
@update:model-value="handleArchitectureChange"
>
<SelectTrigger>
<SelectValue placeholder="选择认证模板" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="template in templates"
:key="template.id"
:value="template.id"
v-for="arch in architectures"
:key="arch.architecture_id"
:value="arch.architecture_id"
>
{{ template.name }}
{{ arch.display_name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 动态表单字段 -->
<template v-if="selectedTemplate">
<template v-if="currentSchema">
<template
v-for="(group, groupIndex) in fieldGroups"
:key="groupIndex"
@@ -268,14 +268,26 @@ import {
SelectValue,
Switch,
} from '@/components/ui'
import { saveProviderOpsConfig, verifyProviderAuth, getProviderOpsConfig, deleteProviderOpsConfig } from '@/api/providerOps'
import {
getArchitectures,
saveProviderOpsConfig,
verifyProviderAuth,
getProviderOpsConfig,
deleteProviderOpsConfig,
type ArchitectureInfo,
} from '@/api/providerOps'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import type { AuthTemplateFieldGroup } from '../auth-templates/types'
import {
authTemplateRegistry,
type AuthTemplate,
type AuthTemplateFieldGroup,
} from '../auth-templates'
schemaToFieldGroups,
buildRequestFromSchema,
parseConfigFromSchema,
validateFromSchema,
formatQuotaFromSchema,
handleSchemaFieldChange,
type CredentialsSchema,
} from '../auth-templates/schema-utils'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
@@ -291,15 +303,19 @@ const emit = defineEmits<{
(e: 'saved'): void
}>()
// 敏感字段列表(用于验证和加载配置时的特殊处理)
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookie', 'proxy_password'] as const
// 敏感字段检测:根据 schema 动态判断
function isSensitiveField(key: string): boolean {
if (!currentSchema.value) return false
const prop = currentSchema.value.properties[key]
return prop?.['x-sensitive'] === true
}
const { success: showSuccess, error: showError } = useToast()
const { confirmDanger } = useConfirm()
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
const proxyNodesStore = useProxyNodesStore()
/** 启用代理时加载节点列表(直接调用 store避免 ref 未挂载时静默失败) */
/** 启用代理时加载节点列表 */
function handleProxyToggle(toggleKey: string, value: boolean) {
formData.value[toggleKey] = value
if (value) {
@@ -320,30 +336,37 @@ const sensitivePlaceholders = ref<Record<string, string>>({})
// 是否有已保存的配置(编辑模式)
const hasExistingConfig = ref(false)
// 模板选择
const selectedTemplateId = ref('new_api')
// 架构列表(从后端获取)
const architectures = ref<ArchitectureInfo[]>([])
const architecturesLoaded = ref(false)
// 当前选择
const selectedArchitectureId = ref('new_api')
const formData = ref<Record<string, any>>({})
// 当前架构的 schema
const currentSchema = computed<CredentialsSchema | null>(() => {
const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value)
return (arch?.credentials_schema as CredentialsSchema) ?? null
})
// 表单是否可以验证(必填字段已填写)
const canVerify = computed(() => {
const template = selectedTemplate.value
if (!template) return false
const schema = currentSchema.value
if (!schema) return false
// 编辑模式下,敏感字段可以为空(使用已保存的值)
let dataToValidate = formData.value
if (hasExistingConfig.value) {
// 创建一个临时数据,把空的敏感字段填充为占位值以通过验证
const tempData = { ...formData.value }
for (const field of SENSITIVE_FIELDS) {
if (!tempData[field] && sensitivePlaceholders.value[field]) {
tempData[field] = 'placeholder'
dataToValidate = { ...formData.value }
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[key] = 'placeholder'
}
}
const error = template.validate(tempData)
if (error) return false
} else {
const error = template.validate(formData.value)
if (error) return false
}
const error = validateFromSchema(schema, dataToValidate)
if (error) return false
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
return !!effectiveBaseUrl
@@ -354,35 +377,26 @@ const canSave = computed(() => {
return verifyStatus.value === 'success' && !formChanged.value
})
// Computed
const templates = computed(() => authTemplateRegistry.getAll())
const selectedTemplate = computed<AuthTemplate | undefined>(() => {
return authTemplateRegistry.get(selectedTemplateId.value)
})
// 字段分组
const fieldGroups = computed<AuthTemplateFieldGroup[]>(() => {
if (!selectedTemplate.value) return []
return selectedTemplate.value.getFields(props.providerWebsite)
if (!currentSchema.value) return []
return schemaToFieldGroups(currentSchema.value, props.providerWebsite)
})
// Methods
function handleTemplateChange() {
// 重置表单数据
function handleArchitectureChange() {
resetFormData()
// 重置验证状态
verifyStatus.value = null
formChanged.value = true
}
function handleFieldChange(fieldKey: string, value: any) {
// 标记表单已变动
formChanged.value = true
// 调用模板的 onFieldChange 回调
const template = selectedTemplate.value
if (template?.onFieldChange) {
template.onFieldChange(fieldKey, value, formData.value)
// 执行 schema 定义的字段钩子
const schema = currentSchema.value
if (schema) {
handleSchemaFieldChange(schema, fieldKey, value, formData.value)
}
}
@@ -390,7 +404,6 @@ function handleFieldChange(fieldKey: string, value: any) {
watch(
formData,
() => {
// 验证成功后任何修改都需要重新验证
if (verifyStatus.value === 'success') {
formChanged.value = true
}
@@ -399,55 +412,52 @@ watch(
)
function resetFormData() {
const template = selectedTemplate.value
if (!template) {
const schema = currentSchema.value
if (!schema) {
formData.value = {}
return
}
// 初始化表单数据,设置默认值
// 初始化表单数据
const data: Record<string, any> = {}
const groups = template.getFields(props.providerWebsite)
for (const group of groups) {
for (const field of group.fields) {
data[field.key] = field.defaultValue ?? ''
}
for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = (prop as any)['x-default-value'] ?? ''
}
// 代理相关默认值
data.proxy_enabled = false
data.proxy_node_id = ''
formData.value = data
}
function formatQuota(quota: number): string {
const template = selectedTemplate.value
if (template?.formatQuota) {
return template.formatQuota(quota)
const schema = currentSchema.value
if (schema) {
return formatQuotaFromSchema(schema, quota)
}
// 默认格式化
return quota.toLocaleString()
}
async function handleVerify() {
const template = selectedTemplate.value
if (!template) return
const schema = currentSchema.value
if (!schema) return
// 验证表单(编辑模式下敏感字段可以为空)
let dataToValidate = formData.value
if (hasExistingConfig.value) {
dataToValidate = { ...formData.value }
for (const field of SENSITIVE_FIELDS) {
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) {
dataToValidate[field] = 'placeholder'
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[key] = 'placeholder'
}
}
}
const error = template.validate(dataToValidate)
const error = validateFromSchema(schema, dataToValidate)
if (error) {
showError(error)
return
}
// 检查 base_url
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
@@ -457,8 +467,12 @@ async function handleVerify() {
isVerifying.value = true
try {
const request = template.buildRequest(formData.value, props.providerWebsite)
// 确保 base_url 是有效字符串,用于 VerifyAuthRequest
const request = buildRequestFromSchema(
schema,
selectedArchitectureId.value,
formData.value,
props.providerWebsite,
)
const verifyRequest = {
...request,
base_url: request.base_url || effectiveBaseUrl,
@@ -466,12 +480,10 @@ async function handleVerify() {
const result = await verifyProviderAuth(props.providerId, verifyRequest)
if (result.success) {
// 检查是否获取到有效的用户信息和余额
const username = result.data?.username?.trim() || result.data?.display_name?.trim()
const quota = result.data?.quota
if (!username || quota === undefined || quota === null) {
// 没有获取到必要信息,视为验证失败
verifyStatus.value = 'error'
const missing: string[] = []
if (!username) missing.push('用户信息')
@@ -479,8 +491,7 @@ async function handleVerify() {
showError(`验证响应缺少: ${missing.join('、')}`)
} else {
verifyStatus.value = 'success'
formChanged.value = false // 验证成功后重置表单变动标记
// Toast 提示
formChanged.value = false
const displayName = result.data?.display_name || result.data?.username
showSuccess(`用户: ${displayName} | 余额: ${formatQuota(quota)}`, '验证成功')
}
@@ -498,26 +509,25 @@ async function handleVerify() {
}
async function handleSave() {
const template = selectedTemplate.value
if (!template) return
const schema = currentSchema.value
if (!schema) return
// 验证表单(编辑模式下敏感字段可以为空)
// 验证表单
let dataToValidate = formData.value
if (hasExistingConfig.value) {
dataToValidate = { ...formData.value }
for (const field of SENSITIVE_FIELDS) {
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) {
dataToValidate[field] = 'placeholder'
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[key] = 'placeholder'
}
}
}
const error = template.validate(dataToValidate)
const error = validateFromSchema(schema, dataToValidate)
if (error) {
showError(error)
return
}
// 检查 base_url
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
@@ -526,7 +536,12 @@ async function handleSave() {
isSaving.value = true
try {
const request = template.buildRequest(formData.value, props.providerWebsite)
const request = buildRequestFromSchema(
schema,
selectedArchitectureId.value,
formData.value,
props.providerWebsite,
)
const result = await saveProviderOpsConfig(props.providerId, request)
if (result.success) {
showSuccess(result.message || '配置已保存', '保存成功')
@@ -557,12 +572,11 @@ async function handleClear() {
const result = await deleteProviderOpsConfig(props.providerId)
if (result.success) {
showSuccess(result.message || '认证信息已清除', '清除成功')
// 重置状态
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
verifyStatus.value = null
formChanged.value = false
selectedTemplateId.value = 'new_api'
selectedArchitectureId.value = 'new_api'
resetFormData()
emit('saved')
emit('update:open', false)
@@ -581,22 +595,21 @@ function loadFromConfig(config: any) {
hasExistingConfig.value = true
// 根据已保存的 architecture_id 选择对应模板,不存在则回退到 new_api
// 根据已保存的 architecture_id 选择对应架构
const architectureId = config.architecture_id || 'new_api'
selectedTemplateId.value = authTemplateRegistry.get(architectureId) ? architectureId : 'new_api'
const template = authTemplateRegistry.get(selectedTemplateId.value)
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
if (template) {
const parsedData = template.parseConfig(config)
const schema = currentSchema.value
if (schema) {
const parsedData = parseConfigFromSchema(schema, config)
// 敏感字段:脱敏值放到 placeholder表单值设为空
sensitivePlaceholders.value = {}
for (const field of SENSITIVE_FIELDS) {
if (parsedData[field]) {
// 保存脱敏值作为 placeholder 提示
sensitivePlaceholders.value[field] = `${parsedData[field]}`
// 表单值设为空
parsedData[field] = ''
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && parsedData[key]) {
sensitivePlaceholders.value[key] = `${parsedData[key]}`
parsedData[key] = ''
}
}
@@ -604,6 +617,17 @@ function loadFromConfig(config: any) {
}
}
/** 确保架构列表已加载 */
async function ensureArchitecturesLoaded(): Promise<void> {
if (architecturesLoaded.value) return
try {
architectures.value = await getArchitectures()
architecturesLoaded.value = true
} catch {
architectures.value = []
}
}
// 打开对话框时初始化
watch(
() => props.open,
@@ -612,6 +636,9 @@ watch(
verifyStatus.value = null
formChanged.value = false
// 确保架构列表已加载
await ensureArchitecturesLoaded()
// 如果传入了 currentConfig直接使用
if (props.currentConfig?.connector) {
loadFromConfig(props.currentConfig)
@@ -624,7 +651,6 @@ watch(
try {
const config = await getProviderOpsConfig(props.providerId)
if (config.is_configured && config.architecture_id) {
// 构建与 loadFromConfig 兼容的格式
const configData = {
architecture_id: config.architecture_id,
base_url: config.base_url,
@@ -634,14 +660,13 @@ watch(
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api'
selectedArchitectureId.value = 'new_api'
resetFormData()
}
} catch {
// 加载失败,使用默认值
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api'
selectedArchitectureId.value = 'new_api'
resetFormData()
} finally {
isLoadingConfig.value = false
@@ -649,7 +674,7 @@ watch(
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api'
selectedArchitectureId.value = 'new_api'
resetFormData()
}
}