mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 04:09:48 +08:00
Merge pull request #679 from zhefox/main
fix(frontend): 优化移动端弹窗并完善提供商配额刷新
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div class="relative flex h-full items-end justify-center overflow-hidden px-3 py-4 text-center sm:items-center sm:p-0 pointer-events-none">
|
<div class="relative flex h-full items-end justify-center overflow-hidden text-center sm:items-center sm:p-0 pointer-events-none">
|
||||||
<!-- 对话框内容 -->
|
<!-- 对话框内容 -->
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="duration-300 ease-out"
|
enter-active-class="duration-300 ease-out"
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
class="relative flex max-h-[calc(100dvh-2rem)] w-full transform flex-col overflow-hidden rounded-lg border border-border bg-background text-left shadow-2xl transition-all pointer-events-auto sm:my-8 sm:w-full sm:max-h-[calc(100dvh-4rem)]"
|
class="relative flex max-h-[100dvh] w-full transform flex-col overflow-hidden rounded-t-xl border border-x-0 border-b-0 border-border bg-background text-left shadow-2xl transition-all pointer-events-auto sm:my-8 sm:w-full sm:max-h-[calc(100dvh-4rem)] sm:rounded-lg sm:border"
|
||||||
:style="{ zIndex: contentZIndex }"
|
:style="{ zIndex: contentZIndex }"
|
||||||
:class="maxWidthClass"
|
:class="maxWidthClass"
|
||||||
@click.stop
|
@click.stop
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<slot name="header">
|
<slot name="header">
|
||||||
<div
|
<div
|
||||||
v-if="title"
|
v-if="title"
|
||||||
class="shrink-0 border-b border-border px-4 py-4 sm:px-6"
|
class="shrink-0 border-b border-border px-4 pb-3 pt-4 sm:px-6 sm:py-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -57,12 +57,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
<h3 class="text-balance text-base font-semibold leading-tight text-foreground sm:text-lg">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
v-if="description"
|
v-if="description"
|
||||||
class="text-xs text-muted-foreground"
|
class="mt-0.5 text-pretty text-xs leading-4 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<!-- Footer 区域:如果有 footer 插槽,自动添加样式 -->
|
<!-- Footer 区域:如果有 footer 插槽,自动添加样式 -->
|
||||||
<div
|
<div
|
||||||
v-if="slots.footer"
|
v-if="slots.footer"
|
||||||
class="shrink-0 border-t border-border bg-muted/10 px-4 py-4 sm:px-6 flex flex-row-reverse gap-3"
|
class="flex shrink-0 flex-col-reverse items-stretch gap-2 border-t border-border bg-background/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur-sm [&>button]:w-full sm:flex-row-reverse sm:items-center sm:gap-3 sm:bg-muted/10 sm:px-6 sm:py-4 sm:[&>button]:w-auto"
|
||||||
>
|
>
|
||||||
<slot name="footer" />
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -993,10 +993,6 @@ import type {
|
|||||||
} from '@/api/endpoints/types'
|
} from '@/api/endpoints/types'
|
||||||
import { formatApiFormatShort } from '@/api/endpoints/types/api-format'
|
import { formatApiFormatShort } from '@/api/endpoints/types/api-format'
|
||||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||||
import {
|
|
||||||
isProviderQuotaAutoRefreshCoolingDown,
|
|
||||||
markProviderQuotaAutoRefreshAttempt,
|
|
||||||
} from '../utils/quotaAutoRefreshCooldown'
|
|
||||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||||
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||||||
import { formatCompactNumber } from '@/utils/format'
|
import { formatCompactNumber } from '@/utils/format'
|
||||||
@@ -1022,6 +1018,7 @@ import {
|
|||||||
formatCodexResetCreditDays,
|
formatCodexResetCreditDays,
|
||||||
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
|
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
|
||||||
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
|
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
|
||||||
|
mergeCodexQuotaDisplays,
|
||||||
} from './codex-reset-credit-display'
|
} from './codex-reset-credit-display'
|
||||||
|
|
||||||
// 扩展端点类型,包含密钥列表
|
// 扩展端点类型,包含密钥列表
|
||||||
@@ -1600,7 +1597,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
|||||||
}
|
}
|
||||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||||
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
||||||
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
void autoRefreshQuotaInBackground()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
|
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1951,20 +1948,6 @@ function getCodexQuotaDisplayFromSnapshot(quota: QuotaStatusSnapshot | null | un
|
|||||||
return Object.keys(display).length > 0 ? display : null
|
return Object.keys(display).length > 0 ? display : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCodexQuotaDisplayUpdatedAt(display: CodexUpstreamMetadata | null | undefined): number | null {
|
|
||||||
const updatedAt = Number(display?.updated_at)
|
|
||||||
return Number.isFinite(updatedAt) ? updatedAt : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function codexDisplayHasUsage(display: CodexUpstreamMetadata | null | undefined): boolean {
|
|
||||||
return !!display && (
|
|
||||||
display.primary_used_percent !== undefined
|
|
||||||
|| display.secondary_used_percent !== undefined
|
|
||||||
|| display.spark_primary_used_percent !== undefined
|
|
||||||
|| display.spark_secondary_used_percent !== undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | undefined): boolean {
|
function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | undefined): boolean {
|
||||||
const count = display?.reset_credits?.available_count
|
const count = display?.reset_credits?.available_count
|
||||||
return typeof count === 'number' && Number.isFinite(count)
|
return typeof count === 'number' && Number.isFinite(count)
|
||||||
@@ -1973,20 +1956,7 @@ function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | und
|
|||||||
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
|
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
|
||||||
const snapshotDisplay = getCodexQuotaDisplayFromSnapshot(getQuotaSnapshotForProvider(key, 'codex'))
|
const snapshotDisplay = getCodexQuotaDisplayFromSnapshot(getQuotaSnapshotForProvider(key, 'codex'))
|
||||||
const metadataDisplay = getCodexQuotaDisplayFromMetadata(key.upstream_metadata?.codex)
|
const metadataDisplay = getCodexQuotaDisplayFromMetadata(key.upstream_metadata?.codex)
|
||||||
|
return mergeCodexQuotaDisplays(snapshotDisplay, metadataDisplay)
|
||||||
if (!snapshotDisplay) return metadataDisplay
|
|
||||||
if (!metadataDisplay) return snapshotDisplay
|
|
||||||
if (!codexDisplayHasUsage(snapshotDisplay) && codexDisplayHasUsage(metadataDisplay)) {
|
|
||||||
return metadataDisplay
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotUpdatedAt = getCodexQuotaDisplayUpdatedAt(snapshotDisplay)
|
|
||||||
const metadataUpdatedAt = getCodexQuotaDisplayUpdatedAt(metadataDisplay)
|
|
||||||
if (metadataUpdatedAt !== null && (snapshotUpdatedAt === null || metadataUpdatedAt > snapshotUpdatedAt)) {
|
|
||||||
return metadataDisplay
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshotDisplay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|
function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||||
@@ -2714,7 +2684,7 @@ function applyQuotaResults(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
||||||
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
|
async function autoRefreshQuotaInBackground() {
|
||||||
const providerId = props.providerId
|
const providerId = props.providerId
|
||||||
if (!providerId) return
|
if (!providerId) return
|
||||||
if (refreshingQuota.value) return
|
if (refreshingQuota.value) return
|
||||||
@@ -2740,7 +2710,6 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
|
|||||||
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
||||||
}
|
}
|
||||||
if (!shouldRefresh) return
|
if (!shouldRefresh) return
|
||||||
if (!options.ignoreCooldown && isProviderQuotaAutoRefreshCoolingDown(providerId)) return
|
|
||||||
|
|
||||||
let hadCachedQuota = false
|
let hadCachedQuota = false
|
||||||
if (providerType === 'codex') {
|
if (providerType === 'codex') {
|
||||||
@@ -2760,7 +2729,6 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshingQuota.value = true
|
refreshingQuota.value = true
|
||||||
markProviderQuotaAutoRefreshAttempt(providerId)
|
|
||||||
try {
|
try {
|
||||||
const result = await refreshProviderQuota(providerId)
|
const result = await refreshProviderQuota(providerId)
|
||||||
const applied = applyQuotaResults(result.results)
|
const applied = applyQuotaResults(result.results)
|
||||||
@@ -2804,7 +2772,7 @@ async function handleKeyChanged() {
|
|||||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
|
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
|
||||||
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
void autoRefreshQuotaInBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换密钥启用状态
|
// 切换密钥启用状态
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
@@ -67,8 +67,10 @@ const nodeOptions = computed(() => {
|
|||||||
|
|
||||||
/** 供父组件调用:启用代理时懒加载节点列表 */
|
/** 供父组件调用:启用代理时懒加载节点列表 */
|
||||||
function ensureLoaded() {
|
function ensureLoaded() {
|
||||||
proxyNodesStore.ensureLoaded()
|
return proxyNodesStore.ensureLoaded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(ensureLoaded)
|
||||||
|
|
||||||
defineExpose({ ensureLoaded })
|
defineExpose({ ensureLoaded })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, defineComponent, h } from 'vue'
|
||||||
|
|
||||||
|
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||||
|
import { createI18n } from '@/i18n'
|
||||||
|
|
||||||
|
const proxyNodesStore = vi.hoisted(() => ({
|
||||||
|
loading: false,
|
||||||
|
nodes: [],
|
||||||
|
onlineNodes: [],
|
||||||
|
ensureLoaded: vi.fn(() => Promise.resolve()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/proxy-nodes', () => ({
|
||||||
|
useProxyNodesStore: () => proxyNodesStore,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
const passthrough = (name: string) => defineComponent({
|
||||||
|
name,
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
Select: passthrough('SelectStub'),
|
||||||
|
SelectTrigger: passthrough('SelectTriggerStub'),
|
||||||
|
SelectValue: passthrough('SelectValueStub'),
|
||||||
|
SelectContent: passthrough('SelectContentStub'),
|
||||||
|
SelectItem: passthrough('SelectItemStub'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProxyNodeSelect', () => {
|
||||||
|
it('loads proxy nodes when mounted', () => {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
const app = createApp(defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () => h(ProxyNodeSelect, { modelValue: '' })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.use(createI18n())
|
||||||
|
app.mount(root)
|
||||||
|
|
||||||
|
expect(proxyNodesStore.ensureLoaded).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
+30
@@ -5,10 +5,40 @@ import {
|
|||||||
formatCodexResetCreditDays,
|
formatCodexResetCreditDays,
|
||||||
getCodexResetCreditAvailableCount,
|
getCodexResetCreditAvailableCount,
|
||||||
getVisibleCodexResetCreditItems,
|
getVisibleCodexResetCreditItems,
|
||||||
|
mergeCodexQuotaDisplays,
|
||||||
} from '@/features/providers/components/codex-reset-credit-display'
|
} from '@/features/providers/components/codex-reset-credit-display'
|
||||||
import type { QuotaResetCreditsSnapshot } from '@/api/endpoints/types'
|
import type { QuotaResetCreditsSnapshot } from '@/api/endpoints/types'
|
||||||
|
|
||||||
describe('codex reset credit display helpers', () => {
|
describe('codex reset credit display helpers', () => {
|
||||||
|
it('keeps reset credits and usage windows when snapshot sources are partially populated', () => {
|
||||||
|
const merged = mergeCodexQuotaDisplays(
|
||||||
|
{
|
||||||
|
updated_at: 1_700_000_100,
|
||||||
|
primary_used_percent: 25,
|
||||||
|
reset_credits: {
|
||||||
|
available_count: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
updated_at: 1_700_000_000,
|
||||||
|
secondary_used_percent: 40,
|
||||||
|
reset_credits: {
|
||||||
|
credits: [{ id: 'credit-1', expires_at: 1_700_086_400 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(merged).toMatchObject({
|
||||||
|
updated_at: 1_700_000_100,
|
||||||
|
primary_used_percent: 25,
|
||||||
|
secondary_used_percent: 40,
|
||||||
|
reset_credits: {
|
||||||
|
available_count: 2,
|
||||||
|
credits: [{ id: 'credit-1', expires_at: 1_700_086_400 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps zero available credits displayable but non-positive detail items hidden', () => {
|
it('keeps zero available credits displayable but non-positive detail items hidden', () => {
|
||||||
const snapshot: QuotaResetCreditsSnapshot = {
|
const snapshot: QuotaResetCreditsSnapshot = {
|
||||||
available_count: 0,
|
available_count: 0,
|
||||||
|
|||||||
@@ -1,8 +1,41 @@
|
|||||||
import type {
|
import type {
|
||||||
|
CodexUpstreamMetadata,
|
||||||
QuotaResetCreditSnapshot,
|
QuotaResetCreditSnapshot,
|
||||||
QuotaResetCreditsSnapshot,
|
QuotaResetCreditsSnapshot,
|
||||||
} from '@/api/endpoints/types'
|
} from '@/api/endpoints/types'
|
||||||
|
|
||||||
|
function codexQuotaUpdatedAt(display: CodexUpstreamMetadata | null | undefined): number | null {
|
||||||
|
const updatedAt = Number(display?.updated_at)
|
||||||
|
return Number.isFinite(updatedAt) ? updatedAt : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCodexQuotaDisplays(
|
||||||
|
snapshotDisplay: CodexUpstreamMetadata | null | undefined,
|
||||||
|
metadataDisplay: CodexUpstreamMetadata | null | undefined,
|
||||||
|
): CodexUpstreamMetadata | null {
|
||||||
|
if (!snapshotDisplay) return metadataDisplay ?? null
|
||||||
|
if (!metadataDisplay) return snapshotDisplay
|
||||||
|
|
||||||
|
const snapshotUpdatedAt = codexQuotaUpdatedAt(snapshotDisplay)
|
||||||
|
const metadataUpdatedAt = codexQuotaUpdatedAt(metadataDisplay)
|
||||||
|
const metadataIsNewer = metadataUpdatedAt !== null
|
||||||
|
&& (snapshotUpdatedAt === null || metadataUpdatedAt > snapshotUpdatedAt)
|
||||||
|
const preferred = metadataIsNewer ? metadataDisplay : snapshotDisplay
|
||||||
|
const fallback = metadataIsNewer ? snapshotDisplay : metadataDisplay
|
||||||
|
const resetCredits = preferred.reset_credits || fallback.reset_credits
|
||||||
|
? {
|
||||||
|
...fallback.reset_credits,
|
||||||
|
...preferred.reset_credits,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
...preferred,
|
||||||
|
...(resetCredits ? { reset_credits: resetCredits } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface CodexResetCreditDisplayItem {
|
export interface CodexResetCreditDisplayItem {
|
||||||
id?: string | null
|
id?: string | null
|
||||||
displayKey: string
|
displayKey: string
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
import {
|
|
||||||
AUTO_QUOTA_REFRESH_COOLDOWN_SECONDS,
|
|
||||||
isProviderQuotaAutoRefreshCoolingDown,
|
|
||||||
markProviderQuotaAutoRefreshAttempt,
|
|
||||||
resetProviderQuotaAutoRefreshCooldownForTests,
|
|
||||||
} from '../quotaAutoRefreshCooldown'
|
|
||||||
|
|
||||||
describe('quota auto refresh cooldown', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetProviderQuotaAutoRefreshCooldownForTests()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('starts cooldown after recording an auto refresh attempt', () => {
|
|
||||||
markProviderQuotaAutoRefreshAttempt('provider-1', 1_000)
|
|
||||||
|
|
||||||
expect(isProviderQuotaAutoRefreshCoolingDown('provider-1', 1_000)).toBe(true)
|
|
||||||
expect(
|
|
||||||
isProviderQuotaAutoRefreshCoolingDown(
|
|
||||||
'provider-1',
|
|
||||||
1_000 + AUTO_QUOTA_REFRESH_COOLDOWN_SECONDS - 1,
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('expires cooldown after the configured window', () => {
|
|
||||||
markProviderQuotaAutoRefreshAttempt('provider-1', 1_000)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
isProviderQuotaAutoRefreshCoolingDown(
|
|
||||||
'provider-1',
|
|
||||||
1_000 + AUTO_QUOTA_REFRESH_COOLDOWN_SECONDS,
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('tracks cooldown independently per provider', () => {
|
|
||||||
markProviderQuotaAutoRefreshAttempt('provider-1', 1_000)
|
|
||||||
markProviderQuotaAutoRefreshAttempt('provider-2', 1_200)
|
|
||||||
|
|
||||||
expect(isProviderQuotaAutoRefreshCoolingDown('provider-1', 1_301)).toBe(false)
|
|
||||||
expect(isProviderQuotaAutoRefreshCoolingDown('provider-2', 1_301)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores empty provider ids', () => {
|
|
||||||
markProviderQuotaAutoRefreshAttempt('', 1_000)
|
|
||||||
|
|
||||||
expect(isProviderQuotaAutoRefreshCoolingDown('', 1_001)).toBe(false)
|
|
||||||
expect(isProviderQuotaAutoRefreshCoolingDown(null, 1_001)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
const AUTO_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
|
|
||||||
|
|
||||||
const lastAutoQuotaRefreshAttemptAtByProvider = new Map<string, number>()
|
|
||||||
|
|
||||||
function normalizeUnixSeconds(value: number): number {
|
|
||||||
return Math.max(Math.floor(value), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProviderQuotaAutoRefreshCoolingDown(
|
|
||||||
providerId: string | null | undefined,
|
|
||||||
nowSeconds = Math.floor(Date.now() / 1000),
|
|
||||||
): boolean {
|
|
||||||
const id = String(providerId || '').trim()
|
|
||||||
if (!id) return false
|
|
||||||
|
|
||||||
const lastAttemptAt = lastAutoQuotaRefreshAttemptAtByProvider.get(id)
|
|
||||||
if (lastAttemptAt == null) return false
|
|
||||||
|
|
||||||
return normalizeUnixSeconds(nowSeconds) - lastAttemptAt < AUTO_QUOTA_REFRESH_COOLDOWN_SECONDS
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markProviderQuotaAutoRefreshAttempt(
|
|
||||||
providerId: string | null | undefined,
|
|
||||||
nowSeconds = Math.floor(Date.now() / 1000),
|
|
||||||
): void {
|
|
||||||
const id = String(providerId || '').trim()
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
lastAutoQuotaRefreshAttemptAtByProvider.set(id, normalizeUnixSeconds(nowSeconds))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetProviderQuotaAutoRefreshCooldownForTests(): void {
|
|
||||||
lastAutoQuotaRefreshAttemptAtByProvider.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
export { AUTO_QUOTA_REFRESH_COOLDOWN_SECONDS }
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4 border-t border-border/60 pt-5">
|
<div class="space-y-4 border-t border-border/60 pt-5">
|
||||||
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 border-b border-border/60 pb-2">
|
<div class="flex items-center justify-between gap-2 border-b border-border/60 pb-2">
|
||||||
<span class="text-sm font-medium">{{ legacyT('组权限') }}</span>
|
<span class="text-sm font-medium">{{ legacyT('组权限') }}</span>
|
||||||
<span class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
<span class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
{{ legacyT('组权限叠加,Key 可再收窄') }}
|
{{ legacyT('组权限叠加,Key 可再收窄') }}
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border/70 bg-muted/40 text-muted-foreground outline-none transition-colors hover:border-primary/50 hover:text-primary focus-visible:border-primary/60 focus-visible:text-primary"
|
class="inline-flex h-10 w-10 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted/60 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
:title="helpText"
|
:title="helpText"
|
||||||
:aria-label="legacyT('查看组权限合并规则')"
|
:aria-label="legacyT('查看组权限合并规则')"
|
||||||
>
|
>
|
||||||
<Info class="h-3 w-3" />
|
<Info class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent class="max-w-72 text-xs leading-5">
|
<TooltipContent class="max-w-72 text-xs leading-5">
|
||||||
@@ -27,11 +27,14 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-sm font-medium">{{ legacyT('允许的提供商') }}</Label>
|
<Label class="text-sm font-medium">{{ legacyT('允许的提供商') }}</Label>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
<div class="flex min-h-10 w-full items-center gap-2 sm:w-auto sm:shrink-0">
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="form.allowed_providers_mode === 'unrestricted'"
|
:model-value="form.allowed_providers_mode === 'unrestricted'"
|
||||||
@update:model-value="setProvidersUnrestricted"
|
@update:model-value="setProvidersUnrestricted"
|
||||||
/>
|
/>
|
||||||
|
<span class="text-xs text-muted-foreground sm:sr-only">
|
||||||
|
{{ legacyT(form.allowed_providers_mode === 'unrestricted' ? '不限制' : '选择提供商') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
@@ -50,11 +53,14 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-sm font-medium">{{ legacyT('允许的端点') }}</Label>
|
<Label class="text-sm font-medium">{{ legacyT('允许的端点') }}</Label>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
<div class="flex min-h-10 w-full items-center gap-2 sm:w-auto sm:shrink-0">
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="form.allowed_api_formats_mode === 'unrestricted'"
|
:model-value="form.allowed_api_formats_mode === 'unrestricted'"
|
||||||
@update:model-value="setApiFormatsUnrestricted"
|
@update:model-value="setApiFormatsUnrestricted"
|
||||||
/>
|
/>
|
||||||
|
<span class="text-xs text-muted-foreground sm:sr-only">
|
||||||
|
{{ legacyT(form.allowed_api_formats_mode === 'unrestricted' ? '不限制' : '选择端点') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
@@ -73,11 +79,14 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-sm font-medium">{{ legacyT('允许的模型') }}</Label>
|
<Label class="text-sm font-medium">{{ legacyT('允许的模型') }}</Label>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
<div class="flex min-h-10 w-full items-center gap-2 sm:w-auto sm:shrink-0">
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="form.allowed_models_mode === 'unrestricted'"
|
:model-value="form.allowed_models_mode === 'unrestricted'"
|
||||||
@update:model-value="setModelsUnrestricted"
|
@update:model-value="setModelsUnrestricted"
|
||||||
/>
|
/>
|
||||||
|
<span class="text-xs text-muted-foreground sm:sr-only">
|
||||||
|
{{ legacyT(form.allowed_models_mode === 'unrestricted' ? '不限制' : '选择模型') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
@@ -96,11 +105,14 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-sm font-medium">{{ legacyT('速率限制 (请求/分钟)') }}</Label>
|
<Label class="text-sm font-medium">{{ legacyT('速率限制 (请求/分钟)') }}</Label>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
<div class="flex min-h-10 w-full items-center gap-2 sm:w-auto sm:shrink-0">
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="form.rate_limit_mode === 'system'"
|
:model-value="form.rate_limit_mode === 'system'"
|
||||||
@update:model-value="setSystemRateLimit"
|
@update:model-value="setSystemRateLimit"
|
||||||
/>
|
/>
|
||||||
|
<span class="text-xs text-muted-foreground sm:sr-only">
|
||||||
|
{{ legacyT(form.rate_limit_mode === 'system' ? '系统默认' : '自定义') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-4 flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h4 class="truncate text-base font-semibold text-foreground">
|
<h4 class="text-balance break-words text-base font-semibold text-foreground">
|
||||||
{{ legacyT(editing ? '编辑分组' : '新建分组') }}
|
{{ legacyT(editing ? '编辑分组' : '新建分组') }}
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-10 w-10"
|
||||||
:class="isDefault ? 'text-emerald-500 hover:text-emerald-500' : ''"
|
:class="isDefault ? 'text-emerald-500 hover:text-emerald-500' : ''"
|
||||||
:disabled="saving || isDefault"
|
:disabled="saving || isDefault"
|
||||||
:title="legacyT(isDefault ? '默认注册组' : '设为默认注册组')"
|
:title="legacyT(isDefault ? '默认注册组' : '设为默认注册组')"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-10 w-10"
|
||||||
:disabled="saving || isDefault"
|
:disabled="saving || isDefault"
|
||||||
:title="legacyT('删除分组')"
|
:title="legacyT('删除分组')"
|
||||||
@click="$emit('delete')"
|
@click="$emit('delete')"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
<div class="rounded-lg border border-border/70 bg-muted/20 p-2.5 sm:rounded-xl sm:p-3">
|
||||||
<div class="mb-3 flex items-center justify-between gap-2">
|
<div class="mb-2 flex items-center justify-between gap-2 sm:mb-3">
|
||||||
<Label class="text-sm font-semibold">{{ legacyT('分组') }}</Label>
|
<Label class="text-sm font-semibold">{{ legacyT('分组') }}</Label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-10 w-10"
|
||||||
:title="legacyT('新建分组')"
|
:title="legacyT('新建分组')"
|
||||||
@click="$emit('create')"
|
@click="$emit('create')"
|
||||||
>
|
>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="max-h-60 space-y-1.5 overflow-y-auto lg:max-h-none lg:overflow-visible"
|
class="flex snap-x snap-mandatory gap-2 overflow-x-auto overscroll-x-contain pb-1 lg:block lg:max-h-none lg:space-y-1.5 lg:overflow-visible lg:pb-0"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="group in groups"
|
v-for="group in groups"
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight class="h-4 w-4 shrink-0 text-muted-foreground" />
|
<ChevronRight class="hidden h-4 w-4 shrink-0 text-muted-foreground lg:block" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +76,7 @@ const { legacyT } = useI18n()
|
|||||||
|
|
||||||
function groupButtonClass(groupId: string): string {
|
function groupButtonClass(groupId: string): string {
|
||||||
return cn(
|
return cn(
|
||||||
'flex w-full items-center gap-2 rounded-lg border px-3 py-2 transition-colors',
|
'flex min-h-10 w-auto min-w-[8.5rem] shrink-0 snap-start items-center gap-2 rounded-lg border px-3 py-2 transition-colors lg:w-full lg:min-w-0',
|
||||||
props.selectedGroupId === groupId
|
props.selectedGroupId === groupId
|
||||||
? 'border-primary/50 bg-primary/10'
|
? 'border-primary/50 bg-primary/10'
|
||||||
: 'border-transparent hover:border-border hover:bg-background',
|
: 'border-transparent hover:border-border hover:bg-background',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
persistent
|
persistent
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 lg:min-h-[560px] lg:grid-cols-[17rem_minmax(0,1fr)]">
|
<div class="grid gap-3 lg:min-h-[560px] lg:grid-cols-[17rem_minmax(0,1fr)] lg:gap-4">
|
||||||
<UserGroupListPanel
|
<UserGroupListPanel
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
@select="selectGroup"
|
@select="selectGroup"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="min-w-0 rounded-xl border border-border/70 bg-background p-3 sm:p-4">
|
<div class="min-w-0 bg-background sm:rounded-xl sm:border sm:border-border/70 sm:p-4">
|
||||||
<UserGroupEditorHeader
|
<UserGroupEditorHeader
|
||||||
:editing="Boolean(editingGroupId)"
|
:editing="Boolean(editingGroupId)"
|
||||||
:is-default="Boolean(selectedGroup?.is_default)"
|
:is-default="Boolean(selectedGroup?.is_default)"
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
function readSource(path: string): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mobile dialog layout', () => {
|
||||||
|
it('uses a full-height mobile sheet with stacked full-width actions', () => {
|
||||||
|
const source = readSource('src/components/ui/dialog/Dialog.vue')
|
||||||
|
|
||||||
|
expect(source).toContain('max-h-[100dvh]')
|
||||||
|
expect(source).toContain('flex-col-reverse')
|
||||||
|
expect(source).toContain('[&>button]:w-full')
|
||||||
|
expect(source).toContain('sm:flex-row-reverse')
|
||||||
|
expect(source).toContain('sm:[&>button]:w-auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps group navigation compact before the editor on mobile', () => {
|
||||||
|
const dialogSource = readSource('src/features/users/components/UserGroupsDialog.vue')
|
||||||
|
const listSource = readSource('src/features/users/components/UserGroupListPanel.vue')
|
||||||
|
|
||||||
|
expect(dialogSource).toContain('lg:grid-cols-[17rem_minmax(0,1fr)]')
|
||||||
|
expect(listSource).toContain('snap-x snap-mandatory')
|
||||||
|
expect(listSource).toContain('overflow-x-auto')
|
||||||
|
expect(listSource).toContain('min-h-10')
|
||||||
|
expect(listSource).toContain('lg:w-full')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provides mobile state labels and 40px icon targets', () => {
|
||||||
|
const accessSource = readSource('src/features/users/components/UserGroupAccessControlFields.vue')
|
||||||
|
const headerSource = readSource('src/features/users/components/UserGroupEditorHeader.vue')
|
||||||
|
|
||||||
|
expect(accessSource).toContain('sm:sr-only')
|
||||||
|
expect(accessSource).toContain('h-10 w-10')
|
||||||
|
expect(headerSource.match(/h-10 w-10/g)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user