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

@@ -8,7 +8,7 @@ import type {
DashboardStats,
TrendDataPoint,
ModelStat,
APIKeyUsageTrendPoint,
ApiKeyUsageTrendPoint,
UserUsageTrendPoint
} from '@/types'
@@ -93,7 +93,7 @@ export interface ApiKeyTrendParams extends TrendParams {
}
export interface ApiKeyTrendResponse {
trend: APIKeyUsageTrendPoint[]
trend: ApiKeyUsageTrendPoint[]
start_date: string
end_date: string
granularity: string

View File

@@ -15,7 +15,6 @@ import subscriptionsAPI from './subscriptions'
import usageAPI from './usage'
import geminiAPI from './gemini'
import antigravityAPI from './antigravity'
import opsAPI from './ops'
import userAttributesAPI from './userAttributes'
/**
@@ -34,7 +33,6 @@ export const adminAPI = {
usage: usageAPI,
gemini: geminiAPI,
antigravity: antigravityAPI,
ops: opsAPI,
userAttributes: userAttributesAPI
}
@@ -51,7 +49,6 @@ export {
usageAPI,
geminiAPI,
antigravityAPI,
opsAPI,
userAttributesAPI
}

View File

@@ -28,7 +28,29 @@
{{ platformDescription }}
</p>
<!-- OS Tabs -->
<!-- Client Tabs (only for Antigravity platform) -->
<div v-if="platform === 'antigravity'" class="border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex space-x-6" aria-label="Client">
<button
v-for="tab in clientTabs"
:key="tab.id"
@click="activeClientTab = tab.id"
:class="[
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
activeClientTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
]"
>
<span class="flex items-center gap-2">
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</span>
</button>
</nav>
</div>
<!-- OS/Shell Tabs -->
<div class="border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
<button
@@ -154,16 +176,21 @@ const { copyToClipboard: clipboardCopy } = useClipboard()
const copiedIndex = ref<number | null>(null)
const activeTab = ref<string>('unix')
const activeClientTab = ref<string>('claude') // Level 1 tab for antigravity platform
// Reset active tab when platform changes
// Reset tabs when platform changes
watch(() => props.platform, (newPlatform) => {
if (newPlatform === 'openai') {
activeTab.value = 'unix'
} else {
activeTab.value = 'unix'
activeTab.value = 'unix'
if (newPlatform === 'antigravity') {
activeClientTab.value = 'claude'
}
})
// Reset shell tab when client changes (for antigravity)
watch(activeClientTab, () => {
activeTab.value = 'unix'
})
// Icon components
const AppleIcon = {
render() {
@@ -189,8 +216,52 @@ const WindowsIcon = {
}
}
// Anthropic tabs (3 shell types)
const anthropicTabs: TabConfig[] = [
// Terminal icon for Claude Code
const TerminalIcon = {
render() {
return h('svg', {
fill: 'none',
stroke: 'currentColor',
viewBox: '0 0 24 24',
'stroke-width': '1.5',
class: 'w-4 h-4'
}, [
h('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'
})
])
}
}
// Sparkle icon for Gemini
const SparkleIcon = {
render() {
return h('svg', {
fill: 'none',
stroke: 'currentColor',
viewBox: '0 0 24 24',
'stroke-width': '1.5',
class: 'w-4 h-4'
}, [
h('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'
})
])
}
}
// Client tabs for Antigravity platform (Level 1)
const clientTabs = computed((): TabConfig[] => [
{ id: 'claude', label: t('keys.useKeyModal.antigravity.claudeCode'), icon: TerminalIcon },
{ id: 'gemini', label: t('keys.useKeyModal.antigravity.geminiCli'), icon: SparkleIcon }
])
// Shell tabs (3 types for environment variable based configs)
const shellTabs: TabConfig[] = [
{ id: 'unix', label: 'macOS / Linux', icon: AppleIcon },
{ id: 'cmd', label: 'Windows CMD', icon: WindowsIcon },
{ id: 'powershell', label: 'PowerShell', icon: WindowsIcon }
@@ -204,26 +275,40 @@ const openaiTabs: TabConfig[] = [
const currentTabs = computed(() => {
if (props.platform === 'openai') {
return openaiTabs
return openaiTabs // 2 tabs: unix, windows
}
return anthropicTabs
// All other platforms (anthropic, gemini, antigravity) use shell tabs
return shellTabs
})
const platformDescription = computed(() => {
if (props.platform === 'openai') {
return t('keys.useKeyModal.openai.description')
switch (props.platform) {
case 'openai':
return t('keys.useKeyModal.openai.description')
case 'gemini':
return t('keys.useKeyModal.gemini.description')
case 'antigravity':
return t('keys.useKeyModal.antigravity.description')
default:
return t('keys.useKeyModal.description')
}
return t('keys.useKeyModal.description')
})
const platformNote = computed(() => {
if (props.platform === 'openai') {
if (activeTab.value === 'windows') {
return t('keys.useKeyModal.openai.noteWindows')
}
return t('keys.useKeyModal.openai.note')
switch (props.platform) {
case 'openai':
return activeTab.value === 'windows'
? t('keys.useKeyModal.openai.noteWindows')
: t('keys.useKeyModal.openai.note')
case 'gemini':
return t('keys.useKeyModal.gemini.note')
case 'antigravity':
return activeClientTab.value === 'claude'
? t('keys.useKeyModal.antigravity.claudeNote')
: t('keys.useKeyModal.antigravity.geminiNote')
default:
return t('keys.useKeyModal.note')
}
return t('keys.useKeyModal.note')
})
// Syntax highlighting helpers
@@ -239,11 +324,20 @@ const currentFiles = computed((): FileConfig[] => {
const baseUrl = props.baseUrl || window.location.origin
const apiKey = props.apiKey
if (props.platform === 'openai') {
return generateOpenAIFiles(baseUrl, apiKey)
switch (props.platform) {
case 'openai':
return generateOpenAIFiles(baseUrl, apiKey)
case 'gemini':
return [generateGeminiCliContent(baseUrl, apiKey)]
case 'antigravity':
// Both Claude Code and Gemini CLI need /antigravity suffix for antigravity platform
if (activeClientTab.value === 'claude') {
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
}
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
default: // anthropic
return generateAnthropicFiles(baseUrl, apiKey)
}
return generateAnthropicFiles(baseUrl, apiKey)
})
function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] {
@@ -282,6 +376,51 @@ ${keyword('$env:')}${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`
return [{ path, content, highlighted }]
}
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
const model = 'gemini-2.5-pro'
const modelComment = t('keys.useKeyModal.gemini.modelComment')
let path: string
let content: string
let highlighted: string
switch (activeTab.value) {
case 'unix':
path = 'Terminal'
content = `export GOOGLE_GEMINI_BASE_URL="${baseUrl}"
export GEMINI_API_KEY="${apiKey}"
export GEMINI_MODEL="${model}" # ${modelComment}`
highlighted = `${keyword('export')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
${keyword('export')} ${variable('GEMINI_API_KEY')}${operator('=')}${string(`"${apiKey}"`)}
${keyword('export')} ${variable('GEMINI_MODEL')}${operator('=')}${string(`"${model}"`)} ${comment(`# ${modelComment}`)}`
break
case 'cmd':
path = 'Command Prompt'
content = `set GOOGLE_GEMINI_BASE_URL=${baseUrl}
set GEMINI_API_KEY=${apiKey}
set GEMINI_MODEL=${model}`
highlighted = `${keyword('set')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${baseUrl}
${keyword('set')} ${variable('GEMINI_API_KEY')}${operator('=')}${apiKey}
${keyword('set')} ${variable('GEMINI_MODEL')}${operator('=')}${model}
${comment(`REM ${modelComment}`)}`
break
case 'powershell':
path = 'PowerShell'
content = `$env:GOOGLE_GEMINI_BASE_URL="${baseUrl}"
$env:GEMINI_API_KEY="${apiKey}"
$env:GEMINI_MODEL="${model}" # ${modelComment}`
highlighted = `${keyword('$env:')}${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
${keyword('$env:')}${variable('GEMINI_API_KEY')}${operator('=')}${string(`"${apiKey}"`)}
${keyword('$env:')}${variable('GEMINI_MODEL')}${operator('=')}${string(`"${model}"`)} ${comment(`# ${modelComment}`)}`
break
default:
path = 'Terminal'
content = ''
highlighted = ''
}
return { path, content, highlighted }
}
function generateOpenAIFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'

View File

@@ -183,21 +183,6 @@ const DashboardIcon = {
)
}
const ActivityIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M3 12h4l3 6 4-12 3 6h4'
})
]
)
}
const KeyIcon = {
render: () =>
h(
@@ -457,7 +442,6 @@ const personalNavItems = computed(() => {
const adminNavItems = computed(() => {
const baseItems = [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/ops', label: t('nav.ops'), icon: ActivityIcon },
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },

View File

@@ -127,8 +127,6 @@ export default {
total: 'Total',
balance: 'Balance',
available: 'Available',
copy: 'Copy',
details: 'Details',
copiedToClipboard: 'Copied to clipboard',
copyFailed: 'Failed to copy',
contactSupport: 'Contact Support',
@@ -137,6 +135,9 @@ export default {
noOptionsFound: 'No options found',
saving: 'Saving...',
refresh: 'Refresh',
notAvailable: 'N/A',
now: 'Now',
unknown: 'Unknown',
time: {
never: 'Never',
justNow: 'Just now',
@@ -149,7 +150,6 @@ export default {
// Navigation
nav: {
dashboard: 'Dashboard',
ops: 'Ops Center',
apiKeys: 'API Keys',
usage: 'Usage',
redeem: 'Redeem',
@@ -324,6 +324,18 @@ export default {
note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.',
noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.',
},
antigravity: {
description: 'Configure API access for Antigravity group. Select the configuration method based on your client.',
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
claudeNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
geminiNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
},
gemini: {
description: 'Add the following environment variables to your terminal profile or run directly in terminal to configure Gemini CLI access.',
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
},
},
customKeyLabel: 'Custom Key',
customKeyPlaceholder: 'Enter your custom key (min 16 chars)',
@@ -331,7 +343,15 @@ export default {
customKeyTooShort: 'Custom key must be at least 16 characters',
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
customKeyRequired: 'Please enter a custom key',
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.'
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.',
ccsClientSelect: {
title: 'Select Client',
description: 'Please select the client type to import to CC-Switch:',
claudeCode: 'Claude Code',
claudeCodeDesc: 'Import as Claude Code configuration',
geminiCli: 'Gemini CLI',
geminiCliDesc: 'Import as Gemini CLI configuration',
},
},
// Usage
@@ -549,123 +569,6 @@ export default {
recentUsage: 'Recent Usage',
failedToLoad: 'Failed to load dashboard statistics'
},
ops: {
title: 'Ops Monitoring Center 2.0',
description: 'Stability metrics, error distribution, and system health',
status: {
title: 'System Health Snapshot',
subtitle: 'Real-time metrics and error visibility',
systemNormal: 'System Normal',
systemDegraded: 'System Degraded',
systemDown: 'System Down',
noData: 'No Data',
monitoring: 'Monitoring',
lastUpdated: 'Last Updated',
live: 'Live',
waiting: 'Waiting for data',
realtime: 'Connected',
disconnected: 'Disconnected'
},
charts: {
errorTrend: 'Error Trend',
errorDistribution: 'Error Distribution',
errorRate: 'Error Rate',
requestCount: 'Request Count',
rateLimits: 'Rate Limits (429)',
serverErrors: 'Server Errors (5xx)',
clientErrors: 'Client Errors (4xx)',
otherErrors: 'Other',
latencyDist: 'Latency Distribution',
providerSla: 'Upstream SLA Comparison',
errorDist: 'Error Type Distribution',
systemStatus: 'System Resources'
},
metrics: {
successRate: 'Success Rate',
errorRate: 'Error Rate',
p95: 'P95 Latency',
p99: 'P99 Latency',
http2Errors: 'HTTP/2 Errors',
activeAlerts: 'Active Alerts',
cpuUsage: 'CPU Usage',
queueDepth: 'Queue Depth',
healthScore: 'Health Score',
sla: 'Availability (SLA)',
qps: 'Real-time QPS',
tps: 'Real-time TPS',
errorCount: 'Error Count'
},
errors: {
title: 'Recent Errors',
subtitle: 'Inspect failures across platforms and phases',
count: '{n} errors'
},
filters: {
allSeverities: 'All severities',
allPlatforms: 'All platforms',
allPhases: 'All phases',
p0: 'P0 (Critical)',
p1: 'P1 (High)',
p2: 'P2 (Medium)',
p3: 'P3 (Low)'
},
searchPlaceholder: 'Search by request ID, model, or message',
range: {
'15m': 'Last 15 minutes',
'1h': 'Last 1 hour',
'24h': 'Last 24 hours',
'7d': 'Last 7 days'
},
platform: {
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
},
phase: {
auth: 'Auth',
concurrency: 'Concurrency',
billing: 'Billing',
scheduling: 'Scheduling',
network: 'Network',
upstream: 'Upstream',
response: 'Response',
internal: 'Internal'
},
severity: {
p0: 'P0',
p1: 'P1',
p2: 'P2',
p3: 'P3'
},
table: {
time: 'Time',
severity: 'Severity',
phase: 'Phase',
statusCode: 'Status',
platform: 'Platform',
model: 'Model',
latency: 'Latency',
requestId: 'Request ID',
message: 'Message'
},
details: {
title: 'Error Details',
requestId: 'Request ID',
errorMessage: 'Error Message',
requestPath: 'Request path',
clientIp: 'Client IP',
userId: 'User ID',
apiKeyId: 'API Key ID',
groupId: 'Group ID',
stream: 'Stream'
},
empty: {
title: 'No ops data yet',
subtitle: 'Enable error logging and metrics to populate this view'
},
failedToLoad: 'Failed to load ops data'
},
// Users
users: {
@@ -1030,6 +933,54 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth'
},
status: {
paused: 'Paused',
limited: 'Limited',
tempUnschedulable: 'Temp Unschedulable'
},
tempUnschedulable: {
title: 'Temp Unschedulable',
statusTitle: 'Temp Unschedulable Status',
hint: 'Disable accounts temporarily when error code and keyword both match.',
notice: 'Rules are evaluated in order and require both error code and keyword match.',
addRule: 'Add Rule',
ruleOrder: 'Rule Order',
ruleIndex: 'Rule #{index}',
errorCode: 'Error Code',
errorCodePlaceholder: 'e.g. 429',
durationMinutes: 'Duration (minutes)',
durationPlaceholder: 'e.g. 30',
keywords: 'Keywords',
keywordsPlaceholder: 'e.g. overloaded, too many requests',
keywordsHint: 'Separate keywords with commas; any keyword match will trigger.',
description: 'Description',
descriptionPlaceholder: 'Optional note for this rule',
rulesInvalid: 'Add at least one rule with error code, keywords, and duration.',
viewDetails: 'View temp unschedulable details',
accountName: 'Account',
triggeredAt: 'Triggered At',
until: 'Until',
remaining: 'Remaining',
matchedKeyword: 'Matched Keyword',
errorMessage: 'Error Details',
reset: 'Reset Status',
resetSuccess: 'Temp unschedulable status reset',
resetFailed: 'Failed to reset temp unschedulable status',
failedToLoad: 'Failed to load temp unschedulable status',
notActive: 'This account is not temporarily unschedulable.',
expired: 'Expired',
remainingMinutes: 'About {minutes} minutes',
remainingHours: 'About {hours} hours',
remainingHoursMinutes: 'About {hours} hours {minutes} minutes',
presets: {
overloadLabel: '529 Overloaded',
overloadDesc: 'Overloaded - pause 60 minutes',
rateLimitLabel: '429 Rate Limit',
rateLimitDesc: 'Rate limited - pause 10 minutes',
unavailableLabel: '503 Unavailable',
unavailableDesc: 'Unavailable - pause 30 minutes'
}
},
columns: {
name: 'Name',
platformType: 'Platform/Type',
@@ -1147,10 +1098,10 @@ export default {
priority: 'Priority',
priorityHint: 'Higher priority accounts are used first',
higherPriorityFirst: 'Higher value means higher priority',
mixedScheduling: 'Mixed Scheduling',
mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
mixedSchedulingTooltip:
'When enabled, this account can be scheduled by /v1/messages and /v1beta endpoints. Otherwise, it will only be scheduled by /antigravity. Note: Anthropic Claude and Antigravity Claude cannot be mixed in the same context. Please manage groups carefully when enabled.',
'!! WARNING !! Antigravity Claude and Anthropic Claude cannot be used in the same context. If you have both Anthropic and Antigravity accounts, enabling this option will cause frequent 400 errors. When enabled, please use the group feature to isolate Antigravity accounts from Anthropic accounts. Make sure you understand this before enabling!!',
creating: 'Creating...',
updating: 'Updating...',
accountCreated: 'Account created successfully',
@@ -1664,6 +1615,7 @@ export default {
siteKey: 'Site Key',
secretKey: 'Secret Key',
siteKeyHint: 'Get this from your Cloudflare Dashboard',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)'
},
defaults: {
@@ -1814,6 +1766,7 @@ export default {
noActiveSubscriptions: 'No Active Subscriptions',
noActiveSubscriptionsDesc:
"You don't have any active subscriptions. Contact administrator to get one.",
failedToLoad: 'Failed to load subscriptions',
status: {
active: 'Active',
expired: 'Expired',

View File

@@ -124,8 +124,6 @@ export default {
total: '总计',
balance: '余额',
available: '可用',
copy: '复制',
details: '详情',
copiedToClipboard: '已复制到剪贴板',
copyFailed: '复制失败',
contactSupport: '联系客服',
@@ -134,6 +132,9 @@ export default {
noOptionsFound: '无匹配选项',
saving: '保存中...',
refresh: '刷新',
notAvailable: '不可用',
now: '现在',
unknown: '未知',
time: {
never: '从未',
justNow: '刚刚',
@@ -146,7 +147,6 @@ export default {
// Navigation
nav: {
dashboard: '仪表盘',
ops: '运维监控',
apiKeys: 'API 密钥',
usage: '使用记录',
redeem: '兑换',
@@ -320,6 +320,18 @@ export default {
note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。',
noteWindows: '按 Win+R输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。',
},
antigravity: {
description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。',
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
claudeNote: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
geminiNote: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
},
gemini: {
description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。',
modelComment: '如果你有 Gemini 3 权限可以填gemini-3-pro-preview',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
},
},
customKeyLabel: '自定义密钥',
customKeyPlaceholder: '输入自定义密钥至少16个字符',
@@ -327,7 +339,15 @@ export default {
customKeyTooShort: '自定义密钥至少需要16个字符',
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
customKeyRequired: '请输入自定义密钥',
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。'
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
ccsClientSelect: {
title: '选择客户端',
description: '请选择您要导入到 CC-Switch 的客户端类型:',
claudeCode: 'Claude Code',
claudeCodeDesc: '导入为 Claude Code 配置',
geminiCli: 'Gemini CLI',
geminiCliDesc: '导入为 Gemini CLI 配置',
},
},
// Usage
@@ -562,123 +582,6 @@ export default {
configureSystem: '配置系统设置',
failedToLoad: '加载仪表盘数据失败'
},
ops: {
title: '运维监控中心 2.0',
description: '稳定性指标、错误分布与系统健康',
status: {
title: '系统健康快照',
subtitle: '实时指标与错误可见性',
systemNormal: '系统正常',
systemDegraded: '系统降级',
systemDown: '系统异常',
noData: '无数据',
monitoring: '监控中',
lastUpdated: '最后更新',
live: '实时',
waiting: '等待数据',
realtime: '实时连接中',
disconnected: '连接已断开'
},
charts: {
errorTrend: '错误趋势',
errorDistribution: '错误分布',
errorRate: '错误率',
requestCount: '请求数',
rateLimits: '限流 (429)',
serverErrors: '服务端错误 (5xx)',
clientErrors: '客户端错误 (4xx)',
otherErrors: '其他',
latencyDist: '请求延迟分布',
providerSla: '上游供应商健康度 (SLA)',
errorDist: '错误类型分布',
systemStatus: '系统运行状态'
},
metrics: {
successRate: '成功率',
errorRate: '错误率',
p95: 'P95 延迟',
p99: 'P99 延迟',
http2Errors: 'HTTP/2 错误',
activeAlerts: '活跃告警',
cpuUsage: 'CPU 使用率',
queueDepth: '排队深度',
healthScore: '健康评分',
sla: '服务可用率 (SLA)',
qps: '实时 QPS',
tps: '实时 TPS',
errorCount: '周期错误数'
},
errors: {
title: '最近错误',
subtitle: '按平台与阶段定位失败原因',
count: '{n} 条错误'
},
filters: {
allSeverities: '全部级别',
allPlatforms: '全部平台',
allPhases: '全部阶段',
p0: 'P0致命',
p1: 'P1',
p2: 'P2',
p3: 'P3'
},
searchPlaceholder: '按请求ID、模型或错误信息搜索',
range: {
'15m': '近 15 分钟',
'1h': '近 1 小时',
'24h': '近 24 小时',
'7d': '近 7 天'
},
platform: {
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
},
phase: {
auth: '认证',
concurrency: '并发',
billing: '计费',
scheduling: '调度',
network: '网络',
upstream: '上游',
response: '响应',
internal: '内部'
},
severity: {
p0: 'P0',
p1: 'P1',
p2: 'P2',
p3: 'P3'
},
table: {
time: '时间',
severity: '级别',
phase: '阶段',
statusCode: '状态码',
platform: '平台',
model: '模型',
latency: '延迟',
requestId: '请求ID',
message: '错误信息'
},
details: {
title: '错误详情',
requestId: '请求ID',
errorMessage: '错误信息',
requestPath: '请求路径',
clientIp: '客户端IP',
userId: '用户ID',
apiKeyId: 'API Key ID',
groupId: '分组ID',
stream: '流式'
},
empty: {
title: '暂无运维数据',
subtitle: '启用错误日志与指标采集后将展示在此处'
},
failedToLoad: '加载运维数据失败'
},
// Users Management
users: {
@@ -1156,6 +1059,54 @@ export default {
error: '错误',
cooldown: '冷却中'
},
status: {
paused: '已暂停',
limited: '受限',
tempUnschedulable: '临时不可调度'
},
tempUnschedulable: {
title: '临时不可调度',
statusTitle: '临时不可调度状态',
hint: '当错误码与关键词同时匹配时,账号会在指定时间内被临时禁用。',
notice: '规则按顺序匹配,需同时满足错误码与关键词。',
addRule: '添加规则',
ruleOrder: '规则序号',
ruleIndex: '规则 #{index}',
errorCode: '错误码',
errorCodePlaceholder: '例如 429',
durationMinutes: '持续时间(分钟)',
durationPlaceholder: '例如 30',
keywords: '关键词',
keywordsPlaceholder: '例如 overloaded, too many requests',
keywordsHint: '多个关键词用逗号分隔,匹配时必须命中其中之一。',
description: '描述',
descriptionPlaceholder: '可选,便于记忆规则用途',
rulesInvalid: '请至少填写一条包含错误码、关键词和时长的规则。',
viewDetails: '查看临时不可调度详情',
accountName: '账号',
triggeredAt: '触发时间',
until: '解除时间',
remaining: '剩余时间',
matchedKeyword: '匹配关键词',
errorMessage: '错误详情',
reset: '重置状态',
resetSuccess: '临时不可调度已重置',
resetFailed: '重置临时不可调度失败',
failedToLoad: '加载临时不可调度状态失败',
notActive: '当前账号未处于临时不可调度状态。',
expired: '已到期',
remainingMinutes: '约 {minutes} 分钟',
remainingHours: '约 {hours} 小时',
remainingHoursMinutes: '约 {hours} 小时 {minutes} 分钟',
presets: {
overloadLabel: '529 过载',
overloadDesc: '服务过载 - 暂停 60 分钟',
rateLimitLabel: '429 限流',
rateLimitDesc: '触发限流 - 暂停 10 分钟',
unavailableLabel: '503 维护',
unavailableDesc: '服务不可用 - 暂停 30 分钟'
}
},
usageWindow: {
statsTitle: '5小时窗口用量统计',
statsTitleDaily: '每日用量统计',
@@ -1300,10 +1251,10 @@ export default {
priority: '优先级',
priorityHint: '优先级越高的账号优先使用',
higherPriorityFirst: '数值越高优先级越高',
mixedScheduling: '混合调度',
mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
mixedSchedulingTooltip:
'开启后,该账户可被 /v1/messages 及 /v1beta 端点调度,否则只被 /antigravity 调度。注意Anthropic Claude 和 Antigravity Claude 无法在同个上下文中混合使用,开启后请自行做好分组管理。',
'!!注意!! Antigravity Claude 和 Anthropic Claude 无法在同个上下文中使用,如果你同时有 Anthropic 账号和 Antigravity 账号,开启此选项会导致经常 400 报错。开启后,请用分组功能做好 Antigravity 账号和 Anthropic 账号的隔离。一定要弄明白再开启!!',
creating: '创建中...',
updating: '更新中...',
accountCreated: '账号创建成功',
@@ -1860,6 +1811,7 @@ export default {
siteKey: '站点密钥',
secretKey: '私密密钥',
siteKeyHint: '从 Cloudflare Dashboard 获取',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: '服务端验证密钥(请保密)'
},
defaults: {
@@ -2007,6 +1959,7 @@ export default {
description: '查看您的订阅计划和用量',
noActiveSubscriptions: '暂无有效订阅',
noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。',
failedToLoad: '加载订阅失败',
status: {
active: '有效',
expired: '已过期',

View File

@@ -163,18 +163,6 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.dashboard.description'
}
},
{
path: '/admin/ops',
name: 'AdminOps',
component: () => import('@/views/admin/ops/OpsDashboard.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Ops Dashboard',
titleKey: 'admin.ops.title',
descriptionKey: 'admin.ops.description'
}
},
{
path: '/admin/users',
name: 'AdminUsers',

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
}