Commit 987589ea authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'test' into release

parents 372e04f6 03f69dd3
......@@ -39,6 +39,7 @@ export const claudeModels = [
'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001',
'claude-opus-4-5-20251101',
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-2.1', 'claude-2.0', 'claude-instant-1.2'
]
......@@ -250,6 +251,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m }))
const anthropicPresetMappings = [
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
......
......@@ -19,12 +19,21 @@ export interface OpenAITokenInfo {
[key: string]: unknown
}
export function useOpenAIOAuth() {
export type OpenAIOAuthPlatform = 'openai' | 'sora'
interface UseOpenAIOAuthOptions {
platform?: OpenAIOAuthPlatform
}
export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
const appStore = useAppStore()
const oauthPlatform = options?.platform ?? 'openai'
const endpointPrefix = oauthPlatform === 'sora' ? '/admin/sora' : '/admin/openai'
// State
const authUrl = ref('')
const sessionId = ref('')
const oauthState = ref('')
const loading = ref(false)
const error = ref('')
......@@ -32,6 +41,7 @@ export function useOpenAIOAuth() {
const resetState = () => {
authUrl.value = ''
sessionId.value = ''
oauthState.value = ''
loading.value = false
error.value = ''
}
......@@ -44,6 +54,7 @@ export function useOpenAIOAuth() {
loading.value = true
authUrl.value = ''
sessionId.value = ''
oauthState.value = ''
error.value = ''
try {
......@@ -56,11 +67,17 @@ export function useOpenAIOAuth() {
}
const response = await adminAPI.accounts.generateAuthUrl(
'/admin/openai/generate-auth-url',
`${endpointPrefix}/generate-auth-url`,
payload
)
authUrl.value = response.auth_url
sessionId.value = response.session_id
try {
const parsed = new URL(response.auth_url)
oauthState.value = parsed.searchParams.get('state') || ''
} catch {
oauthState.value = ''
}
return true
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to generate OpenAI auth URL'
......@@ -75,10 +92,11 @@ export function useOpenAIOAuth() {
const exchangeAuthCode = async (
code: string,
currentSessionId: string,
state: string,
proxyId?: number | null
): Promise<OpenAITokenInfo | null> => {
if (!code.trim() || !currentSessionId) {
error.value = 'Missing auth code or session ID'
if (!code.trim() || !currentSessionId || !state.trim()) {
error.value = 'Missing auth code, session ID, or state'
return null
}
......@@ -86,15 +104,16 @@ export function useOpenAIOAuth() {
error.value = ''
try {
const payload: { session_id: string; code: string; proxy_id?: number } = {
const payload: { session_id: string; code: string; state: string; proxy_id?: number } = {
session_id: currentSessionId,
code: code.trim()
code: code.trim(),
state: state.trim()
}
if (proxyId) {
payload.proxy_id = proxyId
}
const tokenInfo = await adminAPI.accounts.exchangeCode('/admin/openai/exchange-code', payload)
const tokenInfo = await adminAPI.accounts.exchangeCode(`${endpointPrefix}/exchange-code`, payload)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to exchange OpenAI auth code'
......@@ -120,7 +139,11 @@ export function useOpenAIOAuth() {
try {
// Use dedicated refresh-token endpoint
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(refreshToken.trim(), proxyId)
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(
refreshToken.trim(),
proxyId,
`${endpointPrefix}/refresh-token`
)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to validate refresh token'
......@@ -131,6 +154,33 @@ export function useOpenAIOAuth() {
}
}
// Validate Sora session token and get access token
const validateSessionToken = async (
sessionToken: string,
proxyId?: number | null
): Promise<OpenAITokenInfo | null> => {
if (!sessionToken.trim()) {
error.value = 'Missing session token'
return null
}
loading.value = true
error.value = ''
try {
const tokenInfo = await adminAPI.accounts.validateSoraSessionToken(
sessionToken.trim(),
proxyId,
`${endpointPrefix}/st2at`
)
return tokenInfo as OpenAITokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to validate session token'
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
// Build credentials for OpenAI OAuth account
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
const creds: Record<string, unknown> = {
......@@ -172,6 +222,7 @@ export function useOpenAIOAuth() {
// State
authUrl,
sessionId,
oauthState,
loading,
error,
// Methods
......@@ -179,6 +230,7 @@ export function useOpenAIOAuth() {
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
validateSessionToken,
buildCredentials,
buildExtraInfo
}
......
......@@ -576,6 +576,10 @@ export default {
description: 'View and analyze your API usage history',
costDetails: 'Cost Breakdown',
tokenDetails: 'Token Breakdown',
cacheTtlOverriddenHint: 'Cache TTL Override enabled',
cacheTtlOverriddenLabel: 'TTL Override',
cacheTtlOverridden5m: 'Billed as 5m',
cacheTtlOverridden1h: 'Billed as 1h',
totalRequests: 'Total Requests',
totalTokens: 'Total Tokens',
totalCost: 'Total Cost',
......@@ -1346,6 +1350,7 @@ export default {
allPlatforms: 'All Platforms',
allTypes: 'All Types',
allStatus: 'All Status',
allGroups: 'All Groups',
oauthType: 'OAuth',
setupToken: 'Setup Token',
apiKey: 'API Key',
......@@ -1355,7 +1360,7 @@ export default {
schedulableEnabled: 'Scheduling enabled',
schedulableDisabled: 'Scheduling disabled',
failedToToggleSchedulable: 'Failed to toggle scheduling status',
allGroups: '{count} groups total',
groupCountTotal: '{count} groups total',
platforms: {
anthropic: 'Anthropic',
claude: 'Claude',
......@@ -1618,6 +1623,12 @@ export default {
sessionIdMasking: {
label: 'Session ID Masking',
hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session'
},
cacheTTLOverride: {
label: 'Cache TTL Override',
hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)',
target: 'Target TTL',
targetHint: 'Select the TTL tier for billing'
}
},
expired: 'Expired',
......@@ -1731,9 +1742,13 @@ export default {
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
sessionTokenAuth: 'Manual ST Input',
sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
validating: 'Validating...',
validateAndCreate: 'Validate & Create Account',
pleaseEnterRefreshToken: 'Please enter Refresh Token'
pleaseEnterRefreshToken: 'Please enter Refresh Token',
pleaseEnterSessionToken: 'Please enter Session Token'
},
// Gemini specific
gemini: {
......@@ -1954,6 +1969,7 @@ export default {
reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account',
openaiAccount: 'OpenAI Account',
soraAccount: 'Sora Account',
geminiAccount: 'Gemini Account',
antigravityAccount: 'Antigravity Account',
inputMethod: 'Input Method',
......@@ -1979,6 +1995,10 @@ export default {
selectTestModel: 'Select Test Model',
testModel: 'Test model',
testPrompt: 'Prompt: "hi"',
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
soraTestTarget: 'Target: Sora account capability',
soraTestMode: 'Mode: Connectivity + Capability checks',
soraTestingFlow: 'Running Sora connectivity and capability checks...',
// Stats Modal
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',
......@@ -2085,6 +2105,8 @@ export default {
actions: 'Actions'
},
testConnection: 'Test Connection',
qualityCheck: 'Quality Check',
batchQualityCheck: 'Batch Quality Check',
batchTest: 'Test All Proxies',
testFailed: 'Failed',
latencyFailed: 'Connection failed',
......@@ -2145,6 +2167,29 @@ export default {
proxyWorking: 'Proxy is working!',
proxyWorkingWithLatency: 'Proxy is working! Latency: {latency}ms',
proxyTestFailed: 'Proxy test failed',
qualityCheckDone: 'Quality check completed: score {score} ({grade})',
qualityCheckFailed: 'Failed to run proxy quality check',
batchQualityDone:
'Batch quality check completed for {count} proxies: healthy {healthy}, warn {warn}, challenge {challenge}, abnormal {failed}',
batchQualityFailed: 'Batch quality check failed',
batchQualityEmpty: 'No proxies available for quality check',
qualityReportTitle: 'Proxy Quality Report',
qualityGrade: 'Grade {grade}',
qualityExitIP: 'Exit IP',
qualityCountry: 'Exit Region',
qualityBaseLatency: 'Base Latency',
qualityCheckedAt: 'Checked At',
qualityTableTarget: 'Target',
qualityTableStatus: 'Status',
qualityTableLatency: 'Latency',
qualityTableMessage: 'Message',
qualityInline: 'Quality {grade}/{score}',
qualityStatusHealthy: 'Healthy',
qualityStatusPass: 'Pass',
qualityStatusWarn: 'Warn',
qualityStatusFail: 'Fail',
qualityStatusChallenge: 'Challenge',
qualityTargetBase: 'Base Connectivity',
failedToLoad: 'Failed to load proxies',
failedToCreate: 'Failed to create proxy',
failedToUpdate: 'Failed to update proxy',
......@@ -2385,6 +2430,8 @@ export default {
inputTokens: 'Input Tokens',
outputTokens: 'Output Tokens',
cacheCreationTokens: 'Cache Creation Tokens',
cacheCreation5mTokens: 'Cache Write',
cacheCreation1hTokens: 'Cache Write',
cacheReadTokens: 'Cache Read Tokens',
failedToLoad: 'Failed to load usage records',
billingType: 'Billing Type',
......
......@@ -582,6 +582,10 @@ export default {
description: '查看和分析您的 API 使用历史',
costDetails: '成本明细',
tokenDetails: 'Token 明细',
cacheTtlOverriddenHint: '缓存 TTL Override 已启用',
cacheTtlOverriddenLabel: 'TTL 替换',
cacheTtlOverridden5m: '按 5m 计费',
cacheTtlOverridden1h: '按 1h 计费',
totalRequests: '总请求数',
totalTokens: '总 Token',
totalCost: '总消费',
......@@ -1437,6 +1441,7 @@ export default {
allPlatforms: '全部平台',
allTypes: '全部类型',
allStatus: '全部状态',
allGroups: '全部分组',
oauthType: 'OAuth',
// Schedulable toggle
schedulable: '参与调度',
......@@ -1444,7 +1449,7 @@ export default {
schedulableEnabled: '调度已开启',
schedulableDisabled: '调度已关闭',
failedToToggleSchedulable: '切换调度状态失败',
allGroups: '共 {count} 个分组',
groupCountTotal: '共 {count} 个分组',
columns: {
name: '名称',
platformType: '平台/类型',
......@@ -1763,6 +1768,12 @@ export default {
sessionIdMasking: {
label: '会话 ID 伪装',
hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话'
},
cacheTTLOverride: {
label: '缓存 TTL 强制替换',
hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费',
target: '目标 TTL',
targetHint: '选择计费使用的 TTL 类型'
}
},
expired: '已过期',
......@@ -1870,9 +1881,13 @@ export default {
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
sessionTokenAuth: '手动输入 ST',
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token'
pleaseEnterRefreshToken: '请输入 Refresh Token',
pleaseEnterSessionToken: '请输入 Session Token'
},
// Gemini specific
gemini: {
......@@ -2088,6 +2103,7 @@ export default {
reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号',
openaiAccount: 'OpenAI 账号',
soraAccount: 'Sora 账号',
geminiAccount: 'Gemini 账号',
antigravityAccount: 'Antigravity 账号',
inputMethod: '输入方式',
......@@ -2111,6 +2127,10 @@ export default {
selectTestModel: '选择测试模型',
testModel: '测试模型',
testPrompt: '提示词:"hi"',
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
soraTestTarget: '检测目标:Sora 账号能力',
soraTestMode: '模式:连通性 + 能力探测',
soraTestingFlow: '执行 Sora 连通性与能力检测...',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
......@@ -2228,6 +2248,8 @@ export default {
noProxiesYet: '暂无代理',
createFirstProxy: '添加您的第一个代理以开始使用。',
testConnection: '测试连接',
qualityCheck: '质量检测',
batchQualityCheck: '批量质量检测',
batchTest: '批量测试',
testFailed: '失败',
latencyFailed: '链接失败',
......@@ -2275,6 +2297,28 @@ export default {
proxyWorking: '代理连接正常',
proxyWorkingWithLatency: '代理连接正常,延迟 {latency}ms',
proxyTestFailed: '代理测试失败',
qualityCheckDone: '质量检测完成:评分 {score}({grade})',
qualityCheckFailed: '代理质量检测失败',
batchQualityDone: '批量质量检测完成,共检测 {count} 个;优质 {healthy} 个,告警 {warn} 个,挑战 {challenge} 个,异常 {failed} 个',
batchQualityFailed: '批量质量检测失败',
batchQualityEmpty: '暂无可检测质量的代理',
qualityReportTitle: '代理质量检测报告',
qualityGrade: '等级 {grade}',
qualityExitIP: '出口 IP',
qualityCountry: '出口地区',
qualityBaseLatency: '基础延迟',
qualityCheckedAt: '检测时间',
qualityTableTarget: '检测项',
qualityTableStatus: '状态',
qualityTableLatency: '延迟',
qualityTableMessage: '说明',
qualityInline: '质量 {grade}/{score}',
qualityStatusHealthy: '优质',
qualityStatusPass: '通过',
qualityStatusWarn: '告警',
qualityStatusFail: '失败',
qualityStatusChallenge: '挑战',
qualityTargetBase: '基础连通性',
proxyCreatedSuccess: '代理添加成功',
proxyUpdatedSuccess: '代理更新成功',
proxyDeletedSuccess: '代理删除成功',
......@@ -2551,6 +2595,8 @@ export default {
inputTokens: '输入 Token',
outputTokens: '输出 Token',
cacheCreationTokens: '缓存创建 Token',
cacheCreation5mTokens: '缓存创建',
cacheCreation1hTokens: '缓存创建',
cacheReadTokens: '缓存读取 Token',
failedToLoad: '加载使用记录失败',
billingType: '计费类型',
......
......@@ -243,7 +243,7 @@
}
.stat-value {
@apply text-2xl font-bold text-gray-900 dark:text-white;
@apply text-2xl font-bold text-gray-900 dark:text-white truncate;
}
.stat-label {
......
......@@ -512,6 +512,11 @@ export interface Proxy {
country_code?: string
region?: string
city?: string
quality_status?: 'healthy' | 'warn' | 'challenge' | 'failed'
quality_score?: number
quality_grade?: string
quality_summary?: string
quality_checked?: number
created_at: string
updated_at: string
}
......@@ -524,6 +529,32 @@ export interface ProxyAccountSummary {
notes?: string | null
}
export interface ProxyQualityCheckItem {
target: string
status: 'pass' | 'warn' | 'fail' | 'challenge'
http_status?: number
latency_ms?: number
message?: string
cf_ray?: string
}
export interface ProxyQualityCheckResult {
proxy_id: number
score: number
grade: string
summary: string
exit_ip?: string
country?: string
country_code?: string
base_latency_ms?: number
passed_count: number
warn_count: number
failed_count: number
challenge_count: number
checked_at: number
items: ProxyQualityCheckItem[]
}
// Gemini credentials structure for OAuth and API Key authentication
export interface GeminiCredentials {
// API Key authentication
......@@ -627,6 +658,10 @@ export interface Account {
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
session_id_masking_enabled?: boolean | null
// 缓存 TTL 强制替换(仅 Anthropic OAuth/SetupToken 账号有效)
cache_ttl_override_enabled?: boolean | null
cache_ttl_override_target?: string | null
// 运行时状态(仅当启用对应限制时返回)
current_window_cost?: number | null // 当前窗口费用
active_sessions?: number | null // 当前活跃会话数
......@@ -840,6 +875,9 @@ export interface UsageLog {
// User-Agent
user_agent: string | null
// Cache TTL Override
cache_ttl_overridden: boolean
created_at: string
user?: User
......
......@@ -6,6 +6,7 @@
<AccountTableFilters
v-model:searchQuery="params.search"
:filters="params"
:groups="groups"
@update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="debouncedReload"
@update:searchQuery="debouncedReload"
......@@ -466,7 +467,7 @@ const {
handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', search: '' }
initialParams: { platform: '', type: '', status: '', group: '', search: '' }
})
const resetAutoRefreshCache = () => {
......
......@@ -55,6 +55,15 @@
<Icon name="play" size="md" class="mr-2" />
{{ t('admin.proxies.testConnection') }}
</button>
<button
@click="handleBatchQualityCheck"
:disabled="batchQualityChecking || loading"
class="btn btn-secondary"
:title="t('admin.proxies.batchQualityCheck')"
>
<Icon name="shield" size="md" class="mr-2" :class="batchQualityChecking ? 'animate-pulse' : ''" />
{{ t('admin.proxies.batchQualityCheck') }}
</button>
<button
@click="openBatchDelete"
:disabled="selectedCount === 0"
......@@ -151,20 +160,32 @@
</template>
<template #cell-latency="{ row }">
<span
v-if="row.latency_status === 'failed'"
class="badge badge-danger"
:title="row.latency_message || undefined"
>
{{ t('admin.proxies.latencyFailed') }}
</span>
<span
v-else-if="typeof row.latency_ms === 'number'"
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
>
{{ row.latency_ms }}ms
</span>
<span v-else class="text-sm text-gray-400">-</span>
<div class="flex flex-col gap-1">
<span
v-if="row.latency_status === 'failed'"
class="badge badge-danger"
:title="row.latency_message || undefined"
>
{{ t('admin.proxies.latencyFailed') }}
</span>
<span
v-else-if="typeof row.latency_ms === 'number'"
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
>
{{ row.latency_ms }}ms
</span>
<span v-else class="text-sm text-gray-400">-</span>
<div
v-if="typeof row.quality_checked === 'number'"
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
:title="row.quality_summary || undefined"
>
<span>{{ t('admin.proxies.qualityInline', { grade: row.quality_grade || '-', score: row.quality_score ?? '-' }) }}</span>
<span class="badge" :class="qualityOverallClass(row.quality_status)">
{{ qualityOverallLabel(row.quality_status) }}
</span>
</div>
</div>
</template>
<template #cell-status="{ value }">
......@@ -203,6 +224,34 @@
<Icon v-else name="checkCircle" size="sm" />
<span class="text-xs">{{ t('admin.proxies.testConnection') }}</span>
</button>
<button
@click="handleQualityCheck(row)"
:disabled="qualityCheckingProxyIds.has(row.id)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<svg
v-if="qualityCheckingProxyIds.has(row.id)"
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="shield" size="sm" />
<span class="text-xs">{{ t('admin.proxies.qualityCheck') }}</span>
</button>
<button
@click="handleEdit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
......@@ -623,6 +672,82 @@
@imported="handleDataImported"
/>
<BaseDialog
:show="showQualityReportDialog"
:title="t('admin.proxies.qualityReportTitle')"
width="normal"
@close="closeQualityReportDialog"
>
<div v-if="qualityReport" class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ qualityReportProxy?.name || '-' }}
</div>
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
{{ qualityReport.summary }}
</div>
</div>
<div class="text-right">
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
{{ qualityReport.score }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.proxies.qualityGrade', { grade: qualityReport.grade }) }}
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-300">
<div>{{ t('admin.proxies.qualityExitIP') }}: {{ qualityReport.exit_ip || '-' }}</div>
<div>{{ t('admin.proxies.qualityCountry') }}: {{ qualityReport.country || '-' }}</div>
<div>
{{ t('admin.proxies.qualityBaseLatency') }}:
{{ typeof qualityReport.base_latency_ms === 'number' ? `${qualityReport.base_latency_ms}ms` : '-' }}
</div>
<div>{{ t('admin.proxies.qualityCheckedAt') }}: {{ new Date(qualityReport.checked_at * 1000).toLocaleString() }}</div>
</div>
</div>
<div class="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
<tr>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableTarget') }}</th>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableStatus') }}</th>
<th class="px-3 py-2 text-left">HTTP</th>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableLatency') }}</th>
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableMessage') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-for="item in qualityReport.items" :key="item.target">
<td class="px-3 py-2 text-gray-900 dark:text-white">{{ qualityTargetLabel(item.target) }}</td>
<td class="px-3 py-2">
<span class="badge" :class="qualityStatusClass(item.status)">{{ qualityStatusLabel(item.status) }}</span>
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{{ item.http_status ?? '-' }}</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{{ typeof item.latency_ms === 'number' ? `${item.latency_ms}ms` : '-' }}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
<span>{{ item.message || '-' }}</span>
<span v-if="item.cf_ray" class="ml-1 text-xs text-gray-400">(cf-ray: {{ item.cf_ray }})</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="closeQualityReportDialog" class="btn btn-secondary">
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Proxy Accounts Dialog -->
<BaseDialog
:show="showAccountsModal"
......@@ -675,7 +800,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy, ProxyAccountSummary, ProxyProtocol } from '@/types'
import type { Proxy, ProxyAccountSummary, ProxyProtocol, ProxyQualityCheckResult } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
......@@ -756,13 +881,18 @@ const showAccountsModal = ref(false)
const submitting = ref(false)
const exportingData = ref(false)
const testingProxyIds = ref<Set<number>>(new Set())
const qualityCheckingProxyIds = ref<Set<number>>(new Set())
const batchTesting = ref(false)
const batchQualityChecking = ref(false)
const selectedProxyIds = ref<Set<number>>(new Set())
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
const accountsLoading = ref(false)
const editingProxy = ref<Proxy | null>(null)
const deletingProxy = ref<Proxy | null>(null)
const showQualityReportDialog = ref(false)
const qualityReportProxy = ref<Proxy | null>(null)
const qualityReport = ref<ProxyQualityCheckResult | null>(null)
const selectedCount = computed(() => selectedProxyIds.value.size)
const allVisibleSelected = computed(() => {
......@@ -1132,6 +1262,23 @@ const applyLatencyResult = (
target.latency_message = result.message
}
const summarizeQualityStatus = (result: ProxyQualityCheckResult): Proxy['quality_status'] => {
if (result.challenge_count > 0) return 'challenge'
if (result.failed_count > 0) return 'failed'
if (result.warn_count > 0) return 'warn'
return 'healthy'
}
const applyQualityResult = (proxyId: number, result: ProxyQualityCheckResult) => {
const target = proxies.value.find((proxy) => proxy.id === proxyId)
if (!target) return
target.quality_status = summarizeQualityStatus(result)
target.quality_score = result.score
target.quality_grade = result.grade
target.quality_summary = result.summary
target.quality_checked = result.checked_at
}
const formatLocation = (proxy: Proxy) => {
const parts = [proxy.country, proxy.city].filter(Boolean) as string[]
return parts.join(' · ')
......@@ -1150,6 +1297,16 @@ const stopTestingProxy = (proxyId: number) => {
testingProxyIds.value = next
}
const startQualityCheckingProxy = (proxyId: number) => {
qualityCheckingProxyIds.value = new Set([...qualityCheckingProxyIds.value, proxyId])
}
const stopQualityCheckingProxy = (proxyId: number) => {
const next = new Set(qualityCheckingProxyIds.value)
next.delete(proxyId)
qualityCheckingProxyIds.value = next
}
const runProxyTest = async (proxyId: number, notify: boolean) => {
startTestingProxy(proxyId)
try {
......@@ -1183,6 +1340,150 @@ const handleTestConnection = async (proxy: Proxy) => {
await runProxyTest(proxy.id, true)
}
const handleQualityCheck = async (proxy: Proxy) => {
startQualityCheckingProxy(proxy.id)
try {
const result = await adminAPI.proxies.checkProxyQuality(proxy.id)
qualityReportProxy.value = proxy
qualityReport.value = result
showQualityReportDialog.value = true
const baseStep = result.items.find((item) => item.target === 'base_connectivity')
if (baseStep && baseStep.status === 'pass') {
applyLatencyResult(proxy.id, {
success: true,
latency_ms: result.base_latency_ms,
message: result.summary,
ip_address: result.exit_ip,
country: result.country,
country_code: result.country_code
})
}
applyQualityResult(proxy.id, result)
appStore.showSuccess(
t('admin.proxies.qualityCheckDone', { score: result.score, grade: result.grade })
)
} catch (error: any) {
const message = error.response?.data?.detail || t('admin.proxies.qualityCheckFailed')
appStore.showError(message)
console.error('Error checking proxy quality:', error)
} finally {
stopQualityCheckingProxy(proxy.id)
}
}
const runBatchProxyQualityChecks = async (ids: number[]) => {
if (ids.length === 0) return { total: 0, healthy: 0, warn: 0, challenge: 0, failed: 0 }
const concurrency = 3
let index = 0
let healthy = 0
let warn = 0
let challenge = 0
let failed = 0
const worker = async () => {
while (index < ids.length) {
const current = ids[index]
index++
startQualityCheckingProxy(current)
try {
const result = await adminAPI.proxies.checkProxyQuality(current)
const target = proxies.value.find((proxy) => proxy.id === current)
if (target) {
const baseStep = result.items.find((item) => item.target === 'base_connectivity')
if (baseStep && baseStep.status === 'pass') {
applyLatencyResult(current, {
success: true,
latency_ms: result.base_latency_ms,
message: result.summary,
ip_address: result.exit_ip,
country: result.country,
country_code: result.country_code
})
}
}
applyQualityResult(current, result)
if (result.challenge_count > 0) {
challenge++
} else if (result.failed_count > 0) {
failed++
} else if (result.warn_count > 0) {
warn++
} else {
healthy++
}
} catch {
failed++
} finally {
stopQualityCheckingProxy(current)
}
}
}
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
await Promise.all(workers)
return {
total: ids.length,
healthy,
warn,
challenge,
failed
}
}
const closeQualityReportDialog = () => {
showQualityReportDialog.value = false
qualityReportProxy.value = null
qualityReport.value = null
}
const qualityStatusClass = (status: string) => {
if (status === 'pass') return 'badge-success'
if (status === 'warn') return 'badge-warning'
if (status === 'challenge') return 'badge-danger'
return 'badge-danger'
}
const qualityStatusLabel = (status: string) => {
if (status === 'pass') return t('admin.proxies.qualityStatusPass')
if (status === 'warn') return t('admin.proxies.qualityStatusWarn')
if (status === 'challenge') return t('admin.proxies.qualityStatusChallenge')
return t('admin.proxies.qualityStatusFail')
}
const qualityOverallClass = (status?: string) => {
if (status === 'healthy') return 'badge-success'
if (status === 'warn') return 'badge-warning'
if (status === 'challenge') return 'badge-danger'
return 'badge-danger'
}
const qualityOverallLabel = (status?: string) => {
if (status === 'healthy') return t('admin.proxies.qualityStatusHealthy')
if (status === 'warn') return t('admin.proxies.qualityStatusWarn')
if (status === 'challenge') return t('admin.proxies.qualityStatusChallenge')
return t('admin.proxies.qualityStatusFail')
}
const qualityTargetLabel = (target: string) => {
switch (target) {
case 'base_connectivity':
return t('admin.proxies.qualityTargetBase')
case 'openai':
return 'OpenAI'
case 'anthropic':
return 'Anthropic'
case 'gemini':
return 'Gemini'
case 'sora':
return 'Sora'
default:
return target
}
}
const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
const pageSize = 200
const result: Proxy[] = []
......@@ -1253,6 +1554,43 @@ const handleBatchTest = async () => {
}
}
const handleBatchQualityCheck = async () => {
if (batchQualityChecking.value) return
batchQualityChecking.value = true
try {
let ids: number[] = []
if (selectedCount.value > 0) {
ids = Array.from(selectedProxyIds.value)
} else {
const allProxies = await fetchAllProxiesForBatch()
ids = allProxies.map((proxy) => proxy.id)
}
if (ids.length === 0) {
appStore.showInfo(t('admin.proxies.batchQualityEmpty'))
return
}
const summary = await runBatchProxyQualityChecks(ids)
appStore.showSuccess(
t('admin.proxies.batchQualityDone', {
count: summary.total,
healthy: summary.healthy,
warn: summary.warn,
challenge: summary.challenge,
failed: summary.failed
})
)
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchQualityFailed'))
console.error('Error batch checking quality:', error)
} finally {
batchQualityChecking.value = false
}
}
const formatExportTimestamp = () => {
const now = new Date()
const pad2 = (value: number) => String(value).padStart(2, '0')
......
......@@ -233,6 +233,8 @@
<span class="font-medium text-amber-600 dark:text-amber-400">{{
formatCacheTokens(row.cache_creation_tokens)
}}</span>
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
</div>
......@@ -350,9 +352,36 @@
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<!-- 有 5m/1h 明细时,展开显示 -->
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation5mTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation1hTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
</div>
</template>
<!-- 无明细时,只显示聚合值 -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('usage.cacheTtlOverriddenLabel') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
</span>
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment