Commit 3c341947 authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'main' into test-dev

parents 3a7d3387 c01db6b1
<template> <template>
<Modal :show="show" :title="t('admin.accounts.createAccount')" size="xl" @close="handleClose"> <BaseDialog
:show="show"
:title="t('admin.accounts.createAccount')"
width="wide"
@close="handleClose"
>
<!-- Step Indicator for OAuth accounts --> <!-- Step Indicator for OAuth accounts -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center"> <div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
...@@ -34,7 +39,12 @@ ...@@ -34,7 +39,12 @@
</div> </div>
<!-- Step 1: Basic Info --> <!-- Step 1: Basic Info -->
<form v-if="step === 1" @submit.prevent="handleSubmit" class="space-y-5"> <form
v-if="step === 1"
id="create-account-form"
@submit.prevent="handleSubmit"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.accounts.accountName') }}</label> <label class="input-label">{{ t('admin.accounts.accountName') }}</label>
<input <input
...@@ -520,7 +530,7 @@ ...@@ -520,7 +530,7 @@
: 'https://api.anthropic.com' : 'https://api.anthropic.com'
" "
/> />
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p> <p class="input-hint">{{ baseUrlHint }}</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label> <label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
...@@ -537,13 +547,7 @@ ...@@ -537,13 +547,7 @@
: 'sk-ant-...' : 'sk-ant-...'
" "
/> />
<p class="input-hint"> <p class="input-hint">{{ apiKeyHint }}</p>
{{
form.platform === 'gemini'
? t('admin.accounts.gemini.apiKeyHint')
: t('admin.accounts.apiKeyHint')
}}
</p>
</div> </div>
<!-- Model Restriction Section (不适用于 Gemini) --> <!-- Model Restriction Section (不适用于 Gemini) -->
...@@ -960,14 +964,48 @@ ...@@ -960,14 +964,48 @@
</div> </div>
</div> </div>
<!-- Group Selection --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" /> <GroupSelector
v-if="!authStore.isSimpleMode"
v-model="form.group_ids"
:groups="groups"
:platform="form.platform"
/>
</form>
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
</div>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div v-if="step === 1" class="flex justify-end gap-3">
<button @click="handleClose" type="button" class="btn btn-secondary"> <button @click="handleClose" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="create-account-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
...@@ -997,28 +1035,7 @@ ...@@ -997,28 +1035,7 @@
}} }}
</button> </button>
</div> </div>
</form> <div v-else class="flex justify-between gap-3">
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="goBackToBasicInfo"> <button type="button" class="btn btn-secondary" @click="goBackToBasicInfo">
{{ t('common.back') }} {{ t('common.back') }}
</button> </button>
...@@ -1056,14 +1073,15 @@ ...@@ -1056,14 +1073,15 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { import {
useAccountOAuth, useAccountOAuth,
...@@ -1073,7 +1091,7 @@ import { ...@@ -1073,7 +1091,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
...@@ -1090,6 +1108,7 @@ interface OAuthFlowExposed { ...@@ -1090,6 +1108,7 @@ interface OAuthFlowExposed {
} }
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore()
const oauthStepTitle = computed(() => { const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title') if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
...@@ -1097,6 +1116,19 @@ const oauthStepTitle = computed(() => { ...@@ -1097,6 +1116,19 @@ const oauthStepTitle = computed(() => {
return t('admin.accounts.oauth.title') return t('admin.accounts.oauth.title')
}) })
// Platform-specific hints for API Key type
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
return t('admin.accounts.apiKeyHint')
})
interface Props { interface Props {
show: boolean show: boolean
proxies: Proxy[] proxies: Proxy[]
......
<template> <template>
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose"> <BaseDialog
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5"> :show="show"
:title="t('admin.accounts.editAccount')"
width="wide"
@close="handleClose"
>
<form
v-if="account"
id="edit-account-form"
@submit.prevent="handleSubmit"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('common.name') }}</label> <label class="input-label">{{ t('common.name') }}</label>
<input v-model="form.name" type="text" required class="input" /> <input v-model="form.name" type="text" required class="input" />
...@@ -22,7 +32,7 @@ ...@@ -22,7 +32,7 @@
: 'https://api.anthropic.com' : 'https://api.anthropic.com'
" "
/> />
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p> <p class="input-hint">{{ baseUrlHint }}</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label> <label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
...@@ -456,14 +466,27 @@ ...@@ -456,14 +466,27 @@
<Select v-model="form.status" :options="statusOptions" /> <Select v-model="form.status" :options="statusOptions" />
</div> </div>
<!-- Group Selection --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" /> <GroupSelector
v-if="!authStore.isSimpleMode"
v-model="form.group_ids"
:groups="groups"
:platform="account?.platform"
/>
<div class="flex justify-end gap-3 pt-4"> </form>
<template #footer>
<div v-if="account" class="flex justify-end gap-3">
<button @click="handleClose" type="button" class="btn btn-secondary"> <button @click="handleClose" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="edit-account-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
...@@ -487,17 +510,18 @@ ...@@ -487,17 +510,18 @@
{{ submitting ? t('admin.accounts.updating') : t('common.update') }} {{ submitting ? t('admin.accounts.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
...@@ -517,6 +541,15 @@ const emit = defineEmits<{ ...@@ -517,6 +541,15 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
// Platform-specific hint for Base URL
const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
// Model mapping type // Model mapping type
interface ModelMapping { interface ModelMapping {
......
<template> <template>
<div <div
class="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30" class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/30"
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">
......
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.reAuthorizeAccount')" :title="t('admin.accounts.reAuthorizeAccount')"
size="lg" width="wide"
@close="handleClose" @close="handleClose"
> >
<div v-if="account" class="space-y-5"> <div v-if="account" class="space-y-4">
<!-- Account Info --> <!-- Account Info -->
<div <div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
...@@ -53,8 +53,8 @@ ...@@ -53,8 +53,8 @@
</div> </div>
<!-- Add Method Selection (Claude only) --> <!-- Add Method Selection (Claude only) -->
<div v-if="isAnthropic"> <fieldset v-if="isAnthropic" class="border-0 p-0">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label> <legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
<div class="mt-2 flex gap-4"> <div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
...@@ -79,11 +79,11 @@ ...@@ -79,11 +79,11 @@
}}</span> }}</span>
</label> </label>
</div> </div>
</div> </fieldset>
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Selection -->
<div v-if="isGemini"> <fieldset v-if="isGemini" class="border-0 p-0">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label> <legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
<div class="mt-2 grid grid-cols-2 gap-3"> <div class="mt-2 grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
...@@ -187,7 +187,7 @@ ...@@ -187,7 +187,7 @@
</div> </div>
</button> </button>
</div> </div>
</div> </fieldset>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
...@@ -207,7 +207,10 @@ ...@@ -207,7 +207,10 @@
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
/> />
<div class="flex justify-between gap-3 pt-4"> </div>
<template #footer>
<div v-if="account" class="flex justify-between gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose"> <button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
...@@ -245,8 +248,8 @@ ...@@ -245,8 +248,8 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -262,7 +265,7 @@ import { ...@@ -262,7 +265,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types' import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component
......
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.syncFromCrsTitle')" :title="t('admin.accounts.syncFromCrsTitle')"
size="lg" width="normal"
close-on-click-outside close-on-click-outside
@close="handleClose" @close="handleClose"
> >
<div class="space-y-4"> <form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
<div class="text-sm text-gray-600 dark:text-dark-300"> <div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }} {{ t('admin.accounts.syncFromCrsDesc') }}
</div> </div>
...@@ -84,25 +84,30 @@ ...@@ -84,25 +84,30 @@
</div> </div>
</div> </div>
</div> </div>
</div> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button class="btn btn-secondary" :disabled="syncing" @click="handleClose"> <button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button class="btn btn-primary" :disabled="syncing" @click="handleSync"> <button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="syncing"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }} {{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
......
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-overlay"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<!-- Modal panel -->
<div :class="['modal-content', widthClasses]" @click.stop>
<!-- Header -->
<div class="modal-header">
<h3 id="modal-title" class="modal-title">
{{ title }}
</h3>
<button
@click="emit('close')"
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
interface Props {
show: boolean
title: string
width?: DialogWidth
closeOnEscape?: boolean
closeOnClickOutside?: boolean
}
interface Emits {
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
width: 'normal',
closeOnEscape: true,
closeOnClickOutside: false
})
const emit = defineEmits<Emits>()
const widthClasses = computed(() => {
const widths: Record<DialogWidth, string> = {
narrow: 'max-w-md',
normal: 'max-w-lg',
wide: 'max-w-4xl',
'extra-wide': 'max-w-6xl',
full: 'max-w-7xl'
}
return widths[props.width]
})
const handleClose = () => {
if (props.closeOnClickOutside) {
emit('close')
}
}
const handleEscape = (event: KeyboardEvent) => {
if (props.show && props.closeOnEscape && event.key === 'Escape') {
emit('close')
}
}
// Prevent body scroll when modal is open
watch(
() => props.show,
(isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
},
{ immediate: true }
)
onMounted(() => {
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
</script>
<template> <template>
<Modal :show="show" :title="title" size="sm" @close="handleCancel"> <BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p> <p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
</div> </div>
...@@ -27,13 +27,13 @@ ...@@ -27,13 +27,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from './Modal.vue' import BaseDialog from './BaseDialog.vue'
const { t } = useI18n() const { t } = useI18n()
......
...@@ -24,37 +24,6 @@ ...@@ -24,37 +24,6 @@
> >
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span>{{ column.label }}</span> <span>{{ column.label }}</span>
<!-- 操作列展开/折叠按钮 -->
<button
v-if="column.key === 'actions' && hasExpandableActions"
type="button"
@click.stop="toggleActionsExpanded"
class="ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title="actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
>
<!-- 展开状态:收起图标 -->
<svg
v-if="actionsExpanded"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
</svg>
<!-- 折叠状态:展开图标 -->
<svg
v-else
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
</svg>
</button>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg <svg
v-if="sortKey === column.key" v-if="sortKey === column.key"
...@@ -182,8 +151,8 @@ const checkActionsColumnWidth = () => { ...@@ -182,8 +151,8 @@ const checkActionsColumnWidth = () => {
// 等待DOM更新 // 等待DOM更新
nextTick(() => { nextTick(() => {
// 测量所有按钮的总宽度 // 测量所有按钮的总宽度
const buttons = actionsContainer.querySelectorAll('button') const actionItems = actionsContainer.querySelectorAll('button, a, [role="button"]')
if (buttons.length <= 2) { if (actionItems.length <= 2) {
actionsColumnNeedsExpanding.value = false actionsColumnNeedsExpanding.value = false
actionsExpanded.value = wasExpanded actionsExpanded.value = wasExpanded
return return
...@@ -191,9 +160,9 @@ const checkActionsColumnWidth = () => { ...@@ -191,9 +160,9 @@ const checkActionsColumnWidth = () => {
// 计算所有按钮的总宽度(包括gap) // 计算所有按钮的总宽度(包括gap)
let totalWidth = 0 let totalWidth = 0
buttons.forEach((btn, index) => { actionItems.forEach((item, index) => {
totalWidth += (btn as HTMLElement).offsetWidth totalWidth += (item as HTMLElement).offsetWidth
if (index < buttons.length - 1) { if (index < actionItems.length - 1) {
totalWidth += 4 // gap-1 = 4px totalWidth += 4 // gap-1 = 4px
} }
}) })
...@@ -211,6 +180,7 @@ const checkActionsColumnWidth = () => { ...@@ -211,6 +180,7 @@ const checkActionsColumnWidth = () => {
// 监听尺寸变化 // 监听尺寸变化
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let resizeHandler: (() => void) | null = null
onMounted(() => { onMounted(() => {
checkScrollable() checkScrollable()
...@@ -223,17 +193,20 @@ onMounted(() => { ...@@ -223,17 +193,20 @@ onMounted(() => {
resizeObserver.observe(tableWrapperRef.value) resizeObserver.observe(tableWrapperRef.value)
} else { } else {
// 降级方案:不支持 ResizeObserver 时使用 window resize // 降级方案:不支持 ResizeObserver 时使用 window resize
const handleResize = () => { resizeHandler = () => {
checkScrollable() checkScrollable()
checkActionsColumnWidth() checkActionsColumnWidth()
} }
window.addEventListener('resize', handleResize) window.addEventListener('resize', resizeHandler)
} }
}) })
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
window.removeEventListener('resize', checkScrollable) if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
}) })
interface Props { interface Props {
...@@ -298,26 +271,6 @@ const sortedData = computed(() => { ...@@ -298,26 +271,6 @@ const sortedData = computed(() => {
}) })
}) })
// 检查是否有可展开的操作列
const hasExpandableActions = computed(() => {
// 如果明确指定了actionsCount,使用它来判断
if (props.actionsCount !== undefined) {
return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2
}
// 否则使用原来的检测逻辑
return (
props.expandableActions &&
props.columns.some((col) => col.key === 'actions') &&
actionsColumnNeedsExpanding.value
)
})
// 切换操作列展开/折叠状态
const toggleActionsExpanded = () => {
actionsExpanded.value = !actionsExpanded.value
}
// 检查第一列是否为勾选列 // 检查第一列是否为勾选列
const hasSelectColumn = computed(() => { const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select' return props.columns.length > 0 && props.columns[0].key === 'select'
......
...@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => { ...@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') return if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize) emit('update:pageSize', newPageSize)
// Reset to first page when page size changes
if (props.page !== 1) {
emit('update:page', 1)
}
} }
</script> </script>
......
...@@ -30,7 +30,11 @@ ...@@ -30,7 +30,11 @@
</button> </button>
<Transition name="select-dropdown"> <Transition name="select-dropdown">
<div v-if="isOpen" class="select-dropdown"> <div
v-if="isOpen"
ref="dropdownRef"
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
>
<!-- Search input --> <!-- Search input -->
<div v-if="searchable" class="select-search"> <div v-if="searchable" class="select-search">
<svg <svg
...@@ -141,6 +145,8 @@ const isOpen = ref(false) ...@@ -141,6 +145,8 @@ const isOpen = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const getOptionValue = ( const getOptionValue = (
option: SelectOption | Record<string, unknown> option: SelectOption | Record<string, unknown>
...@@ -184,13 +190,37 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean => ...@@ -184,13 +190,37 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
return getOptionValue(option) === props.modelValue return getOptionValue(option) === props.modelValue
} }
const calculateDropdownPosition = () => {
if (!containerRef.value) return
nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return
const triggerRect = containerRef.value.getBoundingClientRect()
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
// If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
dropdownPosition.value = 'top'
} else {
dropdownPosition.value = 'bottom'
}
})
}
const toggle = () => { const toggle = () => {
if (props.disabled) return if (props.disabled) return
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
if (isOpen.value && props.searchable) { if (isOpen.value) {
nextTick(() => { calculateDropdownPosition()
searchInputRef.value?.focus() if (props.searchable) {
}) nextTick(() => {
searchInputRef.value?.focus()
})
}
} }
} }
...@@ -267,7 +297,7 @@ onUnmounted(() => { ...@@ -267,7 +297,7 @@ onUnmounted(() => {
} }
.select-dropdown { .select-dropdown {
@apply absolute z-[100] mt-2 w-full; @apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px];
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
...@@ -275,6 +305,10 @@ onUnmounted(() => { ...@@ -275,6 +305,10 @@ onUnmounted(() => {
@apply overflow-hidden; @apply overflow-hidden;
} }
.select-dropdown-top {
@apply bottom-full mb-2 mt-0;
}
.select-search { .select-search {
@apply flex items-center gap-2 px-3 py-2; @apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700; @apply border-b border-gray-100 dark:border-dark-700;
...@@ -305,7 +339,7 @@ onUnmounted(() => { ...@@ -305,7 +339,7 @@ onUnmounted(() => {
} }
.select-option-label { .select-option-label {
@apply truncate; @apply flex-1 min-w-0 truncate text-left;
} }
.select-empty { .select-empty {
...@@ -322,6 +356,17 @@ onUnmounted(() => { ...@@ -322,6 +356,17 @@ onUnmounted(() => {
.select-dropdown-enter-from, .select-dropdown-enter-from,
.select-dropdown-leave-to { .select-dropdown-leave-to {
opacity: 0; opacity: 0;
}
/* Animation for dropdown opening downward (default) */
.select-dropdown:not(.select-dropdown-top).select-dropdown-enter-from,
.select-dropdown:not(.select-dropdown-top).select-dropdown-leave-to {
transform: translateY(-8px); transform: translateY(-8px);
} }
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from,
.select-dropdown-top.select-dropdown-leave-to {
transform: translateY(8px);
}
</style> </style>
...@@ -178,17 +178,19 @@ ...@@ -178,17 +178,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import subscriptionsAPI from '@/api/subscriptions' import { useSubscriptionStore } from '@/stores'
import type { UserSubscription } from '@/types' import type { UserSubscription } from '@/types'
const { t } = useI18n() const { t } = useI18n()
const subscriptionStore = useSubscriptionStore()
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const tooltipOpen = ref(false) const tooltipOpen = ref(false)
const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false)
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0) // Use store data instead of local state
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
const hasActiveSubscriptions = computed(() => subscriptionStore.hasActiveSubscriptions)
const displaySubscriptions = computed(() => { const displaySubscriptions = computed(() => {
// Sort by most usage (highest percentage first) // Sort by most usage (highest percentage first)
...@@ -275,37 +277,18 @@ function handleClickOutside(event: MouseEvent) { ...@@ -275,37 +277,18 @@ function handleClickOutside(event: MouseEvent) {
} }
} }
async function loadSubscriptions() {
try {
loading.value = true
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions()
} catch (error) {
console.error('Failed to load subscriptions:', error)
activeSubscriptions.value = []
} finally {
loading.value = false
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
loadSubscriptions() // Trigger initial fetch if not already loaded
// The actual data loading is handled by App.vue globally
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to load subscriptions in SubscriptionProgressMini:', error)
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
}) })
// Refresh subscriptions periodically (every 5 minutes)
let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000)
})
onBeforeUnmount(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script> </script>
<style scoped> <style scoped>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
export { default as DataTable } from './DataTable.vue' export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.vue' export { default as Pagination } from './Pagination.vue'
export { default as Modal } from './Modal.vue' export { default as Modal } from './Modal.vue'
export { default as BaseDialog } from './BaseDialog.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue' export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue' export { default as StatCard } from './StatCard.vue'
export { default as Toast } from './Toast.vue' export { default as Toast } from './Toast.vue'
......
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('keys.useKeyModal.title')" :title="t('keys.useKeyModal.title')"
size="lg" width="wide"
@close="emit('close')" @close="emit('close')"
> >
<div class="space-y-4"> <div class="space-y-4">
...@@ -112,13 +112,13 @@ ...@@ -112,13 +112,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, h, watch, type Component } from 'vue' import { ref, computed, h, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import type { GroupPlatform } from '@/types' import type { GroupPlatform } from '@/types'
......
...@@ -45,8 +45,8 @@ ...@@ -45,8 +45,8 @@
</router-link> </router-link>
</div> </div>
<!-- Personal Section for Admin --> <!-- Personal Section for Admin (hidden in simple mode) -->
<div class="sidebar-section"> <div v-if="!authStore.isSimpleMode" class="sidebar-section">
<div v-if="!sidebarCollapsed" class="sidebar-section-title"> <div v-if="!sidebarCollapsed" class="sidebar-section-title">
{{ t('nav.myAccount') }} {{ t('nav.myAccount') }}
</div> </div>
...@@ -402,36 +402,54 @@ const ChevronDoubleRightIcon = { ...@@ -402,36 +402,54 @@ const ChevronDoubleRightIcon = {
} }
// User navigation items (for regular users) // User navigation items (for regular users)
const userNavItems = computed(() => [ const userNavItems = computed(() => {
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, const items = [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
]) { path: '/profile', label: t('nav.profile'), icon: UserIcon }
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Personal navigation items (for admin's "My Account" section, without Dashboard) // Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => [ const personalNavItems = computed(() => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, const items = [
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
]) { path: '/profile', label: t('nav.profile'), icon: UserIcon }
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Admin navigation items // Admin navigation items
const adminNavItems = computed(() => [ const adminNavItems = computed(() => {
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, const baseItems = [
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon }, { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon }, { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon } { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
]) ]
// 简单模式下,在系统设置前插入 API密钥
if (authStore.isSimpleMode) {
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
return filtered
}
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
return baseItems
})
function toggleSidebar() { function toggleSidebar() {
appStore.toggleSidebar() appStore.toggleSidebar()
......
...@@ -119,6 +119,7 @@ export default { ...@@ -119,6 +119,7 @@ export default {
info: 'Info', info: 'Info',
active: 'Active', active: 'Active',
inactive: 'Inactive', inactive: 'Inactive',
more: 'More',
close: 'Close', close: 'Close',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled', disabled: 'Disabled',
...@@ -344,6 +345,8 @@ export default { ...@@ -344,6 +345,8 @@ export default {
allApiKeys: 'All API Keys', allApiKeys: 'All API Keys',
timeRange: 'Time Range', timeRange: 'Time Range',
exportCsv: 'Export CSV', exportCsv: 'Export CSV',
exporting: 'Exporting...',
preparingExport: 'Preparing export...',
model: 'Model', model: 'Model',
type: 'Type', type: 'Type',
tokens: 'Tokens', tokens: 'Tokens',
...@@ -364,6 +367,7 @@ export default { ...@@ -364,6 +367,7 @@ export default {
failedToLoad: 'Failed to load usage logs', failedToLoad: 'Failed to load usage logs',
noDataToExport: 'No data to export', noDataToExport: 'No data to export',
exportSuccess: 'Usage data exported successfully', exportSuccess: 'Usage data exported successfully',
exportFailed: 'Failed to export usage data',
billingType: 'Billing', billingType: 'Billing',
balance: 'Balance', balance: 'Balance',
subscription: 'Subscription' subscription: 'Subscription'
...@@ -406,7 +410,8 @@ export default { ...@@ -406,7 +410,8 @@ export default {
subscriptionDays: '{days} days', subscriptionDays: '{days} days',
days: ' days', days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!', codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.' failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.'
}, },
// Profile // Profile
...@@ -427,6 +432,7 @@ export default { ...@@ -427,6 +432,7 @@ export default {
updating: 'Updating...', updating: 'Updating...',
updateSuccess: 'Profile updated successfully', updateSuccess: 'Profile updated successfully',
updateFailed: 'Failed to update profile', updateFailed: 'Failed to update profile',
usernameRequired: 'Username is required',
changePassword: 'Change Password', changePassword: 'Change Password',
currentPassword: 'Current Password', currentPassword: 'Current Password',
newPassword: 'New Password', newPassword: 'New Password',
...@@ -670,14 +676,21 @@ export default { ...@@ -670,14 +676,21 @@ export default {
description: 'Description', description: 'Description',
platform: 'Platform', platform: 'Platform',
rateMultiplier: 'Rate Multiplier', rateMultiplier: 'Rate Multiplier',
status: 'Status' status: 'Status',
exclusive: 'Exclusive Group'
}, },
enterGroupName: 'Enter group name', enterGroupName: 'Enter group name',
optionalDescription: 'Optional description', optionalDescription: 'Optional description',
platformHint: 'Select the platform this group is associated with', platformHint: 'Select the platform this group is associated with',
platformNotEditable: 'Platform cannot be changed after creation', platformNotEditable: 'Platform cannot be changed after creation',
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)', rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
exclusiveHint: 'Exclusive (requires explicit user access)', exclusiveHint: 'Exclusive group, manually assign to specific users',
exclusiveTooltip: {
title: 'What is an exclusive group?',
description: 'When enabled, users cannot see this group when creating API Keys. Only after an admin manually assigns a user to this group can they use it.',
example: 'Use case:',
exampleContent: 'Public group rate is 0.8. Create an exclusive group with 0.7 rate, manually assign VIP users to give them better pricing.'
},
noGroupsYet: 'No groups yet', noGroupsYet: 'No groups yet',
createFirstGroup: 'Create your first group to organize API keys.', createFirstGroup: 'Create your first group to organize API keys.',
creating: 'Creating...', creating: 'Creating...',
...@@ -902,6 +915,11 @@ export default { ...@@ -902,6 +915,11 @@ export default {
apiKeyRequired: 'API Key *', apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Your Claude Console API Key', apiKeyHint: 'Your Claude Console API Key',
// OpenAI specific hints
openai: {
baseUrlHint: 'Leave default for official OpenAI API',
apiKeyHint: 'Your OpenAI API Key'
},
modelRestriction: 'Model Restriction (Optional)', modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist', modelWhitelist: 'Model Whitelist',
modelMapping: 'Model Mapping', modelMapping: 'Model Mapping',
...@@ -1063,6 +1081,7 @@ export default { ...@@ -1063,6 +1081,7 @@ export default {
modelPassthrough: 'Gemini Model Passthrough', modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc: modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)' apiKeyHint: 'Your Gemini API Key (starts with AIza)'
}, },
// Re-Auth Modal // Re-Auth Modal
...@@ -1172,9 +1191,9 @@ export default { ...@@ -1172,9 +1191,9 @@ export default {
batchAdd: 'Quick Add', batchAdd: 'Quick Add',
batchInput: 'Proxy List', batchInput: 'Proxy List',
batchInputPlaceholder: 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", "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: batchInputHint:
"Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port", "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
parsedCount: '{count} valid', parsedCount: '{count} valid',
invalidCount: '{count} invalid', invalidCount: '{count} invalid',
duplicateCount: '{count} duplicate', duplicateCount: '{count} duplicate',
......
...@@ -116,6 +116,7 @@ export default { ...@@ -116,6 +116,7 @@ export default {
info: '提示', info: '提示',
active: '启用', active: '启用',
inactive: '禁用', inactive: '禁用',
more: '更多',
close: '关闭', close: '关闭',
enabled: '已启用', enabled: '已启用',
disabled: '已禁用', disabled: '已禁用',
...@@ -340,6 +341,8 @@ export default { ...@@ -340,6 +341,8 @@ export default {
allApiKeys: '全部密钥', allApiKeys: '全部密钥',
timeRange: '时间范围', timeRange: '时间范围',
exportCsv: '导出 CSV', exportCsv: '导出 CSV',
exporting: '导出中...',
preparingExport: '正在准备导出...',
model: '模型', model: '模型',
type: '类型', type: '类型',
tokens: 'Token', tokens: 'Token',
...@@ -360,6 +363,7 @@ export default { ...@@ -360,6 +363,7 @@ export default {
failedToLoad: '加载使用记录失败', failedToLoad: '加载使用记录失败',
noDataToExport: '没有可导出的数据', noDataToExport: '没有可导出的数据',
exportSuccess: '使用数据导出成功', exportSuccess: '使用数据导出成功',
exportFailed: '使用数据导出失败',
billingType: '消费类型', billingType: '消费类型',
balance: '余额', balance: '余额',
subscription: '订阅' subscription: '订阅'
...@@ -402,7 +406,8 @@ export default { ...@@ -402,7 +406,8 @@ export default {
subscriptionDays: '{days} 天', subscriptionDays: '{days} 天',
days: '', days: '',
codeRedeemSuccess: '兑换成功!', codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。' failedToRedeem: '兑换失败,请检查兑换码后重试。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。'
}, },
// Profile // Profile
...@@ -423,6 +428,7 @@ export default { ...@@ -423,6 +428,7 @@ export default {
updating: '更新中...', updating: '更新中...',
updateSuccess: '资料更新成功', updateSuccess: '资料更新成功',
updateFailed: '资料更新失败', updateFailed: '资料更新失败',
usernameRequired: '用户名不能为空',
changePassword: '修改密码', changePassword: '修改密码',
currentPassword: '当前密码', currentPassword: '当前密码',
newPassword: '新密码', newPassword: '新密码',
...@@ -727,14 +733,15 @@ export default { ...@@ -727,14 +733,15 @@ export default {
platform: '平台', platform: '平台',
rateMultiplier: '费率倍数', rateMultiplier: '费率倍数',
status: '状态', status: '状态',
exclusive: '专属分组',
nameLabel: '分组名称', nameLabel: '分组名称',
namePlaceholder: '请输入分组名称', namePlaceholder: '请输入分组名称',
descriptionLabel: '描述', descriptionLabel: '描述',
descriptionPlaceholder: '请输入描述(可选)', descriptionPlaceholder: '请输入描述(可选)',
rateMultiplierLabel: '费率倍数', rateMultiplierLabel: '费率倍数',
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
exclusiveLabel: '独占模式', exclusiveLabel: '专属分组',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号', exclusiveHint: '专属分组,可以手动指定给用户',
platformLabel: '平台限制', platformLabel: '平台限制',
platformPlaceholder: '选择平台(留空则不限制)', platformPlaceholder: '选择平台(留空则不限制)',
accountsLabel: '指定账号', accountsLabel: '指定账号',
...@@ -747,8 +754,14 @@ export default { ...@@ -747,8 +754,14 @@ export default {
yes: '', yes: '',
no: '' no: ''
}, },
exclusive: '独占', exclusive: '专属',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号', exclusiveHint: '专属分组,可以手动指定给特定用户',
exclusiveTooltip: {
title: '什么是专属分组?',
description: '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
example: '使用场景:',
exampleContent: '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
},
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
platforms: { platforms: {
all: '全部平台', all: '全部平台',
...@@ -767,8 +780,8 @@ export default { ...@@ -767,8 +780,8 @@ export default {
allPlatforms: '全部平台', allPlatforms: '全部平台',
allStatus: '全部状态', allStatus: '全部状态',
allGroups: '全部分组', allGroups: '全部分组',
exclusiveFilter: '独占', exclusiveFilter: '专属',
nonExclusive: '非独占', nonExclusive: '公开',
public: '公开', public: '公开',
rateAndAccounts: '{rate}x 费率 · {count} 个账号', rateAndAccounts: '{rate}x 费率 · {count} 个账号',
accountsCount: '{count} 个账号', accountsCount: '{count} 个账号',
...@@ -1041,6 +1054,11 @@ export default { ...@@ -1041,6 +1054,11 @@ export default {
apiKeyRequired: 'API Key *', apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: '您的 Claude Console API Key', apiKeyHint: '您的 Claude Console API Key',
// OpenAI specific hints
openai: {
baseUrlHint: '留空使用官方 OpenAI API',
apiKeyHint: '您的 OpenAI API Key'
},
modelRestriction: '模型限制(可选)', modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单', modelWhitelist: '模型白名单',
modelMapping: '模型映射', modelMapping: '模型映射',
...@@ -1184,7 +1202,8 @@ export default { ...@@ -1184,7 +1202,8 @@ export default {
gemini: { gemini: {
modelPassthrough: 'Gemini 直接转发模型', modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
apiKeyHint: 'Your Gemini API Key(以 AIza 开头)' baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)'
}, },
// Re-Auth Modal // Re-Auth Modal
reAuthorizeAccount: '重新授权账号', reAuthorizeAccount: '重新授权账号',
...@@ -1321,8 +1340,8 @@ export default { ...@@ -1321,8 +1340,8 @@ export default {
batchAdd: '快捷添加', batchAdd: '快捷添加',
batchInput: '代理列表', batchInput: '代理列表',
batchInputPlaceholder: batchInputPlaceholder:
"每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443", "每行输入一个代理,支持以下格式:\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 协议,格式:协议://[用户名:密码@]主机:端口", batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
parsedCount: '有效 {count} 个', parsedCount: '有效 {count} 个',
invalidCount: '无效 {count} 个', invalidCount: '无效 {count} 个',
duplicateCount: '重复 {count} 个', duplicateCount: '重复 {count} 个',
......
...@@ -341,6 +341,23 @@ router.beforeEach((to, _from, next) => { ...@@ -341,6 +341,23 @@ router.beforeEach((to, _from, next) => {
return return
} }
// 简易模式下限制访问某些页面
if (authStore.isSimpleMode) {
const restrictedPaths = [
'/admin/groups',
'/admin/subscriptions',
'/admin/redeem',
'/subscriptions',
'/redeem'
]
if (restrictedPaths.some((path) => to.path.startsWith(path))) {
// 简易模式下访问受限页面,重定向到仪表板
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
}
// All checks passed, allow navigation // All checks passed, allow navigation
next() next()
}) })
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed, readonly } from 'vue'
import { authAPI } from '@/api' import { authAPI } from '@/api'
import type { User, LoginRequest, RegisterRequest } from '@/types' import type { User, LoginRequest, RegisterRequest } from '@/types'
...@@ -17,6 +17,7 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -17,6 +17,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
const token = ref<string | null>(null) const token = ref<string | null>(null)
const runMode = ref<'standard' | 'simple'>('standard')
let refreshIntervalId: ReturnType<typeof setInterval> | null = null let refreshIntervalId: ReturnType<typeof setInterval> | null = null
// ==================== Computed ==================== // ==================== Computed ====================
...@@ -29,6 +30,8 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -29,6 +30,8 @@ export const useAuthStore = defineStore('auth', () => {
return user.value?.role === 'admin' return user.value?.role === 'admin'
}) })
const isSimpleMode = computed(() => runMode.value === 'simple')
// ==================== Actions ==================== // ==================== Actions ====================
/** /**
...@@ -98,16 +101,22 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -98,16 +101,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user // Store token and user
token.value = response.access_token token.value = response.access_token
user.value = response.user
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
}
const { run_mode, ...userData } = response.user
user.value = userData
// Persist to localStorage // Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
// Start auto-refresh interval // Start auto-refresh interval
startAutoRefresh() startAutoRefresh()
return response.user return userData
} catch (error) { } catch (error) {
// Clear any partial state on error // Clear any partial state on error
clearAuth() clearAuth()
...@@ -127,16 +136,22 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -127,16 +136,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user // Store token and user
token.value = response.access_token token.value = response.access_token
user.value = response.user
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
}
const { run_mode, ...userDataWithoutRunMode } = response.user
user.value = userDataWithoutRunMode
// Persist to localStorage // Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userDataWithoutRunMode))
// Start auto-refresh interval // Start auto-refresh interval
startAutoRefresh() startAutoRefresh()
return response.user return userDataWithoutRunMode
} catch (error) { } catch (error) {
// Clear any partial state on error // Clear any partial state on error
clearAuth() clearAuth()
...@@ -168,13 +183,17 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -168,13 +183,17 @@ export const useAuthStore = defineStore('auth', () => {
} }
try { try {
const updatedUser = await authAPI.getCurrentUser() const response = await authAPI.getCurrentUser()
user.value = updatedUser if (response.data.run_mode) {
runMode.value = response.data.run_mode
}
const { run_mode, ...userData } = response.data
user.value = userData
// Update localStorage // Update localStorage
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
return updatedUser return userData
} catch (error) { } catch (error) {
// If refresh fails with 401, clear auth state // If refresh fails with 401, clear auth state
if ((error as { status?: number }).status === 401) { if ((error as { status?: number }).status === 401) {
...@@ -204,10 +223,12 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -204,10 +223,12 @@ export const useAuthStore = defineStore('auth', () => {
// State // State
user, user,
token, token,
runMode: readonly(runMode),
// Computed // Computed
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isSimpleMode,
// Actions // Actions
login, login,
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
export { useAuthStore } from './auth' export { useAuthStore } from './auth'
export { useAppStore } from './app' export { useAppStore } from './app'
export { useSubscriptionStore } from './subscriptions'
// Re-export types for convenience // Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
......
/**
* Subscription Store
* Global state management for user subscriptions with caching and deduplication
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types'
// Cache TTL: 60 seconds
const CACHE_TTL_MS = 60_000
// Request generation counter to invalidate stale in-flight responses
let requestGeneration = 0
export const useSubscriptionStore = defineStore('subscriptions', () => {
// State
const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false)
const loaded = ref(false)
const lastFetchedAt = ref<number | null>(null)
// In-flight request deduplication
let activePromise: Promise<UserSubscription[]> | null = null
// Auto-refresh interval
let pollerInterval: ReturnType<typeof setInterval> | null = null
// Computed
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0)
/**
* Fetch active subscriptions with caching and deduplication
* @param force - Force refresh even if cache is valid
*/
async function fetchActiveSubscriptions(force = false): Promise<UserSubscription[]> {
const now = Date.now()
// Return cached data if valid
if (
!force &&
loaded.value &&
lastFetchedAt.value &&
now - lastFetchedAt.value < CACHE_TTL_MS
) {
return activeSubscriptions.value
}
// Return in-flight request if exists (deduplication)
if (activePromise && !force) {
return activePromise
}
const currentGeneration = ++requestGeneration
// Start new request
loading.value = true
const requestPromise = subscriptionsAPI
.getActiveSubscriptions()
.then((data) => {
if (currentGeneration === requestGeneration) {
activeSubscriptions.value = data
loaded.value = true
lastFetchedAt.value = Date.now()
}
return data
})
.catch((error) => {
console.error('Failed to fetch active subscriptions:', error)
throw error
})
.finally(() => {
if (activePromise === requestPromise) {
loading.value = false
activePromise = null
}
})
activePromise = requestPromise
return activePromise
}
/**
* Start auto-refresh polling
*/
function startPolling() {
if (pollerInterval) return
pollerInterval = setInterval(() => {
fetchActiveSubscriptions(true).catch((error) => {
console.error('Subscription polling failed:', error)
})
}, 5 * 60 * 1000)
}
/**
* Stop auto-refresh polling
*/
function stopPolling() {
if (pollerInterval) {
clearInterval(pollerInterval)
pollerInterval = null
}
}
/**
* Clear all subscription data and stop polling
*/
function clear() {
requestGeneration++
activePromise = null
activeSubscriptions.value = []
loaded.value = false
lastFetchedAt.value = null
stopPolling()
}
/**
* Invalidate cache (force next fetch to reload)
*/
function invalidateCache() {
lastFetchedAt.value = null
}
return {
// State
activeSubscriptions,
loading,
hasActiveSubscriptions,
// Actions
fetchActiveSubscriptions,
startPolling,
stopPolling,
clear,
invalidateCache
}
})
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