Commit 195e227c authored by song's avatar song
Browse files

merge: 合并 upstream/main 并保留本地图片计费功能

parents 6fa704d6 752882a0
......@@ -46,20 +46,12 @@
:disabled="loading"
:title="t('version.refresh')"
>
<svg
class="h-4 w-4"
<Icon
name="refresh"
size="sm"
:stroke-width="2"
: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>
......@@ -129,19 +121,12 @@
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50"
>
<svg
class="h-4 w-4 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
<Icon
name="x"
size="sm"
:stroke-width="2"
class="text-red-600 dark:text-red-400"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-red-700 dark:text-red-300">
......@@ -253,19 +238,12 @@
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
>
<svg
class="h-4 w-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"
<Icon
name="download"
size="sm"
:stroke-width="2"
class="text-amber-600 dark:text-amber-400"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">
......@@ -317,19 +295,12 @@
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
>
<svg
class="h-4 w-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"
<Icon
name="download"
size="sm"
:stroke-width="2"
class="text-amber-600 dark:text-amber-400"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">
......@@ -362,20 +333,7 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class="h-4 w-4"
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>
<Icon v-else name="download" size="sm" :stroke-width="2" />
{{ updating ? t('version.updating') : t('version.updateNow') }}
</button>
......@@ -388,19 +346,7 @@
class="flex items-center justify-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200"
>
{{ t('version.viewChangelog') }}
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
<Icon name="externalLink" size="xs" :stroke-width="2" />
</a>
</div>
......@@ -439,6 +385,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useAppStore } from '@/stores'
import { performUpdate, restartService } from '@/api/admin/system'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
......
<template>
<svg
:class="sizeClass"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
:stroke-width="strokeWidth"
>
<path stroke-linecap="round" stroke-linejoin="round" :d="iconPath" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
name: keyof typeof icons
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
strokeWidth?: number
}>(), {
size: 'md',
strokeWidth: 1.5
})
const icons = {
// Actions
play: 'M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z',
refresh: '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',
edit: 'M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10',
trash: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0',
plus: 'M12 4.5v15m7.5-7.5h-15',
search: 'M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z',
more: 'M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z',
// Status & Info
chart: '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',
clock: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
link: 'M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244',
sync: 'M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3',
// Navigation
chevronDown: 'M19.5 8.25l-7.5 7.5-7.5-7.5',
chevronRight: 'M8.25 4.5l7.5 7.5-7.5 7.5',
chevronLeft: 'M15.75 19.5L8.25 12l7.5-7.5',
// UI Elements
check: 'M4.5 12.75l6 6 9-13.5',
x: 'M6 18L18 6M6 6l12 12',
eye: 'M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
eyeOff: 'M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88',
cog: '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 M15 12a3 3 0 11-6 0 3 3 0 016 0z',
grid: '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',
chat: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z',
lightbulb: 'M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18',
// Navigation & Arrows
arrowRight: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3',
arrowLeft: 'M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18',
arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18',
arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3',
chevronUp: 'M5 15l7-7 7 7',
externalLink: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',
// Status & Indicators
checkCircle: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
xCircle: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
exclamationCircle: 'M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z',
exclamationTriangle: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
infoCircle: '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',
questionCircle: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
// User & Account
user: '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',
userCircle: 'M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z',
userPlus: 'M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z',
users: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
// Files & Documents
document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
clipboard: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
inbox: 'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4',
// Actions
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
upload: 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5',
filter: 'M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z',
sort: 'M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9',
// Security
key: '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',
lock: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
shield: 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z',
// UI Elements
menu: 'M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5',
calendar: 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5',
home: 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25',
terminal: 'M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z',
gift: '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',
creditCard: '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',
mail: 'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
// Data & Analytics
chartBar: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
trendingUp: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
database: 'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125',
cube: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
// Misc
bolt: 'M13 10V3L4 14h7v7l9-11h-7z',
sparkles: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z',
cloud: 'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
server: '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',
sun: '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',
moon: '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',
book: 'M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25',
dollar: 'M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
ban: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
login: '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.25V15m3 0l3-3m0 0l-3-3m3 3H9',
swap: 'M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5',
beaker: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5',
cpu: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
chatBubble: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z',
calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z'
} as const
const iconPath = computed(() => icons[props.name])
const sizeClass = computed(() => ({
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
xl: 'h-8 w-8'
}[props.size]))
</script>
export { default as Icon } from './Icon.vue'
......@@ -81,9 +81,7 @@
>
<!-- File Hint (if exists) -->
<p v-if="file.hint" class="text-xs text-amber-600 dark:text-amber-400 mb-1.5 flex items-center gap-1">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<Icon name="exclamationCircle" size="sm" class="flex-shrink-0" />
{{ file.hint }}
</p>
<div class="bg-gray-900 dark:bg-dark-900 rounded-xl overflow-hidden">
......@@ -107,16 +105,17 @@
</button>
</div>
<!-- Code Content -->
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-html="file.highlighted"></code></pre>
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto">
<code v-if="file.highlighted" v-html="file.highlighted"></code>
<code v-else v-text="file.content"></code>
</pre>
</div>
</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>
<Icon name="infoCircle" size="md" class="text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ platformNote }}
</p>
......@@ -141,6 +140,7 @@
import { ref, computed, h, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { useClipboard } from '@/composables/useClipboard'
import type { GroupPlatform } from '@/types'
......@@ -164,8 +164,8 @@ interface TabConfig {
interface FileConfig {
path: string
content: string
highlighted: string
hint?: string // Optional hint message for this file
highlighted?: string
}
const props = defineProps<Props>()
......@@ -311,14 +311,23 @@ const platformNote = computed(() => {
}
})
// Syntax highlighting helpers
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>`
const comment = (text: string) => `<span class="text-gray-500">${text}</span>`
const key = (text: string) => `<span class="text-blue-400">${text}</span>`
const escapeHtml = (value: string) => value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const wrapToken = (className: string, value: string) =>
`<span class="${className}">${escapeHtml(value)}</span>`
const keyword = (value: string) => wrapToken('text-emerald-300', value)
const variable = (value: string) => wrapToken('text-sky-200', value)
const operator = (value: string) => wrapToken('text-slate-400', value)
const string = (value: string) => wrapToken('text-amber-200', value)
const comment = (value: string) => wrapToken('text-slate-500', value)
// Syntax highlighting helpers
// Generate file configs based on platform and active tab
const currentFiles = computed((): FileConfig[] => {
const baseUrl = props.baseUrl || window.location.origin
......@@ -343,37 +352,29 @@ const currentFiles = computed((): FileConfig[] => {
function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] {
let path: string
let content: string
let highlighted: string
switch (activeTab.value) {
case 'unix':
path = 'Terminal'
content = `export ANTHROPIC_BASE_URL="${baseUrl}"
export ANTHROPIC_AUTH_TOKEN="${apiKey}"`
highlighted = `${keyword('export')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
${keyword('export')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
break
case 'cmd':
path = 'Command Prompt'
content = `set ANTHROPIC_BASE_URL=${baseUrl}
set ANTHROPIC_AUTH_TOKEN=${apiKey}`
highlighted = `${keyword('set')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${baseUrl}
${keyword('set')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${apiKey}`
break
case 'powershell':
path = 'PowerShell'
content = `$env:ANTHROPIC_BASE_URL="${baseUrl}"
$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
highlighted = `${keyword('$env:')}${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
${keyword('$env:')}${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
break
default:
path = 'Terminal'
content = ''
highlighted = ''
}
return [{ path, content, highlighted }]
return [{ path, content }]
}
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
......@@ -398,9 +399,9 @@ ${keyword('export')} ${variable('GEMINI_MODEL')}${operator('=')}${string(`"${mod
content = `set GOOGLE_GEMINI_BASE_URL=${baseUrl}
set GEMINI_API_KEY=${apiKey}
set GEMINI_MODEL=${model}`
highlighted = `${keyword('set')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${baseUrl}
${keyword('set')} ${variable('GEMINI_API_KEY')}${operator('=')}${apiKey}
${keyword('set')} ${variable('GEMINI_MODEL')}${operator('=')}${model}
highlighted = `${keyword('set')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${string(baseUrl)}
${keyword('set')} ${variable('GEMINI_API_KEY')}${operator('=')}${string(apiKey)}
${keyword('set')} ${variable('GEMINI_MODEL')}${operator('=')}${string(model)}
${comment(`REM ${modelComment}`)}`
break
case 'powershell':
......@@ -440,40 +441,20 @@ base_url = "${baseUrl}"
wire_api = "responses"
requires_openai_auth = true`
const configHighlighted = `${key('model_provider')} ${operator('=')} ${string('"sub2api"')}
${key('model')} ${operator('=')} ${string('"gpt-5.2-codex"')}
${key('model_reasoning_effort')} ${operator('=')} ${string('"high"')}
${key('network_access')} ${operator('=')} ${string('"enabled"')}
${key('disable_response_storage')} ${operator('=')} ${keyword('true')}
${key('windows_wsl_setup_acknowledged')} ${operator('=')} ${keyword('true')}
${key('model_verbosity')} ${operator('=')} ${string('"high"')}
${comment('[model_providers.sub2api]')}
${key('name')} ${operator('=')} ${string('"sub2api"')}
${key('base_url')} ${operator('=')} ${string(`"${baseUrl}"`)}
${key('wire_api')} ${operator('=')} ${string('"responses"')}
${key('requires_openai_auth')} ${operator('=')} ${keyword('true')}`
// auth.json content
const authContent = `{
"OPENAI_API_KEY": "${apiKey}"
}`
const authHighlighted = `{
${key('"OPENAI_API_KEY"')}: ${string(`"${apiKey}"`)}
}`
return [
{
path: `${configDir}/config.toml`,
content: configContent,
highlighted: configHighlighted,
hint: t('keys.useKeyModal.openai.configTomlHint')
},
{
path: `${configDir}/auth.json`,
content: authContent,
highlighted: authHighlighted
content: authContent
}
]
}
......
......@@ -8,19 +8,7 @@
class="btn-ghost btn-icon lg:hidden"
aria-label="Toggle Menu"
>
<svg
class="h-5 w-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>
<Icon name="menu" size="md" />
</button>
<div class="hidden lg:block">
......@@ -84,19 +72,7 @@
{{ user.role }}
</div>
</div>
<svg
class="hidden h-4 w-4 text-gray-400 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>
<Icon name="chevronDown" size="sm" class="hidden text-gray-400 md:block" />
</button>
<!-- Dropdown Menu -->
......@@ -122,36 +98,12 @@
<div class="py-1">
<router-link to="/profile" @click="closeDropdown" class="dropdown-item">
<svg
class="h-4 w-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>
<Icon name="user" size="sm" />
{{ t('nav.profile') }}
</router-link>
<router-link to="/keys" @click="closeDropdown" class="dropdown-item">
<svg
class="h-4 w-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>
<Icon name="key" size="sm" />
{{ t('nav.apiKeys') }}
</router-link>
......@@ -246,6 +198,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
import Icon from '@/components/icons/Icon.vue'
const router = useRouter()
const route = useRoute()
......
......@@ -63,6 +63,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getPublicSettings } from '@/api/auth'
import { sanitizeUrl } from '@/utils/url'
const siteName = ref('Sub2API')
const siteLogo = ref('')
......@@ -74,7 +75,7 @@ onMounted(async () => {
try {
const settings = await getPublicSettings()
siteName.value = settings.site_name || 'Sub2API'
siteLogo.value = settings.site_logo || ''
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
} catch (error) {
console.error('Failed to load public settings:', error)
......
......@@ -52,18 +52,12 @@
/>
<!-- Select -->
<select
<Select
v-else-if="attr.type === 'select'"
v-model="localValues[attr.id]"
:required="attr.required"
class="input"
:options="attr.options || []"
@change="emitChange"
>
<option value="">{{ t('common.selectOption') }}</option>
<option v-for="opt in attr.options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
/>
<!-- Multi-Select (Checkboxes) -->
<div v-else-if="attr.type === 'multi_select'" class="space-y-2">
......@@ -99,11 +93,9 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { UserAttributeDefinition, UserAttributeValuesMap } from '@/types'
const { t } = useI18n()
import Select from '@/components/common/Select.vue'
interface Props {
userId?: number
......
......@@ -7,9 +7,7 @@
{{ t('admin.users.attributes.description') }}
</p>
<button @click="openCreateModal" class="btn btn-primary btn-sm">
<svg class="mr-1.5 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<Icon name="plus" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('admin.users.attributes.addAttribute') }}
</button>
</div>
......@@ -45,9 +43,7 @@
>
<!-- Drag Handle -->
<div class="cursor-move text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" :title="t('admin.users.attributes.dragToReorder')">
<svg class="h-5 w-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>
<Icon name="menu" size="md" />
</div>
<!-- Attribute Info -->
......@@ -77,18 +73,14 @@
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title="t('common.edit')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
<Icon name="edit" size="sm" />
</button>
<button
@click="confirmDelete(attr)"
class="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
......@@ -142,11 +134,10 @@
<!-- Type -->
<div>
<label class="input-label">{{ t('admin.users.attributes.type') }}</label>
<select v-model="form.type" class="input" required>
<option v-for="type in attributeTypes" :key="type" :value="type">
{{ t(`admin.users.attributes.types.${type}`) }}
</option>
</select>
<Select
v-model="form.type"
:options="attributeTypes.map(type => ({ value: type, label: t(`admin.users.attributes.types.${type}`) }))"
/>
</div>
<!-- Options (for select/multi_select) -->
......@@ -172,15 +163,11 @@
@click="removeOption(index)"
class="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<Icon name="x" size="sm" :stroke-width="2" />
</button>
</div>
<button type="button" @click="addOption" class="btn btn-secondary btn-sm">
<svg class="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<Icon name="plus" size="sm" class="mr-1" :stroke-width="2" />
{{ t('admin.users.attributes.addOption') }}
</button>
</div>
......@@ -257,6 +244,8 @@ import { adminAPI } from '@/api/admin'
import type { UserAttributeDefinition, UserAttributeType, UserAttributeOption } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()
......@@ -344,6 +333,18 @@ const removeOption = (index: number) => {
}
const handleSave = async () => {
if (!form.key.trim()) {
appStore.showError(t('admin.users.attributes.keyRequired'))
return
}
if (!form.name.trim()) {
appStore.showError(t('admin.users.attributes.nameRequired'))
return
}
if ((form.type === 'select' || form.type === 'multi_select') && form.options.length === 0) {
appStore.showError(t('admin.users.attributes.optionsRequired'))
return
}
saving.value = true
try {
const data = {
......
<template>
<div class="space-y-6">
<!-- Date Range Filter -->
<div class="card p-4">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.timeRange') }}:</span>
<DateRangePicker :start-date="startDate" :end-date="endDate" @update:startDate="$emit('update:startDate', $event)" @update:endDate="$emit('update:endDate', $event)" @change="$emit('dateRangeChange', $event)" />
</div>
<div class="ml-auto flex items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.granularity') }}:</span>
<div class="w-28">
<Select :model-value="granularity" :options="[{value:'day', label:t('dashboard.day')}, {value:'hour', label:t('dashboard.hour')}]" @update:model-value="$emit('update:granularity', $event)" @change="$emit('granularityChange')" />
</div>
</div>
</div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Model Distribution Chart -->
<div class="card relative overflow-hidden p-4">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.modelDistribution') }}</h3>
<div class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut v-if="modelData" :data="modelData" :options="doughnutOptions" />
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
<th class="pb-2 text-left">{{ t('dashboard.model') }}</th>
<th class="pb-2 text-right">{{ t('dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="model in models" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
<td class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white" :title="model.model">{{ model.model }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Token Usage Trend Chart -->
<div class="card relative overflow-hidden p-4">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.tokenUsageTrend') }}</h3>
<div class="h-48">
<Line v-if="trendData" :data="trendData" :options="lineOptions" />
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Select from '@/components/common/Select.vue'
import { Line, Doughnut } from 'vue-chartjs'
import type { TrendDataPoint, ModelStat } from '@/types'
import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format'
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler)
const props = defineProps<{ loading: boolean, startDate: string, endDate: string, granularity: string, trend: TrendDataPoint[], models: ModelStat[] }>()
defineEmits(['update:startDate', 'update:endDate', 'update:granularity', 'dateRangeChange', 'granularityChange'])
const { t } = useI18n()
const modelData = computed(() => !props.models?.length ? null : {
labels: props.models.map((m: ModelStat) => m.model),
datasets: [{
data: props.models.map((m: ModelStat) => m.total_tokens),
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
}]
})
const trendData = computed(() => !props.trend?.length ? null : {
labels: props.trend.map((d: TrendDataPoint) => d.date),
datasets: [
{
label: t('dashboard.input'),
data: props.trend.map((d: TrendDataPoint) => d.input_tokens),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
fill: true
},
{
label: t('dashboard.output'),
data: props.trend.map((d: TrendDataPoint) => d.output_tokens),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
fill: true
}
]
})
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: any) => `${context.label}: ${formatTokens(context.parsed)} tokens`
}
}
}
}
const lineOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' as const },
tooltip: {
callbacks: {
label: (context: any) => `${context.dataset.label}: ${formatTokens(context.parsed.y)} tokens`
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => formatTokens(value)
}
}
}
}
</script>
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.quickActions') }}</h2>
</div>
<div class="space-y-3 p-4">
<button @click="router.push('/keys')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 transition-transform group-hover:scale-105 dark:bg-primary-900/30">
<Icon name="key" size="lg" class="text-primary-600 dark:text-primary-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.createApiKey') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.generateNewKey') }}</p>
</div>
<Icon
name="chevronRight"
size="md"
class="text-gray-400 transition-colors group-hover:text-primary-500 dark:text-dark-500"
/>
</button>
<button @click="router.push('/usage')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 transition-transform group-hover:scale-105 dark:bg-emerald-900/30">
<Icon name="chart" size="lg" class="text-emerald-600 dark:text-emerald-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.viewUsage') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.checkDetailedLogs') }}</p>
</div>
<Icon
name="chevronRight"
size="md"
class="text-gray-400 transition-colors group-hover:text-emerald-500 dark:text-dark-500"
/>
</button>
<button @click="router.push('/redeem')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-amber-100 transition-transform group-hover:scale-105 dark:bg-amber-900/30">
<Icon name="gift" size="lg" class="text-amber-600 dark:text-amber-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalance') }}</p>
</div>
<Icon
name="chevronRight"
size="md"
class="text-gray-400 transition-colors group-hover:text-amber-500 dark:text-dark-500"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
const router = useRouter()
const { t } = useI18n()
</script>
<template>
<div class="card">
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.recentUsage') }}</h2>
<span class="badge badge-gray">{{ t('dashboard.last7Days') }}</span>
</div>
<div class="p-6">
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner size="lg" />
</div>
<div v-else-if="data.length === 0" class="py-8">
<EmptyState :title="t('dashboard.noUsageRecords')" :description="t('dashboard.startUsingApi')" />
</div>
<div v-else class="space-y-3">
<div v-for="log in data" :key="log.id" class="flex items-center justify-between rounded-xl bg-gray-50 p-4 transition-colors hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30">
<Icon name="beaker" size="md" class="text-primary-600 dark:text-primary-400" />
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ log.model }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ formatDateTime(log.created_at) }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-semibold">
<span class="text-green-600 dark:text-green-400" :title="t('dashboard.actual')">${{ formatCost(log.actual_cost) }}</span>
<span class="font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(log.total_cost) }}</span>
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ (log.input_tokens + log.output_tokens).toLocaleString() }} tokens</p>
</div>
</div>
<router-link to="/usage" class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
{{ t('dashboard.viewAllUsage') }}
<Icon name="arrowRight" size="sm" />
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatDateTime } from '@/utils/format'
import type { UsageLog } from '@/types'
defineProps<{
data: UsageLog[]
loading: boolean
}>()
const { t } = useI18n()
const formatCost = (c: number) => c.toFixed(4)
</script>
<template>
<!-- Row 1: Core Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Balance -->
<div v-if="!isSimple" class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<svg class="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.balance') }}</p>
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">${{ formatBalance(balance) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.available') }}</p>
</div>
</div>
</div>
<!-- API Keys -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<Icon name="key" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.apiKeys') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats?.total_api_keys || 0 }}</p>
<p class="text-xs text-green-600 dark:text-green-400">{{ stats?.active_api_keys || 0 }} {{ t('common.active') }}</p>
</div>
</div>
</div>
<!-- Today Requests -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Icon name="chart" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayRequests') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats?.today_requests || 0 }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats?.total_requests || 0) }}</p>
</div>
</div>
</div>
<!-- Today Cost -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<Icon name="dollar" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayCost') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats?.today_actual_cost || 0) }}</span>
<span class="text-sm font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats?.today_cost || 0) }}</span>
</p>
<p class="text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('common.total') }}: </span>
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats?.total_actual_cost || 0) }}</span>
<span class="text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats?.total_cost || 0) }}</span>
</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Token Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Today Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats?.today_tokens || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.input') }}: {{ formatTokens(stats?.today_input_tokens || 0) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats?.today_output_tokens || 0) }}</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30">
<Icon name="database" size="md" class="text-indigo-600 dark:text-indigo-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.totalTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats?.total_tokens || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.input') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}</p>
</div>
</div>
</div>
<!-- Performance (RPM/TPM) -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30">
<Icon name="bolt" size="md" class="text-violet-600 dark:text-violet-400" :stroke-width="2" />
</div>
<div class="flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.performance') }}</p>
<div class="flex items-baseline gap-2">
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats?.rpm || 0) }}</p>
<span class="text-xs text-gray-500 dark:text-gray-400">RPM</span>
</div>
<div class="flex items-baseline gap-2">
<p class="text-sm font-semibold text-violet-600 dark:text-violet-400">{{ formatTokens(stats?.tpm || 0) }}</p>
<span class="text-xs text-gray-500 dark:text-gray-400">TPM</span>
</div>
</div>
</div>
</div>
<!-- Avg Response Time -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30">
<Icon name="clock" size="md" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.avgResponse') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(stats?.average_duration_ms || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.averageTime') }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { UserDashboardStats as UserStatsType } from '@/api/usage'
defineProps<{
stats: UserStatsType
balance: number
isSimple: boolean
}>()
const { t } = useI18n()
const formatBalance = (b: number) =>
new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(b)
const formatNumber = (n: number) => n.toLocaleString()
const formatCost = (c: number) => c.toFixed(4)
const formatTokens = (t: number) => {
if (t >= 1_000_000) return `${(t / 1_000_000).toFixed(1)}M`
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
return t.toString()
}
const formatDuration = (ms: number) => ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms.toFixed(0)}ms`
</script>
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.editProfile') }}
</h2>
</div>
<div class="px-6 py-6">
<form @submit.prevent="handleUpdateProfile" class="space-y-4">
<div>
<label for="username" class="input-label">
{{ t('profile.username') }}
</label>
<input
id="username"
v-model="username"
type="text"
class="input"
:placeholder="t('profile.enterUsername')"
/>
</div>
<div class="flex justify-end pt-4">
<button type="submit" :disabled="loading" class="btn btn-primary">
{{ loading ? t('profile.updating') : t('profile.updateProfile') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { userAPI } from '@/api'
const props = defineProps<{
initialUsername: string
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const username = ref(props.initialUsername)
const loading = ref(false)
watch(() => props.initialUsername, (val) => {
username.value = val
})
const handleUpdateProfile = async () => {
if (!username.value.trim()) {
appStore.showError(t('profile.usernameRequired'))
return
}
loading.value = true
try {
const updatedUser = await userAPI.updateProfile({
username: username.value
})
authStore.user = updatedUser
appStore.showSuccess(t('profile.updateSuccess'))
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('profile.updateFailed'))
} finally {
loading.value = false
}
}
</script>
<template>
<div class="card overflow-hidden">
<div
class="border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
>
<div class="flex items-center gap-4">
<!-- Avatar -->
<div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
>
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
{{ user?.email }}
</h2>
<div class="mt-1 flex items-center gap-2">
<span :class="['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']">
{{ user?.role === 'admin' ? t('profile.administrator') : t('profile.user') }}
</span>
<span
:class="['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{ user?.status }}
</span>
</div>
</div>
</div>
</div>
<div class="px-6 py-4">
<div class="space-y-3">
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<Icon name="mail" size="sm" class="text-gray-400 dark:text-gray-500" />
<span class="truncate">{{ user?.email }}</span>
</div>
<div
v-if="user?.username"
class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<Icon name="user" size="sm" class="text-gray-400 dark:text-gray-500" />
<span class="truncate">{{ user.username }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { User } from '@/types'
defineProps<{
user: User | null
}>()
const { t } = useI18n()
</script>
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.changePassword') }}
</h2>
</div>
<div class="px-6 py-6">
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div>
<label for="old_password" class="input-label">
{{ t('profile.currentPassword') }}
</label>
<input
id="old_password"
v-model="form.old_password"
type="password"
required
autocomplete="current-password"
class="input"
/>
</div>
<div>
<label for="new_password" class="input-label">
{{ t('profile.newPassword') }}
</label>
<input
id="new_password"
v-model="form.new_password"
type="password"
required
autocomplete="new-password"
class="input"
/>
<p class="input-hint">
{{ t('profile.passwordHint') }}
</p>
</div>
<div>
<label for="confirm_password" class="input-label">
{{ t('profile.confirmNewPassword') }}
</label>
<input
id="confirm_password"
v-model="form.confirm_password"
type="password"
required
autocomplete="new-password"
class="input"
/>
<p
v-if="form.new_password && form.confirm_password && form.new_password !== form.confirm_password"
class="input-error-text"
>
{{ t('profile.passwordsNotMatch') }}
</p>
</div>
<div class="flex justify-end pt-4">
<button type="submit" :disabled="loading" class="btn btn-primary">
{{ loading ? t('profile.changingPassword') : t('profile.changePasswordButton') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { userAPI } from '@/api'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const form = ref({
old_password: '',
new_password: '',
confirm_password: ''
})
const handleChangePassword = async () => {
if (form.value.new_password !== form.value.confirm_password) {
appStore.showError(t('profile.passwordsNotMatch'))
return
}
if (form.value.new_password.length < 8) {
appStore.showError(t('profile.passwordTooShort'))
return
}
loading.value = true
try {
await userAPI.changePassword(form.value.old_password, form.value.new_password)
form.value = { old_password: '', new_password: '', confirm_password: '' }
appStore.showSuccess(t('profile.passwordChangeSuccess'))
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('profile.passwordChangeFailed'))
} finally {
loading.value = false
}
}
</script>
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { i18n } from '@/i18n'
const { t } = i18n.global
/**
* 检测是否支持 Clipboard API(需要安全上下文:HTTPS/localhost)
......@@ -31,7 +34,7 @@ export function useClipboard() {
const copyToClipboard = async (
text: string,
successMessage = 'Copied to clipboard'
successMessage?: string
): Promise<boolean> => {
if (!text) return false
......@@ -50,12 +53,12 @@ export function useClipboard() {
if (success) {
copied.value = true
appStore.showSuccess(successMessage)
appStore.showSuccess(successMessage || t('common.copiedToClipboard'))
setTimeout(() => {
copied.value = false
}, 2000)
} else {
appStore.showError('Copy failed')
appStore.showError(t('common.copyFailed'))
}
return success
......
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
interface UseFormOptions<T> {
form: T
submitFn: (data: T) => Promise<void>
successMsg?: string
errorMsg?: string
}
/**
* 统一表单提交逻辑
* 管理加载状态、错误捕获及通知
*/
export function useForm<T>(options: UseFormOptions<T>) {
const { form, submitFn, successMsg, errorMsg } = options
const loading = ref(false)
const appStore = useAppStore()
const submit = async () => {
if (loading.value) return
loading.value = true
try {
await submitFn(form)
if (successMsg) {
appStore.showSuccess(successMsg)
}
} catch (error: any) {
const detail = error.response?.data?.detail || error.response?.data?.message || error.message
appStore.showError(errorMsg || detail)
// 继续抛出错误,让组件有机会进行局部处理(如验证错误显示)
throw error
} finally {
loading.value = false
}
}
return {
loading,
submit
}
}
import { ref, reactive, onUnmounted, toRaw } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { BasePaginationResponse, FetchOptions } from '@/types'
interface PaginationState {
page: number
page_size: number
total: number
pages: number
}
interface TableLoaderOptions<T, P> {
fetchFn: (page: number, pageSize: number, params: P, options?: FetchOptions) => Promise<BasePaginationResponse<T>>
initialParams?: P
pageSize?: number
debounceMs?: number
}
/**
* 通用表格数据加载 Composable
* 统一处理分页、筛选、搜索防抖和请求取消
*/
export function useTableLoader<T, P extends Record<string, any>>(options: TableLoaderOptions<T, P>) {
const { fetchFn, initialParams, pageSize = 20, debounceMs = 300 } = options
const items = ref<T[]>([])
const loading = ref(false)
const params = reactive<P>({ ...(initialParams || {}) } as P)
const pagination = reactive<PaginationState>({
page: 1,
page_size: pageSize,
total: 0,
pages: 0
})
let abortController: AbortController | null = null
const isAbortError = (error: any) => {
return error?.name === 'AbortError' || error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError'
}
const load = async () => {
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
loading.value = true
try {
const response = await fetchFn(
pagination.page,
pagination.page_size,
toRaw(params) as P,
{ signal: abortController.signal }
)
items.value = response.items || []
pagination.total = response.total || 0
pagination.pages = response.pages || 0
} catch (error) {
if (!isAbortError(error)) {
console.error('Table load error:', error)
throw error
}
} finally {
if (abortController && !abortController.signal.aborted) {
loading.value = false
}
}
}
const reload = () => {
pagination.page = 1
return load()
}
const debouncedReload = useDebounceFn(reload, debounceMs)
const handlePageChange = (page: number) => {
pagination.page = page
load()
}
const handlePageSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
load()
}
onUnmounted(() => {
abortController?.abort()
})
return {
items,
loading,
params,
pagination,
load,
reload,
debouncedReload,
handlePageChange,
handlePageSizeChange
}
}
......@@ -47,6 +47,7 @@ export default {
description: 'Configure your Sub2API instance',
database: {
title: 'Database Configuration',
description: 'Connect to your PostgreSQL database',
host: 'Host',
port: 'Port',
username: 'Username',
......@@ -63,6 +64,7 @@ export default {
},
redis: {
title: 'Redis Configuration',
description: 'Connect to your Redis server',
host: 'Host',
port: 'Port',
password: 'Password (optional)',
......@@ -71,6 +73,7 @@ export default {
},
admin: {
title: 'Admin Account',
description: 'Create your administrator account',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
......@@ -80,9 +83,21 @@ export default {
},
ready: {
title: 'Ready to Install',
description: 'Review your configuration and complete setup',
database: 'Database',
redis: 'Redis',
adminEmail: 'Admin Email'
},
status: {
testing: 'Testing...',
success: 'Connection Successful',
testConnection: 'Test Connection',
installing: 'Installing...',
completeInstallation: 'Complete Installation',
completed: 'Installation completed!',
redirecting: 'Redirecting to login page...',
restarting: 'Service is restarting, please wait...',
timeout: 'Service restart is taking longer than expected. Please refresh the page manually.'
}
},
......@@ -133,8 +148,10 @@ export default {
selectOption: 'Select an option',
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
noGroupsAvailable: 'No groups available',
unknownError: 'Unknown error occurred',
saving: 'Saving...',
refresh: 'Refresh',
selectedCount: '({count} selected)', refresh: 'Refresh',
notAvailable: 'N/A',
now: 'Now',
unknown: 'Unknown',
......@@ -200,6 +217,7 @@ export default {
registrationFailed: 'Registration failed. Please try again.',
loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.',
turnstileExpired: 'Verification expired, please try again',
turnstileFailed: 'Verification failed, please try again',
completeVerification: 'Please complete the verification',
......@@ -445,7 +463,8 @@ export default {
days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.'
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.',
pleaseEnterCode: 'Please enter a redeem code'
},
// Profile
......@@ -641,6 +660,10 @@ export default {
failedToDelete: 'Failed to delete user',
failedToToggle: 'Failed to update user status',
failedToLoadApiKeys: 'Failed to load user API keys',
emailRequired: 'Please enter email',
concurrencyMin: 'Concurrency must be at least 1',
amountRequired: 'Please enter a valid amount',
insufficientBalance: 'Insufficient balance',
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
setAllowedGroups: 'Set Allowed Groups',
allowedGroupsHint:
......@@ -672,7 +695,10 @@ export default {
failedToDeposit: 'Failed to deposit',
failedToWithdraw: 'Failed to withdraw',
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal',
roles: {
admin: 'Admin',
user: 'User'
},
// Settings Dropdowns
filterSettings: 'Filter Settings',
columnSettings: 'Column Settings',
......@@ -728,6 +754,9 @@ export default {
failedToLoad: 'Failed to load attributes',
failedToCreate: 'Failed to create attribute',
failedToUpdate: 'Failed to update attribute',
keyRequired: 'Please enter attribute key',
nameRequired: 'Please enter display name',
optionsRequired: 'Please add at least one option',
failedToDelete: 'Failed to delete attribute',
failedToReorder: 'Failed to update order',
keyExists: 'Attribute key already exists',
......@@ -739,6 +768,7 @@ export default {
groups: {
title: 'Group Management',
description: 'Manage API key groups and rate multipliers',
searchGroups: 'Search groups...',
createGroup: 'Create Group',
editGroup: 'Edit Group',
deleteGroup: 'Delete Group',
......@@ -794,6 +824,14 @@ export default {
failedToCreate: 'Failed to create group',
failedToUpdate: 'Failed to update group',
failedToDelete: 'Failed to delete group',
nameRequired: 'Please enter group name',
platforms: {
all: 'All Platforms',
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
},
deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
deleteConfirmSubscription:
......@@ -879,6 +917,9 @@ export default {
failedToAssign: 'Failed to assign subscription',
failedToExtend: 'Failed to extend subscription',
failedToRevoke: 'Failed to revoke subscription',
pleaseSelectUser: 'Please select a user',
pleaseSelectGroup: 'Please select a group',
validityDaysRequired: 'Please enter a valid number of days (at least 1)',
revokeConfirm:
"Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone."
},
......@@ -911,6 +952,9 @@ export default {
editAccount: 'Edit Account',
deleteAccount: 'Delete Account',
searchAccounts: 'Search accounts...',
notes: 'Notes',
notesPlaceholder: 'Enter notes',
notesHint: 'Notes are optional',
allPlatforms: 'All Platforms',
allTypes: 'All Types',
allStatus: 'All Status',
......@@ -939,9 +983,33 @@ export default {
antigravityOauth: 'Antigravity OAuth'
},
status: {
active: 'Active',
inactive: 'Inactive',
error: 'Error',
cooldown: 'Cooldown',
paused: 'Paused',
limited: 'Limited',
tempUnschedulable: 'Temp Unschedulable'
tempUnschedulable: 'Temp Unschedulable',
rateLimitedUntil: 'Rate limited until {time}',
overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details'
},
columns: {
name: 'Name',
platformType: 'Platform/Type',
platform: 'Platform',
type: 'Type',
concurrencyStatus: 'Concurrency',
notes: 'Notes',
priority: 'Priority',
weight: 'Weight',
status: 'Status',
schedulable: 'Schedulable',
todayStats: 'Today Stats',
groups: 'Groups',
usageWindows: 'Usage Windows',
lastUsed: 'Last Used',
actions: 'Actions'
},
tempUnschedulable: {
title: 'Temp Unschedulable',
......@@ -986,21 +1054,6 @@ export default {
unavailableDesc: 'Unavailable - pause 30 minutes'
}
},
columns: {
name: 'Name',
platformType: 'Platform/Type',
platform: 'Platform',
type: 'Type',
concurrencyStatus: 'Concurrency',
status: 'Status',
schedulable: 'Schedule',
todayStats: "Today's Stats",
groups: 'Groups',
usageWindows: 'Usage Windows',
priority: 'Priority',
lastUsed: 'Last Used',
actions: 'Actions'
},
clearRateLimit: 'Clear Rate Limit',
testConnection: 'Test Connection',
reAuthorize: 'Re-Authorize',
......@@ -1132,13 +1185,13 @@ export default {
'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>',
step1: 'Login to claude.ai in your browser',
step2: 'Press F12 to open Developer Tools',
step3: 'Go to Application tab',
step4: 'Find Cookies → https://claude.ai',
step5: 'Find the row with key sessionKey',
step6: 'Copy the Value',
sessionKeyFormat: 'sessionKey usually starts with sk-ant-sid01-',
startAutoAuth: 'Start Auto-Auth',
authorizing: 'Authorizing...',
followSteps: 'Follow these steps to authorize your Claude account:',
......@@ -1150,10 +1203,10 @@ export default {
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.',
'Note: 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:',
'After authorization is complete, the page will display an Authorization Code. 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',
......@@ -1174,10 +1227,10 @@ export default {
openUrlDesc:
'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.',
importantNotice:
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to <code>http://localhost...</code>, the authorization is complete.',
'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to http://localhost..., the authorization is complete.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc:
'After authorization is complete, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
'After authorization is complete, when the page URL becomes http://localhost:xxx/auth/callback?code=...:',
authCode: 'Authorization URL or Code',
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
......@@ -1200,7 +1253,7 @@ export default {
'Open the authorization URL in a new tab, log in to your Google account and authorize.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc:
'After authorization, copy the callback URL (recommended) or just the <code>code</code> and paste it below.',
'After authorization, copy the callback URL (recommended) or just the code and paste it below.',
authCode: 'Callback URL or Code',
authCodePlaceholder:
'Option 1 (recommended): Paste the callback URL\nOption 2: Paste only the code value',
......@@ -1242,10 +1295,10 @@ export default {
step2OpenUrl: 'Open the URL in your browser and complete authorization',
openUrlDesc: 'Open the authorization URL in a new tab, log in to your Google account and authorize.',
importantNotice:
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows <code>http://localhost...</code>, authorization is complete.',
'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows http://localhost..., authorization is complete.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc:
'After authorization, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
'After authorization, when the page URL becomes http://localhost:xxx/auth/callback?code=...:',
authCode: 'Authorization URL or Code',
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
......@@ -1488,6 +1541,12 @@ export default {
searchProxies: 'Search proxies...',
allProtocols: 'All Protocols',
allStatus: 'All Status',
protocols: {
http: 'HTTP',
https: 'HTTPS',
socks5: 'SOCKS5',
socks5h: 'SOCKS5H (Remote DNS)'
},
columns: {
name: 'Name',
protocol: 'Protocol',
......@@ -1544,6 +1603,9 @@ export default {
failedToUpdate: 'Failed to update proxy',
failedToDelete: 'Failed to delete proxy',
failedToTest: 'Failed to test proxy',
nameRequired: 'Please enter proxy name',
hostRequired: 'Please enter host address',
portInvalid: 'Port must be between 1-65535',
deleteConfirm:
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed."
},
......@@ -1601,11 +1663,25 @@ export default {
failedToDelete: 'Failed to delete code',
failedToDeleteUnused: 'Failed to delete unused codes',
failedToCopy: 'Failed to copy codes',
types: {
balance: 'Balance',
concurrency: 'Concurrency',
subscription: 'Subscription',
// Admin adjustment types (created when admin modifies user balance/concurrency)
admin_balance: 'Balance (Admin)',
admin_concurrency: 'Concurrency (Admin)'
},
selectGroup: 'Select Group',
selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days',
groupRequired: 'Please select a subscription group',
days: ' days'
days: ' days',
status: {
unused: 'Unused',
used: 'Used',
expired: 'Expired',
disabled: 'Disabled'
}
},
// Usage Records
......@@ -1614,6 +1690,7 @@ export default {
description: 'View and manage all user usage records',
userFilter: 'User',
searchUserPlaceholder: 'Search user by email...',
searchApiKeyPlaceholder: 'Search API key by name...',
selectedUser: 'Selected',
user: 'User',
account: 'Account',
......@@ -1657,8 +1734,8 @@ export default {
secretKey: 'Secret Key',
siteKeyHint: 'Get this from your Cloudflare Dashboard',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)'
},
secretKeyHint: 'Server-side verification key (keep this secret)',
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' },
defaults: {
title: 'Default User Settings',
description: 'Default values for new users',
......@@ -1708,6 +1785,8 @@ export default {
password: 'SMTP Password',
passwordPlaceholder: '********',
passwordHint: 'Leave empty to keep existing password',
passwordConfiguredPlaceholder: '********',
passwordConfiguredHint: 'Password configured. Leave empty to keep the current value.',
fromEmail: 'From Email',
fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: 'From Name',
......
......@@ -44,6 +44,7 @@ export default {
description: '配置您的 Sub2API 实例',
database: {
title: '数据库配置',
description: '连接到您的 PostgreSQL 数据库',
host: '主机',
port: '端口',
username: '用户名',
......@@ -60,6 +61,7 @@ export default {
},
redis: {
title: 'Redis 配置',
description: '连接到您的 Redis 服务器',
host: '主机',
port: '端口',
password: '密码(可选)',
......@@ -68,6 +70,7 @@ export default {
},
admin: {
title: '管理员账户',
description: '创建您的管理员账户',
email: '邮箱',
password: '密码',
confirmPassword: '确认密码',
......@@ -77,9 +80,21 @@ export default {
},
ready: {
title: '准备安装',
description: '检查您的配置并完成安装',
database: '数据库',
redis: 'Redis',
adminEmail: '管理员邮箱'
},
status: {
testing: '测试中...',
success: '连接成功',
testConnection: '测试连接',
installing: '安装中...',
completeInstallation: '完成安装',
completed: '安装完成!',
redirecting: '正在跳转到登录页面...',
restarting: '服务正在重启,请稍候...',
timeout: '服务重启时间超出预期,请手动刷新页面。'
}
},
......@@ -130,7 +145,10 @@ export default {
selectOption: '请选择',
searchPlaceholder: '搜索...',
noOptionsFound: '无匹配选项',
noGroupsAvailable: '无可用分组',
unknownError: '发生未知错误',
saving: '保存中...',
selectedCount: '(已选 {count} 个)',
refresh: '刷新',
notAvailable: '不可用',
now: '现在',
......@@ -197,6 +215,7 @@ export default {
registrationFailed: '注册失败,请重试。',
loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。',
turnstileExpired: '验证已过期,请重试',
turnstileFailed: '验证失败,请重试',
completeVerification: '请完成验证',
......@@ -441,7 +460,8 @@ export default {
days: '',
codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。'
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。',
pleaseEnterCode: '请输入兑换码'
},
// Profile
......@@ -665,10 +685,6 @@ export default {
admin: '管理员',
user: '用户'
},
statuses: {
active: '正常',
banned: '禁用'
},
form: {
emailLabel: '邮箱',
emailPlaceholder: '请输入邮箱',
......@@ -702,6 +718,10 @@ export default {
concurrencyAdjustedSuccess: '并发数调整成功',
failedToSave: '保存用户失败',
failedToAdjust: '调整失败',
emailRequired: '请输入邮箱',
concurrencyMin: '并发数不能小于1',
amountRequired: '请输入有效金额',
insufficientBalance: '余额不足',
setAllowedGroups: '设置允许分组',
allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。',
noStandardGroups: '暂无标准分组',
......@@ -728,7 +748,6 @@ export default {
failedToDeposit: '充值失败',
failedToWithdraw: '退款失败',
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
insufficientBalance: '余额不足,退款后余额不能为负数',
// Settings Dropdowns
filterSettings: '筛选设置',
columnSettings: '列设置',
......@@ -784,6 +803,9 @@ export default {
failedToLoad: '加载属性列表失败',
failedToCreate: '创建属性失败',
failedToUpdate: '更新属性失败',
keyRequired: '请输入属性键',
nameRequired: '请输入显示名称',
optionsRequired: '请至少添加一个选项',
failedToDelete: '删除属性失败',
failedToReorder: '更新排序失败',
keyExists: '属性键已存在',
......@@ -795,6 +817,7 @@ export default {
groups: {
title: '分组管理',
description: '管理 API 密钥分组和费率配置',
searchGroups: '搜索分组...',
createGroup: '创建分组',
editGroup: '编辑分组',
deleteGroup: '删除分组',
......@@ -852,8 +875,10 @@ export default {
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
platforms: {
all: '全部平台',
claude: 'Claude',
openai: 'OpenAI'
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
},
saving: '保存中...',
noGroups: '暂无分组',
......@@ -888,6 +913,7 @@ export default {
groupDeleted: '分组删除成功',
failedToCreate: '创建分组失败',
failedToUpdate: '更新分组失败',
nameRequired: '请输入分组名称',
subscription: {
title: '订阅设置',
type: '计费类型',
......@@ -968,6 +994,9 @@ export default {
failedToAssign: '分配订阅失败',
failedToExtend: '延长订阅失败',
failedToRevoke: '撤销订阅失败',
pleaseSelectUser: '请选择用户',
pleaseSelectGroup: '请选择分组',
validityDaysRequired: '请输入有效的天数(至少1天)',
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。"
},
......@@ -1001,6 +1030,9 @@ export default {
refreshCookie: '刷新 Cookie',
testAccount: '测试账号',
searchAccounts: '搜索账号...',
notes: '备注',
notesPlaceholder: '请输入备注',
notesHint: '备注可选',
// Filter options
allPlatforms: '全部平台',
allTypes: '全部类型',
......@@ -1018,6 +1050,7 @@ export default {
platform: '平台',
type: '类型',
concurrencyStatus: '并发',
notes: '备注',
priority: '优先级',
weight: '权重',
status: '状态',
......@@ -1058,16 +1091,17 @@ export default {
api_key: 'API Key',
cookie: 'Cookie'
},
statuses: {
status: {
active: '正常',
inactive: '停用',
error: '错误',
cooldown: '冷却中'
},
status: {
paused: '已暂停',
limited: '受限',
tempUnschedulable: '临时不可调度'
cooldown: '冷却中',
paused: '暂停',
limited: '限流',
tempUnschedulable: '临时不可调度',
rateLimitedUntil: '限流中,重置时间:{time}',
overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情'
},
tempUnschedulable: {
title: '临时不可调度',
......@@ -1284,13 +1318,13 @@ export default {
'每行一个 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> 开头',
step1: '在浏览器中登录 claude.ai',
step2: 'F12 打开开发者工具',
step3: '切换到 Application 标签',
step4: '找到 Cookies → https://claude.ai',
step5: '找到 sessionKey 所在行',
step6: '复制 Value 列的值',
sessionKeyFormat: 'sessionKey 通常以 sk-ant-sid01- 开头',
startAutoAuth: '开始自动授权',
authorizing: '授权中...',
followSteps: '按照以下步骤授权您的 Claude 账号:',
......@@ -1301,9 +1335,9 @@ export default {
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。',
proxyWarning:
'<strong>注意:</strong>如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
'注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
step3EnterCode: '输入授权码',
authCodeDesc: '授权完成后,页面会显示一个 <strong>授权码</strong>。复制并粘贴到下方:',
authCodeDesc: '授权完成后,页面会显示一个授权码。复制并粘贴到下方:',
authCode: '授权码',
authCodePlaceholder: '粘贴 Claude 页面的授权码...',
authCodeHint: '粘贴从 Claude 页面复制的授权码',
......@@ -1323,10 +1357,10 @@ export default {
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。',
importantNotice:
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
'重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。',
step3EnterCode: '输入授权链接或 Code',
authCodeDesc:
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
'授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:',
authCode: '授权链接或 Code',
authCodePlaceholder:
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
......@@ -1345,7 +1379,7 @@ export default {
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
step3EnterCode: '输入回调链接或 Code',
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 <code>code</code>,粘贴到下方即可。',
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。',
authCode: '回调链接或 Code',
authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值',
authCodeHint: '系统会自动从链接中解析 code/state。',
......@@ -1381,10 +1415,10 @@ export default {
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
importantNotice:
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
'重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。',
step3EnterCode: '输入授权链接或 Code',
authCodeDesc:
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
'授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:',
authCode: '授权链接或 Code',
authCodePlaceholder:
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
......@@ -1603,22 +1637,8 @@ export default {
name: '名称',
protocol: '协议',
address: '地址',
priority: '优先级',
status: '状态',
lastCheck: '最近检测',
actions: '操作'
},
protocols: {
http: 'HTTP',
https: 'HTTPS',
socks5: 'SOCKS5'
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误'
},
form: {
actions: '操作',
nameLabel: '名称',
namePlaceholder: '请输入代理名称',
protocolLabel: '协议',
......@@ -1697,6 +1717,9 @@ export default {
failedToCreate: '创建代理失败',
failedToUpdate: '更新代理失败',
failedToTest: '测试代理失败',
nameRequired: '请输入代理名称',
hostRequired: '请输入主机地址',
portInvalid: '端口必须在 1-65535 之间',
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。"
},
......@@ -1718,7 +1741,10 @@ export default {
types: {
balance: '余额',
concurrency: '并发数',
subscription: '订阅'
subscription: '订阅',
// 管理员在用户管理页面调整余额/并发时产生的记录
admin_balance: '余额(管理员)',
admin_concurrency: '并发数(管理员)'
},
// 用于选择器和筛选器的直接键
balance: '余额',
......@@ -1757,7 +1783,7 @@ export default {
validityDays: '有效天数',
groupRequired: '请选择订阅分组',
days: '',
statuses: {
status: {
unused: '未使用',
used: '已使用',
expired: '已过期',
......@@ -1809,6 +1835,7 @@ export default {
description: '查看和管理所有用户的使用记录',
userFilter: '用户',
searchUserPlaceholder: '按邮箱搜索用户...',
searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
selectedUser: '已选择',
user: '用户',
account: '账户',
......@@ -1852,8 +1879,8 @@ export default {
secretKey: '私密密钥',
siteKeyHint: '从 Cloudflare Dashboard 获取',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: '服务端验证密钥(请保密)'
},
secretKeyHint: '服务端验证密钥(请保密)',
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' },
defaults: {
title: '用户默认设置',
description: '新用户的默认值',
......@@ -1902,6 +1929,8 @@ export default {
password: 'SMTP 密码',
passwordPlaceholder: '********',
passwordHint: '留空以保留现有密码',
passwordConfiguredPlaceholder: '********',
passwordConfiguredHint: '密码已配置,留空以保留当前值。',
fromEmail: '发件人邮箱',
fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: '发件人名称',
......
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