fix(frontend): 优化前端组件和国际化支持
- 添加 Accept-Language 请求头支持后端翻译 - 优化账户状态指示器和测试模态框 - 简化用户属性表单和配置模态框 - 新增多个国际化翻译条目 - 重构管理视图代码,提升可维护性
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
import type { ApiResponse } from '@/types'
|
import type { ApiResponse } from '@/types'
|
||||||
|
import { getLocale } from '@/i18n'
|
||||||
|
|
||||||
// ==================== Axios Instance Configuration ====================
|
// ==================== Axios Instance Configuration ====================
|
||||||
|
|
||||||
@@ -27,6 +28,12 @@ apiClient.interceptors.request.use(
|
|||||||
if (token && config.headers) {
|
if (token && config.headers) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach locale for backend translations
|
||||||
|
if (config.headers) {
|
||||||
|
config.headers['Accept-Language'] = getLocale()
|
||||||
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<div
|
<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"
|
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
|
<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"
|
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>
|
></div>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<div
|
<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"
|
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
|
<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"
|
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>
|
></div>
|
||||||
@@ -96,9 +96,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
import { formatTime } from '@/utils/format'
|
import { formatTime } from '@/utils/format'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
}>()
|
}>()
|
||||||
@@ -140,12 +143,12 @@ const statusClass = computed(() => {
|
|||||||
// Computed: status text
|
// Computed: status text
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
if (!props.account.schedulable) {
|
if (!props.account.schedulable) {
|
||||||
return 'Paused'
|
return t('admin.accounts.statuses.paused')
|
||||||
}
|
}
|
||||||
if (isRateLimited.value || isOverloaded.value) {
|
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
|
// Computed: tier display
|
||||||
|
|||||||
@@ -48,21 +48,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model Selection -->
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{{ t('admin.accounts.selectTestModel') }}
|
{{ t('admin.accounts.selectTestModel') }}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
v-model="selectedModelId"
|
v-model="selectedModelId"
|
||||||
|
:options="availableModels"
|
||||||
:disabled="loadingModels || status === 'connecting'"
|
: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"
|
value-key="id"
|
||||||
>
|
label-key="display_name"
|
||||||
<option v-if="loadingModels" value="">{{ t('common.loading') }}...</option>
|
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
||||||
<option v-for="model in availableModels" :key="model.id" :value="model.id">
|
/>
|
||||||
{{ model.display_name }} ({{ model.id }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal Output -->
|
<!-- Terminal Output -->
|
||||||
@@ -280,6 +277,7 @@
|
|||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
|
import Select from '@/components/common/Select.vue'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, ClaudeModel } from '@/types'
|
import type { Account, ClaudeModel } from '@/types'
|
||||||
|
|||||||
@@ -52,18 +52,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Select -->
|
<!-- Select -->
|
||||||
<select
|
<Select
|
||||||
v-else-if="attr.type === 'select'"
|
v-else-if="attr.type === 'select'"
|
||||||
v-model="localValues[attr.id]"
|
v-model="localValues[attr.id]"
|
||||||
:required="attr.required"
|
:options="attr.options || []"
|
||||||
class="input"
|
|
||||||
@change="emitChange"
|
@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) -->
|
<!-- Multi-Select (Checkboxes) -->
|
||||||
<div v-else-if="attr.type === 'multi_select'" class="space-y-2">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { UserAttributeDefinition, UserAttributeValuesMap } from '@/types'
|
import type { UserAttributeDefinition, UserAttributeValuesMap } from '@/types'
|
||||||
|
import Select from '@/components/common/Select.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|||||||
@@ -142,11 +142,10 @@
|
|||||||
<!-- Type -->
|
<!-- Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.users.attributes.type') }}</label>
|
<label class="input-label">{{ t('admin.users.attributes.type') }}</label>
|
||||||
<select v-model="form.type" class="input" required>
|
<Select
|
||||||
<option v-for="type in attributeTypes" :key="type" :value="type">
|
v-model="form.type"
|
||||||
{{ t(`admin.users.attributes.types.${type}`) }}
|
:options="attributeTypes.map(type => ({ value: type, label: t(`admin.users.attributes.types.${type}`) }))"
|
||||||
</option>
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Options (for select/multi_select) -->
|
<!-- Options (for select/multi_select) -->
|
||||||
@@ -257,6 +256,7 @@ import { adminAPI } from '@/api/admin'
|
|||||||
import type { UserAttributeDefinition, UserAttributeType, UserAttributeOption } from '@/types'
|
import type { UserAttributeDefinition, UserAttributeType, UserAttributeOption } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
|
import Select from '@/components/common/Select.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
description: 'Configure your Sub2API instance',
|
description: 'Configure your Sub2API instance',
|
||||||
database: {
|
database: {
|
||||||
title: 'Database Configuration',
|
title: 'Database Configuration',
|
||||||
|
description: 'Connect to your PostgreSQL database',
|
||||||
host: 'Host',
|
host: 'Host',
|
||||||
port: 'Port',
|
port: 'Port',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
@@ -63,6 +64,7 @@ export default {
|
|||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
title: 'Redis Configuration',
|
title: 'Redis Configuration',
|
||||||
|
description: 'Connect to your Redis server',
|
||||||
host: 'Host',
|
host: 'Host',
|
||||||
port: 'Port',
|
port: 'Port',
|
||||||
password: 'Password (optional)',
|
password: 'Password (optional)',
|
||||||
@@ -71,6 +73,7 @@ export default {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: 'Admin Account',
|
title: 'Admin Account',
|
||||||
|
description: 'Create your administrator account',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
confirmPassword: 'Confirm Password',
|
confirmPassword: 'Confirm Password',
|
||||||
@@ -80,9 +83,21 @@ export default {
|
|||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
title: 'Ready to Install',
|
title: 'Ready to Install',
|
||||||
|
description: 'Review your configuration and complete setup',
|
||||||
database: 'Database',
|
database: 'Database',
|
||||||
redis: 'Redis',
|
redis: 'Redis',
|
||||||
adminEmail: 'Admin Email'
|
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',
|
failedToCreate: 'Failed to create group',
|
||||||
failedToUpdate: 'Failed to update group',
|
failedToUpdate: 'Failed to update group',
|
||||||
failedToDelete: 'Failed to delete 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:
|
deleteConfirm:
|
||||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||||
deleteConfirmSubscription:
|
deleteConfirmSubscription:
|
||||||
@@ -954,6 +981,16 @@ export default {
|
|||||||
tokenRefreshed: 'Token refreshed successfully',
|
tokenRefreshed: 'Token refreshed successfully',
|
||||||
accountDeleted: 'Account deleted successfully',
|
accountDeleted: 'Account deleted successfully',
|
||||||
rateLimitCleared: 'Rate limit cleared 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: {
|
bulkActions: {
|
||||||
selected: '{count} account(s) selected',
|
selected: '{count} account(s) selected',
|
||||||
selectCurrentPage: 'Select this page',
|
selectCurrentPage: 'Select this page',
|
||||||
@@ -1396,6 +1433,17 @@ export default {
|
|||||||
searchProxies: 'Search proxies...',
|
searchProxies: 'Search proxies...',
|
||||||
allProtocols: 'All Protocols',
|
allProtocols: 'All Protocols',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
|
protocols: {
|
||||||
|
http: 'HTTP',
|
||||||
|
https: 'HTTPS',
|
||||||
|
socks5: 'SOCKS5',
|
||||||
|
socks5h: 'SOCKS5H (Remote DNS)'
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
error: 'Error'
|
||||||
|
},
|
||||||
columns: {
|
columns: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
protocol: 'Protocol',
|
protocol: 'Protocol',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default {
|
|||||||
description: '配置您的 Sub2API 实例',
|
description: '配置您的 Sub2API 实例',
|
||||||
database: {
|
database: {
|
||||||
title: '数据库配置',
|
title: '数据库配置',
|
||||||
|
description: '连接到您的 PostgreSQL 数据库',
|
||||||
host: '主机',
|
host: '主机',
|
||||||
port: '端口',
|
port: '端口',
|
||||||
username: '用户名',
|
username: '用户名',
|
||||||
@@ -60,6 +61,7 @@ export default {
|
|||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
title: 'Redis 配置',
|
title: 'Redis 配置',
|
||||||
|
description: '连接到您的 Redis 服务器',
|
||||||
host: '主机',
|
host: '主机',
|
||||||
port: '端口',
|
port: '端口',
|
||||||
password: '密码(可选)',
|
password: '密码(可选)',
|
||||||
@@ -68,6 +70,7 @@ export default {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: '管理员账户',
|
title: '管理员账户',
|
||||||
|
description: '创建您的管理员账户',
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
confirmPassword: '确认密码',
|
confirmPassword: '确认密码',
|
||||||
@@ -77,9 +80,21 @@ export default {
|
|||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
title: '准备安装',
|
title: '准备安装',
|
||||||
|
description: '检查您的配置并完成安装',
|
||||||
database: '数据库',
|
database: '数据库',
|
||||||
redis: 'Redis',
|
redis: 'Redis',
|
||||||
adminEmail: '管理员邮箱'
|
adminEmail: '管理员邮箱'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
testing: '测试中...',
|
||||||
|
success: '连接成功',
|
||||||
|
testConnection: '测试连接',
|
||||||
|
installing: '安装中...',
|
||||||
|
completeInstallation: '完成安装',
|
||||||
|
completed: '安装完成!',
|
||||||
|
redirecting: '正在跳转到登录页面...',
|
||||||
|
restarting: '服务正在重启,请稍候...',
|
||||||
|
timeout: '服务重启时间超出预期,请手动刷新页面。'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -848,8 +863,15 @@ export default {
|
|||||||
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||||
platforms: {
|
platforms: {
|
||||||
all: '全部平台',
|
all: '全部平台',
|
||||||
claude: 'Claude',
|
anthropic: 'Anthropic',
|
||||||
openai: 'OpenAI'
|
openai: 'OpenAI',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
antigravity: 'Antigravity'
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
active: '正常',
|
||||||
|
inactive: '停用',
|
||||||
|
error: '错误'
|
||||||
},
|
},
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
noGroups: '暂无分组',
|
noGroups: '暂无分组',
|
||||||
@@ -1054,7 +1076,11 @@ export default {
|
|||||||
active: '正常',
|
active: '正常',
|
||||||
inactive: '停用',
|
inactive: '停用',
|
||||||
error: '错误',
|
error: '错误',
|
||||||
cooldown: '冷却中'
|
cooldown: '冷却中',
|
||||||
|
paused: '暂停',
|
||||||
|
limited: '限流',
|
||||||
|
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||||
|
overloadedUntil: '负载过重,重置时间:{time}'
|
||||||
},
|
},
|
||||||
usageWindow: {
|
usageWindow: {
|
||||||
statsTitle: '5小时窗口用量统计',
|
statsTitle: '5小时窗口用量统计',
|
||||||
@@ -1520,7 +1546,8 @@ export default {
|
|||||||
protocols: {
|
protocols: {
|
||||||
http: 'HTTP',
|
http: 'HTTP',
|
||||||
https: 'HTTPS',
|
https: 'HTTPS',
|
||||||
socks5: 'SOCKS5'
|
socks5: 'SOCKS5',
|
||||||
|
socks5h: 'SOCKS5H (服务端解析 DNS)'
|
||||||
},
|
},
|
||||||
statuses: {
|
statuses: {
|
||||||
active: '正常',
|
active: '正常',
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ const userTrendChartData = computed(() => {
|
|||||||
if (email && email.includes('@')) {
|
if (email && email.includes('@')) {
|
||||||
return email.split('@')[0]
|
return email.split('@')[0]
|
||||||
}
|
}
|
||||||
return `User #${userId}`
|
return t('admin.redeem.userPrefix', { id: userId })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by user
|
// Group by user
|
||||||
|
|||||||
@@ -88,15 +88,7 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<PlatformIcon :platform="value" size="xs" />
|
<PlatformIcon :platform="value" size="xs" />
|
||||||
{{
|
{{ t('admin.groups.platforms.' + value) }}
|
||||||
value === 'anthropic'
|
|
||||||
? 'Anthropic'
|
|
||||||
: value === 'openai'
|
|
||||||
? 'OpenAI'
|
|
||||||
: value === 'antigravity'
|
|
||||||
? 'Antigravity'
|
|
||||||
: 'Gemini'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -172,7 +164,7 @@
|
|||||||
|
|
||||||
<template #cell-status="{ value }">
|
<template #cell-status="{ value }">
|
||||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||||
{{ value }}
|
{{ t('admin.groups.statuses.' + value) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
|
|
||||||
<template #cell-status="{ value }">
|
<template #cell-status="{ value }">
|
||||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||||
{{ value }}
|
{{ t('admin.proxies.statuses.' + value) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -639,16 +639,16 @@ const statusOptions = computed(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Form options
|
// Form options
|
||||||
const protocolSelectOptions = [
|
const protocolSelectOptions = computed(() => [
|
||||||
{ value: 'http', label: 'HTTP' },
|
{ value: 'http', label: t('admin.proxies.protocols.http') },
|
||||||
{ value: 'https', label: 'HTTPS' },
|
{ value: 'https', label: t('admin.proxies.protocols.https') },
|
||||||
{ value: 'socks5', label: 'SOCKS5' },
|
{ value: 'socks5', label: t('admin.proxies.protocols.socks5') },
|
||||||
{ value: 'socks5h', label: 'SOCKS5H (服务端解析DNS)' }
|
{ value: 'socks5h', label: t('admin.proxies.protocols.socks5h') }
|
||||||
]
|
])
|
||||||
|
|
||||||
const editStatusOptions = computed(() => [
|
const editStatusOptions = computed(() => [
|
||||||
{ value: 'active', label: t('common.active') },
|
{ value: 'active', label: t('admin.proxies.statuses.active') },
|
||||||
{ value: 'inactive', label: t('common.inactive') }
|
{ value: 'inactive', label: t('admin.proxies.statuses.inactive') }
|
||||||
])
|
])
|
||||||
|
|
||||||
const proxies = ref<Proxy[]>([])
|
const proxies = ref<Proxy[]>([])
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
: 'badge-primary'
|
: 'badge-primary'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ value }}
|
{{ t('admin.redeem.types.' + value) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<template v-if="row.type === 'balance'">${{ value.toFixed(2) }}</template>
|
<template v-if="row.type === 'balance'">${{ value.toFixed(2) }}</template>
|
||||||
<template v-else-if="row.type === 'subscription'">
|
<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"
|
<span v-if="row.group" class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||||
>({{ row.group.name }})</span
|
>({{ row.group.name }})</span
|
||||||
>
|
>
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
: 'badge-danger'
|
: 'badge-danger'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ value }}
|
{{ t('admin.redeem.statuses.' + value) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
<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>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,47 +31,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role Filter (visible when enabled) -->
|
<!-- Role Filter (visible when enabled) -->
|
||||||
<div v-if="visibleFilters.has('role')" class="relative">
|
<div v-if="visibleFilters.has('role')" class="w-32">
|
||||||
<select
|
<Select
|
||||||
v-model="filters.role"
|
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"
|
@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>
|
</div>
|
||||||
|
|
||||||
<!-- Status Filter (visible when enabled) -->
|
<!-- Status Filter (visible when enabled) -->
|
||||||
<div v-if="visibleFilters.has('status')" class="relative">
|
<div v-if="visibleFilters.has('status')" class="w-32">
|
||||||
<select
|
<Select
|
||||||
v-model="filters.status"
|
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"
|
@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>
|
</div>
|
||||||
|
|
||||||
<!-- Dynamic Attribute Filters -->
|
<!-- Dynamic Attribute Filters -->
|
||||||
@@ -98,29 +80,16 @@
|
|||||||
/>
|
/>
|
||||||
<!-- Select/Multi-select type -->
|
<!-- Select/Multi-select type -->
|
||||||
<template v-else-if="['select', 'multi_select'].includes(getAttributeDefinition(Number(attrId))?.type || '')">
|
<template v-else-if="['select', 'multi_select'].includes(getAttributeDefinition(Number(attrId))?.type || '')">
|
||||||
<select
|
<div class="w-36">
|
||||||
:value="value"
|
<Select
|
||||||
@change="(e) => { updateAttributeFilter(Number(attrId), (e.target as HTMLSelectElement).value); applyFilter() }"
|
:model-value="value"
|
||||||
class="input w-36 cursor-pointer appearance-none pr-8"
|
:options="[
|
||||||
>
|
{ value: '', label: getAttributeDefinitionName(Number(attrId)) },
|
||||||
<option value="">{{ getAttributeDefinitionName(Number(attrId)) }}</option>
|
...(getAttributeDefinition(Number(attrId))?.options || [])
|
||||||
<option
|
]"
|
||||||
v-for="opt in getAttributeDefinition(Number(attrId))?.options || []"
|
@update:model-value="(val) => { updateAttributeFilter(Number(attrId), String(val ?? '')); applyFilter() }"
|
||||||
:key="opt.value"
|
/>
|
||||||
:value="opt.value"
|
</div>
|
||||||
>
|
|
||||||
{{ 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>
|
|
||||||
</template>
|
</template>
|
||||||
<!-- Fallback -->
|
<!-- Fallback -->
|
||||||
<input
|
<input
|
||||||
@@ -337,7 +306,7 @@
|
|||||||
|
|
||||||
<template #cell-role="{ value }">
|
<template #cell-role="{ value }">
|
||||||
<span :class="['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']">
|
<span :class="['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']">
|
||||||
{{ value }}
|
{{ t('admin.users.roles.' + value) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1419,6 +1388,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
|||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
|
import Select from '@/components/common/Select.vue'
|
||||||
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
|
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
|
||||||
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
|
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
{{ t('setup.database.title') }}
|
{{ t('setup.database.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Connect to your PostgreSQL database
|
{{ t('setup.database.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,12 +145,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('setup.database.sslMode') }}</label>
|
<label class="input-label">{{ t('setup.database.sslMode') }}</label>
|
||||||
<select v-model="formData.database.sslmode" class="input">
|
<Select
|
||||||
<option value="disable">{{ t('setup.database.ssl.disable') }}</option>
|
v-model="formData.database.sslmode"
|
||||||
<option value="require">{{ t('setup.database.ssl.require') }}</option>
|
:options="[
|
||||||
<option value="verify-ca">{{ t('setup.database.ssl.verifyCa') }}</option>
|
{ value: 'disable', label: t('setup.database.ssl.disable') },
|
||||||
<option value="verify-full">{{ t('setup.database.ssl.verifyFull') }}</option>
|
{ value: 'require', label: t('setup.database.ssl.require') },
|
||||||
</select>
|
{ value: 'verify-ca', label: t('setup.database.ssl.verifyCa') },
|
||||||
|
{ value: 'verify-full', label: t('setup.database.ssl.verifyFull') }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,7 +193,11 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
{{
|
{{
|
||||||
testingDb ? 'Testing...' : dbConnected ? 'Connection Successful' : 'Test Connection'
|
testingDb
|
||||||
|
? t('setup.status.testing')
|
||||||
|
: dbConnected
|
||||||
|
? t('setup.status.success')
|
||||||
|
: t('setup.status.testConnection')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +209,7 @@
|
|||||||
{{ t('setup.redis.title') }}
|
{{ t('setup.redis.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Connect to your Redis server
|
{{ t('setup.redis.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,10 +292,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{
|
{{
|
||||||
testingRedis
|
testingRedis
|
||||||
? 'Testing...'
|
? t('setup.status.testing')
|
||||||
: redisConnected
|
: redisConnected
|
||||||
? 'Connection Successful'
|
? t('setup.status.success')
|
||||||
: 'Test Connection'
|
: t('setup.status.testConnection')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +307,7 @@
|
|||||||
{{ t('setup.admin.title') }}
|
{{ t('setup.admin.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Create your administrator account
|
{{ t('setup.admin.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,7 +355,7 @@
|
|||||||
{{ t('setup.ready.title') }}
|
{{ t('setup.ready.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Review your configuration and complete setup
|
{{ t('setup.ready.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,13 +454,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-green-700 dark:text-green-400">
|
<p class="text-sm font-medium text-green-700 dark:text-green-400">
|
||||||
Installation completed!
|
{{ t('setup.status.completed') }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
|
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
|
||||||
{{
|
{{
|
||||||
serviceReady
|
serviceReady
|
||||||
? 'Redirecting to login page...'
|
? t('setup.status.redirecting')
|
||||||
: 'Service is restarting, please wait...'
|
: t('setup.status.restarting')
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +487,7 @@
|
|||||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Previous
|
{{ t('common.back') }}
|
||||||
</button>
|
</button>
|
||||||
<div v-else></div>
|
<div v-else></div>
|
||||||
|
|
||||||
@@ -490,7 +497,7 @@
|
|||||||
:disabled="!canProceed"
|
:disabled="!canProceed"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
>
|
>
|
||||||
Next
|
{{ t('common.next') }}
|
||||||
<svg
|
<svg
|
||||||
class="ml-2 h-4 w-4"
|
class="ml-2 h-4 w-4"
|
||||||
fill="none"
|
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"
|
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>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{{ installing ? 'Installing...' : 'Complete Installation' }}
|
{{ installing ? t('setup.status.installing') : t('setup.status.completeInstallation') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,15 +547,16 @@
|
|||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
|
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
|
||||||
|
import Select from '@/components/common/Select.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const steps = [
|
const steps = computed(() => [
|
||||||
{ id: 'database', title: 'Database' },
|
{ id: 'database', title: t('setup.database.title') },
|
||||||
{ id: 'redis', title: 'Redis' },
|
{ id: 'redis', title: t('setup.redis.title') },
|
||||||
{ id: 'admin', title: 'Admin' },
|
{ id: 'admin', title: t('setup.admin.title') },
|
||||||
{ id: 'complete', title: 'Complete' }
|
{ id: 'complete', title: t('setup.ready.title') }
|
||||||
]
|
])
|
||||||
|
|
||||||
const currentStep = ref(0)
|
const currentStep = ref(0)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
@@ -710,7 +718,6 @@ async function waitForServiceRestart() {
|
|||||||
|
|
||||||
// If we reach here, service didn't restart in time
|
// If we reach here, service didn't restart in time
|
||||||
// Show a message to refresh manually
|
// Show a message to refresh manually
|
||||||
errorMessage.value =
|
errorMessage.value = t('setup.status.timeout')
|
||||||
'Service restart is taking longer than expected. Please refresh the page manually.'
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user