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

Merge branch 'main' into test-dev

parents 3a7d3387 c01db6b1
<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 -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4">
......@@ -34,7 +39,12 @@
</div>
<!-- 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>
<label class="input-label">{{ t('admin.accounts.accountName') }}</label>
<input
......@@ -520,7 +530,7 @@
: 'https://api.anthropic.com'
"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
<p class="input-hint">{{ baseUrlHint }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
......@@ -537,13 +547,7 @@
: 'sk-ant-...'
"
/>
<p class="input-hint">
{{
form.platform === 'gemini'
? t('admin.accounts.gemini.apiKeyHint')
: t('admin.accounts.apiKeyHint')
}}
</p>
<p class="input-hint">{{ apiKeyHint }}</p>
</div>
<!-- Model Restriction Section (不适用于 Gemini) -->
......@@ -960,14 +964,48 @@
</div>
</div>
<!-- Group Selection -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
<!-- Group Selection - 仅标准模式显示 -->
<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">
{{ t('common.cancel') }}
</button>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<button
type="submit"
form="create-account-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -997,28 +1035,7 @@
}}
</button>
</div>
</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 class="flex justify-between gap-3 pt-4">
<div v-else class="flex justify-between gap-3">
<button type="button" class="btn btn-secondary" @click="goBackToBasicInfo">
{{ t('common.back') }}
</button>
......@@ -1056,14 +1073,15 @@
}}
</button>
</div>
</div>
</Modal>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import {
useAccountOAuth,
......@@ -1073,7 +1091,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
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 GroupSelector from '@/components/common/GroupSelector.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
......@@ -1090,6 +1108,7 @@ interface OAuthFlowExposed {
}
const { t } = useI18n()
const authStore = useAuthStore()
const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
......@@ -1097,6 +1116,19 @@ const oauthStepTitle = computed(() => {
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 {
show: boolean
proxies: Proxy[]
......
<template>
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose">
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
<BaseDialog
: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>
<label class="input-label">{{ t('common.name') }}</label>
<input v-model="form.name" type="text" required class="input" />
......@@ -22,7 +32,7 @@
: 'https://api.anthropic.com'
"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
<p class="input-hint">{{ baseUrlHint }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
......@@ -456,14 +466,27 @@
<Select v-model="form.status" :options="statusOptions" />
</div>
<!-- Group Selection -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
<!-- Group Selection - 仅标准模式显示 -->
<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">
{{ t('common.cancel') }}
</button>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<button
type="submit"
form="edit-account-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -487,17 +510,18 @@
{{ submitting ? t('admin.accounts.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
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 ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
......@@ -517,6 +541,15 @@ const emit = defineEmits<{
const { t } = useI18n()
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
interface ModelMapping {
......
<template>
<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 h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">
......
<template>
<Modal
<BaseDialog
:show="show"
:title="t('admin.accounts.reAuthorizeAccount')"
size="lg"
width="wide"
@close="handleClose"
>
<div v-if="account" class="space-y-5">
<div v-if="account" class="space-y-4">
<!-- Account Info -->
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
......@@ -53,8 +53,8 @@
</div>
<!-- Add Method Selection (Claude only) -->
<div v-if="isAnthropic">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label>
<fieldset v-if="isAnthropic" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
......@@ -79,11 +79,11 @@
}}</span>
</label>
</div>
</div>
</fieldset>
<!-- Gemini OAuth Type Selection -->
<div v-if="isGemini">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
<fieldset v-if="isGemini" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
<div class="mt-2 grid grid-cols-2 gap-3">
<button
type="button"
......@@ -187,7 +187,7 @@
</div>
</button>
</div>
</div>
</fieldset>
<OAuthAuthorizationFlow
ref="oauthFlowRef"
......@@ -207,7 +207,10 @@
@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">
{{ t('common.cancel') }}
</button>
......@@ -245,8 +248,8 @@
}}
</button>
</div>
</div>
</Modal>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
......@@ -262,7 +265,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component
......
<template>
<Modal
<BaseDialog
:show="show"
:title="t('admin.accounts.syncFromCrsTitle')"
size="lg"
width="normal"
close-on-click-outside
@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">
{{ t('admin.accounts.syncFromCrsDesc') }}
</div>
......@@ -84,25 +84,30 @@
</div>
</div>
</div>
</div>
</form>
<template #footer>
<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') }}
</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') }}
</button>
</div>
</template>
</Modal>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
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 { 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>
<Modal :show="show" :title="title" size="sm" @close="handleCancel">
<BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
</div>
......@@ -27,13 +27,13 @@
</button>
</div>
</template>
</Modal>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from './Modal.vue'
import BaseDialog from './BaseDialog.vue'
const { t } = useI18n()
......
......@@ -24,37 +24,6 @@
>
<div class="flex items-center space-x-1">
<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">
<svg
v-if="sortKey === column.key"
......@@ -182,8 +151,8 @@ const checkActionsColumnWidth = () => {
// 等待DOM更新
nextTick(() => {
// 测量所有按钮的总宽度
const buttons = actionsContainer.querySelectorAll('button')
if (buttons.length <= 2) {
const actionItems = actionsContainer.querySelectorAll('button, a, [role="button"]')
if (actionItems.length <= 2) {
actionsColumnNeedsExpanding.value = false
actionsExpanded.value = wasExpanded
return
......@@ -191,9 +160,9 @@ const checkActionsColumnWidth = () => {
// 计算所有按钮的总宽度(包括gap)
let totalWidth = 0
buttons.forEach((btn, index) => {
totalWidth += (btn as HTMLElement).offsetWidth
if (index < buttons.length - 1) {
actionItems.forEach((item, index) => {
totalWidth += (item as HTMLElement).offsetWidth
if (index < actionItems.length - 1) {
totalWidth += 4 // gap-1 = 4px
}
})
......@@ -211,6 +180,7 @@ const checkActionsColumnWidth = () => {
// 监听尺寸变化
let resizeObserver: ResizeObserver | null = null
let resizeHandler: (() => void) | null = null
onMounted(() => {
checkScrollable()
......@@ -223,17 +193,20 @@ onMounted(() => {
resizeObserver.observe(tableWrapperRef.value)
} else {
// 降级方案:不支持 ResizeObserver 时使用 window resize
const handleResize = () => {
resizeHandler = () => {
checkScrollable()
checkActionsColumnWidth()
}
window.addEventListener('resize', handleResize)
window.addEventListener('resize', resizeHandler)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', checkScrollable)
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
})
interface Props {
......@@ -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(() => {
return props.columns.length > 0 && props.columns[0].key === 'select'
......
......@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize)
// Reset to first page when page size changes
if (props.page !== 1) {
emit('update:page', 1)
}
}
</script>
......
......@@ -30,7 +30,11 @@
</button>
<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 -->
<div v-if="searchable" class="select-search">
<svg
......@@ -141,6 +145,8 @@ const isOpen = ref(false)
const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const getOptionValue = (
option: SelectOption | Record<string, unknown>
......@@ -184,13 +190,37 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
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 = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
if (isOpen.value && props.searchable) {
nextTick(() => {
searchInputRef.value?.focus()
})
if (isOpen.value) {
calculateDropdownPosition()
if (props.searchable) {
nextTick(() => {
searchInputRef.value?.focus()
})
}
}
}
......@@ -267,7 +297,7 @@ onUnmounted(() => {
}
.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 rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
......@@ -275,6 +305,10 @@ onUnmounted(() => {
@apply overflow-hidden;
}
.select-dropdown-top {
@apply bottom-full mb-2 mt-0;
}
.select-search {
@apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700;
......@@ -305,7 +339,7 @@ onUnmounted(() => {
}
.select-option-label {
@apply truncate;
@apply flex-1 min-w-0 truncate text-left;
}
.select-empty {
......@@ -322,6 +356,17 @@ onUnmounted(() => {
.select-dropdown-enter-from,
.select-dropdown-leave-to {
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);
}
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from,
.select-dropdown-top.select-dropdown-leave-to {
transform: translateY(8px);
}
</style>
......@@ -178,17 +178,19 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import subscriptionsAPI from '@/api/subscriptions'
import { useSubscriptionStore } from '@/stores'
import type { UserSubscription } from '@/types'
const { t } = useI18n()
const subscriptionStore = useSubscriptionStore()
const containerRef = ref<HTMLElement | null>(null)
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(() => {
// Sort by most usage (highest percentage first)
......@@ -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(() => {
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(() => {
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>
<style scoped>
......
......@@ -2,6 +2,7 @@
export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.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 StatCard } from './StatCard.vue'
export { default as Toast } from './Toast.vue'
......
<template>
<Modal
<BaseDialog
:show="show"
:title="t('keys.useKeyModal.title')"
size="lg"
width="wide"
@close="emit('close')"
>
<div class="space-y-4">
......@@ -112,13 +112,13 @@
</button>
</div>
</template>
</Modal>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, h, watch, type Component } from 'vue'
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 type { GroupPlatform } from '@/types'
......
......@@ -45,8 +45,8 @@
</router-link>
</div>
<!-- Personal Section for Admin -->
<div class="sidebar-section">
<!-- Personal Section for Admin (hidden in simple mode) -->
<div v-if="!authStore.isSimpleMode" class="sidebar-section">
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
{{ t('nav.myAccount') }}
</div>
......@@ -402,36 +402,54 @@ const ChevronDoubleRightIcon = {
}
// User navigation items (for regular users)
const userNavItems = computed(() => [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
])
const userNavItems = computed(() => {
const items = [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ 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)
const personalNavItems = computed(() => [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
])
const personalNavItems = computed(() => {
const items = [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ 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
const adminNavItems = computed(() => [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }
])
const adminNavItems = computed(() => {
const baseItems = [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ 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() {
appStore.toggleSidebar()
......
......@@ -119,6 +119,7 @@ export default {
info: 'Info',
active: 'Active',
inactive: 'Inactive',
more: 'More',
close: 'Close',
enabled: 'Enabled',
disabled: 'Disabled',
......@@ -344,6 +345,8 @@ export default {
allApiKeys: 'All API Keys',
timeRange: 'Time Range',
exportCsv: 'Export CSV',
exporting: 'Exporting...',
preparingExport: 'Preparing export...',
model: 'Model',
type: 'Type',
tokens: 'Tokens',
......@@ -364,6 +367,7 @@ export default {
failedToLoad: 'Failed to load usage logs',
noDataToExport: 'No data to export',
exportSuccess: 'Usage data exported successfully',
exportFailed: 'Failed to export usage data',
billingType: 'Billing',
balance: 'Balance',
subscription: 'Subscription'
......@@ -406,7 +410,8 @@ export default {
subscriptionDays: '{days} days',
days: ' days',
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
......@@ -427,6 +432,7 @@ export default {
updating: 'Updating...',
updateSuccess: 'Profile updated successfully',
updateFailed: 'Failed to update profile',
usernameRequired: 'Username is required',
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
......@@ -670,14 +676,21 @@ export default {
description: 'Description',
platform: 'Platform',
rateMultiplier: 'Rate Multiplier',
status: 'Status'
status: 'Status',
exclusive: 'Exclusive Group'
},
enterGroupName: 'Enter group name',
optionalDescription: 'Optional description',
platformHint: 'Select the platform this group is associated with',
platformNotEditable: 'Platform cannot be changed after creation',
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',
createFirstGroup: 'Create your first group to organize API keys.',
creating: 'Creating...',
......@@ -902,6 +915,11 @@ export default {
apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...',
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)',
modelWhitelist: 'Model Whitelist',
modelMapping: 'Model Mapping',
......@@ -1063,6 +1081,7 @@ export default {
modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc:
'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)'
},
// Re-Auth Modal
......@@ -1172,9 +1191,9 @@ export default {
batchAdd: 'Quick Add',
batchInput: 'Proxy List',
batchInputPlaceholder:
"Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
"Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
batchInputHint:
"Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port",
"Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
parsedCount: '{count} valid',
invalidCount: '{count} invalid',
duplicateCount: '{count} duplicate',
......
......@@ -116,6 +116,7 @@ export default {
info: '提示',
active: '启用',
inactive: '禁用',
more: '更多',
close: '关闭',
enabled: '已启用',
disabled: '已禁用',
......@@ -340,6 +341,8 @@ export default {
allApiKeys: '全部密钥',
timeRange: '时间范围',
exportCsv: '导出 CSV',
exporting: '导出中...',
preparingExport: '正在准备导出...',
model: '模型',
type: '类型',
tokens: 'Token',
......@@ -360,6 +363,7 @@ export default {
failedToLoad: '加载使用记录失败',
noDataToExport: '没有可导出的数据',
exportSuccess: '使用数据导出成功',
exportFailed: '使用数据导出失败',
billingType: '消费类型',
balance: '余额',
subscription: '订阅'
......@@ -402,7 +406,8 @@ export default {
subscriptionDays: '{days} 天',
days: '',
codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。'
failedToRedeem: '兑换失败,请检查兑换码后重试。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。'
},
// Profile
......@@ -423,6 +428,7 @@ export default {
updating: '更新中...',
updateSuccess: '资料更新成功',
updateFailed: '资料更新失败',
usernameRequired: '用户名不能为空',
changePassword: '修改密码',
currentPassword: '当前密码',
newPassword: '新密码',
......@@ -727,14 +733,15 @@ export default {
platform: '平台',
rateMultiplier: '费率倍数',
status: '状态',
exclusive: '专属分组',
nameLabel: '分组名称',
namePlaceholder: '请输入分组名称',
descriptionLabel: '描述',
descriptionPlaceholder: '请输入描述(可选)',
rateMultiplierLabel: '费率倍数',
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
exclusiveLabel: '独占模式',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
exclusiveLabel: '专属分组',
exclusiveHint: '专属分组,可以手动指定给用户',
platformLabel: '平台限制',
platformPlaceholder: '选择平台(留空则不限制)',
accountsLabel: '指定账号',
......@@ -747,8 +754,14 @@ export default {
yes: '',
no: ''
},
exclusive: '独占',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
exclusive: '专属',
exclusiveHint: '专属分组,可以手动指定给特定用户',
exclusiveTooltip: {
title: '什么是专属分组?',
description: '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
example: '使用场景:',
exampleContent: '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
},
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
platforms: {
all: '全部平台',
......@@ -767,8 +780,8 @@ export default {
allPlatforms: '全部平台',
allStatus: '全部状态',
allGroups: '全部分组',
exclusiveFilter: '独占',
nonExclusive: '非独占',
exclusiveFilter: '专属',
nonExclusive: '公开',
public: '公开',
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
accountsCount: '{count} 个账号',
......@@ -1041,6 +1054,11 @@ export default {
apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: '您的 Claude Console API Key',
// OpenAI specific hints
openai: {
baseUrlHint: '留空使用官方 OpenAI API',
apiKeyHint: '您的 OpenAI API Key'
},
modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单',
modelMapping: '模型映射',
......@@ -1184,7 +1202,8 @@ export default {
gemini: {
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
apiKeyHint: 'Your Gemini API Key(以 AIza 开头)'
baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)'
},
// Re-Auth Modal
reAuthorizeAccount: '重新授权账号',
......@@ -1321,8 +1340,8 @@ export default {
batchAdd: '快捷添加',
batchInput: '代理列表',
batchInputPlaceholder:
"每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口",
"每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
parsedCount: '有效 {count} 个',
invalidCount: '无效 {count} 个',
duplicateCount: '重复 {count} 个',
......
......@@ -341,6 +341,23 @@ router.beforeEach((to, _from, next) => {
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
next()
})
......
......@@ -4,7 +4,7 @@
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, readonly } from 'vue'
import { authAPI } from '@/api'
import type { User, LoginRequest, RegisterRequest } from '@/types'
......@@ -17,6 +17,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const runMode = ref<'standard' | 'simple'>('standard')
let refreshIntervalId: ReturnType<typeof setInterval> | null = null
// ==================== Computed ====================
......@@ -29,6 +30,8 @@ export const useAuthStore = defineStore('auth', () => {
return user.value?.role === 'admin'
})
const isSimpleMode = computed(() => runMode.value === 'simple')
// ==================== Actions ====================
/**
......@@ -98,16 +101,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user
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
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
startAutoRefresh()
return response.user
return userData
} catch (error) {
// Clear any partial state on error
clearAuth()
......@@ -127,16 +136,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user
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
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
startAutoRefresh()
return response.user
return userDataWithoutRunMode
} catch (error) {
// Clear any partial state on error
clearAuth()
......@@ -168,13 +183,17 @@ export const useAuthStore = defineStore('auth', () => {
}
try {
const updatedUser = await authAPI.getCurrentUser()
user.value = updatedUser
const response = await authAPI.getCurrentUser()
if (response.data.run_mode) {
runMode.value = response.data.run_mode
}
const { run_mode, ...userData } = response.data
user.value = userData
// Update localStorage
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser))
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
return updatedUser
return userData
} catch (error) {
// If refresh fails with 401, clear auth state
if ((error as { status?: number }).status === 401) {
......@@ -204,10 +223,12 @@ export const useAuthStore = defineStore('auth', () => {
// State
user,
token,
runMode: readonly(runMode),
// Computed
isAuthenticated,
isAdmin,
isSimpleMode,
// Actions
login,
......
......@@ -5,6 +5,7 @@
export { useAuthStore } from './auth'
export { useAppStore } from './app'
export { useSubscriptionStore } from './subscriptions'
// Re-export types for convenience
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