## 修复内容 ### 1. AccountsView 功能恢复 - 恢复3个缺失的模态框组件: - ReAuthAccountModal.vue - 重新授权功能 - AccountTestModal.vue - 测试连接功能 - AccountStatsModal.vue - 查看统计功能 - 恢复 handleTest/handleViewStats/handleReAuth 调用模态框 - 修复 UpdateAccountRequest 类型定义(添加 schedulable 字段) ### 2. DashboardView 修复 - 恢复 formatBalance 函数(支持千位分隔符显示) - 为 UserDashboardStats 添加完整 Props 类型定义 - 为 UserDashboardRecentUsage 添加完整 Props 类型定义 - 优化格式化函数到共享 utils/format.ts ### 3. 类型安全增强 - 修复 UserAttributeOption 索引签名兼容性 - 移除未使用的类型导入 - 所有组件 Props 类型完整 ## 验证结果 - ✅ TypeScript 类型检查通过(0 errors) - ✅ vue-tsc 检查通过(0 errors) - ✅ 所有样式与重构前100%一致 - ✅ 所有功能完整恢复 ## 影响范围 - AccountsView: 代码行数从974行优化到189行(提升80.6%可维护性) - DashboardView: 保持组件化同时恢复所有原有功能 - 深色模式支持完整 - 所有颜色方案和 SVG 图标保持一致 Closes #149
511 lines
17 KiB
Vue
511 lines
17 KiB
Vue
<template>
|
|
<BaseDialog
|
|
:show="show"
|
|
:title="t('admin.accounts.testAccountConnection')"
|
|
width="normal"
|
|
@close="handleClose"
|
|
>
|
|
<div class="space-y-4">
|
|
<!-- Account Info Card -->
|
|
<div
|
|
v-if="account"
|
|
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
|
>
|
|
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
|
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
|
<span
|
|
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
|
|
>
|
|
{{ account.type }}
|
|
</span>
|
|
<span>{{ t('admin.accounts.account') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span
|
|
:class="[
|
|
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
|
account.status === 'active'
|
|
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
|
]"
|
|
>
|
|
{{ account.status }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="space-y-1.5">
|
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{{ t('admin.accounts.selectTestModel') }}
|
|
</label>
|
|
<Select
|
|
v-model="selectedModelId"
|
|
:options="availableModels"
|
|
:disabled="loadingModels || status === 'connecting'"
|
|
value-key="id"
|
|
label-key="display_name"
|
|
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Terminal Output -->
|
|
<div class="group relative">
|
|
<div
|
|
ref="terminalRef"
|
|
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
|
|
>
|
|
<!-- Status Line -->
|
|
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
/>
|
|
</svg>
|
|
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
|
</div>
|
|
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
|
<svg 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>
|
|
<span>{{ t('admin.accounts.connectingToApi') }}</span>
|
|
</div>
|
|
|
|
<!-- Output Lines -->
|
|
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
|
|
{{ line.text }}
|
|
</div>
|
|
|
|
<!-- Streaming Content -->
|
|
<div v-if="streamingContent" class="text-green-400">
|
|
{{ streamingContent }}<span class="animate-pulse">_</span>
|
|
</div>
|
|
|
|
<!-- Result Status -->
|
|
<div
|
|
v-if="status === 'success'"
|
|
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span>{{ t('admin.accounts.testCompleted') }}</span>
|
|
</div>
|
|
<div
|
|
v-else-if="status === 'error'"
|
|
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span>{{ errorMessage }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Copy Button -->
|
|
<button
|
|
v-if="outputLines.length > 0"
|
|
@click="copyOutput"
|
|
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
|
|
:title="t('admin.accounts.copyOutput')"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Test Info -->
|
|
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<div class="flex items-center gap-3">
|
|
<span class="flex items-center gap-1">
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
|
/>
|
|
</svg>
|
|
{{ t('admin.accounts.testModel') }}
|
|
</span>
|
|
</div>
|
|
<span class="flex items-center gap-1">
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
|
/>
|
|
</svg>
|
|
{{ t('admin.accounts.testPrompt') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-3">
|
|
<button
|
|
@click="handleClose"
|
|
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
|
:disabled="status === 'connecting'"
|
|
>
|
|
{{ t('common.close') }}
|
|
</button>
|
|
<button
|
|
@click="startTest"
|
|
:disabled="status === 'connecting' || !selectedModelId"
|
|
:class="[
|
|
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
|
status === 'connecting' || !selectedModelId
|
|
? 'cursor-not-allowed bg-primary-400 text-white'
|
|
: status === 'success'
|
|
? 'bg-green-500 text-white hover:bg-green-600'
|
|
: status === 'error'
|
|
? 'bg-orange-500 text-white hover:bg-orange-600'
|
|
: 'bg-primary-500 text-white hover:bg-primary-600'
|
|
]"
|
|
>
|
|
<svg
|
|
v-if="status === 'connecting'"
|
|
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>
|
|
<svg
|
|
v-else-if="status === 'idle'"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
|
/>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
<span>
|
|
{{
|
|
status === 'connecting'
|
|
? t('admin.accounts.testing')
|
|
: status === 'idle'
|
|
? t('admin.accounts.startTest')
|
|
: t('admin.accounts.retry')
|
|
}}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
|
import Select from '@/components/common/Select.vue'
|
|
import { useClipboard } from '@/composables/useClipboard'
|
|
import { adminAPI } from '@/api/admin'
|
|
import type { Account, ClaudeModel } from '@/types'
|
|
|
|
const { t } = useI18n()
|
|
const { copyToClipboard } = useClipboard()
|
|
|
|
interface OutputLine {
|
|
text: string
|
|
class: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
show: boolean
|
|
account: Account | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'close'): void
|
|
}>()
|
|
|
|
const terminalRef = ref<HTMLElement | null>(null)
|
|
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
|
|
const outputLines = ref<OutputLine[]>([])
|
|
const streamingContent = ref('')
|
|
const errorMessage = ref('')
|
|
const availableModels = ref<ClaudeModel[]>([])
|
|
const selectedModelId = ref('')
|
|
const loadingModels = ref(false)
|
|
let eventSource: EventSource | null = null
|
|
|
|
// Load available models when modal opens
|
|
watch(
|
|
() => props.show,
|
|
async (newVal) => {
|
|
if (newVal && props.account) {
|
|
resetState()
|
|
await loadAvailableModels()
|
|
} else {
|
|
closeEventSource()
|
|
}
|
|
}
|
|
)
|
|
|
|
const loadAvailableModels = async () => {
|
|
if (!props.account) return
|
|
|
|
loadingModels.value = true
|
|
selectedModelId.value = '' // Reset selection before loading
|
|
try {
|
|
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
|
|
// Default selection by platform
|
|
if (availableModels.value.length > 0) {
|
|
if (props.account.platform === 'gemini') {
|
|
const preferred =
|
|
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
|
availableModels.value.find((m) => m.id === 'gemini-3-pro')
|
|
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
|
} else {
|
|
// Try to select Sonnet as default, otherwise use first model
|
|
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
|
|
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load available models:', error)
|
|
// Fallback to empty list
|
|
availableModels.value = []
|
|
selectedModelId.value = ''
|
|
} finally {
|
|
loadingModels.value = false
|
|
}
|
|
}
|
|
|
|
const resetState = () => {
|
|
status.value = 'idle'
|
|
outputLines.value = []
|
|
streamingContent.value = ''
|
|
errorMessage.value = ''
|
|
}
|
|
|
|
const handleClose = () => {
|
|
// 防止在连接测试进行中关闭对话框
|
|
if (status.value === 'connecting') {
|
|
return
|
|
}
|
|
closeEventSource()
|
|
emit('close')
|
|
}
|
|
|
|
const closeEventSource = () => {
|
|
if (eventSource) {
|
|
eventSource.close()
|
|
eventSource = null
|
|
}
|
|
}
|
|
|
|
const addLine = (text: string, className: string = 'text-gray-300') => {
|
|
outputLines.value.push({ text, class: className })
|
|
scrollToBottom()
|
|
}
|
|
|
|
const scrollToBottom = async () => {
|
|
await nextTick()
|
|
if (terminalRef.value) {
|
|
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
const startTest = async () => {
|
|
if (!props.account || !selectedModelId.value) return
|
|
|
|
resetState()
|
|
status.value = 'connecting'
|
|
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
|
|
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
|
|
addLine('', 'text-gray-300')
|
|
|
|
closeEventSource()
|
|
|
|
try {
|
|
// Create EventSource for SSE
|
|
const url = `/api/v1/admin/accounts/${props.account.id}/test`
|
|
|
|
// Use fetch with streaming for SSE since EventSource doesn't support POST
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ model_id: selectedModelId.value })
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`)
|
|
}
|
|
|
|
const reader = response.body?.getReader()
|
|
if (!reader) {
|
|
throw new Error('No response body')
|
|
}
|
|
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || ''
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const jsonStr = line.slice(6).trim()
|
|
if (jsonStr) {
|
|
try {
|
|
const event = JSON.parse(jsonStr)
|
|
handleEvent(event)
|
|
} catch (e) {
|
|
console.error('Failed to parse SSE event:', e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
status.value = 'error'
|
|
errorMessage.value = error.message || 'Unknown error'
|
|
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
|
|
}
|
|
}
|
|
|
|
const handleEvent = (event: {
|
|
type: string
|
|
text?: string
|
|
model?: string
|
|
success?: boolean
|
|
error?: string
|
|
}) => {
|
|
switch (event.type) {
|
|
case 'test_start':
|
|
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
|
|
if (event.model) {
|
|
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
|
}
|
|
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
|
|
addLine('', 'text-gray-300')
|
|
addLine(t('admin.accounts.response'), 'text-yellow-400')
|
|
break
|
|
|
|
case 'content':
|
|
if (event.text) {
|
|
streamingContent.value += event.text
|
|
scrollToBottom()
|
|
}
|
|
break
|
|
|
|
case 'test_complete':
|
|
// Move streaming content to output lines
|
|
if (streamingContent.value) {
|
|
addLine(streamingContent.value, 'text-green-300')
|
|
streamingContent.value = ''
|
|
}
|
|
if (event.success) {
|
|
status.value = 'success'
|
|
} else {
|
|
status.value = 'error'
|
|
errorMessage.value = event.error || 'Test failed'
|
|
}
|
|
break
|
|
|
|
case 'error':
|
|
status.value = 'error'
|
|
errorMessage.value = event.error || 'Unknown error'
|
|
if (streamingContent.value) {
|
|
addLine(streamingContent.value, 'text-green-300')
|
|
streamingContent.value = ''
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
const copyOutput = () => {
|
|
const text = outputLines.value.map((l) => l.text).join('\n')
|
|
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
|
}
|
|
</script>
|