fix(frontend): 优化前端组件和国际化支持

- 添加 Accept-Language 请求头支持后端翻译
- 优化账户状态指示器和测试模态框
- 简化用户属性表单和配置模态框
- 新增多个国际化翻译条目
- 重构管理视图代码,提升可维护性
This commit is contained in:
IanShaw027
2026-01-04 20:49:34 +08:00
parent 4df712624e
commit 6c036d7b59
14 changed files with 189 additions and 142 deletions

View File

@@ -5,6 +5,7 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { ApiResponse } from '@/types'
import { getLocale } from '@/i18n'
// ==================== Axios Instance Configuration ====================
@@ -27,6 +28,12 @@ apiClient.interceptors.request.use(
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
// Attach locale for backend translations
if (config.headers) {
config.headers['Accept-Language'] = getLocale()
}
return config
},
(error) => {

View File

@@ -52,7 +52,7 @@
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Rate limited until {{ formatTime(account.rate_limit_reset_at) }}
{{ t('admin.accounts.statuses.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
@@ -77,7 +77,7 @@
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Overloaded until {{ formatTime(account.overload_until) }}
{{ t('admin.accounts.statuses.overloadedUntil', { time: formatTime(account.overload_until) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
@@ -96,9 +96,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import { formatTime } from '@/utils/format'
const { t } = useI18n()
const props = defineProps<{
account: Account
}>()
@@ -140,12 +143,12 @@ const statusClass = computed(() => {
// Computed: status text
const statusText = computed(() => {
if (!props.account.schedulable) {
return 'Paused'
return t('admin.accounts.statuses.paused')
}
if (isRateLimited.value || isOverloaded.value) {
return 'Limited'
return t('admin.accounts.statuses.limited')
}
return props.account.status
return t(`admin.accounts.statuses.${props.account.status}`)
})
// Computed: tier display

View File

@@ -48,21 +48,18 @@
</span>
</div>
<!-- Model Selection -->
<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
<Select
v-model="selectedModelId"
:options="availableModels"
:disabled="loadingModels || status === 'connecting'"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
>
<option v-if="loadingModels" value="">{{ t('common.loading') }}...</option>
<option v-for="model in availableModels" :key="model.id" :value="model.id">
{{ model.display_name }} ({{ model.id }})
</option>
</select>
value-key="id"
label-key="display_name"
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<!-- Terminal Output -->
@@ -280,6 +277,7 @@
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'

View File

@@ -52,18 +52,12 @@
/>
<!-- Select -->
<select
<Select
v-else-if="attr.type === 'select'"
v-model="localValues[attr.id]"
:required="attr.required"
class="input"
:options="attr.options || []"
@change="emitChange"
>
<option value="">{{ t('common.selectOption') }}</option>
<option v-for="opt in attr.options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
/>
<!-- Multi-Select (Checkboxes) -->
<div v-else-if="attr.type === 'multi_select'" class="space-y-2">
@@ -102,6 +96,7 @@ import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { UserAttributeDefinition, UserAttributeValuesMap } from '@/types'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()

View File

@@ -142,11 +142,10 @@
<!-- Type -->
<div>
<label class="input-label">{{ t('admin.users.attributes.type') }}</label>
<select v-model="form.type" class="input" required>
<option v-for="type in attributeTypes" :key="type" :value="type">
{{ t(`admin.users.attributes.types.${type}`) }}
</option>
</select>
<Select
v-model="form.type"
:options="attributeTypes.map(type => ({ value: type, label: t(`admin.users.attributes.types.${type}`) }))"
/>
</div>
<!-- Options (for select/multi_select) -->
@@ -257,6 +256,7 @@ import { adminAPI } from '@/api/admin'
import type { UserAttributeDefinition, UserAttributeType, UserAttributeOption } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()

View File

@@ -47,6 +47,7 @@ export default {
description: 'Configure your Sub2API instance',
database: {
title: 'Database Configuration',
description: 'Connect to your PostgreSQL database',
host: 'Host',
port: 'Port',
username: 'Username',
@@ -63,6 +64,7 @@ export default {
},
redis: {
title: 'Redis Configuration',
description: 'Connect to your Redis server',
host: 'Host',
port: 'Port',
password: 'Password (optional)',
@@ -71,6 +73,7 @@ export default {
},
admin: {
title: 'Admin Account',
description: 'Create your administrator account',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
@@ -80,9 +83,21 @@ export default {
},
ready: {
title: 'Ready to Install',
description: 'Review your configuration and complete setup',
database: 'Database',
redis: 'Redis',
adminEmail: 'Admin Email'
},
status: {
testing: 'Testing...',
success: 'Connection Successful',
testConnection: 'Test Connection',
installing: 'Installing...',
completeInstallation: 'Complete Installation',
completed: 'Installation completed!',
redirecting: 'Redirecting to login page...',
restarting: 'Service is restarting, please wait...',
timeout: 'Service restart is taking longer than expected. Please refresh the page manually.'
}
},
@@ -790,6 +805,18 @@ export default {
failedToCreate: 'Failed to create group',
failedToUpdate: 'Failed to update group',
failedToDelete: 'Failed to delete group',
platforms: {
all: 'All Platforms',
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
},
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error'
},
deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
deleteConfirmSubscription:
@@ -954,6 +981,16 @@ export default {
tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared successfully',
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error',
cooldown: 'Cooldown',
paused: 'Paused',
limited: 'Limited',
rateLimitedUntil: 'Rate limited until {time}',
overloadedUntil: 'Overloaded until {time}'
},
bulkActions: {
selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page',
@@ -1396,6 +1433,17 @@ export default {
searchProxies: 'Search proxies...',
allProtocols: 'All Protocols',
allStatus: 'All Status',
protocols: {
http: 'HTTP',
https: 'HTTPS',
socks5: 'SOCKS5',
socks5h: 'SOCKS5H (Remote DNS)'
},
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error'
},
columns: {
name: 'Name',
protocol: 'Protocol',

View File

@@ -44,6 +44,7 @@ export default {
description: '配置您的 Sub2API 实例',
database: {
title: '数据库配置',
description: '连接到您的 PostgreSQL 数据库',
host: '主机',
port: '端口',
username: '用户名',
@@ -60,6 +61,7 @@ export default {
},
redis: {
title: 'Redis 配置',
description: '连接到您的 Redis 服务器',
host: '主机',
port: '端口',
password: '密码(可选)',
@@ -68,6 +70,7 @@ export default {
},
admin: {
title: '管理员账户',
description: '创建您的管理员账户',
email: '邮箱',
password: '密码',
confirmPassword: '确认密码',
@@ -77,9 +80,21 @@ export default {
},
ready: {
title: '准备安装',
description: '检查您的配置并完成安装',
database: '数据库',
redis: 'Redis',
adminEmail: '管理员邮箱'
},
status: {
testing: '测试中...',
success: '连接成功',
testConnection: '测试连接',
installing: '安装中...',
completeInstallation: '完成安装',
completed: '安装完成!',
redirecting: '正在跳转到登录页面...',
restarting: '服务正在重启,请稍候...',
timeout: '服务重启时间超出预期,请手动刷新页面。'
}
},
@@ -848,8 +863,15 @@ export default {
rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍',
platforms: {
all: '全部平台',
claude: 'Claude',
openai: 'OpenAI'
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误'
},
saving: '保存中...',
noGroups: '暂无分组',
@@ -1054,7 +1076,11 @@ export default {
active: '正常',
inactive: '停用',
error: '错误',
cooldown: '冷却中'
cooldown: '冷却中',
paused: '暂停',
limited: '限流',
rateLimitedUntil: '限流中,重置时间:{time}',
overloadedUntil: '负载过重,重置时间:{time}'
},
usageWindow: {
statsTitle: '5小时窗口用量统计',
@@ -1520,7 +1546,8 @@ export default {
protocols: {
http: 'HTTP',
https: 'HTTPS',
socks5: 'SOCKS5'
socks5: 'SOCKS5',
socks5h: 'SOCKS5H (服务端解析 DNS)'
},
statuses: {
active: '正常',

View File

@@ -504,7 +504,7 @@ const userTrendChartData = computed(() => {
if (email && email.includes('@')) {
return email.split('@')[0]
}
return `User #${userId}`
return t('admin.redeem.userPrefix', { id: userId })
}
// Group by user

View File

@@ -88,15 +88,7 @@
]"
>
<PlatformIcon :platform="value" size="xs" />
{{
value === 'anthropic'
? 'Anthropic'
: value === 'openai'
? 'OpenAI'
: value === 'antigravity'
? 'Antigravity'
: 'Gemini'
}}
{{ t('admin.groups.platforms.' + value) }}
</span>
</template>
@@ -172,7 +164,7 @@
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ value }}
{{ t('admin.groups.statuses.' + value) }}
</span>
</template>

View File

@@ -103,7 +103,7 @@
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ value }}
{{ t('admin.proxies.statuses.' + value) }}
</span>
</template>
@@ -639,16 +639,16 @@ const statusOptions = computed(() => [
])
// Form options
const protocolSelectOptions = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'socks5h', label: 'SOCKS5H (服务端解析DNS)' }
]
const protocolSelectOptions = computed(() => [
{ value: 'http', label: t('admin.proxies.protocols.http') },
{ value: 'https', label: t('admin.proxies.protocols.https') },
{ value: 'socks5', label: t('admin.proxies.protocols.socks5') },
{ value: 'socks5h', label: t('admin.proxies.protocols.socks5h') }
])
const editStatusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
{ value: 'active', label: t('admin.proxies.statuses.active') },
{ value: 'inactive', label: t('admin.proxies.statuses.inactive') }
])
const proxies = ref<Proxy[]>([])

View File

@@ -112,7 +112,7 @@
: 'badge-primary'
]"
>
{{ value }}
{{ t('admin.redeem.types.' + value) }}
</span>
</template>
@@ -120,7 +120,7 @@
<span class="text-sm font-medium text-gray-900 dark:text-white">
<template v-if="row.type === 'balance'">${{ value.toFixed(2) }}</template>
<template v-else-if="row.type === 'subscription'">
{{ row.validity_days || 30 }}{{ t('admin.redeem.days') }}
{{ row.validity_days || 30 }} {{ t('admin.redeem.days') }}
<span v-if="row.group" class="ml-1 text-xs text-gray-500 dark:text-gray-400"
>({{ row.group.name }})</span
>
@@ -140,7 +140,7 @@
: 'badge-danger'
]"
>
{{ value }}
{{ t('admin.redeem.statuses.' + value) }}
</span>
</template>

View File

@@ -72,7 +72,7 @@
</span>
</div>
<span class="font-medium text-gray-900 dark:text-white">{{
row.user?.email || `User #${row.user_id}`
row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id })
}}</span>
</div>
</template>

View File

@@ -31,47 +31,29 @@
</div>
<!-- Role Filter (visible when enabled) -->
<div v-if="visibleFilters.has('role')" class="relative">
<select
<div v-if="visibleFilters.has('role')" class="w-32">
<Select
v-model="filters.role"
:options="[
{ value: '', label: t('admin.users.allRoles') },
{ value: 'admin', label: t('admin.users.admin') },
{ value: 'user', label: t('admin.users.user') }
]"
@change="applyFilter"
class="input w-32 cursor-pointer appearance-none pr-8"
>
<option value="">{{ t('admin.users.allRoles') }}</option>
<option value="admin">{{ t('admin.users.admin') }}</option>
<option value="user">{{ t('admin.users.user') }}</option>
</select>
<svg
class="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
/>
</div>
<!-- Status Filter (visible when enabled) -->
<div v-if="visibleFilters.has('status')" class="relative">
<select
<div v-if="visibleFilters.has('status')" class="w-32">
<Select
v-model="filters.status"
:options="[
{ value: '', label: t('admin.users.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'disabled', label: t('admin.users.disabled') }
]"
@change="applyFilter"
class="input w-32 cursor-pointer appearance-none pr-8"
>
<option value="">{{ t('admin.users.allStatus') }}</option>
<option value="active">{{ t('common.active') }}</option>
<option value="disabled">{{ t('admin.users.disabled') }}</option>
</select>
<svg
class="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
/>
</div>
<!-- Dynamic Attribute Filters -->
@@ -98,29 +80,16 @@
/>
<!-- Select/Multi-select type -->
<template v-else-if="['select', 'multi_select'].includes(getAttributeDefinition(Number(attrId))?.type || '')">
<select
:value="value"
@change="(e) => { updateAttributeFilter(Number(attrId), (e.target as HTMLSelectElement).value); applyFilter() }"
class="input w-36 cursor-pointer appearance-none pr-8"
>
<option value="">{{ getAttributeDefinitionName(Number(attrId)) }}</option>
<option
v-for="opt in getAttributeDefinition(Number(attrId))?.options || []"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<svg
class="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
<div class="w-36">
<Select
:model-value="value"
:options="[
{ value: '', label: getAttributeDefinitionName(Number(attrId)) },
...(getAttributeDefinition(Number(attrId))?.options || [])
]"
@update:model-value="(val) => { updateAttributeFilter(Number(attrId), String(val ?? '')); applyFilter() }"
/>
</div>
</template>
<!-- Fallback -->
<input
@@ -337,7 +306,7 @@
<template #cell-role="{ value }">
<span :class="['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']">
{{ value }}
{{ t('admin.users.roles.' + value) }}
</span>
</template>
@@ -1419,6 +1388,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import Select from '@/components/common/Select.vue'
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'

View File

@@ -87,7 +87,7 @@
{{ t('setup.database.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Connect to your PostgreSQL database
{{ t('setup.database.description') }}
</p>
</div>
@@ -145,12 +145,15 @@
</div>
<div>
<label class="input-label">{{ t('setup.database.sslMode') }}</label>
<select v-model="formData.database.sslmode" class="input">
<option value="disable">{{ t('setup.database.ssl.disable') }}</option>
<option value="require">{{ t('setup.database.ssl.require') }}</option>
<option value="verify-ca">{{ t('setup.database.ssl.verifyCa') }}</option>
<option value="verify-full">{{ t('setup.database.ssl.verifyFull') }}</option>
</select>
<Select
v-model="formData.database.sslmode"
:options="[
{ value: 'disable', label: t('setup.database.ssl.disable') },
{ value: 'require', label: t('setup.database.ssl.require') },
{ value: 'verify-ca', label: t('setup.database.ssl.verifyCa') },
{ value: 'verify-full', label: t('setup.database.ssl.verifyFull') }
]"
/>
</div>
</div>
@@ -190,7 +193,11 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{{
testingDb ? 'Testing...' : dbConnected ? 'Connection Successful' : 'Test Connection'
testingDb
? t('setup.status.testing')
: dbConnected
? t('setup.status.success')
: t('setup.status.testConnection')
}}
</button>
</div>
@@ -202,7 +209,7 @@
{{ t('setup.redis.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Connect to your Redis server
{{ t('setup.redis.description') }}
</p>
</div>
@@ -285,10 +292,10 @@
</svg>
{{
testingRedis
? 'Testing...'
? t('setup.status.testing')
: redisConnected
? 'Connection Successful'
: 'Test Connection'
? t('setup.status.success')
: t('setup.status.testConnection')
}}
</button>
</div>
@@ -300,7 +307,7 @@
{{ t('setup.admin.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Create your administrator account
{{ t('setup.admin.description') }}
</p>
</div>
@@ -348,7 +355,7 @@
{{ t('setup.ready.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Review your configuration and complete setup
{{ t('setup.ready.description') }}
</p>
</div>
@@ -447,13 +454,13 @@
</svg>
<div>
<p class="text-sm font-medium text-green-700 dark:text-green-400">
Installation completed!
{{ t('setup.status.completed') }}
</p>
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
{{
serviceReady
? 'Redirecting to login page...'
: 'Service is restarting, please wait...'
? t('setup.status.redirecting')
: t('setup.status.restarting')
}}
</p>
</div>
@@ -480,7 +487,7 @@
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
Previous
{{ t('common.back') }}
</button>
<div v-else></div>
@@ -490,7 +497,7 @@
:disabled="!canProceed"
class="btn btn-primary"
>
Next
{{ t('common.next') }}
<svg
class="ml-2 h-4 w-4"
fill="none"
@@ -528,7 +535,7 @@
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>
{{ installing ? 'Installing...' : 'Complete Installation' }}
{{ installing ? t('setup.status.installing') : t('setup.status.completeInstallation') }}
</button>
</div>
</div>
@@ -540,15 +547,16 @@
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const steps = [
{ id: 'database', title: 'Database' },
{ id: 'redis', title: 'Redis' },
{ id: 'admin', title: 'Admin' },
{ id: 'complete', title: 'Complete' }
]
const steps = computed(() => [
{ id: 'database', title: t('setup.database.title') },
{ id: 'redis', title: t('setup.redis.title') },
{ id: 'admin', title: t('setup.admin.title') },
{ id: 'complete', title: t('setup.ready.title') }
])
const currentStep = ref(0)
const errorMessage = ref('')
@@ -710,7 +718,6 @@ async function waitForServiceRestart() {
// If we reach here, service didn't restart in time
// Show a message to refresh manually
errorMessage.value =
'Service restart is taking longer than expected. Please refresh the page manually.'
errorMessage.value = t('setup.status.timeout')
}
</script>