feat(frontend): 增强用户界面和使用教程

主要改进:
- 扩展 UseKeyModal 支持 Antigravity/Gemini 平台教程
- 添加 CCS (Claude Code Settings) 导入说明
- 添加混合渠道风险警告提示
- 优化登录/注册页面样式
- 更新 Antigravity 混合调度选项文案
- 完善中英文国际化文案
This commit is contained in:
ianshaw
2026-01-03 06:35:50 -08:00
parent 09da6904f5
commit ff3f514f6b
13 changed files with 440 additions and 328 deletions

View File

@@ -240,7 +240,7 @@
href="https://dash.cloudflare.com/"
target="_blank"
class="text-primary-600 hover:text-primary-500"
>Cloudflare Dashboard</a
>{{ t('admin.settings.turnstile.cloudflareDashboard') }}</a
>
</p>
</div>

View File

@@ -295,12 +295,12 @@ function onTurnstileVerify(token: string): void {
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification expired, please try again'
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification failed, please try again'
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
@@ -315,25 +315,25 @@ function validateForm(): boolean {
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required'
errors.email = t('auth.emailRequired')
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Please enter a valid email address'
errors.email = t('auth.invalidEmail')
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required'
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters'
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = 'Please complete the verification'
errors.turnstile = t('auth.completeVerification')
isValid = false
}
@@ -362,7 +362,7 @@ async function handleLogin(): Promise<void> {
})
// Show success toast
appStore.showSuccess('Login successful! Welcome back.')
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
@@ -382,7 +382,7 @@ async function handleLogin(): Promise<void> {
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Login failed. Please check your credentials and try again.'
errorMessage.value = t('auth.loginFailed')
}
// Also show error toast

View File

@@ -340,12 +340,12 @@ function onTurnstileVerify(token: string): void {
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification expired, please try again'
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification failed, please try again'
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
@@ -365,25 +365,25 @@ function validateForm(): boolean {
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required'
errors.email = t('auth.emailRequired')
isValid = false
} else if (!validateEmail(formData.email)) {
errors.email = 'Please enter a valid email address'
errors.email = t('auth.invalidEmail')
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required'
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters'
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = 'Please complete the verification'
errors.turnstile = t('auth.completeVerification')
isValid = false
}
@@ -429,7 +429,7 @@ async function handleRegister(): Promise<void> {
})
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.')
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
// Redirect to dashboard
await router.push('/dashboard')
@@ -448,7 +448,7 @@ async function handleRegister(): Promise<void> {
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Registration failed. Please try again.'
errorMessage.value = t('auth.registrationFailed')
}
// Also show error toast

View File

@@ -173,7 +173,7 @@
</button>
<!-- Import to CC Switch Button -->
<button
@click="importToCcswitch(row.key)"
@click="importToCcswitch(row)"
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 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<svg
@@ -453,6 +453,49 @@
@close="closeUseKeyModal"
/>
<!-- CCS Client Selection Dialog for Antigravity -->
<BaseDialog
:show="showCcsClientSelect"
:title="t('keys.ccsClientSelect.title')"
width="narrow"
@close="closeCcsClientSelect"
>
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('keys.ccsClientSelect.description') }}
</p>
<div class="grid grid-cols-2 gap-3">
<button
@click="handleCcsClientSelect('claude')"
class="flex flex-col items-center gap-2 p-4 rounded-xl border-2 border-gray-200 dark:border-dark-600 hover:border-primary-500 dark:hover:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
>
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 17.25V6.75A2.25 2.25 0 0 0 18.75 4.5H5.25A2.25 2.25 0 0 0 3 6.75v10.5A2.25 2.25 0 0 0 5.25 20.25Z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ t('keys.ccsClientSelect.claudeCode') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('keys.ccsClientSelect.claudeCodeDesc') }}</span>
</button>
<button
@click="handleCcsClientSelect('gemini')"
class="flex flex-col items-center gap-2 p-4 rounded-xl border-2 border-gray-200 dark:border-dark-600 hover:border-primary-500 dark:hover:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
>
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ t('keys.ccsClientSelect.geminiCli') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('keys.ccsClientSelect.geminiCliDesc') }}</span>
</button>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="closeCcsClientSelect" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Group Selector Dropdown (Teleported to body to avoid overflow clipping) -->
<Teleport to="body">
<div
@@ -563,6 +606,8 @@ const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showUseKeyModal = ref(false)
const showCcsClientSelect = ref(false)
const pendingCcsRow = ref<ApiKey | null>(null)
const selectedKey = ref<ApiKey | null>(null)
const copiedKeyId = ref<number | null>(null)
const groupSelectorKeyId = ref<number | null>(null)
@@ -871,8 +916,48 @@ const closeModals = () => {
}
}
const importToCcswitch = (apiKey: string) => {
const importToCcswitch = (row: ApiKey) => {
const platform = row.group?.platform || 'anthropic'
// For antigravity platform, show client selection dialog
if (platform === 'antigravity') {
pendingCcsRow.value = row
showCcsClientSelect.value = true
return
}
// For other platforms, execute directly
executeCcsImport(row, platform === 'gemini' ? 'gemini' : 'claude')
}
const executeCcsImport = (row: ApiKey, clientType: 'claude' | 'gemini') => {
const baseUrl = publicSettings.value?.api_base_url || window.location.origin
const platform = row.group?.platform || 'anthropic'
// Determine app name and endpoint based on platform and client type
let app: string
let endpoint: string
if (platform === 'antigravity') {
// Antigravity always uses /antigravity suffix
app = clientType === 'gemini' ? 'gemini' : 'claude'
endpoint = `${baseUrl}/antigravity`
} else {
switch (platform) {
case 'openai':
app = 'codex'
endpoint = baseUrl
break
case 'gemini':
app = 'gemini'
endpoint = baseUrl
break
default: // anthropic
app = 'claude'
endpoint = baseUrl
}
}
const usageScript = `({
request: {
url: "{{baseUrl}}/v1/usage",
@@ -889,11 +974,11 @@ const importToCcswitch = (apiKey: string) => {
})`
const params = new URLSearchParams({
resource: 'provider',
app: 'claude',
app: app,
name: 'sub2api',
homepage: baseUrl,
endpoint: baseUrl,
apiKey: apiKey,
endpoint: endpoint,
apiKey: row.key,
configFormat: 'json',
usageEnabled: 'true',
usageScript: btoa(usageScript),
@@ -916,6 +1001,19 @@ const importToCcswitch = (apiKey: string) => {
}
}
const handleCcsClientSelect = (clientType: 'claude' | 'gemini') => {
if (pendingCcsRow.value) {
executeCcsImport(pendingCcsRow.value, clientType)
}
showCcsClientSelect.value = false
pendingCcsRow.value = null
}
const closeCcsClientSelect = () => {
showCcsClientSelect.value = false
pendingCcsRow.value = null
}
onMounted(() => {
loadApiKeys()
loadGroups()

View File

@@ -500,7 +500,7 @@ const getHistoryItemTitle = (item: RedeemHistoryItem) => {
} else if (item.type === 'subscription') {
return t('redeem.subscriptionAssigned')
}
return 'Unknown'
return t('common.unknown')
}
const formatHistoryValue = (item: RedeemHistoryItem) => {

View File

@@ -279,7 +279,7 @@ async function loadSubscriptions() {
subscriptions.value = await subscriptionsAPI.getMySubscriptions()
} catch (error) {
console.error('Failed to load subscriptions:', error)
appStore.showError('Failed to load subscriptions')
appStore.showError(t('userSubscriptions.failedToLoad'))
} finally {
loading.value = false
}