mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge PR #469: 修复用户可见性、额度、验证与 Codex 探测
This commit is contained in:
@@ -30,7 +30,7 @@ export interface UserPreferences {
|
||||
avatar_url?: string
|
||||
bio?: string
|
||||
default_provider_id?: string // UUID
|
||||
default_provider?: Record<string, unknown>
|
||||
default_provider?: Record<string, unknown> | string | null // 仅管理员可见
|
||||
theme: string
|
||||
language: string
|
||||
timezone?: string
|
||||
@@ -52,7 +52,7 @@ export interface ProviderConfig {
|
||||
// 使用记录接口
|
||||
export interface UsageRecordDetail {
|
||||
id: string
|
||||
provider: string
|
||||
provider?: string // 仅管理员可见
|
||||
model: string
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
|
||||
@@ -272,9 +272,9 @@ const showRegisterDialog = ref(false)
|
||||
const requireEmailVerification = ref(false)
|
||||
const emailConfigured = ref(true) // 邮箱服务是否已配置
|
||||
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
|
||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||
const turnstileEnabled = ref(false)
|
||||
const turnstileSiteKey = ref<string | null>(null)
|
||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||
|
||||
// LDAP authentication settings
|
||||
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
|
||||
|
||||
@@ -55,6 +55,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="turnstileRequired"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>人机验证 <span class="text-destructive">*</span></Label>
|
||||
<TurnstileWidget
|
||||
ref="turnstileWidgetRef"
|
||||
v-model="turnstileToken"
|
||||
:site-key="turnstileSiteKey"
|
||||
:action="currentTurnstileAction"
|
||||
:disabled="isLoading || isSendingCode"
|
||||
@error="handleTurnstileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Verification Code Section (仅当需要邮箱验证时显示) -->
|
||||
<div
|
||||
v-if="emailConfigured && requireEmailVerification"
|
||||
@@ -197,11 +212,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TurnstileWidget
|
||||
v-if="turnstileRequired && turnstileSiteKey"
|
||||
ref="turnstileWidgetRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
@@ -240,7 +250,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { authApi, type RegisterRequest } from '@/api/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
@@ -392,32 +402,53 @@ const codeSentAt = ref<number | null>(null)
|
||||
const cooldownSeconds = ref(0)
|
||||
const expireMinutes = ref(5)
|
||||
const cooldownTimer = ref<number | null>(null)
|
||||
type TurnstileAction = 'send_verification_code' | 'register'
|
||||
const turnstileToken = ref('')
|
||||
const turnstileWidgetRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileAction = ref<'send_verification_code' | 'register' | null>(null)
|
||||
const turnstileRequired = computed(() => !!props.turnstileEnabled && !!props.turnstileSiteKey)
|
||||
|
||||
const turnstileSiteKey = computed(() => props.turnstileSiteKey || '')
|
||||
const turnstileRequired = computed(() => !!props.turnstileEnabled && !!turnstileSiteKey.value)
|
||||
const currentTurnstileAction = computed<TurnstileAction>(() =>
|
||||
props.requireEmailVerification && !emailVerified.value
|
||||
? 'send_verification_code'
|
||||
: 'register'
|
||||
)
|
||||
|
||||
const resetTurnstile = () => {
|
||||
turnstileToken.value = ''
|
||||
turnstileWidgetRef.value?.reset()
|
||||
}
|
||||
|
||||
const handleTurnstileError = (message: string) => {
|
||||
showError(message, '人机验证失败')
|
||||
}
|
||||
|
||||
// Send code cooldown timer
|
||||
const canSendCode = computed(() => {
|
||||
if (!formData.value.email) return false
|
||||
if (cooldownSeconds.value > 0) return false
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'send_verification_code' &&
|
||||
!turnstileToken.value
|
||||
) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const sendCodeButtonText = computed(() => {
|
||||
if (isSendingCode.value) {
|
||||
return turnstileAction.value === 'send_verification_code' ? '验证中...' : '发送中...'
|
||||
}
|
||||
if (isSendingCode.value) return '发送中...'
|
||||
if (emailVerified.value) return '验证成功'
|
||||
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'send_verification_code' &&
|
||||
!turnstileToken.value
|
||||
) return '请先完成人机验证'
|
||||
if (codeSentAt.value) return '重新发送验证码'
|
||||
return '发送验证码'
|
||||
})
|
||||
|
||||
const sendCodeLoadingText = computed(() =>
|
||||
turnstileAction.value === 'send_verification_code'
|
||||
? '正在进行人机验证...'
|
||||
: '正在发送验证码...'
|
||||
)
|
||||
const sendCodeLoadingText = computed(() => '正在发送验证码...')
|
||||
|
||||
// 用户名验证
|
||||
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
|
||||
@@ -454,6 +485,14 @@ const canSubmit = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'register' &&
|
||||
!turnstileToken.value
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check password match
|
||||
if (formData.value.password !== formData.value.confirmPassword) {
|
||||
return false
|
||||
@@ -508,6 +547,7 @@ watch(
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
resetTurnstile()
|
||||
}
|
||||
|
||||
// 清除之前的定时器
|
||||
@@ -526,6 +566,10 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(currentTurnstileAction, () => {
|
||||
resetTurnstile()
|
||||
})
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
@@ -575,6 +619,7 @@ const resetForm = () => {
|
||||
isSendingCode.value = false
|
||||
codeSentAt.value = null
|
||||
cooldownSeconds.value = 0
|
||||
resetTurnstile()
|
||||
|
||||
// Reset password field nonce
|
||||
formNonce.value = createFormNonce()
|
||||
@@ -590,24 +635,6 @@ const resetForm = () => {
|
||||
resetTurnstile()
|
||||
}
|
||||
|
||||
const resetTurnstile = () => {
|
||||
turnstileAction.value = null
|
||||
turnstileWidgetRef.value?.reset()
|
||||
}
|
||||
|
||||
const executeTurnstile = async (action: 'send_verification_code' | 'register') => {
|
||||
if (!turnstileRequired.value) return undefined
|
||||
turnstileAction.value = action
|
||||
try {
|
||||
return await turnstileWidgetRef.value?.execute(action)
|
||||
} catch {
|
||||
showError('人机验证失败,请重试', '验证失败')
|
||||
return null
|
||||
} finally {
|
||||
turnstileAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!formData.value.email) {
|
||||
showError('请输入邮箱')
|
||||
@@ -624,16 +651,13 @@ const handleSendCode = async () => {
|
||||
isSendingCode.value = true
|
||||
|
||||
try {
|
||||
const turnstileToken = await executeTurnstile('send_verification_code')
|
||||
if (turnstileRequired.value && !turnstileToken) {
|
||||
return
|
||||
}
|
||||
const response = await authApi.sendVerificationCode(
|
||||
formData.value.email,
|
||||
turnstileToken || undefined
|
||||
turnstileRequired.value ? turnstileToken.value : undefined
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
resetTurnstile()
|
||||
codeSentAt.value = Date.now()
|
||||
if (response.expire_minutes) {
|
||||
expireMinutes.value = response.expire_minutes
|
||||
@@ -649,9 +673,11 @@ const handleSendCode = async () => {
|
||||
codeInputRefs.value[0]?.focus()
|
||||
})
|
||||
} else {
|
||||
resetTurnstile()
|
||||
showError(response.message || '请稍后重试', '发送失败')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
resetTurnstile()
|
||||
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
|
||||
} finally {
|
||||
isSendingCode.value = false
|
||||
@@ -708,23 +734,21 @@ const handleSubmit = async () => {
|
||||
showError('请先完成邮箱验证')
|
||||
return
|
||||
}
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'register' &&
|
||||
!turnstileToken.value
|
||||
) {
|
||||
showError('请先完成人机验证')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = turnstileRequired.value ? '验证中...' : '注册中...'
|
||||
loadingText.value = '注册中...'
|
||||
|
||||
try {
|
||||
const turnstileToken = await executeTurnstile('register')
|
||||
if (turnstileRequired.value && !turnstileToken) {
|
||||
return
|
||||
}
|
||||
loadingText.value = '注册中...'
|
||||
// 构建请求数据:邮箱可选
|
||||
const registerData: {
|
||||
email?: string
|
||||
username: string
|
||||
password: string
|
||||
turnstile_token?: string
|
||||
} = {
|
||||
const registerData: RegisterRequest = {
|
||||
username: formData.value.username,
|
||||
password: formData.value.password
|
||||
}
|
||||
@@ -732,8 +756,8 @@ const handleSubmit = async () => {
|
||||
if (formData.value.email && formData.value.email.trim()) {
|
||||
registerData.email = formData.value.email
|
||||
}
|
||||
if (turnstileToken) {
|
||||
registerData.turnstile_token = turnstileToken
|
||||
if (turnstileRequired.value && currentTurnstileAction.value === 'register') {
|
||||
registerData.turnstile_token = turnstileToken.value
|
||||
}
|
||||
|
||||
const response = await authApi.register(registerData)
|
||||
@@ -743,6 +767,7 @@ const handleSubmit = async () => {
|
||||
emit('success')
|
||||
isOpen.value = false
|
||||
} catch (error: unknown) {
|
||||
resetTurnstile()
|
||||
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="min-h-[1px]"
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="min-h-[65px]"
|
||||
:class="disabled ? 'pointer-events-none opacity-60' : ''"
|
||||
/>
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
siteKey: string
|
||||
}
|
||||
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
type TurnstileWidgetId = string
|
||||
|
||||
@@ -27,7 +34,7 @@ interface TurnstileRenderOptions {
|
||||
|
||||
interface TurnstileApi {
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => TurnstileWidgetId
|
||||
execute: (widgetId: TurnstileWidgetId) => void
|
||||
execute?: (widgetId: TurnstileWidgetId) => void
|
||||
reset: (widgetId: TurnstileWidgetId) => void
|
||||
remove?: (widgetId: TurnstileWidgetId) => void
|
||||
}
|
||||
@@ -39,9 +46,25 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
siteKey: string
|
||||
action?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
error: [message: string]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const widgetId = ref<TurnstileWidgetId | null>(null)
|
||||
const errorMessage = ref('')
|
||||
let pendingReject: ((error: Error) => void) | null = null
|
||||
|
||||
function loadTurnstileScript(): Promise<void> {
|
||||
@@ -51,6 +74,7 @@ function loadTurnstileScript(): Promise<void> {
|
||||
if (window.__aetherTurnstileScriptPromise) {
|
||||
return window.__aetherTurnstileScriptPromise
|
||||
}
|
||||
|
||||
window.__aetherTurnstileScriptPromise = new Promise((resolve, reject) => {
|
||||
const rejectAndReset = (script: HTMLScriptElement) => {
|
||||
script.remove()
|
||||
@@ -67,8 +91,9 @@ function loadTurnstileScript(): Promise<void> {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
script.src = TURNSTILE_SCRIPT_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.aetherTurnstile = 'true'
|
||||
@@ -76,24 +101,66 @@ function loadTurnstileScript(): Promise<void> {
|
||||
script.onerror = () => rejectAndReset(script)
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return window.__aetherTurnstileScriptPromise
|
||||
}
|
||||
|
||||
function clearWidget() {
|
||||
if (!widgetId.value || !window.turnstile) return
|
||||
if (window.turnstile.remove) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} else {
|
||||
window.turnstile.reset(widgetId.value)
|
||||
if (widgetId.value && window.turnstile) {
|
||||
if (window.turnstile.remove) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} else {
|
||||
window.turnstile.reset(widgetId.value)
|
||||
}
|
||||
}
|
||||
widgetId.value = null
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
async function renderWidget() {
|
||||
if (!props.siteKey || !containerRef.value) return
|
||||
clearWidget()
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await loadTurnstileScript()
|
||||
await nextTick()
|
||||
if (!window.turnstile || !containerRef.value) return
|
||||
widgetId.value = window.turnstile.render(containerRef.value, {
|
||||
sitekey: props.siteKey,
|
||||
action: props.action,
|
||||
callback: (token: string) => {
|
||||
errorMessage.value = ''
|
||||
emit('update:modelValue', token)
|
||||
},
|
||||
'expired-callback': () => {
|
||||
emit('update:modelValue', '')
|
||||
},
|
||||
'error-callback': () => {
|
||||
const message = '人机验证加载失败,请重试'
|
||||
errorMessage.value = message
|
||||
emit('update:modelValue', '')
|
||||
emit('error', message)
|
||||
},
|
||||
'timeout-callback': () => {
|
||||
const message = '人机验证超时,请重试'
|
||||
errorMessage.value = message
|
||||
emit('update:modelValue', '')
|
||||
emit('error', message)
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
const message = '人机验证加载失败,请重试'
|
||||
errorMessage.value = message
|
||||
emit('update:modelValue', '')
|
||||
emit('error', message)
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(action: string): Promise<string> {
|
||||
await loadTurnstileScript()
|
||||
const turnstile = window.turnstile
|
||||
const container = containerRef.value
|
||||
if (!turnstile || !container) {
|
||||
if (!turnstile || !container || !turnstile.execute) {
|
||||
throw new Error('Turnstile unavailable')
|
||||
}
|
||||
|
||||
@@ -133,13 +200,36 @@ function reset() {
|
||||
pendingReject(new Error('Turnstile reset'))
|
||||
pendingReject = null
|
||||
}
|
||||
clearWidget()
|
||||
emit('update:modelValue', '')
|
||||
errorMessage.value = ''
|
||||
if (widgetId.value && window.turnstile) {
|
||||
window.turnstile.reset(widgetId.value)
|
||||
return
|
||||
}
|
||||
void renderWidget()
|
||||
}
|
||||
|
||||
onBeforeUnmount(reset)
|
||||
|
||||
defineExpose({
|
||||
execute,
|
||||
reset,
|
||||
onMounted(() => {
|
||||
void renderWidget()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pendingReject) {
|
||||
pendingReject(new Error('Turnstile reset'))
|
||||
pendingReject = null
|
||||
}
|
||||
if (widgetId.value && window.turnstile) {
|
||||
if (window.turnstile.remove) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} else {
|
||||
window.turnstile.reset(widgetId.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.siteKey, () => props.action], () => {
|
||||
void renderWidget()
|
||||
})
|
||||
|
||||
defineExpose({ execute, reset })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import RegisterDialog from '../RegisterDialog.vue'
|
||||
|
||||
const authApiMocks = vi.hoisted(() => ({
|
||||
sendVerificationCode: vi.fn(),
|
||||
getVerificationStatus: vi.fn(),
|
||||
verifyEmail: vi.fn(),
|
||||
register: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: authApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/errorParser', () => ({
|
||||
parseApiError: (_error: unknown, fallback: string) => fallback,
|
||||
}))
|
||||
|
||||
vi.mock('../TurnstileWidget.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'TurnstileWidgetStub',
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
siteKey: { type: String, required: true },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(_props, { emit, expose }) {
|
||||
expose({ reset: vi.fn() })
|
||||
return () =>
|
||||
h('button', {
|
||||
type: 'button',
|
||||
'data-testid': 'turnstile-widget',
|
||||
onClick: () => emit('update:modelValue', 'turnstile-token-123'),
|
||||
}, 'Turnstile')
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
Dialog: defineComponent({
|
||||
name: 'DialogStub',
|
||||
props: { open: { type: Boolean, default: false } },
|
||||
emits: ['update:open'],
|
||||
setup(props, { slots }) {
|
||||
return () => props.open
|
||||
? h('div', [slots.default?.(), slots.footer?.()])
|
||||
: null
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ButtonStub',
|
||||
props: {
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { attrs, emit, slots }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: props.type,
|
||||
disabled: props.disabled,
|
||||
onClick: (event: MouseEvent) => emit('click', event),
|
||||
}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/input.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'InputStub',
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'text' },
|
||||
id: { type: String, default: undefined },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
id: props.id,
|
||||
type: props.type,
|
||||
disabled: props.disabled,
|
||||
value: props.modelValue,
|
||||
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/label.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'LabelStub',
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('label', attrs, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountRegisterDialog() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(RegisterDialog, {
|
||||
open: true,
|
||||
requireEmailVerification: true,
|
||||
emailConfigured: true,
|
||||
turnstileEnabled: true,
|
||||
turnstileSiteKey: 'site-key-123',
|
||||
'onUpdate:open': vi.fn(),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMocks.sendVerificationCode.mockReset()
|
||||
authApiMocks.getVerificationStatus.mockReset()
|
||||
authApiMocks.verifyEmail.mockReset()
|
||||
authApiMocks.register.mockReset()
|
||||
authApiMocks.getVerificationStatus.mockResolvedValue({
|
||||
has_pending_code: false,
|
||||
is_verified: false,
|
||||
cooldown_remaining: null,
|
||||
code_expires_in: null,
|
||||
})
|
||||
authApiMocks.sendVerificationCode.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'ok',
|
||||
expire_minutes: 5,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('RegisterDialog Turnstile verification flow', () => {
|
||||
it('shows Turnstile before sending code and includes the token in the request', async () => {
|
||||
const root = mountRegisterDialog()
|
||||
await settle()
|
||||
|
||||
expect(root.textContent).toContain('Turnstile')
|
||||
const emailInput = root.querySelector('#reg-email') as HTMLInputElement
|
||||
emailInput.value = 'alice@example.com'
|
||||
emailInput.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
|
||||
const sendButtonBeforeToken = Array.from(root.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('请先完成人机验证')) as HTMLButtonElement
|
||||
expect(sendButtonBeforeToken.disabled).toBe(true)
|
||||
|
||||
const turnstileButton = root.querySelector('[data-testid="turnstile-widget"]') as HTMLButtonElement
|
||||
turnstileButton.click()
|
||||
await settle()
|
||||
|
||||
const sendButton = Array.from(root.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('发送验证码')) as HTMLButtonElement
|
||||
expect(sendButton.disabled).toBe(false)
|
||||
sendButton.click()
|
||||
await settle()
|
||||
|
||||
expect(authApiMocks.sendVerificationCode).toHaveBeenCalledWith(
|
||||
'alice@example.com',
|
||||
'turnstile-token-123'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,8 @@ vi.mock('@/composables/useToast', () => ({
|
||||
}))
|
||||
|
||||
type TurnstileRenderOptions = {
|
||||
action?: string
|
||||
execution?: string
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: () => void
|
||||
}
|
||||
@@ -40,29 +42,39 @@ function flushPromises() {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function installTurnstileMock(mode: 'success' | 'error'): TurnstileMock {
|
||||
function installTurnstileMock(): TurnstileMock & {
|
||||
succeed: (token?: string) => void
|
||||
fail: () => void
|
||||
lastOptions: () => TurnstileRenderOptions | null
|
||||
} {
|
||||
let renderOptions: TurnstileRenderOptions | null = null
|
||||
const turnstile = {
|
||||
render: vi.fn((_container: HTMLElement, options: TurnstileRenderOptions) => {
|
||||
renderOptions = options
|
||||
return 'widget-id'
|
||||
}),
|
||||
execute: vi.fn(() => {
|
||||
window.queueMicrotask(() => {
|
||||
if (mode === 'success') {
|
||||
renderOptions?.callback?.('turnstile-token')
|
||||
} else {
|
||||
renderOptions?.['error-callback']?.()
|
||||
}
|
||||
})
|
||||
}),
|
||||
execute: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
succeed: (token = 'turnstile-token') => {
|
||||
renderOptions?.callback?.(token)
|
||||
},
|
||||
fail: () => {
|
||||
renderOptions?.['error-callback']?.()
|
||||
},
|
||||
lastOptions: () => renderOptions,
|
||||
}
|
||||
;(window as unknown as { turnstile: TurnstileMock }).turnstile = turnstile
|
||||
return turnstile
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountRegisterDialog() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
@@ -137,38 +149,43 @@ describe('RegisterDialog Turnstile flow', () => {
|
||||
})
|
||||
|
||||
it('gets a Turnstile token before submitting registration', async () => {
|
||||
const turnstile = installTurnstileMock('success')
|
||||
const turnstile = installTurnstileMock()
|
||||
mounted = await mountRegisterDialog()
|
||||
await fillRegistrationForm()
|
||||
|
||||
await clickRegister()
|
||||
await settle()
|
||||
|
||||
expect(turnstile.render).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({
|
||||
sitekey: 'site-public-key',
|
||||
action: 'register',
|
||||
execution: 'execute',
|
||||
})
|
||||
)
|
||||
expect(turnstile.execute).toHaveBeenCalledWith('widget-id')
|
||||
expect(turnstile.lastOptions()?.execution).toBeUndefined()
|
||||
turnstile.succeed('turnstile-token')
|
||||
await settle()
|
||||
|
||||
await clickRegister()
|
||||
|
||||
expect(turnstile.execute).not.toHaveBeenCalled()
|
||||
expect(registerMock).toHaveBeenCalledWith({
|
||||
username: 'alice',
|
||||
password: 'secret123',
|
||||
turnstile_token: 'turnstile-token',
|
||||
})
|
||||
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
|
||||
expect(turnstile.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('resets Turnstile and blocks registration when verification fails', async () => {
|
||||
const turnstile = installTurnstileMock('error')
|
||||
const turnstile = installTurnstileMock()
|
||||
mounted = await mountRegisterDialog()
|
||||
await fillRegistrationForm()
|
||||
await settle()
|
||||
|
||||
await clickRegister()
|
||||
turnstile.fail()
|
||||
await settle()
|
||||
|
||||
expect(registerMock).not.toHaveBeenCalled()
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('人机验证失败,请重试', '验证失败')
|
||||
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('人机验证加载失败,请重试', '人机验证失败')
|
||||
})
|
||||
})
|
||||
|
||||
169
frontend/src/views/shared/__tests__/Dashboard.spec.ts
Normal file
169
frontend/src/views/shared/__tests__/Dashboard.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import Dashboard from '../Dashboard.vue'
|
||||
|
||||
const dashboardApiMocks = vi.hoisted(() => ({
|
||||
getStats: vi.fn(),
|
||||
getDailyStats: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
canAccessAdmin: false,
|
||||
isAdmin: false,
|
||||
isAuditAdmin: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/dashboard', () => ({
|
||||
dashboardApi: dashboardApiMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/announcements', () => ({
|
||||
announcementApi: {
|
||||
getAnnouncements: vi.fn().mockResolvedValue({ items: [] }),
|
||||
markAsRead: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/charts/BarChart.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return { default: defineComponent({ name: 'BarChartStub', setup: () => () => h('div') }) }
|
||||
})
|
||||
|
||||
vi.mock('@/components/charts/DoughnutChart.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return { default: defineComponent({ name: 'DoughnutChartStub', setup: () => () => h('div') }) }
|
||||
})
|
||||
|
||||
vi.mock('@/components/charts/LineChart.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return { default: defineComponent({ name: 'LineChartStub', setup: () => () => h('div') }) }
|
||||
})
|
||||
|
||||
vi.mock('@/components/common', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
TimeRangePicker: defineComponent({
|
||||
name: 'TimeRangePickerStub',
|
||||
setup() {
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = (name: string, tag = 'div') => defineComponent({
|
||||
name,
|
||||
setup(_, { slots }) {
|
||||
return () => h(tag, slots.default?.())
|
||||
},
|
||||
})
|
||||
return {
|
||||
Card: passthrough('CardStub', 'section'),
|
||||
Badge: passthrough('BadgeStub', 'span'),
|
||||
Button: passthrough('ButtonStub', 'button'),
|
||||
Skeleton: defineComponent({ name: 'SkeletonStub', setup: () => () => h('div') }),
|
||||
Dialog: passthrough('DialogStub'),
|
||||
Table: passthrough('TableStub', 'table'),
|
||||
TableHeader: passthrough('TableHeaderStub', 'thead'),
|
||||
TableBody: passthrough('TableBodyStub', 'tbody'),
|
||||
TableRow: passthrough('TableRowStub', 'tr'),
|
||||
TableHead: passthrough('TableHeadStub', 'th'),
|
||||
TableCell: passthrough('TableCellStub', 'td'),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-vue-next', async () => {
|
||||
const Icon = defineComponent({
|
||||
name: 'IconStub',
|
||||
setup() {
|
||||
return () => h('span')
|
||||
},
|
||||
})
|
||||
return {
|
||||
Users: Icon,
|
||||
Activity: Icon,
|
||||
TrendingUp: Icon,
|
||||
DollarSign: Icon,
|
||||
Key: Icon,
|
||||
Hash: Icon,
|
||||
Zap: Icon,
|
||||
Bell: Icon,
|
||||
AlertCircle: Icon,
|
||||
AlertTriangle: Icon,
|
||||
Info: Icon,
|
||||
Wrench: Icon,
|
||||
Loader2: Icon,
|
||||
Clock: Icon,
|
||||
Database: Icon,
|
||||
Shuffle: Icon,
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountDashboard() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Dashboard)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dashboardApiMocks.getStats.mockReset()
|
||||
dashboardApiMocks.getDailyStats.mockReset()
|
||||
dashboardApiMocks.getDailyStats.mockResolvedValue({
|
||||
daily_stats: [],
|
||||
model_summary: [],
|
||||
period: { start_date: '2026-05-01', end_date: '2026-05-15', days: 15 },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Dashboard ordinary user wallet card', () => {
|
||||
it('renders package and wallet balance split from mocked stats', async () => {
|
||||
dashboardApiMocks.getStats.mockResolvedValue({
|
||||
stats: [
|
||||
{ name: 'API 密钥', value: '0', subValue: '活跃 0', icon: 'Activity' },
|
||||
{ name: '本月请求', value: '0', subValue: '今日 0', icon: 'Users' },
|
||||
{
|
||||
name: '钱包余额',
|
||||
value: '$110.00',
|
||||
subValue: '套餐额度 $100.00 · 钱包余额 $10.00',
|
||||
icon: 'DollarSign',
|
||||
},
|
||||
{ name: '本月 Token', value: '0', subValue: '输入 0 / 输出 0', icon: 'Zap' },
|
||||
],
|
||||
today: { requests: 0, tokens: 0, cost: 0 },
|
||||
cache_stats: { cache_creation_tokens: 0, cache_read_tokens: 0, total_cache_tokens: 0 },
|
||||
token_breakdown: { input: 0, output: 0, cache_creation: 0, cache_read: 0 },
|
||||
monthly_cost: 0,
|
||||
})
|
||||
|
||||
const root = mountDashboard()
|
||||
await settle()
|
||||
|
||||
expect(root.textContent).toContain('$110.00')
|
||||
expect(root.textContent).toContain('套餐额度 $100.00 · 钱包余额 $10.00')
|
||||
})
|
||||
})
|
||||
69
frontend/src/views/user/__tests__/ModelCatalog.spec.ts
Normal file
69
frontend/src/views/user/__tests__/ModelCatalog.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
|
||||
import type { PublicGlobalModel } from '@/api/public-models'
|
||||
import UserModelDetailDrawer from '../components/UserModelDetailDrawer.vue'
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function model(overrides: Partial<PublicGlobalModel> = {}): PublicGlobalModel {
|
||||
return {
|
||||
id: 'gm-test',
|
||||
name: 'gpt-5',
|
||||
display_name: 'GPT 5',
|
||||
is_active: true,
|
||||
default_tiered_pricing: null,
|
||||
default_price_per_request: null,
|
||||
supported_capabilities: ['chat'],
|
||||
config: null,
|
||||
usage_count: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountDrawer(selectedModel: PublicGlobalModel) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(UserModelDetailDrawer, {
|
||||
open: true,
|
||||
model: selectedModel,
|
||||
'onUpdate:open': vi.fn(),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('user model catalog detail drawer', () => {
|
||||
it('does not render model mapping fields for ordinary users', async () => {
|
||||
mountDrawer(model({
|
||||
config: {
|
||||
description: 'User visible description',
|
||||
model_mappings: ['gpt-5-upstream'],
|
||||
provider_model_mappings: [{ name: 'provider-gpt-5' }],
|
||||
},
|
||||
}))
|
||||
await nextTick()
|
||||
|
||||
const text = document.body.textContent || ''
|
||||
expect(text).toContain('GPT 5')
|
||||
expect(text).toContain('User visible description')
|
||||
expect(text).not.toContain('模型映射')
|
||||
expect(text).not.toContain('gpt-5-upstream')
|
||||
expect(text).not.toContain('provider-gpt-5')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user