Commit 642842c2 authored by shaw's avatar shaw
Browse files

First commit

parent 569f4882
<template>
<Teleport to="body">
<div
class="fixed top-4 right-4 z-[9999] space-y-3 pointer-events-none"
aria-live="polite"
aria-atomic="true"
>
<TransitionGroup
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 translate-x-full"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 translate-x-full"
>
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'pointer-events-auto min-w-[320px] max-w-md overflow-hidden rounded-lg shadow-lg',
'bg-white dark:bg-dark-800',
'border-l-4',
getBorderColor(toast.type)
]"
>
<div class="p-4">
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex-shrink-0 mt-0.5">
<component
:is="getIcon(toast.type)"
:class="['w-5 h-5', getIconColor(toast.type)]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p
v-if="toast.title"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
{{ toast.title }}
</p>
<p
:class="[
'text-sm leading-relaxed',
toast.title ? 'mt-1 text-gray-600 dark:text-gray-300' : 'text-gray-900 dark:text-white'
]"
>
{{ toast.message }}
</p>
</div>
<!-- Close button -->
<button
@click="removeToast(toast.id)"
class="flex-shrink-0 p-1 -m-1 text-gray-400 dark:text-gray-500 transition-colors rounded hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700"
aria-label="Close notification"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
<!-- Progress bar -->
<div
v-if="toast.duration"
class="h-1 bg-gray-100 dark:bg-dark-700"
>
<div
:class="['h-full transition-all', getProgressBarColor(toast.type)]"
:style="{ width: `${getProgress(toast)}%` }"
></div>
</div>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, h } from 'vue'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const toasts = computed(() => appStore.toasts)
const getIcon = (type: string) => {
const icons = {
success: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z',
'clip-rule': 'evenodd'
})
]
),
error: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z',
'clip-rule': 'evenodd'
})
]
),
warning: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z',
'clip-rule': 'evenodd'
})
]
),
info: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z',
'clip-rule': 'evenodd'
})
]
)
}
return icons[type as keyof typeof icons] || icons.info
}
const getIconColor = (type: string): string => {
const colors: Record<string, string> = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500'
}
return colors[type] || colors.info
}
const getBorderColor = (type: string): string => {
const colors: Record<string, string> = {
success: 'border-green-500',
error: 'border-red-500',
warning: 'border-yellow-500',
info: 'border-blue-500'
}
return colors[type] || colors.info
}
const getProgressBarColor = (type: string): string => {
const colors: Record<string, string> = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
}
return colors[type] || colors.info
}
const getProgress = (toast: any): number => {
if (!toast.duration || !toast.startTime) return 100
const elapsed = Date.now() - toast.startTime
const progress = Math.max(0, 100 - (elapsed / toast.duration) * 100)
return progress
}
const removeToast = (id: string) => {
appStore.hideToast(id)
}
let intervalId: number | undefined
onMounted(() => {
// Check for expired toasts every 100ms
intervalId = window.setInterval(() => {
const now = Date.now()
toasts.value.forEach((toast) => {
if (toast.duration && toast.startTime) {
if (now - toast.startTime >= toast.duration) {
removeToast(toast.id)
}
}
})
}, 100)
})
onUnmounted(() => {
if (intervalId !== undefined) {
clearInterval(intervalId)
}
})
</script>
<template>
<button
type="button"
@click="toggle"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-800"
:class="[
modelValue
? 'bg-primary-600'
: 'bg-gray-200 dark:bg-dark-600'
]"
role="switch"
:aria-checked="modelValue"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[
modelValue ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
function toggle() {
emit('update:modelValue', !props.modelValue);
}
</script>
<template>
<div class="relative">
<!-- Admin: Full version badge with dropdown -->
<template v-if="isAdmin">
<button
@click="toggleDropdown"
class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-lg transition-colors"
:class="[
hasUpdate
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
: 'bg-gray-100 dark:bg-dark-800 text-gray-600 dark:text-dark-400 hover:bg-gray-200 dark:hover:bg-dark-700'
]"
:title="hasUpdate ? 'New version available' : 'Up to date'"
>
<span class="font-medium">v{{ currentVersion }}</span>
<!-- Update indicator -->
<span v-if="hasUpdate" class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
</span>
</button>
<!-- Dropdown -->
<transition name="dropdown">
<div
v-if="dropdownOpen"
ref="dropdownRef"
class="absolute left-0 mt-2 w-64 bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-700 z-50 overflow-hidden"
>
<!-- Header with refresh button -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-dark-700">
<span class="text-sm font-medium text-gray-700 dark:text-dark-300">{{ t('version.currentVersion') }}</span>
<button
@click="refreshVersion(true)"
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-dark-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:disabled="loading"
:title="t('version.refresh')"
>
<svg class="w-4 h-4" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
</div>
<div class="p-4">
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-6">
<svg class="animate-spin h-6 w-6 text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" 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>
</div>
<!-- Content -->
<template v-else>
<!-- Version display - centered and prominent -->
<div class="text-center mb-4">
<div class="inline-flex items-center gap-2">
<span class="text-2xl font-bold text-gray-900 dark:text-white">v{{ currentVersion }}</span>
<!-- Show check mark when up to date -->
<span v-if="!hasUpdate" class="flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30">
<svg class="w-3 h-3 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
</div>
<p class="text-xs text-gray-500 dark:text-dark-400 mt-1">
{{ hasUpdate ? t('version.latestVersion') + ': v' + latestVersion : t('version.upToDate') }}
</p>
</div>
<!-- Update available for source build - show git pull hint -->
<div v-if="hasUpdate && !isReleaseBuild" class="space-y-2">
<a
v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group"
>
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
</div>
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
<!-- Source build hint -->
<div class="flex items-center gap-2 p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
<svg class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-xs text-blue-600 dark:text-blue-400">{{ t('version.sourceModeHint') }}</p>
</div>
</div>
<!-- Update available for release build - show download link -->
<a
v-else-if="hasUpdate && isReleaseBuild && releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group"
>
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
</div>
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
<!-- GitHub link when up to date -->
<a
v-else-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 py-2 text-sm text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-dark-200 transition-colors"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
</svg>
{{ t('version.viewRelease') }}
</a>
</template>
</div>
</div>
</transition>
</template>
<!-- Non-admin: Simple static version text -->
<span
v-else-if="version"
class="text-xs text-gray-500 dark:text-dark-400"
>
v{{ version }}
</span>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores';
import { checkUpdates, type VersionInfo, type ReleaseInfo } from '@/api/admin/system';
const { t } = useI18n();
const props = defineProps<{
version?: string;
}>();
const authStore = useAuthStore();
const isAdmin = computed(() => authStore.isAdmin);
const loading = ref(false);
const dropdownOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);
const currentVersion = ref('0.1.0');
const latestVersion = ref('0.1.0');
const hasUpdate = ref(false);
const releaseInfo = ref<ReleaseInfo | null>(null);
const buildType = ref('source'); // "source" or "release"
// Only show update check for release builds (binary/docker deployment)
const isReleaseBuild = computed(() => buildType.value === 'release');
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value;
}
function closeDropdown() {
dropdownOpen.value = false;
}
async function refreshVersion(force = true) {
if (!isAdmin.value) return;
loading.value = true;
try {
const data: VersionInfo = await checkUpdates(force);
currentVersion.value = data.current_version;
latestVersion.value = data.latest_version;
buildType.value = data.build_type || 'source';
// Show update indicator for all build types
hasUpdate.value = data.has_update;
releaseInfo.value = data.release_info || null;
} catch (error) {
console.error('Failed to check updates:', error);
} finally {
loading.value = false;
}
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
const button = (event.target as Element).closest('button');
if (dropdownRef.value && !dropdownRef.value.contains(target) && !button?.contains(target)) {
closeDropdown();
}
}
onMounted(() => {
if (isAdmin.value) {
refreshVersion(false);
} else if (props.version) {
currentVersion.value = props.version;
}
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
// Export all common components
export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.vue'
export { default as Modal } from './Modal.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue'
export { default as Toast } from './Toast.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
// Export types
export type { Column } from './DataTable.vue'
<template>
<Modal
:show="show"
:title="t('keys.useKeyModal.title')"
size="lg"
@close="emit('close')"
>
<div class="space-y-4">
<!-- Description -->
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('keys.useKeyModal.description') }}
</p>
<!-- OS Tabs -->
<div class="border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="[
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
activeTab === 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>
<!-- Code Block -->
<div class="relative">
<div class="bg-gray-900 dark:bg-dark-900 rounded-xl overflow-hidden">
<!-- Code Header -->
<div class="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-dark-800 border-b border-gray-700 dark:border-dark-700">
<span class="text-xs text-gray-400 font-mono">{{ activeTabConfig?.filename }}</span>
<button
@click="copyConfig"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
:class="copied
? 'bg-green-500/20 text-green-400'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white'"
>
<svg v-if="copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
{{ copied ? t('keys.useKeyModal.copied') : t('keys.useKeyModal.copy') }}
</button>
</div>
<!-- Code Content -->
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-html="highlightedCode"></code></pre>
</div>
</div>
<!-- Usage Note -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800">
<svg class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ t('keys.useKeyModal.note') }}
</p>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button
@click="emit('close')"
class="btn btn-secondary"
>
{{ t('common.close') }}
</button>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue'
import { useAppStore } from '@/stores/app'
interface Props {
show: boolean
apiKey: string
baseUrl: string
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
const copied = ref(false)
const activeTab = ref<'unix' | 'cmd' | 'powershell'>('unix')
// Icon components
const AppleIcon = {
render() {
return h('svg', {
fill: 'currentColor',
viewBox: '0 0 24 24',
class: 'w-4 h-4'
}, [
h('path', { d: 'M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z' })
])
}
}
const WindowsIcon = {
render() {
return h('svg', {
fill: 'currentColor',
viewBox: '0 0 24 24',
class: 'w-4 h-4'
}, [
h('path', { d: 'M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm7 .25l10 .15V21l-10-1.91v-5.84z' })
])
}
}
const tabs = [
{ id: 'unix' as const, label: 'macOS / Linux', icon: AppleIcon, filename: 'Terminal' },
{ id: 'cmd' as const, label: 'Windows CMD', icon: WindowsIcon, filename: 'Command Prompt' },
{ id: 'powershell' as const, label: 'PowerShell', icon: WindowsIcon, filename: 'PowerShell' }
]
const activeTabConfig = computed(() => tabs.find(tab => tab.id === activeTab.value))
const configCode = computed(() => {
const baseUrl = props.baseUrl || window.location.origin
const apiKey = props.apiKey
switch (activeTab.value) {
case 'unix':
return `export ANTHROPIC_BASE_URL="${baseUrl}"
export ANTHROPIC_AUTH_TOKEN="${apiKey}"`
case 'cmd':
return `set ANTHROPIC_BASE_URL=${baseUrl}
set ANTHROPIC_AUTH_TOKEN=${apiKey}`
case 'powershell':
return `$env:ANTHROPIC_BASE_URL="${baseUrl}"
$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
default:
return ''
}
})
const highlightedCode = computed(() => {
const baseUrl = props.baseUrl || window.location.origin
const apiKey = props.apiKey
// Build highlighted code directly to avoid regex replacement conflicts
const keyword = (text: string) => `<span class="text-purple-400">${text}</span>`
const variable = (text: string) => `<span class="text-cyan-400">${text}</span>`
const string = (text: string) => `<span class="text-green-400">${text}</span>`
const operator = (text: string) => `<span class="text-yellow-400">${text}</span>`
switch (activeTab.value) {
case 'unix':
return `${keyword('export')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
${keyword('export')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
case 'cmd':
return `${keyword('set')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${baseUrl}
${keyword('set')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${apiKey}`
case 'powershell':
return `${keyword('$env:')}${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
${keyword('$env:')}${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
default:
return ''
}
})
const copyConfig = async () => {
try {
await navigator.clipboard.writeText(configCode.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
}
}
</script>
<template>
<header class="sticky top-0 z-30 glass border-b border-gray-200/50 dark:border-dark-700/50">
<div class="flex items-center justify-between h-16 px-4 md:px-6">
<!-- Left: Mobile Menu Toggle + Page Title -->
<div class="flex items-center gap-4">
<button
@click="toggleMobileSidebar"
class="lg:hidden btn-ghost btn-icon"
aria-label="Toggle Menu"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<div class="hidden lg:block">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ pageTitle }}
</h1>
<p v-if="pageDescription" class="text-xs text-gray-500 dark:text-dark-400">
{{ pageDescription }}
</p>
</div>
</div>
<!-- Right: Language + Subscriptions + Balance + User Dropdown -->
<div class="flex items-center gap-3">
<!-- Language Switcher -->
<LocaleSwitcher />
<!-- Subscription Progress (for users with active subscriptions) -->
<SubscriptionProgressMini v-if="user" />
<!-- Balance Display -->
<div v-if="user" class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-primary-50 dark:bg-primary-900/20">
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
<span class="text-sm font-semibold text-primary-700 dark:text-primary-300">
${{ user.balance?.toFixed(2) || '0.00' }}
</span>
</div>
<!-- User Dropdown -->
<div v-if="user" class="relative" ref="dropdownRef">
<button
@click="toggleDropdown"
class="flex items-center gap-2 p-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
aria-label="User Menu"
>
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-white flex items-center justify-center text-sm font-medium shadow-sm">
{{ userInitials }}
</div>
<div class="hidden md:block text-left">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ displayName }}
</div>
<div class="text-xs text-gray-500 dark:text-dark-400 capitalize">
{{ user.role }}
</div>
</div>
<svg class="w-4 h-4 text-gray-400 hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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>
</button>
<!-- Dropdown Menu -->
<transition name="dropdown">
<div
v-if="dropdownOpen"
class="dropdown right-0 mt-2 w-56"
>
<!-- User Info -->
<div class="px-4 py-3 border-b border-gray-100 dark:border-dark-700">
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ displayName }}</div>
<div class="text-xs text-gray-500 dark:text-dark-400">{{ user.email }}</div>
</div>
<!-- Balance (mobile only) -->
<div class="sm:hidden px-4 py-2 border-b border-gray-100 dark:border-dark-700">
<div class="text-xs text-gray-500 dark:text-dark-400">{{ t('common.balance') }}</div>
<div class="text-sm font-semibold text-primary-600 dark:text-primary-400">
${{ user.balance?.toFixed(2) || '0.00' }}
</div>
</div>
<div class="py-1">
<router-link
to="/profile"
@click="closeDropdown"
class="dropdown-item"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
{{ t('nav.profile') }}
</router-link>
<router-link
to="/keys"
@click="closeDropdown"
class="dropdown-item"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
{{ t('nav.apiKeys') }}
</router-link>
<a
href="https://github.com/fangyuan99/sub2api"
target="_blank"
rel="noopener noreferrer"
@click="closeDropdown"
class="dropdown-item"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
</svg>
{{ t('nav.github') }}
</a>
</div>
<!-- Contact Support (only show if configured) -->
<div v-if="contactInfo" class="border-t border-gray-100 dark:border-dark-700 px-4 py-2.5">
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
<span>{{ t('common.contactSupport') }}:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ contactInfo }}</span>
</div>
</div>
<div class="border-t border-gray-100 dark:border-dark-700 py-1">
<button
@click="handleLogout"
class="dropdown-item w-full text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
{{ t('nav.logout') }}
</button>
</div>
</div>
</transition>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAppStore, useAuthStore } from '@/stores';
import { authAPI } from '@/api';
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue';
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const appStore = useAppStore();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
const dropdownOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);
const contactInfo = ref('');
const userInitials = computed(() => {
if (!user.value) return '';
// Prefer username, fallback to email
if (user.value.username) {
return user.value.username.substring(0, 2).toUpperCase();
}
if (user.value.email) {
// Get the part before @ and take first 2 chars
const localPart = user.value.email.split('@')[0];
return localPart.substring(0, 2).toUpperCase();
}
return '';
});
const displayName = computed(() => {
if (!user.value) return '';
return user.value.username || user.value.email?.split('@')[0] || '';
});
const pageTitle = computed(() => {
const titleKey = route.meta.titleKey as string;
if (titleKey) {
return t(titleKey);
}
return (route.meta.title as string) || '';
});
const pageDescription = computed(() => {
const descKey = route.meta.descriptionKey as string;
if (descKey) {
return t(descKey);
}
return (route.meta.description as string) || '';
});
function toggleMobileSidebar() {
appStore.toggleSidebar();
}
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value;
}
function closeDropdown() {
dropdownOpen.value = false;
}
async function handleLogout() {
closeDropdown();
authStore.logout();
await router.push('/login');
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
closeDropdown();
}
}
onMounted(async () => {
document.addEventListener('click', handleClickOutside);
try {
const settings = await authAPI.getPublicSettings();
contactInfo.value = settings.contact_info || '';
} catch (error) {
console.error('Failed to load contact info:', error);
}
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
</style>
<template>
<div class="min-h-screen bg-gray-50 dark:bg-dark-950">
<!-- Background Decoration -->
<div class="fixed inset-0 bg-mesh-gradient pointer-events-none"></div>
<!-- Sidebar -->
<AppSidebar />
<!-- Main Content Area -->
<div
class="relative min-h-screen transition-all duration-300"
:class="[
sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64',
]"
>
<!-- Header -->
<AppHeader />
<!-- Main Content -->
<main class="p-4 md:p-6 lg:p-8">
<slot />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useAppStore } from '@/stores';
import AppSidebar from './AppSidebar.vue';
import AppHeader from './AppHeader.vue';
const appStore = useAppStore();
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
</script>
<template>
<aside
class="sidebar"
:class="[
sidebarCollapsed ? 'w-[72px]' : 'w-64',
{ '-translate-x-full lg:translate-x-0': !mobileOpen }
]"
>
<!-- Logo/Brand -->
<div class="sidebar-header">
<!-- Custom Logo or Default Logo -->
<div class="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-glow">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
</div>
<transition name="fade">
<div v-if="!sidebarCollapsed" class="flex flex-col">
<span class="text-lg font-bold text-gray-900 dark:text-white">
{{ siteName }}
</span>
<!-- Version Badge -->
<VersionBadge :version="siteVersion" />
</div>
</transition>
</div>
<!-- Navigation -->
<nav class="sidebar-nav scrollbar-hide">
<!-- Admin View: Admin menu first, then personal menu -->
<template v-if="isAdmin">
<!-- Admin Section -->
<div class="sidebar-section">
<router-link
v-for="item in adminNavItems"
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined"
>
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
</router-link>
</div>
<!-- Personal Section for Admin -->
<div class="sidebar-section">
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
{{ t('nav.myAccount') }}
</div>
<div v-else class="h-px bg-gray-200 dark:bg-dark-700 mx-3 my-3"></div>
<router-link
v-for="item in personalNavItems"
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined"
>
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
</router-link>
</div>
</template>
<!-- Regular User View -->
<template v-else>
<div class="sidebar-section">
<router-link
v-for="item in userNavItems"
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined"
>
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
</router-link>
</div>
</template>
</nav>
<!-- Bottom Section -->
<div class="mt-auto border-t border-gray-100 dark:border-dark-800 p-3">
<!-- Theme Toggle -->
<button
@click="toggleTheme"
class="sidebar-link w-full mb-2"
:title="sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
>
<SunIcon v-if="isDark" class="w-5 h-5 flex-shrink-0 text-amber-500" />
<MoonIcon v-else class="w-5 h-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ isDark ? t('nav.lightMode') : t('nav.darkMode') }}</span>
</transition>
</button>
<!-- Collapse Button -->
<button
@click="toggleSidebar"
class="sidebar-link w-full"
:title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
>
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="w-5 h-5 flex-shrink-0" />
<ChevronDoubleRightIcon v-else class="w-5 h-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ t('nav.collapse') }}</span>
</transition>
</button>
</div>
</aside>
<!-- Mobile Overlay -->
<transition name="fade">
<div
v-if="mobileOpen"
class="fixed inset-0 bg-black/50 z-30 lg:hidden"
@click="closeMobile"
></div>
</transition>
</template>
<script setup lang="ts">
import { computed, h, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAppStore, useAuthStore } from '@/stores';
import { getPublicSettings } from '@/api/auth';
import VersionBadge from '@/components/common/VersionBadge.vue';
const { t } = useI18n();
const route = useRoute();
const appStore = useAppStore();
const authStore = useAuthStore();
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
const isAdmin = computed(() => authStore.isAdmin);
const isDark = ref(document.documentElement.classList.contains('dark'));
const mobileOpen = ref(false);
// Site settings
const siteName = ref('Sub2API');
const siteLogo = ref('');
const siteVersion = ref('');
onMounted(async () => {
try {
const settings = await getPublicSettings();
siteName.value = settings.site_name || 'Sub2API';
siteLogo.value = settings.site_logo || '';
siteVersion.value = settings.version || '';
} catch (error) {
console.error('Failed to load public settings:', error);
}
});
// SVG Icon Components
const DashboardIcon = {
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.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z' })
])
};
const KeyIcon = {
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: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z' })
])
};
const ChartIcon = {
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 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' })
])
};
const GiftIcon = {
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: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z' })
])
};
const UserIcon = {
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: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z' })
])
};
const UsersIcon = {
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: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' })
])
};
const FolderIcon = {
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: 'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z' })
])
};
const CreditCardIcon = {
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: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z' })
])
};
const GlobeIcon = {
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: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418' })
])
};
const ServerIcon = {
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: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z' })
])
};
const TicketIcon = {
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: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z' })
])
};
const CogIcon = {
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: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z' }),
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' })
])
};
const SunIcon = {
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: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z' })
])
};
const MoonIcon = {
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: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z' })
])
};
const ChevronDoubleLeftIcon = {
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: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5' })
])
};
const ChevronDoubleRightIcon = {
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: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5' })
])
};
// User navigation items (for regular users)
const userNavItems = computed(() => [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
]);
// Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
]);
// Admin navigation items
const adminNavItems = computed(() => [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon },
]);
function toggleSidebar() {
appStore.toggleSidebar();
}
function toggleTheme() {
isDark.value = !isDark.value;
document.documentElement.classList.toggle('dark', isDark.value);
localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
}
function closeMobile() {
mobileOpen.value = false;
}
function isActive(path: string): boolean {
return route.path === path || route.path.startsWith(path + '/');
}
// Initialize theme
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true;
document.documentElement.classList.add('dark');
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<template>
<div class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
<!-- Background -->
<div class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"></div>
<!-- Decorative Elements -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<!-- Gradient Orbs -->
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-400/20 rounded-full blur-3xl"></div>
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/15 rounded-full blur-3xl"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-primary-300/10 rounded-full blur-3xl"></div>
<!-- Grid Pattern -->
<div class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"></div>
</div>
<!-- Content Container -->
<div class="relative w-full max-w-md z-10">
<!-- Logo/Brand -->
<div class="text-center mb-8">
<!-- Custom Logo or Default Logo -->
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl overflow-hidden shadow-lg shadow-primary-500/30 mb-4">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
</div>
<h1 class="text-3xl font-bold text-gradient mb-2">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
</p>
</div>
<!-- Card Container -->
<div class="card-glass rounded-2xl p-8 shadow-glass">
<slot />
</div>
<!-- Footer Links -->
<div class="text-center mt-6 text-sm">
<slot name="footer" />
</div>
<!-- Copyright -->
<div class="text-center mt-8 text-xs text-gray-400 dark:text-dark-500">
&copy; {{ currentYear }} {{ siteName }}. All rights reserved.
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { getPublicSettings } from '@/api/auth';
const siteName = ref('Sub2API');
const siteLogo = ref('');
const siteSubtitle = ref('Subscription to API Conversion Platform');
const currentYear = computed(() => new Date().getFullYear());
onMounted(async () => {
try {
const settings = await getPublicSettings();
siteName.value = settings.site_name || 'Sub2API';
siteLogo.value = settings.site_logo || '';
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform';
} catch (error) {
console.error('Failed to load public settings:', error);
}
});
</script>
<style scoped>
.text-gradient {
@apply bg-gradient-to-r from-primary-600 to-primary-500 bg-clip-text text-transparent;
}
</style>
# Layout Component Examples
## Example 1: Dashboard Page
```vue
<template>
<AppLayout>
<div class="space-y-6">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Stats Cards -->
<div class="bg-white p-6 rounded-lg shadow">
<div class="text-sm text-gray-600">API Keys</div>
<div class="text-2xl font-bold text-gray-900">5</div>
</div>
<div class="bg-white p-6 rounded-lg shadow">
<div class="text-sm text-gray-600">Total Usage</div>
<div class="text-2xl font-bold text-gray-900">1,234</div>
</div>
<div class="bg-white p-6 rounded-lg shadow">
<div class="text-sm text-gray-600">Balance</div>
<div class="text-2xl font-bold text-indigo-600">${{ balance }}</div>
</div>
<div class="bg-white p-6 rounded-lg shadow">
<div class="text-sm text-gray-600">Status</div>
<div class="text-2xl font-bold text-green-600">Active</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">Recent Activity</h2>
<p class="text-gray-600">No recent activity</p>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { AppLayout } from '@/components/layout';
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
</script>
```
---
## Example 2: Login Page
```vue
<template>
<AuthLayout>
<h2 class="text-2xl font-bold text-gray-900 mb-6">Welcome Back</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
v-model="form.username"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Enter your username"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Enter your password"
/>
</div>
<button
type="submit"
:disabled="loading"
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<template #footer>
<p class="text-gray-600">
Don't have an account?
<router-link to="/register" class="text-indigo-600 hover:underline font-medium">
Sign up
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { AuthLayout } from '@/components/layout';
import { useAuthStore, useAppStore } from '@/stores';
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
const form = ref({
username: '',
password: '',
});
const loading = ref(false);
async function handleSubmit() {
loading.value = true;
try {
await authStore.login(form.value);
appStore.showSuccess('Login successful!');
await router.push('/dashboard');
} catch (error) {
appStore.showError('Invalid username or password');
} finally {
loading.value = false;
}
}
</script>
```
---
## Example 3: API Keys Page with Custom Header Title
```vue
<template>
<AppLayout>
<div class="space-y-6">
<!-- Custom page header -->
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
<button
@click="showCreateModal = true"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Create New Key
</button>
</div>
<!-- API Keys List -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Key
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="key in apiKeys" :key="key.id">
<td class="px-6 py-4 whitespace-nowrap">{{ key.name }}</td>
<td class="px-6 py-4 font-mono text-sm">{{ key.key }}</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 text-xs rounded-full"
:class="key.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
>
{{ key.status }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ new Date(key.created_at).toLocaleDateString() }}
</td>
<td class="px-6 py-4 text-right">
<button class="text-red-600 hover:text-red-800 text-sm">
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { AppLayout } from '@/components/layout';
import type { ApiKey } from '@/types';
const showCreateModal = ref(false);
const apiKeys = ref<ApiKey[]>([]);
// Fetch API keys on mount
// fetchApiKeys();
</script>
```
---
## Example 4: Admin Users Page
```vue
<template>
<AppLayout>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900">User Management</h1>
<button
@click="showCreateUser = true"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Create User
</button>
</div>
<!-- Users Table -->
<div class="bg-white rounded-lg shadow">
<div class="p-6">
<div class="space-y-4">
<div v-for="user in users" :key="user.id" class="flex items-center justify-between border-b pb-4">
<div>
<div class="font-medium text-gray-900">{{ user.username }}</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</div>
<div class="flex items-center space-x-4">
<span
class="px-2 py-1 text-xs rounded-full"
:class="user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
>
{{ user.role }}
</span>
<span class="text-sm font-medium text-gray-700">
${{ user.balance.toFixed(2) }}
</span>
<button class="text-indigo-600 hover:text-indigo-800 text-sm">
Edit
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { AppLayout } from '@/components/layout';
import type { User } from '@/types';
const showCreateUser = ref(false);
const users = ref<User[]>([]);
// Fetch users on mount
// fetchUsers();
</script>
```
---
## Example 5: Profile Page
```vue
<template>
<AppLayout>
<div class="max-w-2xl space-y-6">
<h1 class="text-3xl font-bold text-gray-900">Profile Settings</h1>
<!-- User Info Card -->
<div class="bg-white rounded-lg shadow p-6 space-y-4">
<h2 class="text-xl font-semibold text-gray-900">Account Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
{{ user?.username }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
{{ user?.email }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg">
<span
class="px-2 py-1 text-xs rounded-full"
:class="user?.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
>
{{ user?.role }}
</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Balance
</label>
<div class="px-3 py-2 bg-gray-50 rounded-lg text-indigo-600 font-semibold">
${{ user?.balance.toFixed(2) }}
</div>
</div>
</div>
</div>
<!-- Change Password Card -->
<div class="bg-white rounded-lg shadow p-6 space-y-4">
<h2 class="text-xl font-semibold text-gray-900">Change Password</h2>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div>
<label for="old-password" class="block text-sm font-medium text-gray-700 mb-1">
Current Password
</label>
<input
id="old-password"
v-model="passwordForm.old_password"
type="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
id="new-password"
v-model="passwordForm.new_password"
type="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Update Password
</button>
</form>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { AppLayout } from '@/components/layout';
import { useAuthStore, useAppStore } from '@/stores';
const authStore = useAuthStore();
const appStore = useAppStore();
const user = computed(() => authStore.user);
const passwordForm = ref({
old_password: '',
new_password: '',
});
async function handleChangePassword() {
try {
// await changePasswordAPI(passwordForm.value);
appStore.showSuccess('Password updated successfully!');
passwordForm.value = { old_password: '', new_password: '' };
} catch (error) {
appStore.showError('Failed to update password');
}
}
</script>
```
---
## Tips for Using Layouts
1. **Page Titles**: Set route meta to automatically display page titles in the header
2. **Loading States**: Use `appStore.setLoading(true/false)` for global loading indicators
3. **Toast Notifications**: Use `appStore.showSuccess()`, `appStore.showError()`, etc.
4. **Authentication**: All authenticated pages should use `AppLayout`
5. **Auth Pages**: Login and Register pages should use `AuthLayout`
6. **Sidebar State**: The sidebar state persists across navigation
7. **Mobile First**: All examples are responsive by default using Tailwind's mobile-first approach
# Layout Components Integration Guide
## Quick Start
### 1. Import Layout Components
```typescript
// In your view files
import { AppLayout, AuthLayout } from '@/components/layout';
```
### 2. Use in Routes
```typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
// Views
import DashboardView from '@/views/DashboardView.vue';
import LoginView from '@/views/auth/LoginView.vue';
import RegisterView from '@/views/auth/RegisterView.vue';
const routes: RouteRecordRaw[] = [
// Auth routes (no layout needed - views use AuthLayout internally)
{
path: '/login',
name: 'Login',
component: LoginView,
meta: { requiresAuth: false },
},
{
path: '/register',
name: 'Register',
component: RegisterView,
meta: { requiresAuth: false },
},
// User routes (use AppLayout)
{
path: '/dashboard',
name: 'Dashboard',
component: DashboardView,
meta: { requiresAuth: true, title: 'Dashboard' },
},
{
path: '/api-keys',
name: 'ApiKeys',
component: () => import('@/views/ApiKeysView.vue'),
meta: { requiresAuth: true, title: 'API Keys' },
},
{
path: '/usage',
name: 'Usage',
component: () => import('@/views/UsageView.vue'),
meta: { requiresAuth: true, title: 'Usage Statistics' },
},
{
path: '/redeem',
name: 'Redeem',
component: () => import('@/views/RedeemView.vue'),
meta: { requiresAuth: true, title: 'Redeem Code' },
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/ProfileView.vue'),
meta: { requiresAuth: true, title: 'Profile Settings' },
},
// Admin routes (use AppLayout, admin only)
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import('@/views/admin/DashboardView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Admin Dashboard' },
},
{
path: '/admin/users',
name: 'AdminUsers',
component: () => import('@/views/admin/UsersView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'User Management' },
},
{
path: '/admin/groups',
name: 'AdminGroups',
component: () => import('@/views/admin/GroupsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' },
},
{
path: '/admin/accounts',
name: 'AdminAccounts',
component: () => import('@/views/admin/AccountsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' },
},
{
path: '/admin/proxies',
name: 'AdminProxies',
component: () => import('@/views/admin/ProxiesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' },
},
{
path: '/admin/redeem-codes',
name: 'AdminRedeemCodes',
component: () => import('@/views/admin/RedeemCodesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' },
},
// Default redirect
{
path: '/',
redirect: '/dashboard',
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// Navigation guards
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// Redirect to login if not authenticated
next('/login');
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
// Redirect to dashboard if not admin
next('/dashboard');
} else {
next();
}
});
export default router;
```
### 3. Initialize Stores in main.ts
```typescript
// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './style.css';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
// Initialize auth state on app startup
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
authStore.checkAuth();
app.mount('#app');
```
### 4. Update App.vue
```vue
<!-- src/App.vue -->
<template>
<router-view />
</template>
<script setup lang="ts">
// App.vue just renders the router view
// Layouts are handled by individual views
</script>
```
---
## View Component Templates
### Authenticated Page Template
```vue
<!-- src/views/DashboardView.vue -->
<template>
<AppLayout>
<div class="space-y-6">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<!-- Your content here -->
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { AppLayout } from '@/components/layout';
// Your component logic here
</script>
```
### Auth Page Template
```vue
<!-- src/views/auth/LoginView.vue -->
<template>
<AuthLayout>
<h2 class="text-2xl font-bold text-gray-900 mb-6">Login</h2>
<!-- Your login form here -->
<template #footer>
<p class="text-gray-600">
Don't have an account?
<router-link to="/register" class="text-indigo-600 hover:underline">
Sign up
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { AuthLayout } from '@/components/layout';
// Your login logic here
</script>
```
---
## Customization
### Changing Colors
The components use Tailwind's indigo color scheme by default. To change:
```vue
<!-- Change all instances of indigo-* to your preferred color -->
<div class="bg-blue-600"> <!-- Instead of bg-indigo-600 -->
<div class="text-blue-600"> <!-- Instead of text-indigo-600 -->
```
### Adding Custom Icons
Replace HTML entity icons with your preferred icon library:
```vue
<!-- Before (HTML entities) -->
<span class="text-lg">&#128200;</span>
<!-- After (Heroicons example) -->
<ChartBarIcon class="w-5 h-5" />
```
### Sidebar Customization
Modify navigation items in `AppSidebar.vue`:
```typescript
// Add/remove/modify navigation items
const userNavItems = [
{ path: '/dashboard', label: 'Dashboard', icon: '&#128200;' },
{ path: '/new-page', label: 'New Page', icon: '&#128196;' }, // Add new item
// ...
];
```
### Header Customization
Modify user dropdown in `AppHeader.vue`:
```vue
<!-- Add new dropdown items -->
<router-link
to="/settings"
@click="closeDropdown"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<span class="mr-2">&#9881;</span>
Settings
</router-link>
```
---
## Mobile Responsive Behavior
### Sidebar
- **Desktop (md+)**: Always visible, can be collapsed to icon-only view
- **Mobile**: Hidden by default, shown via menu toggle in header
### Header
- **Desktop**: Shows full user info and balance
- **Mobile**: Shows compact view with hamburger menu
To improve mobile experience, you can add overlay and transitions:
```vue
<!-- AppSidebar.vue enhancement for mobile -->
<aside
class="fixed left-0 top-0 h-screen transition-transform duration-300 z-40"
:class="[
sidebarCollapsed ? 'w-16' : 'w-64',
// Hide on mobile when collapsed
'md:translate-x-0',
sidebarCollapsed ? '-translate-x-full md:translate-x-0' : 'translate-x-0'
]"
>
<!-- ... -->
</aside>
<!-- Add overlay for mobile -->
<div
v-if="!sidebarCollapsed"
@click="toggleSidebar"
class="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden"
></div>
```
---
## State Management Integration
### Auth Store Usage
```typescript
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
// Check if user is authenticated
if (authStore.isAuthenticated) {
// User is logged in
}
// Check if user is admin
if (authStore.isAdmin) {
// User has admin role
}
// Get current user
const user = authStore.user;
```
### App Store Usage
```typescript
import { useAppStore } from '@/stores';
const appStore = useAppStore();
// Toggle sidebar
appStore.toggleSidebar();
// Show notifications
appStore.showSuccess('Operation completed!');
appStore.showError('Something went wrong');
appStore.showInfo('Did you know...');
appStore.showWarning('Be careful!');
// Loading state
appStore.setLoading(true);
// ... perform operation
appStore.setLoading(false);
// Or use helper
await appStore.withLoading(async () => {
// Your async operation
});
```
---
## Accessibility Features
All layout components include:
- **Semantic HTML**: Proper use of `<nav>`, `<header>`, `<main>`, `<aside>`
- **ARIA labels**: Buttons have descriptive labels
- **Keyboard navigation**: All interactive elements are keyboard accessible
- **Focus management**: Proper focus states with Tailwind's `focus:` utilities
- **Color contrast**: WCAG AA compliant color combinations
To enhance further:
```vue
<!-- Add skip to main content link -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-white px-4 py-2 rounded"
>
Skip to main content
</a>
<main id="main-content">
<!-- Content -->
</main>
```
---
## Testing
### Unit Testing Layout Components
```typescript
// AppHeader.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import AppHeader from '@/components/layout/AppHeader.vue';
describe('AppHeader', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('renders user info when authenticated', () => {
const wrapper = mount(AppHeader);
// Add assertions
});
it('shows dropdown when clicked', async () => {
const wrapper = mount(AppHeader);
await wrapper.find('button').trigger('click');
expect(wrapper.find('.dropdown').exists()).toBe(true);
});
});
```
---
## Performance Optimization
### Lazy Loading
Views using layouts are already lazy loaded in the router example above.
### Code Splitting
Layout components are automatically code-split when imported:
```typescript
// This creates a separate chunk for layout components
import { AppLayout } from '@/components/layout';
```
### Reducing Re-renders
Layout components use `computed` refs to prevent unnecessary re-renders:
```typescript
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
// This only re-renders when sidebarCollapsed changes
```
---
## Troubleshooting
### Sidebar not showing
- Check if `useAppStore` is properly initialized
- Verify Tailwind classes are being processed
- Check z-index conflicts with other components
### Routes not highlighting in sidebar
- Ensure route paths match exactly
- Check `isActive()` function logic
- Verify `useRoute()` is working correctly
### User info not displaying
- Ensure auth store is initialized with `checkAuth()`
- Verify user is logged in
- Check localStorage for auth data
### Mobile menu not working
- Verify `toggleSidebar()` is called correctly
- Check responsive breakpoints (md:)
- Test on actual mobile device or browser dev tools
# Layout Components
Vue 3 layout components for the Sub2API frontend, built with Composition API, TypeScript, and TailwindCSS.
## Components
### 1. AppLayout.vue
Main application layout with sidebar and header.
**Usage:**
```vue
<template>
<AppLayout>
<!-- Your page content here -->
<h1>Dashboard</h1>
<p>Welcome to your dashboard!</p>
</AppLayout>
</template>
<script setup lang="ts">
import { AppLayout } from '@/components/layout';
</script>
```
**Features:**
- Responsive sidebar (collapsible)
- Fixed header at top
- Main content area with slot
- Automatically adjusts margin based on sidebar state
---
### 2. AppSidebar.vue
Navigation sidebar with user and admin sections.
**Features:**
- Logo/brand at top
- User navigation links:
- Dashboard
- API Keys
- Usage
- Redeem
- Profile
- Admin navigation links (shown only if user is admin):
- Admin Dashboard
- Users
- Groups
- Accounts
- Proxies
- Redeem Codes
- Collapsible sidebar with toggle button
- Active route highlighting
- Icons using HTML entities
- Responsive (mobile-friendly)
**Used automatically by AppLayout** - no need to import separately.
---
### 3. AppHeader.vue
Top header with user info and actions.
**Features:**
- Mobile menu toggle button
- Page title (from route meta or slot)
- User balance display (desktop only)
- User dropdown menu with:
- Profile link
- Logout button
- User avatar with initials
- Click-outside handling for dropdown
- Responsive design
**Usage with custom title:**
```vue
<template>
<AppLayout>
<template #title>
Custom Page Title
</template>
<!-- Your content -->
</AppLayout>
</template>
```
**Used automatically by AppLayout** - no need to import separately.
---
### 4. AuthLayout.vue
Simple centered layout for authentication pages (login/register).
**Usage:**
```vue
<template>
<AuthLayout>
<!-- Login/Register form content -->
<h2 class="text-2xl font-bold mb-6">Login</h2>
<form @submit.prevent="handleLogin">
<!-- Form fields -->
</form>
<!-- Optional footer slot -->
<template #footer>
<p>
Don't have an account?
<router-link to="/register" class="text-indigo-600 hover:underline">
Sign up
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { AuthLayout } from '@/components/layout';
function handleLogin() {
// Login logic
}
</script>
```
**Features:**
- Centered card container
- Gradient background
- Logo/brand at top
- Main content slot
- Optional footer slot for links
- Fully responsive
---
## Route Configuration
To set page titles in the header, add meta to your routes:
```typescript
// router/index.ts
const routes = [
{
path: '/dashboard',
component: DashboardView,
meta: { title: 'Dashboard' },
},
{
path: '/api-keys',
component: ApiKeysView,
meta: { title: 'API Keys' },
},
// ...
];
```
---
## Store Dependencies
These components use the following Pinia stores:
- **useAuthStore**: For user authentication state, role checking, and logout
- **useAppStore**: For sidebar state management and toast notifications
Make sure these stores are properly initialized in your app.
---
## Styling
All components use TailwindCSS utility classes. Make sure your `tailwind.config.js` includes the component paths:
```js
module.exports = {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
// ...
}
```
---
## Icons
Components use HTML entity icons for simplicity:
- &#128200; Chart (Dashboard)
- &#128273; Key (API Keys)
- &#128202; Bar Chart (Usage)
- &#127873; Gift (Redeem)
- &#128100; User (Profile)
- &#128268; Admin
- &#128101; Users
- &#128193; Folder (Groups)
- &#127760; Globe (Accounts)
- &#128260; Network (Proxies)
- &#127991; Ticket (Redeem Codes)
You can replace these with your preferred icon library (e.g., Heroicons, Font Awesome) if needed.
---
## Mobile Responsiveness
All components are fully responsive:
- **AppSidebar**: Fixed positioning on desktop, hidden by default on mobile
- **AppHeader**: Shows mobile menu toggle on small screens, hides balance display
- **AuthLayout**: Adapts padding and card size for mobile devices
The sidebar uses Tailwind's responsive breakpoints (md:) to adjust behavior.
/**
* Layout Components
* Export all layout components for easy importing
*/
export { default as AppLayout } from './AppLayout.vue';
export { default as AppSidebar } from './AppSidebar.vue';
export { default as AppHeader } from './AppHeader.vue';
export { default as AuthLayout } from './AuthLayout.vue';
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
export type AddMethod = 'oauth' | 'setup-token'
export type AuthInputMethod = 'manual' | 'cookie'
export interface OAuthState {
authUrl: string
authCode: string
sessionId: string
sessionKey: string
loading: boolean
error: string
}
export interface TokenInfo {
org_uuid?: string
account_uuid?: string
[key: string]: unknown
}
export function useAccountOAuth() {
const appStore = useAppStore()
// State
const authUrl = ref('')
const authCode = ref('')
const sessionId = ref('')
const sessionKey = ref('')
const loading = ref(false)
const error = ref('')
// Reset state
const resetState = () => {
authUrl.value = ''
authCode.value = ''
sessionId.value = ''
sessionKey.value = ''
loading.value = false
error.value = ''
}
// Generate auth URL
const generateAuthUrl = async (
addMethod: AddMethod,
proxyId?: number | null
): Promise<boolean> => {
loading.value = true
authUrl.value = ''
sessionId.value = ''
error.value = ''
try {
const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
const endpoint = addMethod === 'oauth'
? '/admin/accounts/generate-auth-url'
: '/admin/accounts/generate-setup-token-url'
const response = await adminAPI.accounts.generateAuthUrl(endpoint, proxyConfig)
authUrl.value = response.auth_url
sessionId.value = response.session_id
return true
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to generate auth URL'
appStore.showError(error.value)
return false
} finally {
loading.value = false
}
}
// Exchange auth code for tokens
const exchangeAuthCode = async (
addMethod: AddMethod,
proxyId?: number | null
): Promise<TokenInfo | null> => {
if (!authCode.value.trim() || !sessionId.value) {
error.value = 'Missing auth code or session ID'
return null
}
loading.value = true
error.value = ''
try {
const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
const endpoint = addMethod === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId.value,
code: authCode.value.trim(),
...proxyConfig
})
return tokenInfo as TokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to exchange auth code'
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
// Cookie-based authentication
const cookieAuth = async (
addMethod: AddMethod,
sessionKeyValue: string,
proxyId?: number | null
): Promise<TokenInfo | null> => {
if (!sessionKeyValue.trim()) {
error.value = 'Please enter sessionKey'
return null
}
loading.value = true
error.value = ''
try {
const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
const endpoint = addMethod === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
code: sessionKeyValue.trim(),
...proxyConfig
})
return tokenInfo as TokenInfo
} catch (err: any) {
error.value = err.response?.data?.detail || 'Cookie authorization failed'
return null
} finally {
loading.value = false
}
}
// Parse multiple session keys
const parseSessionKeys = (input: string): string[] => {
return input.split('\n').map(k => k.trim()).filter(k => k)
}
// Build extra info from token response
const buildExtraInfo = (tokenInfo: TokenInfo): Record<string, string> | undefined => {
const extra: Record<string, string> = {}
if (tokenInfo.org_uuid) {
extra.org_uuid = tokenInfo.org_uuid
}
if (tokenInfo.account_uuid) {
extra.account_uuid = tokenInfo.account_uuid
}
return Object.keys(extra).length > 0 ? extra : undefined
}
return {
// State
authUrl,
authCode,
sessionId,
sessionKey,
loading,
error,
// Methods
resetState,
generateAuthUrl,
exchangeAuthCode,
cookieAuth,
parseSessionKeys,
buildExtraInfo
}
}
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
export function useClipboard() {
const appStore = useAppStore()
const copied = ref(false)
const copyToClipboard = async (text: string, successMessage = 'Copied to clipboard') => {
if (!text) return false
try {
await navigator.clipboard.writeText(text)
copied.value = true
appStore.showSuccess(successMessage)
setTimeout(() => {
copied.value = false
}, 2000)
return true
} catch {
// Fallback for older browsers
const input = document.createElement('input')
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
copied.value = true
appStore.showSuccess(successMessage)
setTimeout(() => {
copied.value = false
}, 2000)
return true
}
}
return {
copied,
copyToClipboard
}
}
import { createI18n } from 'vue-i18n'
import en from './locales/en'
import zh from './locales/zh'
const LOCALE_KEY = 'sub2api_locale'
function getDefaultLocale(): string {
// Check localStorage first
const saved = localStorage.getItem(LOCALE_KEY)
if (saved && ['en', 'zh'].includes(saved)) {
return saved
}
// Check browser language
const browserLang = navigator.language.toLowerCase()
if (browserLang.startsWith('zh')) {
return 'zh'
}
return 'en'
}
export const i18n = createI18n({
legacy: false,
locale: getDefaultLocale(),
fallbackLocale: 'en',
messages: {
en,
zh,
},
})
export function setLocale(locale: string) {
if (['en', 'zh'].includes(locale)) {
i18n.global.locale.value = locale as 'en' | 'zh'
localStorage.setItem(LOCALE_KEY, locale)
document.documentElement.setAttribute('lang', locale)
}
}
export function getLocale(): string {
return i18n.global.locale.value
}
export const availableLocales = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'zh', name: '中文', flag: '🇨🇳' },
]
export default i18n
export default {
// Home Page
home: {
viewOnGithub: 'View on GitHub',
switchToLight: 'Switch to Light Mode',
switchToDark: 'Switch to Dark Mode',
dashboard: 'Dashboard',
login: 'Login',
getStarted: 'Get Started',
goToDashboard: 'Go to Dashboard',
tags: {
subscriptionToApi: 'Subscription to API',
stickySession: 'Sticky Session',
realtimeBilling: 'Real-time Billing',
},
features: {
unifiedGateway: 'Unified API Gateway',
unifiedGatewayDesc: 'Convert Claude subscriptions to API endpoints. Access AI capabilities through standard /v1/messages interface.',
multiAccount: 'Multi-Account Pool',
multiAccountDesc: 'Manage multiple upstream accounts with smart load balancing. Support OAuth and API Key authentication.',
balanceQuota: 'Balance & Quota',
balanceQuotaDesc: 'Token-based billing with precise usage tracking. Manage quotas and recharge with redeem codes.',
},
providers: {
title: 'Supported Providers',
description: 'Unified API interface for AI services',
supported: 'Supported',
soon: 'Soon',
},
footer: {
allRightsReserved: 'All rights reserved.',
},
},
// Common
common: {
loading: 'Loading...',
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
create: 'Create',
update: 'Update',
confirm: 'Confirm',
reset: 'Reset',
search: 'Search',
filter: 'Filter',
export: 'Export',
import: 'Import',
actions: 'Actions',
status: 'Status',
name: 'Name',
email: 'Email',
password: 'Password',
submit: 'Submit',
back: 'Back',
next: 'Next',
yes: 'Yes',
no: 'No',
all: 'All',
none: 'None',
noData: 'No data',
success: 'Success',
error: 'Error',
warning: 'Warning',
info: 'Info',
active: 'Active',
inactive: 'Inactive',
close: 'Close',
enabled: 'Enabled',
disabled: 'Disabled',
total: 'Total',
balance: 'Balance',
available: 'Available',
copiedToClipboard: 'Copied to clipboard',
copyFailed: 'Failed to copy',
contactSupport: 'Contact Support',
selectOption: 'Select an option',
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
saving: 'Saving...',
},
// Navigation
nav: {
dashboard: 'Dashboard',
apiKeys: 'API Keys',
usage: 'Usage',
redeem: 'Redeem',
profile: 'Profile',
users: 'Users',
groups: 'Groups',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
proxies: 'Proxies',
redeemCodes: 'Redeem Codes',
settings: 'Settings',
myAccount: 'My Account',
lightMode: 'Light Mode',
darkMode: 'Dark Mode',
collapse: 'Collapse',
expand: 'Expand',
logout: 'Logout',
github: 'GitHub',
mySubscriptions: 'My Subscriptions',
},
// Auth
auth: {
welcomeBack: 'Welcome Back',
signInToAccount: 'Sign in to your account to continue',
signIn: 'Sign In',
signingIn: 'Signing in...',
createAccount: 'Create Account',
signUpToStart: 'Sign up to start using {siteName}',
signUp: 'Sign up',
processing: 'Processing...',
continue: 'Continue',
rememberMe: 'Remember me',
dontHaveAccount: "Don't have an account?",
alreadyHaveAccount: 'Already have an account?',
registrationDisabled: 'Registration is currently disabled. Please contact the administrator.',
emailLabel: 'Email',
emailPlaceholder: 'Enter your email',
passwordLabel: 'Password',
passwordPlaceholder: 'Enter your password',
createPasswordPlaceholder: 'Create a strong password',
passwordHint: 'At least 6 characters',
emailRequired: 'Email is required',
invalidEmail: 'Please enter a valid email address',
passwordRequired: 'Password is required',
passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.',
registrationFailed: 'Registration failed. Please try again.',
loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
turnstileExpired: 'Verification expired, please try again',
turnstileFailed: 'Verification failed, please try again',
completeVerification: 'Please complete the verification',
},
// Dashboard
dashboard: {
title: 'Dashboard',
welcomeMessage: "Welcome back! Here's an overview of your account.",
balance: 'Balance',
apiKeys: 'API Keys',
todayRequests: 'Today Requests',
todayCost: 'Today Cost',
todayTokens: 'Today Tokens',
totalTokens: 'Total Tokens',
cacheToday: 'Cache (Today)',
avgResponse: 'Avg Response',
averageTime: 'Average time',
timeRange: 'Time Range',
granularity: 'Granularity',
day: 'Day',
hour: 'Hour',
modelDistribution: 'Model Distribution',
tokenUsageTrend: 'Token Usage Trend',
noDataAvailable: 'No data available',
model: 'Model',
requests: 'Requests',
tokens: 'Tokens',
actual: 'Actual',
standard: 'Standard',
input: 'Input',
output: 'Output',
cache: 'Cache',
recentUsage: 'Recent Usage',
last7Days: 'Last 7 days',
noUsageRecords: 'No usage records',
startUsingApi: 'Start using the API to see your usage history here.',
viewAllUsage: 'View all usage',
quickActions: 'Quick Actions',
createApiKey: 'Create API Key',
generateNewKey: 'Generate a new API key',
viewUsage: 'View Usage',
checkDetailedLogs: 'Check detailed usage logs',
redeemCode: 'Redeem Code',
addBalanceWithCode: 'Add balance with a code',
},
// API Keys
keys: {
title: 'API Keys',
description: 'Manage your API keys and access tokens',
createKey: 'Create API Key',
editKey: 'Edit API Key',
deleteKey: 'Delete API Key',
deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.",
apiKey: 'API Key',
group: 'Group',
noGroup: 'No group',
created: 'Created',
copyToClipboard: 'Copy to clipboard',
copied: 'Copied!',
importToCcSwitch: 'Import to CC Switch',
enable: 'Enable',
disable: 'Disable',
nameLabel: 'Name',
namePlaceholder: 'My API Key',
groupLabel: 'Group',
selectGroup: 'Select a group',
statusLabel: 'Status',
selectStatus: 'Select status',
saving: 'Saving...',
noKeysYet: 'No API keys yet',
createFirstKey: 'Create your first API key to get started with the API.',
keyCreatedSuccess: 'API key created successfully',
keyUpdatedSuccess: 'API key updated successfully',
keyDeletedSuccess: 'API key deleted successfully',
keyEnabledSuccess: 'API key enabled successfully',
keyDisabledSuccess: 'API key disabled successfully',
failedToLoad: 'Failed to load API keys',
failedToSave: 'Failed to save API key',
failedToDelete: 'Failed to delete API key',
failedToUpdateStatus: 'Failed to update API key status',
clickToChangeGroup: 'Click to change group',
groupChangedSuccess: 'Group changed successfully',
failedToChangeGroup: 'Failed to change group',
groupRequired: 'Please select a group',
usage: 'Usage',
today: 'Today',
total: 'Total',
useKey: 'Use Key',
useKeyModal: {
title: 'Use API Key',
description: 'Add the following environment variables to your terminal profile or run directly in terminal to configure API access.',
copy: 'Copy',
copied: 'Copied',
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)',
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
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',
},
// Usage
usage: {
title: 'Usage Records',
description: 'View and analyze your API usage history',
totalRequests: 'Total Requests',
totalTokens: 'Total Tokens',
totalCost: 'Total Cost',
standardCost: 'Standard',
actualCost: 'Actual',
avgDuration: 'Avg Duration',
inSelectedRange: 'in selected range',
perRequest: 'per request',
apiKeyFilter: 'API Key',
allApiKeys: 'All API Keys',
timeRange: 'Time Range',
exportCsv: 'Export CSV',
model: 'Model',
type: 'Type',
tokens: 'Tokens',
cost: 'Cost',
firstToken: 'First Token',
duration: 'Duration',
time: 'Time',
stream: 'Stream',
sync: 'Sync',
in: 'In',
out: 'Out',
rate: 'Rate',
original: 'Original',
billed: 'Billed',
noRecords: 'No usage records found. Try adjusting your filters.',
failedToLoad: 'Failed to load usage logs',
noDataToExport: 'No data to export',
exportSuccess: 'Usage data exported successfully',
billingType: 'Billing',
balance: 'Balance',
subscription: 'Subscription',
},
// Redeem
redeem: {
title: 'Redeem Code',
description: 'Enter your redeem code to add balance or increase concurrency',
currentBalance: 'Current Balance',
concurrency: 'Concurrency',
requests: 'requests',
redeemCodeLabel: 'Redeem Code',
redeemCodePlaceholder: 'Enter your redeem code',
redeemCodeHint: 'Redeem codes are case-sensitive',
redeeming: 'Redeeming...',
redeemButton: 'Redeem Code',
redeemSuccess: 'Code Redeemed Successfully!',
redeemFailed: 'Redemption Failed',
added: 'Added',
concurrentRequests: 'concurrent requests',
newBalance: 'New Balance',
newConcurrency: 'New Concurrency',
aboutCodes: 'About Redeem Codes',
codeRule1: 'Each code can only be used once',
codeRule2: 'Codes may add balance, increase concurrency, or grant trial access',
codeRule3: 'Contact support if you have issues redeeming a code',
codeRule4: 'Balance and concurrency updates are immediate',
recentActivity: 'Recent Activity',
historyWillAppear: 'Your redemption history will appear here',
balanceAddedRedeem: 'Balance Added (Redeem)',
balanceAddedAdmin: 'Balance Added (Admin)',
balanceDeductedAdmin: 'Balance Deducted (Admin)',
concurrencyAddedRedeem: 'Concurrency Added (Redeem)',
concurrencyAddedAdmin: 'Concurrency Added (Admin)',
concurrencyReducedAdmin: 'Concurrency Reduced (Admin)',
adminAdjustment: 'Admin Adjustment',
subscriptionAssigned: 'Subscription Assigned',
subscriptionAssignedDesc: 'You have been granted access to {groupName}',
subscriptionDays: '{days} days',
days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
},
// Profile
profile: {
title: 'Profile Settings',
description: 'Manage your account information and settings',
accountBalance: 'Account Balance',
concurrencyLimit: 'Concurrency Limit',
memberSince: 'Member Since',
administrator: 'Administrator',
user: 'User',
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
confirmNewPassword: 'Confirm New Password',
passwordHint: 'Password must be at least 8 characters long',
changingPassword: 'Changing...',
changePasswordButton: 'Change Password',
passwordsNotMatch: 'New passwords do not match',
passwordTooShort: 'Password must be at least 8 characters long',
passwordChangeSuccess: 'Password changed successfully',
passwordChangeFailed: 'Failed to change password',
},
// Empty States
empty: {
noData: 'No data found',
},
// Pagination
pagination: {
showing: 'Showing',
to: 'to',
of: 'of',
results: 'results',
page: 'Page',
pageOf: 'Page {page} of {total}',
previous: 'Previous',
next: 'Next',
perPage: 'Per page',
goToPage: 'Go to page {page}',
},
// Errors
errors: {
somethingWentWrong: 'Something went wrong',
pageNotFound: 'Page not found',
unauthorized: 'Unauthorized',
forbidden: 'Forbidden',
serverError: 'Server error',
networkError: 'Network error',
timeout: 'Request timeout',
tryAgain: 'Please try again',
},
// Dates
dates: {
today: 'Today',
yesterday: 'Yesterday',
thisWeek: 'This Week',
lastWeek: 'Last Week',
thisMonth: 'This Month',
lastMonth: 'Last Month',
last7Days: 'Last 7 Days',
last14Days: 'Last 14 Days',
last30Days: 'Last 30 Days',
custom: 'Custom',
startDate: 'Start Date',
endDate: 'End Date',
apply: 'Apply',
selectDateRange: 'Select date range',
},
// Admin
admin: {
// Dashboard
dashboard: {
title: 'Admin Dashboard',
description: 'System overview and real-time statistics',
apiKeys: 'API Keys',
accounts: 'Accounts',
users: 'Users',
todayRequests: 'Today Requests',
newUsersToday: 'New Users Today',
todayTokens: 'Today Tokens',
totalTokens: 'Total Tokens',
cacheToday: 'Cache (Today)',
avgResponse: 'Avg Response',
active: 'active',
ok: 'ok',
err: 'err',
activeUsers: 'active users',
create: 'Create',
timeRange: 'Time Range',
granularity: 'Granularity',
day: 'Day',
hour: 'Hour',
modelDistribution: 'Model Distribution',
tokenUsageTrend: 'Token Usage Trend',
userUsageTrend: 'User Usage Trend (Top 12)',
model: 'Model',
requests: 'Requests',
tokens: 'Tokens',
actual: 'Actual',
standard: 'Standard',
noDataAvailable: 'No data available',
failedToLoad: 'Failed to load dashboard statistics',
},
// Users
users: {
title: 'User Management',
description: 'Manage users and their permissions',
createUser: 'Create User',
editUser: 'Edit User',
deleteUser: 'Delete User',
searchUsers: 'Search users...',
allRoles: 'All Roles',
allStatus: 'All Status',
admin: 'Admin',
user: 'User',
disabled: 'Disabled',
columns: {
user: 'User',
role: 'Role',
balance: 'Balance',
usage: 'Usage',
concurrency: 'Concurrency',
status: 'Status',
created: 'Created',
actions: 'Actions',
},
today: 'Today',
total: 'Total',
disableUser: 'Disable User',
enableUser: 'Enable User',
viewApiKeys: 'View API Keys',
userApiKeys: 'User API Keys',
noApiKeys: 'This user has no API keys',
group: 'Group',
none: 'None',
noUsersYet: 'No users yet',
createFirstUser: 'Create your first user to get started.',
email: 'Email',
password: 'Password',
enterEmail: 'Enter email',
enterPassword: 'Enter password',
enterNewPassword: 'Enter new password (optional)',
leaveEmptyToKeep: 'Leave empty to keep current password',
generatePassword: 'Generate random password',
copyPassword: 'Copy password',
creating: 'Creating...',
updating: 'Updating...',
userCreated: 'User created successfully',
userUpdated: 'User updated successfully',
userDeleted: 'User deleted successfully',
userEnabled: 'User enabled successfully',
userDisabled: 'User disabled successfully',
failedToLoad: 'Failed to load users',
failedToCreate: 'Failed to create user',
failedToUpdate: 'Failed to update user',
failedToDelete: 'Failed to delete user',
failedToToggle: 'Failed to update user status',
failedToLoadApiKeys: 'Failed to load user API keys',
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
setAllowedGroups: 'Set Allowed Groups',
allowedGroupsHint: 'Select which standard groups this user can use. Subscription groups are managed separately.',
noStandardGroups: 'No standard groups available',
allowAllGroups: 'Allow All Groups',
allowAllGroupsHint: 'User can use any non-exclusive group',
allowedGroupsUpdated: 'Allowed groups updated successfully',
failedToLoadGroups: 'Failed to load groups',
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
},
// Groups
groups: {
title: 'Group Management',
description: 'Manage API key groups and rate multipliers',
createGroup: 'Create Group',
editGroup: 'Edit Group',
deleteGroup: 'Delete Group',
allPlatforms: 'All Platforms',
allStatus: 'All Status',
allGroups: 'All Groups',
exclusive: 'Exclusive',
nonExclusive: 'Non-Exclusive',
public: 'Public',
columns: {
name: 'Name',
platform: 'Platform',
rateMultiplier: 'Rate Multiplier',
type: 'Type',
accounts: 'Accounts',
status: 'Status',
actions: 'Actions',
},
accountsCount: '{count} accounts',
form: {
name: 'Name',
description: 'Description',
platform: 'Platform',
rateMultiplier: 'Rate Multiplier',
status: 'Status',
},
enterGroupName: 'Enter group name',
optionalDescription: 'Optional description',
platformHint: 'Select the platform this group is associated with',
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
exclusiveHint: 'Exclusive (requires explicit user access)',
noGroupsYet: 'No groups yet',
createFirstGroup: 'Create your first group to organize API keys.',
creating: 'Creating...',
updating: 'Updating...',
groupCreated: 'Group created successfully',
groupUpdated: 'Group updated successfully',
groupDeleted: 'Group deleted successfully',
failedToLoad: 'Failed to load groups',
failedToCreate: 'Failed to create group',
failedToUpdate: 'Failed to update group',
failedToDelete: 'Failed to delete group',
deleteConfirm: "Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
deleteConfirmSubscription: "Are you sure you want to delete subscription group '{name}'? This will invalidate all API keys bound to this subscription and delete all related subscription records. This action cannot be undone.",
subscription: {
title: 'Subscription Settings',
type: 'Billing Type',
typeHint: 'Standard billing deducts from user balance. Subscription mode uses quota limits instead.',
standard: 'Standard (Balance)',
subscription: 'Subscription (Quota)',
dailyLimit: 'Daily Limit (USD)',
weeklyLimit: 'Weekly Limit (USD)',
monthlyLimit: 'Monthly Limit (USD)',
defaultValidityDays: 'Default Validity (Days)',
validityHint: 'Number of days the subscription is valid when assigned to a user',
noLimit: 'No limit',
},
},
// Subscriptions
subscriptions: {
title: 'Subscription Management',
description: 'Manage user subscriptions and quota limits',
assignSubscription: 'Assign Subscription',
extendSubscription: 'Extend Subscription',
revokeSubscription: 'Revoke Subscription',
allStatus: 'All Status',
allGroups: 'All Groups',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
noLimits: 'No limits configured',
daysRemaining: 'days remaining',
noExpiration: 'No expiration',
status: {
active: 'Active',
expired: 'Expired',
revoked: 'Revoked',
},
columns: {
user: 'User',
group: 'Group',
usage: 'Usage',
expires: 'Expires',
status: 'Status',
actions: 'Actions',
},
form: {
user: 'User',
group: 'Subscription Group',
validityDays: 'Validity (Days)',
extendDays: 'Extend by (Days)',
},
selectUser: 'Select a user',
selectGroup: 'Select a subscription group',
groupHint: 'Only groups with subscription billing type are shown',
validityHint: 'Number of days the subscription will be valid',
extendingFor: 'Extending subscription for',
currentExpiration: 'Current expiration',
assign: 'Assign',
assigning: 'Assigning...',
extend: 'Extend',
extending: 'Extending...',
revoke: 'Revoke',
noSubscriptionsYet: 'No subscriptions yet',
assignFirstSubscription: 'Assign a subscription to get started.',
subscriptionAssigned: 'Subscription assigned successfully',
subscriptionExtended: 'Subscription extended successfully',
subscriptionRevoked: 'Subscription revoked successfully',
failedToLoad: 'Failed to load subscriptions',
failedToAssign: 'Failed to assign subscription',
failedToExtend: 'Failed to extend subscription',
failedToRevoke: 'Failed to revoke subscription',
revokeConfirm: "Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone.",
},
// Accounts
accounts: {
title: 'Account Management',
description: 'Manage AI platform accounts and credentials',
createAccount: 'Create Account',
editAccount: 'Edit Account',
deleteAccount: 'Delete Account',
searchAccounts: 'Search accounts...',
allPlatforms: 'All Platforms',
allTypes: 'All Types',
allStatus: 'All Status',
oauthType: 'OAuth',
setupToken: 'Setup Token',
apiKey: 'API Key',
// Schedulable toggle
schedulable: 'Schedulable',
schedulableHint: 'Enable to include this account in API request scheduling',
schedulableEnabled: 'Scheduling enabled',
schedulableDisabled: 'Scheduling disabled',
failedToToggleSchedulable: 'Failed to toggle scheduling status',
platforms: {
anthropic: 'Anthropic',
claude: 'Claude',
openai: 'OpenAI',
},
columns: {
name: 'Name',
platform: 'Platform',
type: 'Type',
status: 'Status',
schedulable: 'Schedule',
todayStats: "Today's Stats",
usageWindows: 'Usage Windows',
priority: 'Priority',
lastUsed: 'Last Used',
actions: 'Actions',
},
clearRateLimit: 'Clear Rate Limit',
testConnection: 'Test Connection',
reAuthorize: 'Re-Authorize',
refreshToken: 'Refresh Token',
noAccountsYet: 'No accounts yet',
createFirstAccount: 'Create your first account to start using AI services.',
tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared successfully',
failedToLoad: 'Failed to load accounts',
failedToRefresh: 'Failed to refresh token',
failedToDelete: 'Failed to delete account',
failedToClearRateLimit: 'Failed to clear rate limit',
deleteConfirm: "Are you sure you want to delete '{name}'? This action cannot be undone.",
// Create/Edit Account Modal
accountName: 'Account Name',
enterAccountName: 'Enter account name',
accountType: 'Account Type',
claudeCode: 'Claude Code',
claudeConsole: 'Claude Console',
oauthSetupToken: 'OAuth / Setup Token',
addMethod: 'Add Method',
setupTokenLongLived: 'Setup Token (Long-lived)',
baseUrl: 'Base URL',
baseUrlHint: 'Leave default for official Anthropic API',
apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Your Claude Console API Key',
modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist',
modelMapping: 'Model Mapping',
selectAllowedModels: 'Select allowed models. Leave empty to support all models.',
mapRequestModels: 'Map request models to actual models. Left is the requested model, right is the actual model sent to API.',
selectedModels: 'Selected {count} model(s)',
supportsAllModels: '(supports all models)',
requestModel: 'Request model',
actualModel: 'Actual model',
addMapping: 'Add Mapping',
mappingExists: 'Mapping for {model} already exists',
customErrorCodes: 'Custom Error Codes',
customErrorCodesHint: 'Only stop scheduling for selected error codes',
customErrorCodesWarning: 'Only selected error codes will stop scheduling. Other errors will return 500.',
selectedErrorCodes: 'Selected',
noneSelectedUsesDefault: 'None selected (uses default policy)',
enterErrorCode: 'Enter error code (100-599)',
invalidErrorCode: 'Please enter a valid HTTP error code (100-599)',
errorCodeExists: 'This error code is already selected',
proxy: 'Proxy',
noProxy: 'No Proxy',
concurrency: 'Concurrency',
priority: 'Priority',
priorityHint: 'Higher priority accounts are used first',
higherPriorityFirst: 'Higher value means higher priority',
creating: 'Creating...',
updating: 'Updating...',
accountCreated: 'Account created successfully',
accountUpdated: 'Account updated successfully',
failedToCreate: 'Failed to create account',
failedToUpdate: 'Failed to update account',
pleaseEnterAccountName: 'Please enter account name',
pleaseEnterApiKey: 'Please enter API Key',
apiKeyIsRequired: 'API Key is required',
leaveEmptyToKeep: 'Leave empty to keep current key',
// OAuth flow
oauth: {
title: 'Claude Account Authorization',
authMethod: 'Authorization Method',
manualAuth: 'Manual Authorization',
cookieAutoAuth: 'Cookie Auto-Auth',
cookieAutoAuthDesc: 'Use claude.ai sessionKey to automatically complete OAuth authorization without manually opening browser.',
sessionKey: 'sessionKey',
keysCount: '{count} keys',
batchCreateAccounts: 'Will batch create {count} accounts',
sessionKeyPlaceholder: 'One sessionKey per line, e.g.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...',
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
howToGetSessionKey: 'How to get sessionKey',
step1: 'Login to <strong>claude.ai</strong> in your browser',
step2: 'Press <kbd>F12</kbd> to open Developer Tools',
step3: 'Go to <strong>Application</strong> tab',
step4: 'Find <strong>Cookies</strong> → <strong>https://claude.ai</strong>',
step5: 'Find the row with key <strong>sessionKey</strong>',
step6: 'Copy the <strong>Value</strong>',
sessionKeyFormat: 'sessionKey usually starts with <code>sk-ant-sid01-</code>',
startAutoAuth: 'Start Auto-Auth',
authorizing: 'Authorizing...',
followSteps: 'Follow these steps to authorize your Claude account:',
step1GenerateUrl: 'Click the button below to generate the authorization URL',
generateAuthUrl: 'Generate Auth URL',
generating: 'Generating...',
regenerate: 'Regenerate',
step2OpenUrl: 'Open the URL in your browser and complete authorization',
openUrlDesc: 'Open the authorization URL in a new tab, log in to your Claude account and authorize.',
proxyWarning: '<strong>Note:</strong> If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.',
step3EnterCode: 'Enter the Authorization Code',
authCodeDesc: 'After authorization is complete, the page will display an <strong>Authorization Code</strong>. Copy and paste it below:',
authCode: 'Authorization Code',
authCodePlaceholder: 'Paste the Authorization Code from Claude page...',
authCodeHint: 'Paste the Authorization Code copied from the Claude page',
completeAuth: 'Complete Authorization',
verifying: 'Verifying...',
pleaseEnterSessionKey: 'Please enter at least one valid sessionKey',
authFailed: 'Authorization failed',
cookieAuthFailed: 'Cookie authorization failed',
keyAuthFailed: 'Key {index}: {error}',
successCreated: 'Successfully created {count} account(s)',
},
// Re-Auth Modal
reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account',
inputMethod: 'Input Method',
reAuthorizedSuccess: 'Account re-authorized successfully',
// Test Modal
testAccountConnection: 'Test Account Connection',
account: 'Account',
readyToTest: 'Ready to test. Click "Start Test" to begin...',
connectingToApi: 'Connecting to API...',
testCompleted: 'Test completed successfully!',
testFailed: 'Test failed',
connectedToApi: 'Connected to API',
usingModel: 'Using model: {model}',
sendingTestMessage: 'Sending test message: "hi"',
response: 'Response:',
startTest: 'Start Test',
testing: 'Testing...',
retry: 'Retry',
copyOutput: 'Copy output',
startingTestForAccount: 'Starting test for account: {name}',
testAccountTypeLabel: 'Account type: {type}',
testModel: 'claude-sonnet-4-5-20250929',
testPrompt: 'Prompt: "hi"',
},
// Proxies
proxies: {
title: 'Proxy Management',
description: 'Manage proxy servers for accounts',
createProxy: 'Create Proxy',
editProxy: 'Edit Proxy',
deleteProxy: 'Delete Proxy',
searchProxies: 'Search proxies...',
allProtocols: 'All Protocols',
allStatus: 'All Status',
columns: {
name: 'Name',
protocol: 'Protocol',
address: 'Address',
status: 'Status',
actions: 'Actions',
},
testConnection: 'Test Connection',
batchTest: 'Test All Proxies',
testFailed: 'Failed',
name: 'Name',
protocol: 'Protocol',
host: 'Host',
port: 'Port',
username: 'Username (Optional)',
password: 'Password (Optional)',
status: 'Status',
enterProxyName: 'Enter proxy name',
leaveEmptyToKeep: 'Leave empty to keep current',
optionalAuth: 'Optional authentication',
noProxiesYet: 'No proxies yet',
createFirstProxy: 'Create your first proxy to route traffic through it.',
// Batch import
standardAdd: 'Standard Add',
batchAdd: 'Quick Add',
batchInput: 'Proxy List',
batchInputPlaceholder: 'Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443',
batchInputHint: 'Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port',
parsedCount: '{count} valid',
invalidCount: '{count} invalid',
duplicateCount: '{count} duplicate',
importing: 'Importing...',
importProxies: 'Import {count} proxies',
batchImportSuccess: 'Successfully imported {created} proxies, skipped {skipped} duplicates',
batchImportAllSkipped: 'All {skipped} proxies already exist, skipped import',
failedToImport: 'Failed to batch import',
// Other messages
creating: 'Creating...',
updating: 'Updating...',
proxyCreated: 'Proxy created successfully',
proxyUpdated: 'Proxy updated successfully',
proxyDeleted: 'Proxy deleted successfully',
proxyWorking: 'Proxy is working!',
proxyWorkingWithLatency: 'Proxy is working! Latency: {latency}ms',
proxyTestFailed: 'Proxy test failed',
failedToLoad: 'Failed to load proxies',
failedToCreate: 'Failed to create proxy',
failedToUpdate: 'Failed to update proxy',
failedToDelete: 'Failed to delete proxy',
failedToTest: 'Failed to test proxy',
deleteConfirm: "Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.",
},
// Redeem Codes
redeem: {
title: 'Redeem Code Management',
description: 'Generate and manage redeem codes',
generateCodes: 'Generate Codes',
searchCodes: 'Search codes...',
allTypes: 'All Types',
allStatus: 'All Status',
balance: 'Balance',
concurrency: 'Concurrency',
subscription: 'Subscription',
unused: 'Unused',
used: 'Used',
columns: {
code: 'Code',
type: 'Type',
value: 'Value',
status: 'Status',
usedBy: 'Used By',
usedAt: 'Used At',
actions: 'Actions',
},
userPrefix: 'User #{id}',
exportCsv: 'Export CSV',
deleteAllUnused: 'Delete All Unused Codes',
deleteCode: 'Delete Redeem Code',
deleteCodeConfirm: 'Are you sure you want to delete this redeem code? This action cannot be undone.',
deleteAllUnusedConfirm: 'Are you sure you want to delete all unused (active) redeem codes? This action cannot be undone.',
deleteAll: 'Delete All',
generateCodesTitle: 'Generate Redeem Codes',
generatedSuccessfully: 'Generated Successfully',
codesCreated: '{count} redeem code(s) created',
codeType: 'Code Type',
amount: 'Amount ($)',
value: 'Value',
count: 'Count',
generating: 'Generating...',
generate: 'Generate',
copyAll: 'Copy All',
copied: 'Copied!',
download: 'Download',
codesExported: 'Codes exported successfully',
codeDeleted: 'Redeem code deleted successfully',
codesDeleted: 'Successfully deleted {count} unused code(s)',
noUnusedCodes: 'No unused codes to delete',
failedToLoad: 'Failed to load redeem codes',
failedToGenerate: 'Failed to generate codes',
failedToExport: 'Failed to export codes',
failedToDelete: 'Failed to delete code',
failedToDeleteUnused: 'Failed to delete unused codes',
failedToCopy: 'Failed to copy codes',
selectGroup: 'Select Group',
selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days',
groupRequired: 'Please select a subscription group',
days: ' days',
},
// Usage Records
usage: {
title: 'Usage Records',
description: 'View and manage all user usage records',
userFilter: 'User',
searchUserPlaceholder: 'Search user by email...',
selectedUser: 'Selected',
user: 'User',
failedToLoad: 'Failed to load usage records',
},
// Settings
settings: {
title: 'System Settings',
description: 'Manage registration, email verification, default values, and SMTP settings',
registration: {
title: 'Registration Settings',
description: 'Control user registration and verification',
enableRegistration: 'Enable Registration',
enableRegistrationHint: 'Allow new users to register',
emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations',
},
turnstile: {
title: 'Cloudflare Turnstile',
description: 'Bot protection for login and registration',
enableTurnstile: 'Enable Turnstile',
enableTurnstileHint: 'Require Cloudflare Turnstile verification',
siteKey: 'Site Key',
secretKey: 'Secret Key',
siteKeyHint: 'Get this from your Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)',
},
defaults: {
title: 'Default User Settings',
description: 'Default values for new users',
defaultBalance: 'Default Balance',
defaultBalanceHint: 'Initial balance for new users',
defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
},
site: {
title: 'Site Settings',
description: 'Customize site branding',
siteName: 'Site Name',
siteNameHint: 'Displayed in emails and page titles',
siteSubtitle: 'Site Subtitle',
siteSubtitleHint: 'Displayed on login and register pages',
apiBaseUrl: 'API Base URL',
apiBaseUrlHint: 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
contactInfo: 'Contact Info',
contactInfoPlaceholder: 'e.g., QQ: 123456789',
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
siteLogo: 'Site Logo',
uploadImage: 'Upload Image',
remove: 'Remove',
logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.',
logoSizeError: 'Image size exceeds 300KB limit ({size}KB)',
logoTypeError: 'Please select an image file',
logoReadError: 'Failed to read the image file',
},
smtp: {
title: 'SMTP Settings',
description: 'Configure email sending for verification codes',
testConnection: 'Test Connection',
testing: 'Testing...',
host: 'SMTP Host',
port: 'SMTP Port',
username: 'SMTP Username',
password: 'SMTP Password',
passwordHint: 'Leave empty to keep existing password',
fromEmail: 'From Email',
fromName: 'From Name',
useTls: 'Use TLS',
useTlsHint: 'Enable TLS encryption for SMTP connection',
},
testEmail: {
title: 'Send Test Email',
description: 'Send a test email to verify your SMTP configuration',
recipientEmail: 'Recipient Email',
sendTestEmail: 'Send Test Email',
sending: 'Sending...',
enterRecipientHint: 'Please enter a recipient email address',
},
saveSettings: 'Save Settings',
saving: 'Saving...',
settingsSaved: 'Settings saved successfully',
smtpConnectionSuccess: 'SMTP connection successful',
testEmailSent: 'Test email sent successfully',
failedToLoad: 'Failed to load settings',
failedToSave: 'Failed to save settings',
failedToTestSmtp: 'SMTP connection test failed',
failedToSendTestEmail: 'Failed to send test email',
},
},
// Subscription Progress (Header component)
subscriptionProgress: {
title: 'My Subscriptions',
viewDetails: 'View subscription details',
activeCount: '{count} active subscription(s)',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
daysRemaining: '{days} days left',
expired: 'Expired',
expiresToday: 'Expires today',
expiresTomorrow: 'Expires tomorrow',
viewAll: 'View all subscriptions',
noSubscriptions: 'No active subscriptions',
},
// Version Badge
version: {
currentVersion: 'Current Version',
latestVersion: 'Latest Version',
upToDate: "You're running the latest version.",
updateAvailable: 'A new version is available!',
releaseNotes: 'Release Notes',
noReleaseNotes: 'No release notes',
viewUpdate: 'View Update',
viewRelease: 'View Release',
refresh: 'Refresh',
sourceMode: 'Source Build',
sourceModeHint: 'Update detection is disabled for source builds. Use git pull to update.',
},
// User Subscriptions Page
userSubscriptions: {
title: 'My Subscriptions',
description: 'View your subscription plans and usage',
noActiveSubscriptions: 'No Active Subscriptions',
noActiveSubscriptionsDesc: 'You don\'t have any active subscriptions. Contact administrator to get one.',
status: {
active: 'Active',
expired: 'Expired',
revoked: 'Revoked',
},
usage: 'Usage',
expires: 'Expires',
noExpiration: 'No expiration',
unlimited: 'Unlimited',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
daysRemaining: '{days} days remaining',
expiresOn: 'Expires on {date}',
resetIn: 'Resets in {time}',
usageOf: '{used} of {limit}',
},
}
export default {
// Home Page
home: {
viewOnGithub: '在 GitHub 上查看',
switchToLight: '切换到浅色模式',
switchToDark: '切换到深色模式',
dashboard: '控制台',
login: '登录',
getStarted: '开始使用',
goToDashboard: '进入控制台',
tags: {
subscriptionToApi: '订阅转 API',
stickySession: '粘性会话',
realtimeBilling: '实时计费',
},
features: {
unifiedGateway: '统一 API 网关',
unifiedGatewayDesc: '将 Claude 订阅转换为 API 接口,通过标准 /v1/messages 接口访问 AI 能力。',
multiAccount: '多账号池',
multiAccountDesc: '智能负载均衡管理多个上游账号,支持 OAuth 和 API Key 认证。',
balanceQuota: '余额与配额',
balanceQuotaDesc: '基于 Token 的精确计费和用量追踪,支持配额管理和兑换码充值。',
},
providers: {
title: '支持的服务商',
description: 'AI 服务的统一 API 接口',
supported: '已支持',
soon: '即将推出',
},
footer: {
allRightsReserved: '保留所有权利。',
},
},
// Common
common: {
loading: '加载中...',
save: '保存',
cancel: '取消',
delete: '删除',
edit: '编辑',
create: '创建',
update: '更新',
confirm: '确认',
reset: '重置',
search: '搜索',
filter: '筛选',
export: '导出',
import: '导入',
actions: '操作',
status: '状态',
name: '名称',
email: '邮箱',
password: '密码',
submit: '提交',
back: '返回',
next: '下一步',
yes: '',
no: '',
all: '全部',
none: '',
noData: '暂无数据',
success: '成功',
error: '错误',
warning: '警告',
info: '提示',
active: '启用',
inactive: '禁用',
close: '关闭',
enabled: '已启用',
disabled: '已禁用',
total: '总计',
balance: '余额',
available: '可用',
copiedToClipboard: '已复制到剪贴板',
copyFailed: '复制失败',
contactSupport: '联系客服',
selectOption: '请选择',
searchPlaceholder: '搜索...',
noOptionsFound: '无匹配选项',
saving: '保存中...',
},
// Navigation
nav: {
dashboard: '仪表盘',
apiKeys: 'API 密钥',
usage: '使用记录',
redeem: '兑换',
profile: '个人资料',
users: '用户管理',
groups: '分组管理',
subscriptions: '订阅管理',
accounts: '账号管理',
proxies: '代理管理',
redeemCodes: '兑换码',
settings: '系统设置',
myAccount: '我的账户',
lightMode: '浅色模式',
darkMode: '深色模式',
collapse: '收起',
expand: '展开',
logout: '退出登录',
github: 'GitHub',
mySubscriptions: '我的订阅',
},
// Auth
auth: {
welcomeBack: '欢迎回来',
signInToAccount: '登录您的账户以继续',
signIn: '登录',
signingIn: '登录中...',
createAccount: '创建账户',
signUpToStart: '注册以开始使用 {siteName}',
signUp: '注册',
processing: '处理中...',
continue: '继续',
rememberMe: '记住我',
dontHaveAccount: '还没有账户?',
alreadyHaveAccount: '已有账户?',
registrationDisabled: '注册功能暂时关闭,请联系管理员。',
emailLabel: '邮箱',
emailPlaceholder: '请输入邮箱',
passwordLabel: '密码',
passwordPlaceholder: '请输入密码',
createPasswordPlaceholder: '创建一个安全的密码',
passwordHint: '至少 6 个字符',
emailRequired: '请输入邮箱',
invalidEmail: '请输入有效的邮箱地址',
passwordRequired: '请输入密码',
passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。',
registrationFailed: '注册失败,请重试。',
loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
turnstileExpired: '验证已过期,请重试',
turnstileFailed: '验证失败,请重试',
completeVerification: '请完成验证',
},
// Dashboard
dashboard: {
title: '仪表盘',
welcomeMessage: '欢迎回来!这是您账户的概览。',
balance: '余额',
apiKeys: 'API 密钥',
todayRequests: '今日请求',
todayCost: '今日消费',
todayTokens: '今日 Token',
totalTokens: '累计 Token',
cacheToday: '今日缓存',
avgResponse: '平均响应',
averageTime: '平均时间',
timeRange: '时间范围',
granularity: '粒度',
day: '按天',
hour: '按小时',
modelDistribution: '模型分布',
tokenUsageTrend: 'Token 使用趋势',
noDataAvailable: '暂无数据',
model: '模型',
requests: '请求',
tokens: 'Token',
actual: '实际',
standard: '标准',
input: '输入',
output: '输出',
cache: '缓存',
recentUsage: '最近使用',
last7Days: '近 7 天',
noUsageRecords: '暂无使用记录',
startUsingApi: '开始使用 API 后,您的使用历史将显示在这里。',
viewAllUsage: '查看全部',
quickActions: '快捷操作',
createApiKey: '创建 API 密钥',
generateNewKey: '生成新的 API 密钥',
viewUsage: '查看使用记录',
checkDetailedLogs: '查看详细的使用日志',
redeemCode: '兑换码',
addBalanceWithCode: '使用兑换码充值',
},
// API Keys
keys: {
title: 'API 密钥',
description: '管理您的 API 密钥和访问令牌',
createKey: '创建密钥',
editKey: '编辑密钥',
deleteKey: '删除密钥',
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
apiKey: 'API 密钥',
group: '分组',
noGroup: '无分组',
created: '创建时间',
copyToClipboard: '复制到剪贴板',
copied: '已复制!',
importToCcSwitch: '导入到 CC Switch',
enable: '启用',
disable: '禁用',
nameLabel: '名称',
namePlaceholder: '我的 API 密钥',
groupLabel: '分组',
selectGroup: '选择分组',
statusLabel: '状态',
selectStatus: '选择状态',
saving: '保存中...',
noKeysYet: '暂无 API 密钥',
createFirstKey: '创建您的第一个 API 密钥以开始使用 API。',
keyCreatedSuccess: 'API 密钥创建成功',
keyUpdatedSuccess: 'API 密钥更新成功',
keyDeletedSuccess: 'API 密钥删除成功',
keyEnabledSuccess: 'API 密钥已启用',
keyDisabledSuccess: 'API 密钥已禁用',
failedToLoad: '加载 API 密钥失败',
failedToSave: '保存 API 密钥失败',
failedToDelete: '删除 API 密钥失败',
failedToUpdateStatus: '更新 API 密钥状态失败',
clickToChangeGroup: '点击更换分组',
groupChangedSuccess: '分组更换成功',
failedToChangeGroup: '更换分组失败',
groupRequired: '请选择分组',
usage: '用量',
today: '今日',
total: '累计',
useKey: '使用密钥',
useKeyModal: {
title: '使用 API 密钥',
description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行。',
copy: '复制',
copied: '已复制',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
},
customKeyLabel: '自定义密钥',
customKeyPlaceholder: '输入自定义密钥(至少16个字符)',
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
customKeyTooShort: '自定义密钥至少需要16个字符',
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
customKeyRequired: '请输入自定义密钥',
},
// Usage
usage: {
title: '使用记录',
description: '查看和分析您的 API 使用历史',
totalRequests: '总请求数',
totalTokens: '总 Token',
totalCost: '总消费',
standardCost: '标准',
actualCost: '实际',
avgDuration: '平均耗时',
inSelectedRange: '所选范围内',
perRequest: '每次请求',
apiKeyFilter: 'API 密钥',
allApiKeys: '全部密钥',
timeRange: '时间范围',
exportCsv: '导出 CSV',
model: '模型',
type: '类型',
tokens: 'Token',
cost: '费用',
firstToken: '首 Token',
duration: '耗时',
time: '时间',
stream: '流式',
sync: '同步',
in: '输入',
out: '输出',
rate: '倍率',
original: '原始',
billed: '计费',
noRecords: '未找到使用记录,请尝试调整筛选条件。',
failedToLoad: '加载使用记录失败',
noDataToExport: '没有可导出的数据',
exportSuccess: '使用数据导出成功',
billingType: '消费类型',
balance: '余额',
subscription: '订阅',
},
// Redeem
redeem: {
title: '兑换码',
description: '输入兑换码以充值余额或增加并发数',
currentBalance: '当前余额',
concurrency: '并发数',
requests: '请求',
redeemCodeLabel: '兑换码',
redeemCodePlaceholder: '请输入兑换码',
redeemCodeHint: '兑换码区分大小写',
redeeming: '兑换中...',
redeemButton: '兑换',
redeemSuccess: '兑换成功!',
redeemFailed: '兑换失败',
added: '已添加',
concurrentRequests: '并发请求',
newBalance: '新余额',
newConcurrency: '新并发数',
aboutCodes: '关于兑换码',
codeRule1: '每个兑换码只能使用一次',
codeRule2: '兑换码可以增加余额、并发数或试用权限',
codeRule3: '如有兑换问题,请联系客服',
codeRule4: '余额和并发数即时更新',
recentActivity: '最近活动',
historyWillAppear: '您的兑换历史将显示在这里',
balanceAddedRedeem: '余额充值(兑换)',
balanceAddedAdmin: '余额充值(管理员)',
balanceDeductedAdmin: '余额扣除(管理员)',
concurrencyAddedRedeem: '并发增加(兑换)',
concurrencyAddedAdmin: '并发增加(管理员)',
concurrencyReducedAdmin: '并发减少(管理员)',
adminAdjustment: '管理员调整',
subscriptionAssigned: '订阅已分配',
subscriptionAssignedDesc: '您已获得 {groupName} 的访问权限',
subscriptionDays: '{days} 天',
days: '',
codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。',
},
// Profile
profile: {
title: '个人设置',
description: '管理您的账户信息和设置',
accountBalance: '账户余额',
concurrencyLimit: '并发限制',
memberSince: '注册时间',
administrator: '管理员',
user: '用户',
changePassword: '修改密码',
currentPassword: '当前密码',
newPassword: '新密码',
confirmNewPassword: '确认新密码',
passwordHint: '密码至少需要 8 个字符',
changingPassword: '修改中...',
changePasswordButton: '修改密码',
passwordsNotMatch: '两次输入的密码不一致',
passwordTooShort: '密码至少需要 8 个字符',
passwordChangeSuccess: '密码修改成功',
passwordChangeFailed: '密码修改失败',
},
// Empty States
empty: {
noData: '暂无数据',
},
// Pagination
pagination: {
showing: '显示',
to: '',
of: '',
results: '条结果',
page: '',
pageOf: '第 {page} / {total} 页',
previous: '上一页',
next: '下一页',
perPage: '每页',
goToPage: '跳转到第 {page} 页',
},
// Errors
errors: {
somethingWentWrong: '出错了',
pageNotFound: '页面未找到',
unauthorized: '未授权',
forbidden: '禁止访问',
serverError: '服务器错误',
networkError: '网络错误',
timeout: '请求超时',
tryAgain: '请重试',
},
// Dates
dates: {
today: '今天',
yesterday: '昨天',
thisWeek: '本周',
lastWeek: '上周',
thisMonth: '本月',
lastMonth: '上月',
last7Days: '近 7 天',
last14Days: '近 14 天',
last30Days: '近 30 天',
custom: '自定义',
startDate: '开始日期',
endDate: '结束日期',
apply: '应用',
selectDateRange: '选择日期范围',
},
// Admin
admin: {
// Dashboard
dashboard: {
title: '管理控制台',
description: '系统概览与统计数据',
apiKeys: 'API 密钥',
totalApiKeys: 'API 密钥总数',
activeApiKeys: '活跃密钥',
users: '用户',
totalUsers: '用户总数',
activeUsers: '活跃用户',
accounts: '账号',
totalAccounts: '账号总数',
activeAccounts: '活跃账号',
todayRequests: '今日请求',
totalRequests: '总请求数',
todayCost: '今日消费',
totalCost: '总消费',
actual: '实际',
standard: '标准',
todayTokens: '今日 Token',
totalTokens: '总 Token',
input: '输入',
output: '输出',
cacheToday: '今日缓存',
avgResponse: '平均响应',
averageTime: '平均时间',
timeRange: '时间范围',
granularity: '粒度',
day: '按天',
hour: '按小时',
modelDistribution: '模型分布',
tokenUsageTrend: 'Token 使用趋势',
noDataAvailable: '暂无数据',
model: '模型',
requests: '请求',
tokens: 'Token',
cache: '缓存',
recentUsage: '最近使用',
last7Days: '近 7 天',
noUsageRecords: '暂无使用记录',
startUsingApi: '开始使用 API 后,使用历史将显示在这里。',
viewAllUsage: '查看全部',
quickActions: '快捷操作',
manageUsers: '管理用户',
viewUserAccounts: '查看和管理用户账户',
manageAccounts: '管理账号',
configureAiAccounts: '配置 AI 平台账号',
systemSettings: '系统设置',
configureSystem: '配置系统设置',
failedToLoad: '加载仪表盘数据失败',
},
// Users Management
users: {
title: '用户管理',
description: '管理用户账户和权限',
createUser: '创建用户',
editUser: '编辑用户',
deleteUser: '删除用户',
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
searchPlaceholder: '搜索用户...',
roleFilter: '角色筛选',
allRoles: '全部角色',
statusFilter: '状态筛选',
allStatuses: '全部状态',
columns: {
email: '邮箱',
role: '角色',
balance: '余额',
usage: '用量',
concurrency: '并发数',
status: '状态',
created: '创建时间',
actions: '操作',
},
today: '今日',
total: '累计',
roles: {
admin: '管理员',
user: '用户',
},
statuses: {
active: '正常',
banned: '禁用',
},
form: {
emailLabel: '邮箱',
emailPlaceholder: '请输入邮箱',
passwordLabel: '密码',
passwordPlaceholder: '请输入密码(留空则不修改)',
roleLabel: '角色',
selectRole: '选择角色',
balanceLabel: '余额',
concurrencyLabel: '并发数',
statusLabel: '状态',
selectStatus: '选择状态',
},
adjustBalance: '调整余额',
adjustConcurrency: '调整并发数',
adjustmentAmount: '调整金额',
adjustmentAmountHint: '正数增加,负数减少',
currentBalance: '当前余额',
currentConcurrency: '当前并发数',
saving: '保存中...',
noUsers: '暂无用户',
noUsersDescription: '创建您的第一个用户以开始使用系统。',
userCreatedSuccess: '用户创建成功',
userUpdatedSuccess: '用户更新成功',
userDeletedSuccess: '用户删除成功',
balanceAdjustedSuccess: '余额调整成功',
concurrencyAdjustedSuccess: '并发数调整成功',
failedToLoad: '加载用户列表失败',
failedToSave: '保存用户失败',
failedToDelete: '删除用户失败',
failedToAdjust: '调整失败',
setAllowedGroups: '设置允许分组',
allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。',
noStandardGroups: '暂无标准分组',
allowAllGroups: '允许全部分组',
allowAllGroupsHint: '用户可以使用任何非专属分组',
allowedGroupsUpdated: '允许分组更新成功',
failedToLoadGroups: '加载分组列表失败',
failedToUpdateAllowedGroups: '更新允许分组失败',
},
// Groups Management
groups: {
title: '分组管理',
description: '管理 API 密钥分组和费率配置',
createGroup: '创建分组',
editGroup: '编辑分组',
deleteGroup: '删除分组',
deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。",
deleteConfirmSubscription: "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。",
columns: {
name: '名称',
rateMultiplier: '费率倍数',
exclusive: '独占',
platforms: '平台',
priority: '优先级',
apiKeys: 'API 密钥数',
status: '状态',
actions: '操作',
},
form: {
name: '名称',
description: '描述',
platform: '平台',
rateMultiplier: '费率倍数',
status: '状态',
nameLabel: '分组名称',
namePlaceholder: '请输入分组名称',
descriptionLabel: '描述',
descriptionPlaceholder: '请输入描述(可选)',
rateMultiplierLabel: '费率倍数',
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
exclusiveLabel: '独占模式',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
platformLabel: '平台限制',
platformPlaceholder: '选择平台(留空则不限制)',
accountsLabel: '指定账号',
accountsPlaceholder: '选择账号(留空则不限制)',
priorityLabel: '优先级',
priorityHint: '数值越高优先级越高,用于账号调度',
statusLabel: '状态',
},
exclusive: {
yes: '',
no: '',
},
platforms: {
all: '全部平台',
claude: 'Claude',
openai: 'OpenAI',
},
saving: '保存中...',
noGroups: '暂无分组',
noGroupsDescription: '创建分组以更好地管理 API 密钥和费率。',
groupCreatedSuccess: '分组创建成功',
groupUpdatedSuccess: '分组更新成功',
groupDeletedSuccess: '分组删除成功',
failedToLoad: '加载分组列表失败',
failedToSave: '保存分组失败',
failedToDelete: '删除分组失败',
allPlatforms: '全部平台',
allStatus: '全部状态',
allGroups: '全部分组',
exclusiveFilter: '独占',
nonExclusive: '非独占',
public: '公开',
accountsCount: '{count} 个账号',
enterGroupName: '请输入分组名称',
optionalDescription: '可选描述',
platformHint: '选择此分组关联的平台',
noGroupsYet: '暂无分组',
createFirstGroup: '创建您的第一个分组来组织 API 密钥。',
creating: '创建中...',
updating: '更新中...',
groupCreated: '分组创建成功',
groupUpdated: '分组更新成功',
groupDeleted: '分组删除成功',
failedToCreate: '创建分组失败',
failedToUpdate: '更新分组失败',
subscription: {
title: '订阅设置',
type: '计费类型',
typeHint: '标准计费从用户余额扣除。订阅模式使用配额限制。',
standard: '标准(余额)',
subscription: '订阅(配额)',
dailyLimit: '每日限额(USD)',
weeklyLimit: '每周限额(USD)',
monthlyLimit: '每月限额(USD)',
defaultValidityDays: '默认有效期(天)',
validityHint: '分配给用户时订阅的有效天数',
noLimit: '无限制',
},
},
// Subscriptions Management
subscriptions: {
title: '订阅管理',
description: '管理用户订阅和配额限制',
assignSubscription: '分配订阅',
extendSubscription: '延长订阅',
revokeSubscription: '撤销订阅',
allStatus: '全部状态',
allGroups: '全部分组',
daily: '每日',
weekly: '每周',
monthly: '每月',
noLimits: '未配置限额',
daysRemaining: '天剩余',
noExpiration: '无过期时间',
status: {
active: '生效中',
expired: '已过期',
revoked: '已撤销',
},
columns: {
user: '用户',
group: '分组',
usage: '用量',
expires: '到期时间',
status: '状态',
actions: '操作',
},
form: {
user: '用户',
group: '订阅分组',
validityDays: '有效期(天)',
extendDays: '延长天数',
},
selectUser: '选择用户',
selectGroup: '选择订阅分组',
groupHint: '仅显示订阅计费类型的分组',
validityHint: '订阅的有效天数',
extendingFor: '为以下用户延长订阅',
currentExpiration: '当前到期时间',
assign: '分配',
assigning: '分配中...',
extend: '延长',
extending: '延长中...',
revoke: '撤销',
noSubscriptionsYet: '暂无订阅',
assignFirstSubscription: '分配一个订阅以开始使用。',
subscriptionAssigned: '订阅分配成功',
subscriptionExtended: '订阅延长成功',
subscriptionRevoked: '订阅撤销成功',
failedToLoad: '加载订阅列表失败',
failedToAssign: '分配订阅失败',
failedToExtend: '延长订阅失败',
failedToRevoke: '撤销订阅失败',
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。",
},
// Accounts Management
accounts: {
title: '账号管理',
description: '管理 AI 平台账号和 Cookie',
createAccount: '添加账号',
editAccount: '编辑账号',
deleteAccount: '删除账号',
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
refreshCookie: '刷新 Cookie',
testAccount: '测试账号',
// Filter options
allPlatforms: '全部平台',
allTypes: '全部类型',
allStatus: '全部状态',
oauthType: 'OAuth',
// Schedulable toggle
schedulable: '参与调度',
schedulableHint: '开启后账号参与API请求调度',
schedulableEnabled: '调度已开启',
schedulableDisabled: '调度已关闭',
failedToToggleSchedulable: '切换调度状态失败',
columns: {
name: '名称',
platform: '平台',
type: '类型',
priority: '优先级',
weight: '权重',
status: '状态',
schedulable: '调度',
lastUsed: '最近使用',
actions: '操作',
},
platforms: {
claude: 'Claude',
openai: 'OpenAI',
anthropic: 'Anthropic',
},
types: {
oauth: 'OAuth',
api_key: 'API Key',
cookie: 'Cookie',
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误',
cooldown: '冷却中',
},
form: {
nameLabel: '账号名称',
namePlaceholder: '请输入账号名称',
platformLabel: '平台',
selectPlatform: '选择平台',
typeLabel: '类型',
selectType: '选择类型',
credentialsLabel: '凭证',
credentialsPlaceholder: '请输入 Cookie 或 API Key',
priorityLabel: '优先级',
priorityHint: '数值越高优先级越高',
weightLabel: '权重',
weightHint: '用于负载均衡的权重值',
statusLabel: '状态',
},
filters: {
platform: '平台',
allPlatforms: '全部平台',
type: '类型',
allTypes: '全部类型',
status: '状态',
allStatuses: '全部状态',
},
saving: '保存中...',
refreshing: '刷新中...',
testing: '测试中...',
noAccounts: '暂无账号',
noAccountsDescription: '添加 AI 平台账号以开始使用 API 网关。',
accountCreatedSuccess: '账号添加成功',
accountUpdatedSuccess: '账号更新成功',
accountDeletedSuccess: '账号删除成功',
cookieRefreshedSuccess: 'Cookie 刷新成功',
testSuccess: '账号测试通过',
testFailed: '账号测试失败',
failedToLoad: '加载账号列表失败',
failedToSave: '保存账号失败',
failedToDelete: '删除账号失败',
failedToRefresh: '刷新 Cookie 失败',
// Create/Edit Account Modal
accountName: '账号名称',
enterAccountName: '请输入账号名称',
accountType: '账号类型',
claudeCode: 'Claude Code',
claudeConsole: 'Claude Console',
oauthSetupToken: 'OAuth / Setup Token',
addMethod: '添加方式',
setupTokenLongLived: 'Setup Token(长期有效)',
baseUrl: 'Base URL',
baseUrlHint: '留空使用官方 Anthropic API',
apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: '您的 Claude Console API Key',
modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单',
modelMapping: '模型映射',
selectAllowedModels: '选择允许的模型。留空则支持所有模型。',
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
selectedModels: '已选择 {count} 个模型',
supportsAllModels: '(支持所有模型)',
requestModel: '请求模型',
actualModel: '实际模型',
addMapping: '添加映射',
mappingExists: '模型 {model} 的映射已存在',
customErrorCodes: '自定义错误码',
customErrorCodesHint: '仅对选中的错误码停止调度',
customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。',
selectedErrorCodes: '已选择',
noneSelectedUsesDefault: '未选择(使用默认策略)',
enterErrorCode: '输入错误码 (100-599)',
invalidErrorCode: '请输入有效的 HTTP 错误码 (100-599)',
errorCodeExists: '该错误码已被选中',
proxy: '代理',
noProxy: '无代理',
concurrency: '并发数',
priority: '优先级',
priorityHint: '优先级越高的账号优先使用',
higherPriorityFirst: '数值越高优先级越高',
creating: '创建中...',
updating: '更新中...',
accountCreated: '账号创建成功',
accountUpdated: '账号更新成功',
failedToCreate: '创建账号失败',
failedToUpdate: '更新账号失败',
pleaseEnterAccountName: '请输入账号名称',
pleaseEnterApiKey: '请输入 API Key',
apiKeyIsRequired: 'API Key 是必需的',
leaveEmptyToKeep: '留空以保持当前密钥',
// OAuth flow
oauth: {
title: 'Claude 账号授权',
authMethod: '授权方式',
manualAuth: '手动授权',
cookieAutoAuth: 'Cookie 自动授权',
cookieAutoAuthDesc: '使用 claude.ai sessionKey 自动完成 OAuth 授权,无需手动打开浏览器。',
sessionKey: 'sessionKey',
keysCount: '{count} 个密钥',
batchCreateAccounts: '将批量创建 {count} 个账号',
sessionKeyPlaceholder: '每行一个 sessionKey,例如:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...',
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
howToGetSessionKey: '如何获取 sessionKey',
step1: '在浏览器中登录 <strong>claude.ai</strong>',
step2: '按 <kbd>F12</kbd> 打开开发者工具',
step3: '切换到 <strong>Application</strong> 标签',
step4: '找到 <strong>Cookies</strong> → <strong>https://claude.ai</strong>',
step5: '找到 <strong>sessionKey</strong> 所在行',
step6: '复制 <strong>Value</strong> 列的值',
sessionKeyFormat: 'sessionKey 通常以 <code>sk-ant-sid01-</code> 开头',
startAutoAuth: '开始自动授权',
authorizing: '授权中...',
followSteps: '按照以下步骤授权您的 Claude 账号:',
step1GenerateUrl: '点击下方按钮生成授权 URL',
generateAuthUrl: '生成授权 URL',
generating: '生成中...',
regenerate: '重新生成',
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。',
proxyWarning: '<strong>注意:</strong>如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
step3EnterCode: '输入授权码',
authCodeDesc: '授权完成后,页面会显示一个 <strong>授权码</strong>。复制并粘贴到下方:',
authCode: '授权码',
authCodePlaceholder: '粘贴 Claude 页面的授权码...',
authCodeHint: '粘贴从 Claude 页面复制的授权码',
completeAuth: '完成授权',
verifying: '验证中...',
pleaseEnterSessionKey: '请输入至少一个有效的 sessionKey',
authFailed: '授权失败',
cookieAuthFailed: 'Cookie 授权失败',
keyAuthFailed: '密钥 {index}: {error}',
successCreated: '成功创建 {count} 个账号',
},
// Re-Auth Modal
reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号',
inputMethod: '输入方式',
reAuthorizedSuccess: '账号重新授权成功',
// Test Modal
testAccountConnection: '测试账号连接',
account: '账号',
readyToTest: '准备测试。点击"开始测试"按钮开始...',
connectingToApi: '连接 API 中...',
testCompleted: '测试完成!',
connectedToApi: '已连接到 API',
usingModel: '使用模型:{model}',
sendingTestMessage: '发送测试消息:"hi"',
response: '响应:',
startTest: '开始测试',
retry: '重试',
copyOutput: '复制输出',
startingTestForAccount: '开始测试账号:{name}',
testAccountTypeLabel: '账号类型:{type}',
testModel: 'claude-sonnet-4-5-20250929',
testPrompt: '提示词:"hi"',
},
// Proxies Management
proxies: {
title: '代理管理',
description: '管理代理服务器配置',
createProxy: '添加代理',
editProxy: '编辑代理',
deleteProxy: '删除代理',
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
testProxy: '测试代理',
columns: {
name: '名称',
protocol: '协议',
address: '地址',
priority: '优先级',
status: '状态',
lastCheck: '最近检测',
actions: '操作',
},
protocols: {
http: 'HTTP',
https: 'HTTPS',
socks5: 'SOCKS5',
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误',
},
form: {
nameLabel: '名称',
namePlaceholder: '请输入代理名称',
protocolLabel: '协议',
selectProtocol: '选择协议',
hostLabel: '主机',
hostPlaceholder: '请输入主机地址',
portLabel: '端口',
portPlaceholder: '请输入端口',
usernameLabel: '用户名(可选)',
usernamePlaceholder: '请输入用户名',
passwordLabel: '密码(可选)',
passwordPlaceholder: '请输入密码',
priorityLabel: '优先级',
statusLabel: '状态',
},
filters: {
protocol: '协议',
allProtocols: '全部协议',
status: '状态',
allStatuses: '全部状态',
},
// Additional keys used in ProxiesView
allProtocols: '全部协议',
allStatus: '全部状态',
searchProxies: '搜索代理...',
name: '名称',
protocol: '协议',
host: '主机',
port: '端口',
username: '用户名(可选)',
password: '密码(可选)',
status: '状态',
enterProxyName: '请输入代理名称',
optionalAuth: '可选认证信息',
leaveEmptyToKeep: '留空保持不变',
noProxiesYet: '暂无代理',
createFirstProxy: '添加您的第一个代理以开始使用。',
testConnection: '测试连接',
batchTest: '批量测试',
testFailed: '失败',
// Batch import
standardAdd: '标准添加',
batchAdd: '快捷添加',
batchInput: '代理列表',
batchInputPlaceholder: '每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443',
batchInputHint: '支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口',
parsedCount: '有效 {count} 个',
invalidCount: '无效 {count} 个',
duplicateCount: '重复 {count} 个',
importing: '导入中...',
importProxies: '导入 {count} 个代理',
batchImportSuccess: '成功导入 {created} 个代理,跳过 {skipped} 个重复',
batchImportAllSkipped: '全部 {skipped} 个代理已存在,跳过导入',
failedToImport: '批量导入失败',
// Other messages
saving: '保存中...',
testing: '测试中...',
creating: '创建中...',
updating: '更新中...',
noProxies: '暂无代理',
noProxiesDescription: '添加代理服务器以增强 API 访问稳定性。',
proxyCreated: '代理添加成功',
proxyUpdated: '代理更新成功',
proxyDeleted: '代理删除成功',
proxyWorking: '代理连接正常',
proxyWorkingWithLatency: '代理连接正常,延迟 {latency}ms',
proxyTestFailed: '代理测试失败',
proxyCreatedSuccess: '代理添加成功',
proxyUpdatedSuccess: '代理更新成功',
proxyDeletedSuccess: '代理删除成功',
testSuccess: '代理测试通过',
failedToLoad: '加载代理列表失败',
failedToSave: '保存代理失败',
failedToDelete: '删除代理失败',
failedToCreate: '创建代理失败',
failedToUpdate: '更新代理失败',
failedToTest: '测试代理失败',
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。",
},
// Redeem Codes Management
redeem: {
title: '兑换码管理',
description: '生成和管理兑换码',
generateCodes: '生成兑换码',
columns: {
code: '兑换码',
type: '类型',
value: '面值',
status: '状态',
usedBy: '使用者',
usedAt: '使用时间',
createdAt: '创建时间',
actions: '操作',
},
types: {
balance: '余额',
concurrency: '并发数',
subscription: '订阅',
},
// 用于选择器和筛选器的直接键
balance: '余额',
concurrency: '并发数',
subscription: '订阅',
allTypes: '全部类型',
allStatus: '全部状态',
unused: '未使用',
used: '已使用',
searchCodes: '搜索兑换码...',
exportCsv: '导出 CSV',
deleteAllUnused: '删除全部未使用',
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。',
deleteAll: '全部删除',
generateCodesTitle: '生成兑换码',
generatedSuccessfully: '生成成功',
codesCreated: '已创建 {count} 个兑换码',
codeType: '类型',
amount: '金额 ($)',
value: '面值',
count: '数量',
generate: '生成',
copyAll: '全部复制',
download: '下载',
codesExported: '兑换码导出成功',
codeDeleted: '兑换码删除成功',
codesDeleted: '成功删除 {count} 个未使用的兑换码',
noUnusedCodes: '没有未使用的兑换码可删除',
userPrefix: '用户 #{id}',
failedToExport: '导出兑换码失败',
failedToDeleteUnused: '删除未使用的兑换码失败',
failedToCopy: '复制失败',
selectGroup: '选择分组',
selectGroupPlaceholder: '选择订阅分组',
validityDays: '有效天数',
groupRequired: '请选择订阅分组',
days: '',
statuses: {
unused: '未使用',
used: '已使用',
expired: '已过期',
disabled: '已禁用',
},
form: {
typeLabel: '类型',
selectType: '选择类型',
valueLabel: '面值',
valuePlaceholder: '请输入面值',
balanceHint: '余额金额(美元)',
concurrencyHint: '并发数增量',
countLabel: '数量',
countPlaceholder: '请输入数量',
countHint: '要生成的兑换码数量',
prefixLabel: '前缀(可选)',
prefixPlaceholder: '例如:GIFT',
expiresLabel: '过期时间(可选)',
},
filters: {
type: '类型',
allTypes: '全部类型',
status: '状态',
allStatuses: '全部状态',
search: '搜索兑换码',
},
generating: '生成中...',
copyCode: '复制',
copied: '已复制!',
disableCode: '禁用',
enableCode: '启用',
deleteCode: '删除',
deleteConfirmMessage: '确定要删除此兑换码吗?',
noCodes: '暂无兑换码',
noCodesDescription: '生成兑换码以向用户分发余额或并发数。',
codesGeneratedSuccess: '兑换码生成成功,共 {count} 个',
codeDisabledSuccess: '兑换码已禁用',
codeEnabledSuccess: '兑换码已启用',
codeDeletedSuccess: '兑换码删除成功',
failedToLoad: '加载兑换码列表失败',
failedToGenerate: '生成兑换码失败',
failedToUpdate: '更新兑换码失败',
failedToDelete: '删除兑换码失败',
},
// Usage Records
usage: {
title: '使用记录',
description: '查看和管理所有用户的使用记录',
userFilter: '用户',
searchUserPlaceholder: '按邮箱搜索用户...',
selectedUser: '已选择',
user: '用户',
failedToLoad: '加载使用记录失败',
},
// Settings
settings: {
title: '系统设置',
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
registration: {
title: '注册设置',
description: '控制用户注册和验证',
enableRegistration: '开放注册',
enableRegistrationHint: '允许新用户注册',
emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱',
},
turnstile: {
title: 'Cloudflare Turnstile',
description: '登录和注册的机器人防护',
enableTurnstile: '启用 Turnstile',
enableTurnstileHint: '需要 Cloudflare Turnstile 验证',
siteKey: '站点密钥',
secretKey: '私密密钥',
siteKeyHint: '从 Cloudflare Dashboard 获取',
secretKeyHint: '服务端验证密钥(请保密)',
},
defaults: {
title: '用户默认设置',
description: '新用户的默认值',
defaultBalance: '默认余额',
defaultBalanceHint: '新用户的初始余额',
defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数',
},
site: {
title: '站点设置',
description: '自定义站点品牌',
siteName: '站点名称',
siteNameHint: '显示在邮件和页面标题中',
siteSubtitle: '站点副标题',
siteSubtitleHint: '显示在登录和注册页面',
apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
contactInfo: '客服联系方式',
contactInfoPlaceholder: '例如:QQ: 123456789',
contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置',
siteLogo: '站点Logo',
uploadImage: '上传图片',
remove: '移除',
logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。',
logoSizeError: '图片大小超过 300KB 限制({size}KB)',
logoTypeError: '请选择图片文件',
logoReadError: '读取图片文件失败',
},
smtp: {
title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务',
testConnection: '测试连接',
testing: '测试中...',
host: 'SMTP 主机',
port: 'SMTP 端口',
username: 'SMTP 用户名',
password: 'SMTP 密码',
passwordHint: '留空以保留现有密码',
fromEmail: '发件人邮箱',
fromName: '发件人名称',
useTls: '使用 TLS',
useTlsHint: '为 SMTP 连接启用 TLS 加密',
},
testEmail: {
title: '发送测试邮件',
description: '发送测试邮件以验证 SMTP 配置',
recipientEmail: '收件人邮箱',
sendTestEmail: '发送测试邮件',
sending: '发送中...',
enterRecipientHint: '请输入收件人邮箱地址',
},
saveSettings: '保存设置',
saving: '保存中...',
settingsSaved: '设置保存成功',
smtpConnectionSuccess: 'SMTP 连接成功',
testEmailSent: '测试邮件发送成功',
failedToLoad: '加载设置失败',
failedToSave: '保存设置失败',
failedToTestSmtp: 'SMTP 连接测试失败',
failedToSendTestEmail: '发送测试邮件失败',
},
},
// Subscription Progress (Header component)
subscriptionProgress: {
title: '我的订阅',
viewDetails: '查看订阅详情',
activeCount: '{count} 个有效订阅',
daily: '每日',
weekly: '每周',
monthly: '每月',
daysRemaining: '剩余 {days} 天',
expired: '已过期',
expiresToday: '今天到期',
expiresTomorrow: '明天到期',
viewAll: '查看全部订阅',
noSubscriptions: '暂无有效订阅',
},
// Version Badge
version: {
currentVersion: '当前版本',
latestVersion: '最新版本',
upToDate: '已是最新版本',
updateAvailable: '有新版本可用!',
releaseNotes: '更新日志',
noReleaseNotes: '暂无更新日志',
viewUpdate: '查看更新',
viewRelease: '查看发布',
refresh: '刷新',
sourceMode: '源码构建',
sourceModeHint: '源码构建模式不支持更新检测,请使用 git pull 更新代码。',
},
// User Subscriptions Page
userSubscriptions: {
title: '我的订阅',
description: '查看您的订阅计划和用量',
noActiveSubscriptions: '暂无有效订阅',
noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。',
status: {
active: '有效',
expired: '已过期',
revoked: '已撤销',
},
usage: '用量',
expires: '到期时间',
noExpiration: '无到期时间',
unlimited: '无限制',
daily: '每日',
weekly: '每周',
monthly: '每月',
daysRemaining: '剩余 {days} 天',
expiresOn: '{date} 到期',
resetIn: '{time} 后重置',
usageOf: '已用 {used} / {limit}',
},
}
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.mount('#app')
# Vue Router Configuration
## Overview
This directory contains the Vue Router configuration for the Sub2API frontend application. The router implements a comprehensive navigation system with authentication guards, role-based access control, and lazy loading.
## Files
- **index.ts**: Main router configuration with route definitions and navigation guards
- **meta.d.ts**: TypeScript type definitions for route meta fields
## Route Structure
### Public Routes (No Authentication Required)
| Path | Component | Description |
|------|-----------|-------------|
| `/login` | LoginView | User login page |
| `/register` | RegisterView | User registration page |
### User Routes (Authentication Required)
| Path | Component | Description |
|------|-----------|-------------|
| `/` | - | Redirects to `/dashboard` |
| `/dashboard` | DashboardView | User dashboard with stats |
| `/keys` | KeysView | API key management |
| `/usage` | UsageView | Usage records and statistics |
| `/redeem` | RedeemView | Redeem code interface |
| `/profile` | ProfileView | User profile settings |
### Admin Routes (Admin Role Required)
| Path | Component | Description |
|------|-----------|-------------|
| `/admin` | - | Redirects to `/admin/dashboard` |
| `/admin/dashboard` | AdminDashboardView | Admin dashboard |
| `/admin/users` | AdminUsersView | User management |
| `/admin/groups` | AdminGroupsView | Group management |
| `/admin/accounts` | AdminAccountsView | Account management |
| `/admin/proxies` | AdminProxiesView | Proxy management |
| `/admin/redeem` | AdminRedeemView | Redeem code management |
### Special Routes
| Path | Component | Description |
|------|-----------|-------------|
| `/:pathMatch(.*)` | NotFoundView | 404 error page |
## Navigation Guards
### Authentication Guard (beforeEach)
The router implements a comprehensive navigation guard that:
1. **Sets Page Title**: Updates document title based on route meta
2. **Checks Authentication**:
- Public routes (`requiresAuth: false`) are accessible without login
- Protected routes require authentication
- Redirects to `/login` if not authenticated
3. **Prevents Double Login**:
- Redirects authenticated users away from login/register pages
4. **Role-Based Access Control**:
- Admin routes (`requiresAdmin: true`) require admin role
- Non-admin users are redirected to `/dashboard`
5. **Preserves Intended Destination**:
- Saves original URL in query parameter for post-login redirect
### Flow Diagram
```
User navigates to route
Set page title from meta
Is route public? ──Yes──→ Already authenticated? ──Yes──→ Redirect to /dashboard
↓ No ↓ No
↓ Allow access
Is user authenticated? ──No──→ Redirect to /login with redirect query
↓ Yes
Requires admin role? ──Yes──→ Is user admin? ──No──→ Redirect to /dashboard
↓ No ↓ Yes
↓ ↓
Allow access ←────────────────────────────────┘
```
## Route Meta Fields
Each route can define the following meta fields:
```typescript
interface RouteMeta {
requiresAuth?: boolean; // Default: true (requires authentication)
requiresAdmin?: boolean; // Default: false (admin access only)
title?: string; // Page title
breadcrumbs?: Array<{ // Breadcrumb navigation
label: string;
to?: string;
}>;
icon?: string; // Icon for navigation menu
hideInMenu?: boolean; // Hide from navigation menu
}
```
## Lazy Loading
All route components use dynamic imports for code splitting:
```typescript
component: () => import('@/views/user/DashboardView.vue')
```
Benefits:
- Reduced initial bundle size
- Faster initial page load
- Components loaded on-demand
- Automatic code splitting by Vite
## Authentication Store Integration
The router integrates with the Pinia auth store (`@/stores/auth`):
```typescript
const authStore = useAuthStore();
// Check authentication status
authStore.isAuthenticated
// Check admin role
authStore.isAdmin
```
## Usage Examples
### Programmatic Navigation
```typescript
import { useRouter } from 'vue-router';
const router = useRouter();
// Navigate to a route
router.push('/dashboard');
// Navigate with query parameters
router.push({
path: '/usage',
query: { filter: 'today' }
});
// Navigate to admin route (will be blocked if not admin)
router.push('/admin/users');
```
### Route Links
```vue
<template>
<!-- Simple link -->
<router-link to="/dashboard">Dashboard</router-link>
<!-- Named route -->
<router-link :to="{ name: 'Keys' }">API Keys</router-link>
<!-- With query parameters -->
<router-link :to="{ path: '/usage', query: { page: 1 } }">
Usage
</router-link>
</template>
```
### Checking Current Route
```typescript
import { useRoute } from 'vue-router';
const route = useRoute();
// Check if on admin page
const isAdminPage = route.path.startsWith('/admin');
// Get route meta
const requiresAdmin = route.meta.requiresAdmin;
```
## Scroll Behavior
The router implements automatic scroll management:
- **Browser Navigation**: Restores saved scroll position
- **New Routes**: Scrolls to top of page
- **Hash Links**: Scrolls to anchor (when implemented)
## Error Handling
The router includes error handling for navigation failures:
```typescript
router.onError((error) => {
console.error('Router error:', error);
});
```
## Testing Routes
To test navigation guards and route access:
1. **Public Route Access**: Visit `/login` without authentication
2. **Protected Route**: Try accessing `/dashboard` without login (should redirect)
3. **Admin Access**: Login as regular user, try `/admin/users` (should redirect to dashboard)
4. **Admin Success**: Login as admin, access `/admin/users` (should succeed)
5. **404 Handling**: Visit non-existent route (should show 404 page)
## Development Tips
### Adding New Routes
1. Add route definition in `routes` array
2. Create corresponding view component
3. Set appropriate meta fields (`requiresAuth`, `requiresAdmin`)
4. Use lazy loading with `() => import()`
5. Update this README with route documentation
### Debugging Navigation
Enable Vue Router debug mode:
```typescript
// In browser console
window.__VUE_ROUTER__ = router;
// Check current route
router.currentRoute.value
```
### Common Issues
**Issue**: 404 on page refresh
- **Cause**: Server not configured for SPA
- **Solution**: Configure server to serve `index.html` for all routes
**Issue**: Navigation guard runs twice
- **Cause**: Multiple `next()` calls
- **Solution**: Ensure only one `next()` call per code path
**Issue**: User data not loaded
- **Cause**: Auth store not initialized
- **Solution**: Call `authStore.checkAuth()` in App.vue or main.ts
## Security Considerations
1. **Client-Side Only**: Navigation guards are client-side; server must also validate
2. **Token Validation**: API should verify JWT token on every request
3. **Role Checking**: Backend must verify admin role, not just frontend
4. **XSS Protection**: Vue automatically escapes template content
5. **CSRF Protection**: Use CSRF tokens for state-changing operations
## Performance Optimization
1. **Lazy Loading**: All routes use dynamic imports
2. **Code Splitting**: Vite automatically splits route chunks
3. **Prefetching**: Consider adding route prefetch for common paths
4. **Route Caching**: Vue Router caches component instances
## Future Enhancements
- [ ] Add breadcrumb navigation system
- [ ] Implement route-based permissions beyond admin/user
- [ ] Add route transition animations
- [ ] Implement route prefetching for anticipated navigation
- [ ] Add navigation analytics tracking
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment