mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 添加 Provider Ops 扩展操作系统,支持余额监控
主要更改: - 新增 Provider Ops 服务框架,支持通过架构配置执行余额查询等扩展操作 - 后端:添加 provider_ops 服务层和 API 路由 - 前端:添加 ProviderAuthDialog 组件配置认证信息 - 前端:添加 providerOps API 和认证模板系统 UI/组件优化: - Input 组件:新增 masked 属性,使用 CSS 遮蔽敏感信息,避免触发密码管理器 - Pagination 组件:移除首页/末页/上下页按钮,改为页码跳转输入框 - KeyFormDialog:使用 masked 属性简化 API Key 输入逻辑 - ProviderManagement:重新设计表格布局,显示余额监控数据
This commit is contained in:
@@ -310,6 +310,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
unhealthy_endpoints: number
|
||||
api_formats: string[]
|
||||
endpoint_health_details: EndpointHealthDetail[]
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
353
frontend/src/api/providerOps.ts
Normal file
353
frontend/src/api/providerOps.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Provider 操作 API
|
||||
*
|
||||
* 提供 Provider 扩展操作相关的 API:
|
||||
* - 架构管理
|
||||
* - 连接管理
|
||||
* - 操作执行(余额查询、签到等)
|
||||
*/
|
||||
|
||||
import client from './client'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/** 认证类型 */
|
||||
export type ConnectorAuthType = 'api_key' | 'session_login' | 'oauth' | 'cookie' | 'none'
|
||||
|
||||
/** 操作类型 */
|
||||
export type ProviderActionType =
|
||||
| 'query_balance'
|
||||
| 'checkin'
|
||||
| 'claim_quota'
|
||||
| 'refresh_token'
|
||||
| 'get_usage'
|
||||
| 'get_models'
|
||||
| 'custom'
|
||||
|
||||
/** 操作状态 */
|
||||
export type ActionStatus =
|
||||
| 'success'
|
||||
| 'auth_failed'
|
||||
| 'auth_expired'
|
||||
| 'rate_limited'
|
||||
| 'network_error'
|
||||
| 'parse_error'
|
||||
| 'not_configured'
|
||||
| 'not_supported'
|
||||
| 'already_done'
|
||||
| 'unknown_error'
|
||||
|
||||
/** 连接状态 */
|
||||
export type ConnectorStatus = 'disconnected' | 'connecting' | 'connected' | 'expired' | 'error'
|
||||
|
||||
/** 架构信息 */
|
||||
export interface ArchitectureInfo {
|
||||
architecture_id: string
|
||||
display_name: string
|
||||
description: string
|
||||
supported_auth_types: Array<{
|
||||
type: string
|
||||
display_name: string
|
||||
}>
|
||||
supported_actions: Array<{
|
||||
type: string
|
||||
display_name: string
|
||||
description: string
|
||||
config_schema: Record<string, any>
|
||||
}>
|
||||
default_connector: string | null
|
||||
}
|
||||
|
||||
/** 连接状态响应 */
|
||||
export interface ConnectionStatusResponse {
|
||||
status: ConnectorStatus
|
||||
auth_type: ConnectorAuthType
|
||||
connected_at: string | null
|
||||
expires_at: string | null
|
||||
last_error: string | null
|
||||
}
|
||||
|
||||
/** Provider 操作状态响应 */
|
||||
export interface ProviderOpsStatusResponse {
|
||||
provider_id: string
|
||||
is_configured: boolean
|
||||
architecture_id: string | null
|
||||
connection_status: ConnectionStatusResponse
|
||||
enabled_actions: string[]
|
||||
}
|
||||
|
||||
/** 余额信息 */
|
||||
export interface BalanceInfo {
|
||||
total_granted: number | null
|
||||
total_used: number | null
|
||||
total_available: number | null
|
||||
expires_at: string | null
|
||||
currency: string
|
||||
extra: Record<string, any>
|
||||
}
|
||||
|
||||
/** 签到信息 */
|
||||
export interface CheckinInfo {
|
||||
reward: number | null
|
||||
streak_days: number | null
|
||||
next_reward: number | null
|
||||
message: string | null
|
||||
extra: Record<string, any>
|
||||
}
|
||||
|
||||
/** 操作结果响应 */
|
||||
export interface ActionResultResponse {
|
||||
status: ActionStatus
|
||||
action_type: ProviderActionType
|
||||
data: BalanceInfo | CheckinInfo | Record<string, unknown> | null
|
||||
message: string | null
|
||||
executed_at: string
|
||||
response_time_ms: number | null
|
||||
cache_ttl_seconds: number
|
||||
}
|
||||
|
||||
/** 连接器配置请求 */
|
||||
export interface ConnectorConfigRequest {
|
||||
auth_type: ConnectorAuthType
|
||||
config: Record<string, any>
|
||||
credentials: Record<string, any>
|
||||
}
|
||||
|
||||
/** 操作配置请求 */
|
||||
export interface ActionConfigRequest {
|
||||
enabled: boolean
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
/** 保存配置请求 */
|
||||
export interface SaveConfigRequest {
|
||||
architecture_id: string
|
||||
base_url?: string
|
||||
connector: ConnectorConfigRequest
|
||||
actions: Record<string, ActionConfigRequest>
|
||||
schedule: Record<string, string>
|
||||
}
|
||||
|
||||
/** 连接请求 */
|
||||
export interface ConnectRequest {
|
||||
credentials?: Record<string, any>
|
||||
}
|
||||
|
||||
/** 执行操作请求 */
|
||||
export interface ExecuteActionRequest {
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
const BASE_URL = '/api/admin/provider-ops'
|
||||
|
||||
/**
|
||||
* 获取所有可用的架构
|
||||
*/
|
||||
export async function getArchitectures(): Promise<ArchitectureInfo[]> {
|
||||
const response = await client.get<ArchitectureInfo[]>(`${BASE_URL}/architectures`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定架构的详情
|
||||
*/
|
||||
export async function getArchitecture(architectureId: string): Promise<ArchitectureInfo> {
|
||||
const response = await client.get<ArchitectureInfo>(
|
||||
`${BASE_URL}/architectures/${architectureId}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Provider 的操作状态
|
||||
*/
|
||||
export async function getProviderOpsStatus(
|
||||
providerId: string
|
||||
): Promise<ProviderOpsStatusResponse> {
|
||||
const response = await client.get<ProviderOpsStatusResponse>(
|
||||
`${BASE_URL}/providers/${providerId}/status`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Provider 操作配置响应(脱敏) */
|
||||
export interface ProviderOpsConfigResponse {
|
||||
provider_id: string
|
||||
is_configured: boolean
|
||||
architecture_id?: string
|
||||
base_url?: string
|
||||
connector?: {
|
||||
auth_type: string
|
||||
config: Record<string, any>
|
||||
credentials: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Provider 的操作配置(脱敏)
|
||||
*/
|
||||
export async function getProviderOpsConfig(
|
||||
providerId: string
|
||||
): Promise<ProviderOpsConfigResponse> {
|
||||
const response = await client.get<ProviderOpsConfigResponse>(
|
||||
`${BASE_URL}/providers/${providerId}/config`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Provider 的操作配置
|
||||
*/
|
||||
export async function saveProviderOpsConfig(
|
||||
providerId: string,
|
||||
config: SaveConfigRequest
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await client.put<{ success: boolean; message: string }>(
|
||||
`${BASE_URL}/providers/${providerId}/config`,
|
||||
config
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Provider 的操作配置
|
||||
*/
|
||||
export async function deleteProviderOpsConfig(
|
||||
providerId: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await client.delete<{ success: boolean; message: string }>(
|
||||
`${BASE_URL}/providers/${providerId}/config`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立与 Provider 的连接
|
||||
*/
|
||||
export async function connectProvider(
|
||||
providerId: string,
|
||||
request?: ConnectRequest
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await client.post<{ success: boolean; message: string }>(
|
||||
`${BASE_URL}/providers/${providerId}/connect`,
|
||||
request || {}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开与 Provider 的连接
|
||||
*/
|
||||
export async function disconnectProvider(
|
||||
providerId: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await client.post<{ success: boolean; message: string }>(
|
||||
`${BASE_URL}/providers/${providerId}/disconnect`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行指定操作
|
||||
*/
|
||||
export async function executeAction(
|
||||
providerId: string,
|
||||
actionType: ProviderActionType,
|
||||
request?: ExecuteActionRequest
|
||||
): Promise<ActionResultResponse> {
|
||||
const response = await client.post<ActionResultResponse>(
|
||||
`${BASE_URL}/providers/${providerId}/actions/${actionType}`,
|
||||
request || {}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取余额(优先返回缓存,后台异步刷新)
|
||||
* @param providerId Provider ID
|
||||
* @param refresh 是否触发后台刷新(默认 true)
|
||||
*/
|
||||
export async function getBalance(
|
||||
providerId: string,
|
||||
refresh: boolean = true
|
||||
): Promise<ActionResultResponse> {
|
||||
const response = await client.get<ActionResultResponse>(
|
||||
`${BASE_URL}/providers/${providerId}/balance`,
|
||||
{ params: { refresh } }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即刷新余额(同步等待结果)
|
||||
*/
|
||||
export async function refreshBalance(providerId: string): Promise<ActionResultResponse> {
|
||||
const response = await client.post<ActionResultResponse>(
|
||||
`${BASE_URL}/providers/${providerId}/balance`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到(快捷方法)
|
||||
*/
|
||||
export async function checkin(providerId: string): Promise<ActionResultResponse> {
|
||||
const response = await client.post<ActionResultResponse>(
|
||||
`${BASE_URL}/providers/${providerId}/checkin`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询余额
|
||||
*/
|
||||
export async function batchQueryBalance(
|
||||
providerIds?: string[]
|
||||
): Promise<Record<string, ActionResultResponse>> {
|
||||
const response = await client.post<Record<string, ActionResultResponse>>(
|
||||
`${BASE_URL}/batch/balance`,
|
||||
providerIds
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 验证认证请求 */
|
||||
export interface VerifyAuthRequest {
|
||||
architecture_id: string
|
||||
base_url: string
|
||||
connector: ConnectorConfigRequest
|
||||
actions?: Record<string, ActionConfigRequest>
|
||||
schedule?: Record<string, string>
|
||||
}
|
||||
|
||||
/** 验证认证响应 */
|
||||
export interface VerifyAuthResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
username?: string
|
||||
display_name?: string
|
||||
email?: string
|
||||
quota?: number
|
||||
used_quota?: number
|
||||
request_count?: number
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Provider 认证配置
|
||||
* 在保存前测试认证是否有效
|
||||
*/
|
||||
export async function verifyProviderAuth(
|
||||
providerId: string,
|
||||
config: VerifyAuthRequest
|
||||
): Promise<VerifyAuthResponse> {
|
||||
const response = await client.post<VerifyAuthResponse>(
|
||||
`${BASE_URL}/providers/${providerId}/verify`,
|
||||
config
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,35 +1,151 @@
|
||||
<template>
|
||||
<div v-if="masked" class="group relative">
|
||||
<input
|
||||
ref="inputRef"
|
||||
:class="inputClass"
|
||||
:style="inputStyle"
|
||||
:value="modelValue"
|
||||
:type="effectiveType"
|
||||
:autocomplete="autocompleteAttr"
|
||||
:data-lpignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-1p-ignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-form-type="shouldDisableAutofill ? 'other' : undefined"
|
||||
:data-protonpass-ignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-bwignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-bitwarden-watching="shouldDisableAutofill ? 'false' : undefined"
|
||||
:name="shouldDisableAutofill ? randomName : undefined"
|
||||
v-bind="filteredAttrs"
|
||||
@input="handleInput"
|
||||
>
|
||||
<button
|
||||
v-if="hasValue"
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/20 hover:text-muted-foreground/50 transition-colors"
|
||||
tabindex="-1"
|
||||
:aria-label="isVisible ? '隐藏内容' : '显示内容'"
|
||||
@click="toggleVisibility"
|
||||
>
|
||||
<EyeOff v-if="isVisible" class="h-4 w-4" />
|
||||
<Eye v-else class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
:class="inputClass"
|
||||
:style="inputStyle"
|
||||
:value="modelValue"
|
||||
:type="effectiveType"
|
||||
:autocomplete="autocompleteAttr"
|
||||
:data-lpignore="disableAutofill ? 'true' : undefined"
|
||||
:data-1p-ignore="disableAutofill ? 'true' : undefined"
|
||||
:data-form-type="disableAutofill ? 'other' : undefined"
|
||||
v-bind="$attrs"
|
||||
:data-lpignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-1p-ignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-form-type="shouldDisableAutofill ? 'other' : undefined"
|
||||
:data-protonpass-ignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-bwignore="shouldDisableAutofill ? 'true' : undefined"
|
||||
:data-bitwarden-watching="shouldDisableAutofill ? 'false' : undefined"
|
||||
:name="shouldDisableAutofill ? randomName : undefined"
|
||||
v-bind="filteredAttrs"
|
||||
@input="handleInput"
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, useAttrs, ref } from 'vue'
|
||||
import { Eye, EyeOff } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 开发环境警告:type="password" 已被弃用
|
||||
const warnPasswordType = import.meta.env.DEV
|
||||
? (() => {
|
||||
let warned = false
|
||||
return () => {
|
||||
if (!warned) {
|
||||
warned = true
|
||||
console.warn(
|
||||
'[Input] type="password" 已被弃用,请使用 masked 属性代替。\n' +
|
||||
'示例:<Input v-model="apiKey" masked />\n' +
|
||||
'masked 属性使用 CSS 遮蔽而非 password 类型,不会触发浏览器密码管理器。'
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
: () => {}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number
|
||||
class?: string
|
||||
autocomplete?: string
|
||||
/**
|
||||
* 遮蔽显示内容(用于 API Key 等敏感信息)
|
||||
* 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器
|
||||
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
|
||||
* 注意:Firefox 不支持 -webkit-text-security,会显示明文(但仍可通过按钮切换)
|
||||
*/
|
||||
masked?: boolean
|
||||
/**
|
||||
* 禁用浏览器自动填充
|
||||
* - true: 禁用自动填充
|
||||
* - false: 允许自动填充(默认)
|
||||
*/
|
||||
disableAutofill?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const attrs = useAttrs()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const isVisible = ref(false)
|
||||
|
||||
// 判断是否有值
|
||||
const hasValue = computed(() => {
|
||||
return props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
|
||||
})
|
||||
|
||||
function toggleVisibility() {
|
||||
isVisible.value = !isVisible.value
|
||||
}
|
||||
|
||||
// 计算是否应该禁用自动填充
|
||||
const shouldDisableAutofill = computed(() => {
|
||||
// masked 模式默认禁用自动填充
|
||||
if (props.masked && props.disableAutofill === undefined) {
|
||||
return true
|
||||
}
|
||||
return props.disableAutofill ?? false
|
||||
})
|
||||
|
||||
// 始终使用 text 类型,永远不用 password
|
||||
const effectiveType = computed(() => {
|
||||
const attrType = attrs.type as string | undefined
|
||||
// 如果传入 password,强制转为 text(配合 masked 使用)
|
||||
if (attrType === 'password') {
|
||||
warnPasswordType()
|
||||
return 'text'
|
||||
}
|
||||
return attrType
|
||||
})
|
||||
|
||||
// 过滤掉 type 和 class 属性,因为我们会单独处理
|
||||
const filteredAttrs = computed(() => {
|
||||
const { type, class: _, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
|
||||
// 生成一个稳定的随机值(组件实例级别)
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 8)
|
||||
const randomName = `field_${randomSuffix}`
|
||||
|
||||
const autocompleteAttr = computed(() => {
|
||||
if (props.disableAutofill) {
|
||||
return 'one-time-code'
|
||||
// 如果显式设置了 autocomplete 且不禁用自动填充,使用该值
|
||||
if (props.autocomplete && !shouldDisableAutofill.value) {
|
||||
return props.autocomplete
|
||||
}
|
||||
// 禁用自动填充时,使用浏览器无法识别的随机值
|
||||
if (shouldDisableAutofill.value) {
|
||||
return `off-${randomSuffix}`
|
||||
}
|
||||
return props.autocomplete ?? 'off'
|
||||
})
|
||||
@@ -37,12 +153,25 @@ const autocompleteAttr = computed(() => {
|
||||
const inputClass = computed(() =>
|
||||
cn(
|
||||
'flex h-11 w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all',
|
||||
props.masked && 'pr-10',
|
||||
props.class
|
||||
)
|
||||
)
|
||||
|
||||
// 当 masked 为 true 且未显示时,用 CSS 遮蔽文字
|
||||
const inputStyle = computed(() => {
|
||||
if (props.masked && !isVisible.value) {
|
||||
// 使用 -webkit-text-security(Chrome, Safari, Edge 支持)
|
||||
// Firefox 不支持此属性,会显示明文,但仍可通过小眼睛按钮切换
|
||||
return { '-webkit-text-security': 'disc' }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
defineExpose({ inputRef })
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row gap-4 border-t border-border/60 px-6 py-4 bg-muted/20">
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 border-t border-border/60 px-4 sm:px-6 py-3 sm:py-4 bg-muted/20">
|
||||
<!-- 左侧:记录范围和每页数量 -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm text-muted-foreground">
|
||||
<span class="font-medium">
|
||||
<div class="flex items-center justify-between sm:justify-start gap-3 text-sm text-muted-foreground">
|
||||
<span class="font-medium whitespace-nowrap">
|
||||
显示 <span class="text-foreground font-semibold">{{ recordRange.start }}-{{ recordRange.end }}</span> 条,共 <span class="text-foreground font-semibold">{{ total }}</span> 条
|
||||
</span>
|
||||
<Select
|
||||
@@ -11,8 +11,10 @@
|
||||
:model-value="String(pageSize)"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<SelectTrigger class="w-36 h-9 border-border/60">
|
||||
<SelectValue />
|
||||
<SelectTrigger class="w-[120px] h-8 sm:h-9 border-border/60 text-xs sm:text-sm">
|
||||
<span class="flex-1 text-center">
|
||||
<SelectValue />
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
@@ -27,26 +29,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧:分页按钮 -->
|
||||
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 px-3"
|
||||
:disabled="current === 1"
|
||||
@click="handlePageChange(1)"
|
||||
>
|
||||
首页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 px-3"
|
||||
:disabled="current === 1"
|
||||
@click="handlePageChange(current - 1)"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-1.5 sm:gap-2 sm:ml-auto">
|
||||
<!-- 页码按钮(智能省略) -->
|
||||
<template
|
||||
v-for="page in pageNumbers"
|
||||
@@ -68,24 +51,24 @@
|
||||
>{{ page }}</span>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 px-3"
|
||||
:disabled="current === totalPages"
|
||||
@click="handlePageChange(current + 1)"
|
||||
<!-- 页码跳转 -->
|
||||
<div
|
||||
v-if="totalPages > 7"
|
||||
class="flex items-center gap-1.5 ml-2 text-sm text-muted-foreground"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 px-3"
|
||||
:disabled="current === totalPages"
|
||||
@click="handlePageChange(totalPages)"
|
||||
>
|
||||
末页
|
||||
</Button>
|
||||
<span class="hidden sm:inline">跳至</span>
|
||||
<input
|
||||
v-model="jumpPageInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
class="w-12 h-9 px-2 text-center text-sm border border-border/60 rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60"
|
||||
@keydown.enter="handleJumpPage"
|
||||
@blur="handleJumpPage"
|
||||
@input="filterNumericInput"
|
||||
>
|
||||
<span class="hidden sm:inline">页</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -116,6 +99,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const pageSizeSelectOpen = ref(false)
|
||||
const jumpPageInput = ref('')
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
|
||||
|
||||
@@ -175,4 +159,18 @@ function handlePageSizeChange(value: string) {
|
||||
emit('update:current', 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpPage() {
|
||||
const page = parseInt(jumpPageInput.value)
|
||||
if (!isNaN(page) && page >= 1 && page <= totalPages.value && page !== props.current) {
|
||||
emit('update:current', page)
|
||||
}
|
||||
jumpPageInput.value = ''
|
||||
}
|
||||
|
||||
function filterNumericInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.value = input.value.replace(/[^0-9]/g, '')
|
||||
jumpPageInput.value = input.value
|
||||
}
|
||||
</script>
|
||||
|
||||
66
frontend/src/features/providers/auth-templates/index.ts
Normal file
66
frontend/src/features/providers/auth-templates/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 提供商认证模板注册表
|
||||
*
|
||||
* 集中管理所有认证模板。
|
||||
*
|
||||
* ## 添加新模板的步骤
|
||||
*
|
||||
* 1. 在 `auth-templates/` 目录下创建新的模板文件(如 `my-api.ts`)
|
||||
* 2. 实现 `AuthTemplate` 接口
|
||||
* 3. 在本文件中导入并注册到 `templates` 数组
|
||||
*
|
||||
* ## 模板需要实现的内容
|
||||
*
|
||||
* - `id`: 模板唯一标识(对应后端的 architecture_id)
|
||||
* - `name`: 显示名称
|
||||
* - `description`: 描述文本
|
||||
* - `getFields()`: 返回表单字段定义
|
||||
* - `buildRequest()`: 构建后端 API 请求
|
||||
* - `parseConfig()`: 从已有配置解析表单数据
|
||||
* - `validate()`: 验证表单数据
|
||||
* - `formatQuota()`: (可选)格式化 quota 显示
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateRegistry } from './types'
|
||||
import { newApiTemplate } from './new-api'
|
||||
|
||||
// ==================== 模板注册 ====================
|
||||
// 在这里添加新模板
|
||||
|
||||
const templates: AuthTemplate[] = [newApiTemplate]
|
||||
|
||||
// ==================== 注册表实现 ====================
|
||||
|
||||
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 { newApiTemplate } from './new-api'
|
||||
97
frontend/src/features/providers/auth-templates/new-api.ts
Normal file
97
frontend/src/features/providers/auth-templates/new-api.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
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: 'API 地址',
|
||||
type: 'text',
|
||||
placeholder: providerWebsite || 'https://api.example.com',
|
||||
helpText: '提供商的 API 基础地址,留空则使用提供商官网',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'api_key',
|
||||
label: '访问令牌 (API Key)',
|
||||
type: 'password',
|
||||
placeholder: 'sk-xxx',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
key: 'user_id',
|
||||
label: '用户 ID',
|
||||
type: 'text',
|
||||
placeholder: '用户 ID',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
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',
|
||||
},
|
||||
credentials: {
|
||||
api_key: formData.api_key,
|
||||
user_id: formData.user_id,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
api_key: config?.connector?.credentials?.api_key || '',
|
||||
user_id: config?.connector?.credentials?.user_id || '',
|
||||
}
|
||||
},
|
||||
|
||||
validate(formData: Record<string, any>): string | null {
|
||||
if (!formData.api_key?.trim()) {
|
||||
return '请填写访问令牌'
|
||||
}
|
||||
if (!formData.user_id?.trim()) {
|
||||
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)}`
|
||||
},
|
||||
}
|
||||
120
frontend/src/features/providers/auth-templates/types.ts
Normal file
120
frontend/src/features/providers/auth-templates/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 提供商认证模板类型定义
|
||||
*
|
||||
* 认证模板定义了:
|
||||
* - 需要收集的表单字段
|
||||
* - 如何构建后端请求
|
||||
* - 如何解析已有配置
|
||||
*/
|
||||
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
|
||||
/**
|
||||
* 表单字段类型
|
||||
*/
|
||||
export type FieldType = 'text' | 'password' | 'select' | 'textarea'
|
||||
|
||||
/**
|
||||
* 表单字段定义
|
||||
*/
|
||||
export interface AuthTemplateField {
|
||||
/** 字段 key(用于表单数据) */
|
||||
key: string
|
||||
/** 显示标签 */
|
||||
label: string
|
||||
/** 字段类型 */
|
||||
type: FieldType
|
||||
/** 占位符 */
|
||||
placeholder?: string
|
||||
/** 帮助文本 */
|
||||
helpText?: string
|
||||
/** 是否必填 */
|
||||
required?: boolean
|
||||
/** 是否为敏感字段(使用 masked 输入) */
|
||||
sensitive?: boolean
|
||||
/** select 类型的选项 */
|
||||
options?: Array<{ value: string; label: string }>
|
||||
/** 默认值 */
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段分组
|
||||
*/
|
||||
export interface AuthTemplateFieldGroup {
|
||||
/** 分组标题(可选,为空则不显示标题) */
|
||||
title?: string
|
||||
/** 分组内的字段 */
|
||||
fields: AuthTemplateField[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证结果数据
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证模板注册表类型
|
||||
*/
|
||||
export interface AuthTemplateRegistry {
|
||||
/** 获取所有模板 */
|
||||
getAll(): AuthTemplate[]
|
||||
/** 根据 ID 获取模板 */
|
||||
get(id: string): AuthTemplate | undefined
|
||||
/** 获取默认模板 */
|
||||
getDefault(): AuthTemplate
|
||||
/** 注册模板 */
|
||||
register(template: AuthTemplate): void
|
||||
}
|
||||
@@ -38,19 +38,9 @@
|
||||
:id="apiKeyInputId"
|
||||
v-model="form.api_key"
|
||||
:name="apiKeyFieldName"
|
||||
:type="apiKeyInputType"
|
||||
masked
|
||||
:required="!editingKey"
|
||||
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
|
||||
:class="getApiKeyInputClass()"
|
||||
autocomplete="new-password"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
@focus="apiKeyFocused = true"
|
||||
@blur="apiKeyFocused = form.api_key.trim().length > 0"
|
||||
/>
|
||||
<p
|
||||
v-if="apiKeyError"
|
||||
@@ -329,10 +319,6 @@ const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
|
||||
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
|
||||
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
|
||||
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
|
||||
const apiKeyFocused = ref(false)
|
||||
const apiKeyInputType = computed(() =>
|
||||
apiKeyFocused.value || form.value.api_key.trim().length > 0 ? 'password' : 'text'
|
||||
)
|
||||
|
||||
// 可用的能力列表
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
@@ -397,18 +383,6 @@ function updateRateMultiplier(format: string, value: string | number) {
|
||||
form.value.rate_multipliers = newMultipliers
|
||||
}
|
||||
|
||||
// API 密钥输入框样式计算
|
||||
function getApiKeyInputClass(): string {
|
||||
const classes = []
|
||||
if (apiKeyError.value) {
|
||||
classes.push('border-destructive')
|
||||
}
|
||||
if (!apiKeyFocused.value && !form.value.api_key) {
|
||||
classes.push('text-transparent caret-transparent selection:bg-transparent selection:text-transparent')
|
||||
}
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
|
||||
// API 密钥验证错误信息
|
||||
const apiKeyError = computed(() => {
|
||||
@@ -433,7 +407,6 @@ const apiKeyError = computed(() => {
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value = {
|
||||
name: '',
|
||||
api_key: '',
|
||||
@@ -453,7 +426,6 @@ function resetForm() {
|
||||
// 添加成功后清除部分字段以便继续添加
|
||||
function clearForNextAdd() {
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value.name = ''
|
||||
form.value.api_key = ''
|
||||
}
|
||||
@@ -462,7 +434,6 @@ function clearForNextAdd() {
|
||||
function loadKeyData() {
|
||||
if (!props.editingKey) return
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value = {
|
||||
name: props.editingKey.name,
|
||||
api_key: '',
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
title="用户认证"
|
||||
description="配置提供商的用户认证信息,用于余额查询、签到等操作"
|
||||
:icon="KeyRound"
|
||||
size="md"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<form :name="`provider-auth-${Date.now()}`" autocomplete="off" @submit.prevent>
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="isLoadingConfig"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">加载配置中...</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- 认证模板选择 -->
|
||||
<div class="space-y-2">
|
||||
<Label>认证模板</Label>
|
||||
<Select
|
||||
v-model="selectedTemplateId"
|
||||
v-model:open="templateSelectOpen"
|
||||
@update:model-value="handleTemplateChange"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择认证模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
>
|
||||
{{ template.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 动态表单字段 -->
|
||||
<template v-if="selectedTemplate">
|
||||
<template
|
||||
v-for="(group, groupIndex) in fieldGroups"
|
||||
:key="groupIndex"
|
||||
>
|
||||
<!-- 分组标题 -->
|
||||
<div
|
||||
v-if="group.title"
|
||||
class="pt-2 text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ group.title }}
|
||||
</div>
|
||||
|
||||
<!-- 字段列表 -->
|
||||
<div
|
||||
v-for="field in group.fields"
|
||||
:key="field.key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>
|
||||
{{ field.label }}
|
||||
<span
|
||||
v-if="field.required"
|
||||
class="text-muted-foreground/70"
|
||||
>*</span>
|
||||
</Label>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<Input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
disable-autofill
|
||||
/>
|
||||
|
||||
<!-- 密码/敏感输入 -->
|
||||
<Input
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
|
||||
masked
|
||||
/>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="formData[field.key]"
|
||||
@update:model-value="handleFieldChange(field.key, $event)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="field.placeholder || '请选择'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<Textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<!-- 帮助文本 -->
|
||||
<p
|
||||
v-if="field.helpText"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ field.helpText }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="isSaving || !canSave"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="isVerifying || !canVerify"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ isVerifying ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { KeyRound } from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { saveProviderOpsConfig, verifyProviderAuth, getProviderOpsConfig } from '@/api/providerOps'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
authTemplateRegistry,
|
||||
type AuthTemplate,
|
||||
type AuthTemplateFieldGroup,
|
||||
} from '../auth-templates'
|
||||
|
||||
// 敏感字段列表(用于验证和加载配置时的特殊处理)
|
||||
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'cookie_string', 'cookies'] as const
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerWebsite?: string
|
||||
currentConfig?: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
// State
|
||||
const isSaving = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isLoadingConfig = ref(false)
|
||||
const verifyStatus = ref<'success' | 'error' | null>(null)
|
||||
const formChanged = ref(false)
|
||||
|
||||
// 敏感字段的 placeholder(存储脱敏后的已保存值)
|
||||
const sensitivePlaceholders = ref<Record<string, string>>({})
|
||||
// 是否有已保存的配置(编辑模式)
|
||||
const hasExistingConfig = ref(false)
|
||||
|
||||
// Select 下拉框状态
|
||||
const templateSelectOpen = ref(false)
|
||||
|
||||
// 模板选择
|
||||
const selectedTemplateId = ref('new_api')
|
||||
const formData = ref<Record<string, any>>({})
|
||||
|
||||
// 表单是否可以验证(必填字段已填写)
|
||||
const canVerify = computed(() => {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) return false
|
||||
|
||||
// 编辑模式下,敏感字段可以为空(使用已保存的值)
|
||||
if (hasExistingConfig.value) {
|
||||
// 创建一个临时数据,把空的敏感字段填充为占位值以通过验证
|
||||
const tempData = { ...formData.value }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (!tempData[field] && sensitivePlaceholders.value[field]) {
|
||||
tempData[field] = 'placeholder'
|
||||
}
|
||||
}
|
||||
const error = template.validate(tempData)
|
||||
if (error) return false
|
||||
} else {
|
||||
const error = template.validate(formData.value)
|
||||
if (error) return false
|
||||
}
|
||||
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
return !!effectiveBaseUrl
|
||||
})
|
||||
|
||||
// 保存按钮是否可用:验证成功且表单未变动
|
||||
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)
|
||||
})
|
||||
|
||||
// Methods
|
||||
function handleTemplateChange() {
|
||||
// 重置表单数据
|
||||
resetFormData()
|
||||
// 重置验证状态
|
||||
verifyStatus.value = null
|
||||
formChanged.value = true
|
||||
}
|
||||
|
||||
function handleFieldChange(_fieldKey: string, _value: any) {
|
||||
// 标记表单已变动
|
||||
formChanged.value = true
|
||||
}
|
||||
|
||||
// 监听 formData 变化,验证成功后的修改需要重新验证
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
// 验证成功后任何修改都需要重新验证
|
||||
if (verifyStatus.value === 'success') {
|
||||
formChanged.value = true
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function resetFormData() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) {
|
||||
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 ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
formData.value = data
|
||||
}
|
||||
|
||||
function formatQuota(quota: number): string {
|
||||
const template = selectedTemplate.value
|
||||
if (template?.formatQuota) {
|
||||
return template.formatQuota(quota)
|
||||
}
|
||||
// 默认格式化
|
||||
return quota.toLocaleString()
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
const error = template.validate(dataToValidate)
|
||||
if (error) {
|
||||
showError(error)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 base_url
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
if (!effectiveBaseUrl) {
|
||||
showError('请填写 API 地址')
|
||||
return
|
||||
}
|
||||
|
||||
isVerifying.value = true
|
||||
|
||||
try {
|
||||
const request = template.buildRequest(formData.value, props.providerWebsite)
|
||||
// 确保 base_url 是有效字符串,用于 VerifyAuthRequest
|
||||
const verifyRequest = {
|
||||
...request,
|
||||
base_url: request.base_url || effectiveBaseUrl,
|
||||
}
|
||||
const result = await verifyProviderAuth(props.providerId, verifyRequest)
|
||||
|
||||
if (result.success) {
|
||||
verifyStatus.value = 'success'
|
||||
formChanged.value = false // 验证成功后重置表单变动标记
|
||||
// Toast 提示
|
||||
const displayName = result.data?.display_name || result.data?.username || '未知'
|
||||
const quotaStr = result.data?.quota !== undefined ? ` | 余额: ${formatQuota(result.data.quota)}` : ''
|
||||
showSuccess(`用户: ${displayName}${quotaStr}`, '验证成功')
|
||||
} else {
|
||||
verifyStatus.value = 'error'
|
||||
showError(result.message || '验证失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
verifyStatus.value = 'error'
|
||||
const errMsg = error.response?.data?.detail || error.message || '验证失败'
|
||||
showError(errMsg)
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
const error = template.validate(dataToValidate)
|
||||
if (error) {
|
||||
showError(error)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 base_url
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
if (!effectiveBaseUrl) {
|
||||
showError('请填写 API 地址')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const request = template.buildRequest(formData.value, props.providerWebsite)
|
||||
const result = await saveProviderOpsConfig(props.providerId, request)
|
||||
if (result.success) {
|
||||
showSuccess(result.message || '配置已保存', '保存成功')
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
} else {
|
||||
showError(result.message || '保存失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || error.message, '保存失败')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromConfig(config: any) {
|
||||
if (!config?.connector) return
|
||||
|
||||
hasExistingConfig.value = true
|
||||
|
||||
// 目前只支持 new_api 模板
|
||||
selectedTemplateId.value = 'new_api'
|
||||
|
||||
// 使用模板解析配置
|
||||
const template = authTemplateRegistry.get(selectedTemplateId.value)
|
||||
if (template) {
|
||||
const parsedData = template.parseConfig(config)
|
||||
|
||||
// 敏感字段:脱敏值放到 placeholder,表单值设为空
|
||||
sensitivePlaceholders.value = {}
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (parsedData[field]) {
|
||||
// 保存脱敏值作为 placeholder 提示
|
||||
sensitivePlaceholders.value[field] = `${parsedData[field]}`
|
||||
// 表单值设为空
|
||||
parsedData[field] = ''
|
||||
}
|
||||
}
|
||||
|
||||
formData.value = parsedData
|
||||
}
|
||||
}
|
||||
|
||||
// 打开对话框时初始化
|
||||
watch(
|
||||
() => props.open,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
verifyStatus.value = null
|
||||
formChanged.value = false
|
||||
|
||||
// 如果传入了 currentConfig,直接使用
|
||||
if (props.currentConfig?.connector) {
|
||||
loadFromConfig(props.currentConfig)
|
||||
return
|
||||
}
|
||||
|
||||
// 否则尝试从后端加载现有配置
|
||||
if (props.providerId) {
|
||||
isLoadingConfig.value = true
|
||||
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,
|
||||
connector: config.connector,
|
||||
}
|
||||
loadFromConfig(configData)
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
resetFormData()
|
||||
}
|
||||
} catch {
|
||||
// 加载失败,使用默认值
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
resetFormData()
|
||||
} finally {
|
||||
isLoadingConfig.value = false
|
||||
}
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
resetFormData()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -10,3 +10,4 @@ export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<!-- 提供商表格 -->
|
||||
<Card
|
||||
variant="default"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
|
||||
@@ -99,24 +98,18 @@
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/40 hover:bg-transparent">
|
||||
<TableHead class="w-[150px] h-11 font-medium text-foreground/80">
|
||||
<TableHead class="w-[180px] h-11 font-medium text-foreground/80">
|
||||
提供商信息
|
||||
</TableHead>
|
||||
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">
|
||||
计费类型
|
||||
</TableHead>
|
||||
<TableHead class="w-[120px] h-11 font-medium text-foreground/80">
|
||||
官网
|
||||
<TableHead class="w-[140px] h-11 font-medium text-foreground/80 text-center">
|
||||
余额监控
|
||||
</TableHead>
|
||||
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">
|
||||
资源统计
|
||||
</TableHead>
|
||||
<TableHead class="w-[240px] h-11 font-medium text-foreground/80">
|
||||
<TableHead class="w-[280px] h-11 font-medium text-foreground/80">
|
||||
端点健康
|
||||
</TableHead>
|
||||
<TableHead class="w-[140px] h-11 font-medium text-foreground/80">
|
||||
配额/限流
|
||||
</TableHead>
|
||||
<TableHead class="w-[80px] h-11 font-medium text-foreground/80 text-center">
|
||||
状态
|
||||
</TableHead>
|
||||
@@ -134,28 +127,49 @@
|
||||
@click="handleRowClick($event, provider.id)"
|
||||
>
|
||||
<TableCell class="py-3.5">
|
||||
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[160px]"
|
||||
:title="provider.website"
|
||||
@click.stop
|
||||
>
|
||||
{{ formatWebsiteDisplay(provider.website) }}
|
||||
</a>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-xs font-normal border-border/50"
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<!-- 显示从上游 API 查询的余额 -->
|
||||
<div
|
||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[100px]"
|
||||
:title="provider.website"
|
||||
@click.stop
|
||||
<span class="font-semibold text-foreground/90">
|
||||
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 显示本地配置的月度配额 -->
|
||||
<div
|
||||
v-else-if="provider.billing_type === 'monthly_quota'"
|
||||
class="space-y-0.5 text-xs"
|
||||
>
|
||||
{{ formatWebsiteDisplay(provider.website) }}
|
||||
</a>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] font-normal border-border/50"
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type) }}
|
||||
</Badge>
|
||||
<div class="text-muted-foreground/70 pt-0.5">
|
||||
<span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/50"
|
||||
@@ -204,25 +218,6 @@
|
||||
class="text-xs text-muted-foreground/50"
|
||||
>暂无端点</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div
|
||||
v-if="provider.billing_type === 'monthly_quota'"
|
||||
class="text-muted-foreground/70"
|
||||
>
|
||||
配额: <span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-muted-foreground/50"
|
||||
>
|
||||
按量付费
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
@@ -254,6 +249,15 @@
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="扩展操作配置"
|
||||
@click="openOpsConfigDialog(provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -311,10 +315,29 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="查看详情"
|
||||
@click="openProviderDrawer(provider.id)"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑"
|
||||
@click="openEditProviderDialog(provider)"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="扩展操作配置"
|
||||
@click="openOpsConfigDialog(provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -334,7 +357,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:计费类型 + 资源统计 -->
|
||||
<!-- 第二行:计费类型 + 余额/配额 + 资源统计 -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -342,6 +365,23 @@
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
<!-- 余额(从上游 API 查询) -->
|
||||
<span
|
||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
</span>
|
||||
<!-- 本地配额 -->
|
||||
<span
|
||||
v-else-if="provider.billing_type === 'monthly_quota'"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
配额 <span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span>/<span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
|
||||
</span>
|
||||
@@ -371,19 +411,6 @@
|
||||
{{ endpoint.api_format }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 第四行:配额 -->
|
||||
<div
|
||||
v-if="provider.billing_type === 'monthly_quota'"
|
||||
class="flex items-center gap-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>
|
||||
配额: <span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -421,6 +448,13 @@
|
||||
@toggle-status="toggleProviderStatus"
|
||||
@refresh="loadProviders"
|
||||
/>
|
||||
|
||||
<ProviderAuthDialog
|
||||
v-model:open="opsConfigDialogOpen"
|
||||
:provider-id="opsConfigProviderId"
|
||||
:provider-website="opsConfigProviderWebsite"
|
||||
@saved="handleOpsConfigSaved"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -432,7 +466,8 @@ import {
|
||||
Eye,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
Power
|
||||
Power,
|
||||
KeyRound
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -446,7 +481,7 @@ import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import Pagination from '@/components/ui/pagination.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import { ProviderFormDialog, PriorityManagementDialog } from '@/features/providers/components'
|
||||
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
|
||||
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -458,6 +493,7 @@ import {
|
||||
type ProviderWithEndpointsSummary
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
@@ -473,6 +509,17 @@ const priorityMode = ref<'provider' | 'global_key'>('provider')
|
||||
const providerDrawerOpen = ref(false)
|
||||
const selectedProviderId = ref<string | null>(null)
|
||||
|
||||
// 扩展操作配置对话框
|
||||
const opsConfigDialogOpen = ref(false)
|
||||
const opsConfigProviderId = ref('')
|
||||
const opsConfigProviderWebsite = ref('')
|
||||
|
||||
// 余额数据缓存 {providerId: ActionResultResponse}
|
||||
const balanceCache = ref<Record<string, ActionResultResponse>>({})
|
||||
// 余额加载请求版本计数器(用于防止竞态条件)
|
||||
// 使用普通变量而非 ref,因为不需要响应式,仅用于比较请求版本
|
||||
let balanceLoadVersion = 0
|
||||
|
||||
// 搜索
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -542,8 +589,12 @@ async function loadPriorityMode() {
|
||||
// 加载提供商列表
|
||||
async function loadProviders() {
|
||||
loading.value = true
|
||||
// 清空旧的余额缓存,避免数据累积
|
||||
balanceCache.value = {}
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
// 异步加载配置了 ops 的 provider 的余额数据
|
||||
loadBalances()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载提供商列表失败', '错误')
|
||||
} finally {
|
||||
@@ -551,6 +602,70 @@ async function loadProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
// 异步加载余额数据(使用批量接口)
|
||||
async function loadBalances() {
|
||||
const currentVersion = ++balanceLoadVersion
|
||||
try {
|
||||
const opsProviderIds = providers.value
|
||||
.filter(p => p.ops_configured)
|
||||
.map(p => p.id)
|
||||
if (opsProviderIds.length === 0) return
|
||||
|
||||
const results = await batchQueryBalance(opsProviderIds)
|
||||
|
||||
// 检查是否有新的请求已经开始,如果有则丢弃当前结果
|
||||
if (currentVersion !== balanceLoadVersion) return
|
||||
|
||||
// 将成功的结果存入缓存
|
||||
for (const [providerId, result] of Object.entries(results)) {
|
||||
if (result.status === 'success') {
|
||||
balanceCache.value[providerId] = result
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[loadBalances] 加载余额数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:检查是否为 BalanceInfo(简化版)
|
||||
* 只检查余额显示所需的字段,完整的 BalanceInfo 还包含 total_granted, total_used, expires_at, extra
|
||||
*/
|
||||
function isBalanceInfo(data: unknown): data is { total_available: number | null; currency: string } {
|
||||
if (typeof data !== 'object' || data === null) return false
|
||||
if (!('total_available' in data) || !('currency' in data)) return false
|
||||
const d = data as Record<string, unknown>
|
||||
// total_available 必须是 number 或 null
|
||||
if (d.total_available !== null && typeof d.total_available !== 'number') return false
|
||||
// currency 必须是 string
|
||||
if (typeof d.currency !== 'string') return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取 provider 的余额显示
|
||||
function getProviderBalance(providerId: string): { available: number | null; currency: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result || result.status !== 'success' || !result.data) {
|
||||
return null
|
||||
}
|
||||
if (!isBalanceInfo(result.data)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
available: result.data.total_available,
|
||||
currency: result.data.currency || 'USD'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化余额显示
|
||||
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
|
||||
if (!balance || balance.available == null) {
|
||||
return '-'
|
||||
}
|
||||
const symbol = balance.currency === 'USD' ? '$' : balance.currency
|
||||
return `${symbol}${balance.available.toFixed(2)}`
|
||||
}
|
||||
|
||||
|
||||
// 格式化官网显示
|
||||
function formatWebsiteDisplay(url: string): string {
|
||||
@@ -660,6 +775,19 @@ function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
|
||||
providerDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 打开扩展操作配置对话框
|
||||
function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
|
||||
opsConfigProviderId.value = provider.id
|
||||
opsConfigProviderWebsite.value = provider.website || ''
|
||||
opsConfigDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 扩展操作配置保存回调
|
||||
function handleOpsConfigSaved() {
|
||||
opsConfigDialogOpen.value = false
|
||||
loadProviders()
|
||||
}
|
||||
|
||||
// 处理提供商编辑完成
|
||||
function handleProviderUpdated() {
|
||||
loadProviders()
|
||||
|
||||
Reference in New Issue
Block a user