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 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) => {

View File

@@ -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

View File

@@ -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'

View File

@@ -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()

View File

@@ -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()

View File

@@ -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',

View File

@@ -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: '正常',

View File

@@ -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

View File

@@ -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>

View File

@@ -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[]>([])

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>