mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
merge: 同步主线并解决用户侧验证冲突
This commit is contained in:
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="turnstileRequired && (!requireEmailVerification || !emailVerified)"
|
||||
v-if="turnstileRequired"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>人机验证 <span class="text-destructive">*</span></Label>
|
||||
@@ -64,6 +64,7 @@
|
||||
ref="turnstileWidgetRef"
|
||||
v-model="turnstileToken"
|
||||
:site-key="turnstileSiteKey"
|
||||
:action="currentTurnstileAction"
|
||||
:disabled="isLoading || isSendingCode"
|
||||
@error="handleTurnstileError"
|
||||
/>
|
||||
@@ -113,7 +114,9 @@
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm">正在发送验证码...</span>
|
||||
<span class="text-sm">
|
||||
{{ sendCodeLoadingText }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 验证码输入框 -->
|
||||
<template v-else>
|
||||
@@ -208,6 +211,7 @@
|
||||
两次输入的密码不一致
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
@@ -398,11 +402,17 @@ 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 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 = ''
|
||||
@@ -417,7 +427,11 @@ const handleTurnstileError = (message: string) => {
|
||||
const canSendCode = computed(() => {
|
||||
if (!formData.value.email) return false
|
||||
if (cooldownSeconds.value > 0) return false
|
||||
if (turnstileRequired.value && !turnstileToken.value) return false
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'send_verification_code' &&
|
||||
!turnstileToken.value
|
||||
) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -425,11 +439,17 @@ const sendCodeButtonText = computed(() => {
|
||||
if (isSendingCode.value) return '发送中...'
|
||||
if (emailVerified.value) return '验证成功'
|
||||
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||
if (turnstileRequired.value && !turnstileToken.value) return '请先完成人机验证'
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'send_verification_code' &&
|
||||
!turnstileToken.value
|
||||
) return '请先完成人机验证'
|
||||
if (codeSentAt.value) return '重新发送验证码'
|
||||
return '发送验证码'
|
||||
})
|
||||
|
||||
const sendCodeLoadingText = computed(() => '正在发送验证码...')
|
||||
|
||||
// 用户名验证
|
||||
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
|
||||
const usernameError = computed(() => {
|
||||
@@ -463,7 +483,13 @@ const canSubmit = computed(() => {
|
||||
if (!formData.value.email || !emailVerified.value) {
|
||||
return false
|
||||
}
|
||||
} else if (turnstileRequired.value && !turnstileToken.value) {
|
||||
}
|
||||
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'register' &&
|
||||
!turnstileToken.value
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -540,6 +566,10 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(currentTurnstileAction, () => {
|
||||
resetTurnstile()
|
||||
})
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
@@ -602,6 +632,7 @@ const resetForm = () => {
|
||||
|
||||
// Clear verification code inputs
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
resetTurnstile()
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
@@ -650,6 +681,7 @@ const handleSendCode = async () => {
|
||||
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
|
||||
} finally {
|
||||
isSendingCode.value = false
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,7 +734,11 @@ const handleSubmit = async () => {
|
||||
showError('请先完成邮箱验证')
|
||||
return
|
||||
}
|
||||
if (!props.requireEmailVerification && turnstileRequired.value && !turnstileToken.value) {
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'register' &&
|
||||
!turnstileToken.value
|
||||
) {
|
||||
showError('请先完成人机验证')
|
||||
return
|
||||
}
|
||||
@@ -720,7 +756,7 @@ const handleSubmit = async () => {
|
||||
if (formData.value.email && formData.value.email.trim()) {
|
||||
registerData.email = formData.value.email
|
||||
}
|
||||
if (!props.requireEmailVerification && turnstileRequired.value) {
|
||||
if (turnstileRequired.value && currentTurnstileAction.value === 'register') {
|
||||
registerData.turnstile_token = turnstileToken.value
|
||||
}
|
||||
|
||||
@@ -735,6 +771,7 @@ const handleSubmit = async () => {
|
||||
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,26 +19,41 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
let loadTurnstilePromise: Promise<void> | null = null
|
||||
type TurnstileWidgetId = string
|
||||
|
||||
interface TurnstileRenderOptions {
|
||||
sitekey: string
|
||||
action?: string
|
||||
execution?: 'render' | 'execute'
|
||||
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: () => void
|
||||
'expired-callback'?: () => void
|
||||
'timeout-callback'?: () => void
|
||||
}
|
||||
|
||||
interface TurnstileApi {
|
||||
render: (container: HTMLElement, options: Record<string, unknown>) => string
|
||||
reset: (widgetId: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => TurnstileWidgetId
|
||||
execute?: (widgetId: TurnstileWidgetId) => void
|
||||
reset: (widgetId: TurnstileWidgetId) => void
|
||||
remove?: (widgetId: TurnstileWidgetId) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
__aetherTurnstileScriptPromise?: Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
siteKey: string
|
||||
action?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
@@ -48,28 +63,32 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const widgetId = ref<string | null>(null)
|
||||
const widgetId = ref<TurnstileWidgetId | null>(null)
|
||||
const errorMessage = ref('')
|
||||
let pendingReject: ((error: Error) => void) | null = null
|
||||
|
||||
function loadTurnstileScript(): Promise<void> {
|
||||
if (window.turnstile) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (loadTurnstilePromise) {
|
||||
return loadTurnstilePromise
|
||||
if (window.__aetherTurnstileScriptPromise) {
|
||||
return window.__aetherTurnstileScriptPromise
|
||||
}
|
||||
|
||||
loadTurnstilePromise = new Promise((resolve, reject) => {
|
||||
window.__aetherTurnstileScriptPromise = new Promise((resolve, reject) => {
|
||||
const rejectAndReset = (script: HTMLScriptElement) => {
|
||||
script.remove()
|
||||
delete window.__aetherTurnstileScriptPromise
|
||||
reject(new Error('Turnstile script failed'))
|
||||
}
|
||||
const existing = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${TURNSTILE_SCRIPT_URL}"]`
|
||||
'script[data-aether-turnstile="true"]'
|
||||
)
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true })
|
||||
existing.addEventListener('error', () => {
|
||||
existing.remove()
|
||||
loadTurnstilePromise = null
|
||||
reject(new Error('turnstile script failed'))
|
||||
}, { once: true })
|
||||
existing.addEventListener('error', () => rejectAndReset(existing), {
|
||||
once: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,21 +96,22 @@ function loadTurnstileScript(): Promise<void> {
|
||||
script.src = TURNSTILE_SCRIPT_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.aetherTurnstile = 'true'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => {
|
||||
script.remove()
|
||||
loadTurnstilePromise = null
|
||||
reject(new Error('turnstile script failed'))
|
||||
}
|
||||
script.onerror = () => rejectAndReset(script)
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return loadTurnstilePromise
|
||||
return window.__aetherTurnstileScriptPromise
|
||||
}
|
||||
|
||||
function clearWidget() {
|
||||
if (widgetId.value && window.turnstile) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
if (window.turnstile.remove) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} else {
|
||||
window.turnstile.reset(widgetId.value)
|
||||
}
|
||||
}
|
||||
widgetId.value = null
|
||||
emit('update:modelValue', '')
|
||||
@@ -107,6 +127,7 @@ async function renderWidget() {
|
||||
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)
|
||||
@@ -120,6 +141,12 @@ async function renderWidget() {
|
||||
emit('update:modelValue', '')
|
||||
emit('error', message)
|
||||
},
|
||||
'timeout-callback': () => {
|
||||
const message = '人机验证超时,请重试'
|
||||
errorMessage.value = message
|
||||
emit('update:modelValue', '')
|
||||
emit('error', message)
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
const message = '人机验证加载失败,请重试'
|
||||
@@ -129,7 +156,50 @@ async function renderWidget() {
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(action: string): Promise<string> {
|
||||
await loadTurnstileScript()
|
||||
const turnstile = window.turnstile
|
||||
const container = containerRef.value
|
||||
if (!turnstile || !container || !turnstile.execute) {
|
||||
throw new Error('Turnstile unavailable')
|
||||
}
|
||||
|
||||
clearWidget()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingReject = reject
|
||||
const id = turnstile.render(container, {
|
||||
sitekey: props.siteKey,
|
||||
action,
|
||||
execution: 'execute',
|
||||
appearance: 'interaction-only',
|
||||
callback: (token: string) => {
|
||||
pendingReject = null
|
||||
resolve(token)
|
||||
},
|
||||
'error-callback': () => {
|
||||
pendingReject = null
|
||||
reject(new Error('Turnstile challenge failed'))
|
||||
},
|
||||
'expired-callback': () => {
|
||||
pendingReject = null
|
||||
reject(new Error('Turnstile token expired'))
|
||||
},
|
||||
'timeout-callback': () => {
|
||||
pendingReject = null
|
||||
reject(new Error('Turnstile challenge timed out'))
|
||||
},
|
||||
})
|
||||
widgetId.value = id
|
||||
turnstile.execute(id)
|
||||
})
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (pendingReject) {
|
||||
pendingReject(new Error('Turnstile reset'))
|
||||
pendingReject = null
|
||||
}
|
||||
emit('update:modelValue', '')
|
||||
errorMessage.value = ''
|
||||
if (widgetId.value && window.turnstile) {
|
||||
@@ -144,14 +214,22 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pendingReject) {
|
||||
pendingReject(new Error('Turnstile reset'))
|
||||
pendingReject = null
|
||||
}
|
||||
if (widgetId.value && window.turnstile) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
if (window.turnstile.remove) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} else {
|
||||
window.turnstile.reset(widgetId.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.siteKey, () => {
|
||||
watch([() => props.siteKey, () => props.action], () => {
|
||||
void renderWidget()
|
||||
})
|
||||
|
||||
defineExpose({ reset })
|
||||
defineExpose({ execute, reset })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import RegisterDialog from '../RegisterDialog.vue'
|
||||
|
||||
const { registerMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
|
||||
registerMock: vi.fn(),
|
||||
toastErrorMock: vi.fn(),
|
||||
toastSuccessMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: {
|
||||
register: registerMock,
|
||||
sendVerificationCode: vi.fn(),
|
||||
verifyEmail: vi.fn(),
|
||||
getVerificationStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: toastSuccessMock,
|
||||
error: toastErrorMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
type TurnstileRenderOptions = {
|
||||
action?: string
|
||||
execution?: string
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: () => void
|
||||
}
|
||||
|
||||
type TurnstileMock = {
|
||||
render: ReturnType<typeof vi.fn>
|
||||
execute: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
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(),
|
||||
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)
|
||||
const app = createApp(RegisterDialog, {
|
||||
open: true,
|
||||
emailConfigured: false,
|
||||
requireEmailVerification: false,
|
||||
passwordPolicyLevel: 'weak',
|
||||
turnstileEnabled: true,
|
||||
turnstileSiteKey: 'site-public-key',
|
||||
})
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
return {
|
||||
app,
|
||||
root,
|
||||
unmount: () => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fillRegistrationForm() {
|
||||
const inputs = Array.from(document.body.querySelectorAll('input'))
|
||||
const usernameInput = inputs.find((input) => input.placeholder === '请输入用户名')
|
||||
const passwordInput = inputs.find((input) => input.placeholder.includes('至少'))
|
||||
const confirmInput = inputs.find((input) => input.placeholder === '再次输入密码')
|
||||
|
||||
for (const [input, value] of [
|
||||
[usernameInput, 'alice'],
|
||||
[passwordInput, 'secret123'],
|
||||
[confirmInput, 'secret123'],
|
||||
] as const) {
|
||||
expect(input).toBeTruthy()
|
||||
input!.value = value
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function clickRegister() {
|
||||
const registerButton = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === '注册'
|
||||
)
|
||||
expect(registerButton).toBeTruthy()
|
||||
expect(registerButton!.hasAttribute('disabled')).toBe(false)
|
||||
registerButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('RegisterDialog Turnstile flow', () => {
|
||||
let mounted: Awaited<ReturnType<typeof mountRegisterDialog>> | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
registerMock.mockReset()
|
||||
registerMock.mockResolvedValue({ message: '注册成功' })
|
||||
toastErrorMock.mockReset()
|
||||
toastSuccessMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.unmount()
|
||||
mounted = null
|
||||
document.body.innerHTML = ''
|
||||
delete (window as unknown as { turnstile?: TurnstileMock }).turnstile
|
||||
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
|
||||
.__aetherTurnstileScriptPromise
|
||||
})
|
||||
|
||||
it('gets a Turnstile token before submitting registration', async () => {
|
||||
const turnstile = installTurnstileMock()
|
||||
mounted = await mountRegisterDialog()
|
||||
await fillRegistrationForm()
|
||||
await settle()
|
||||
|
||||
expect(turnstile.render).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({
|
||||
sitekey: 'site-public-key',
|
||||
action: 'register',
|
||||
})
|
||||
)
|
||||
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.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('resets Turnstile and blocks registration when verification fails', async () => {
|
||||
const turnstile = installTurnstileMock()
|
||||
mounted = await mountRegisterDialog()
|
||||
await fillRegistrationForm()
|
||||
await settle()
|
||||
|
||||
turnstile.fail()
|
||||
await settle()
|
||||
|
||||
expect(registerMock).not.toHaveBeenCalled()
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('人机验证加载失败,请重试', '人机验证失败')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import TurnstileWidget from '../TurnstileWidget.vue'
|
||||
|
||||
function mountTurnstileWidget() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(TurnstileWidget, { siteKey: 'site-public-key' })
|
||||
const instance = app.mount(root) as unknown as {
|
||||
execute: (action: string) => Promise<string>
|
||||
}
|
||||
return {
|
||||
instance,
|
||||
unmount: () => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function turnstileScripts() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>('script[data-aether-turnstile="true"]')
|
||||
)
|
||||
}
|
||||
|
||||
describe('TurnstileWidget script loading', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('script[data-aether-turnstile="true"]').forEach((script) => {
|
||||
script.remove()
|
||||
})
|
||||
delete (window as unknown as { turnstile?: unknown }).turnstile
|
||||
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
|
||||
.__aetherTurnstileScriptPromise
|
||||
})
|
||||
|
||||
it('retries loading the Turnstile script after a transient load failure', async () => {
|
||||
const mounted = mountTurnstileWidget()
|
||||
|
||||
const firstAttempt = mounted.instance.execute('register')
|
||||
const firstScript = turnstileScripts()[0]
|
||||
expect(firstScript).toBeTruthy()
|
||||
firstScript.dispatchEvent(new Event('error'))
|
||||
await expect(firstAttempt).rejects.toThrow('Turnstile script failed')
|
||||
|
||||
const secondAttempt = mounted.instance.execute('register')
|
||||
const scriptsAfterRetry = turnstileScripts()
|
||||
expect(scriptsAfterRetry).toHaveLength(1)
|
||||
expect(scriptsAfterRetry[0]).not.toBe(firstScript)
|
||||
scriptsAfterRetry[0].dispatchEvent(new Event('error'))
|
||||
await expect(secondAttempt).rejects.toThrow('Turnstile script failed')
|
||||
|
||||
mounted.unmount()
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制自动冷却、主动探测、异常清理和全局调度优先级。
|
||||
控制自动冷却、自适应热池、异常清理和全局调度优先级。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -67,28 +67,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.probing_enabled"
|
||||
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测间隔
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probing_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="10"
|
||||
@update:model-value="(v) => form.probing_interval_minutes = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.account_self_check_enabled"
|
||||
class="space-y-3 rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
|
||||
@@ -261,7 +239,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制刷新 OAuth、主动探测和批量额度处理时的并行请求数。
|
||||
控制刷新 OAuth、自适应热池和批量额度处理时的并行请求数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -708,7 +686,6 @@ const form = ref({
|
||||
request_failure_penalty: null as number | null | undefined,
|
||||
probe_failure_cooldown_threshold: null as number | null | undefined,
|
||||
probing_enabled: false,
|
||||
probing_interval_minutes: null as number | null | undefined,
|
||||
account_self_check_enabled: false,
|
||||
account_self_check_interval_minutes: null as number | null | undefined,
|
||||
account_self_check_concurrency: null as number | null | undefined,
|
||||
@@ -807,7 +784,6 @@ watch(() => props.modelValue, (open) => {
|
||||
request_failure_penalty: scoreRules?.request_failure_penalty ?? null,
|
||||
probe_failure_cooldown_threshold: scoreRules?.probe_failure_cooldown_threshold ?? null,
|
||||
probing_enabled: cfg?.probing_enabled ?? false,
|
||||
probing_interval_minutes: cfg?.probing_interval_minutes ?? null,
|
||||
account_self_check_enabled: cfg?.account_self_check_enabled ?? false,
|
||||
account_self_check_interval_minutes: cfg?.account_self_check_interval_minutes ?? null,
|
||||
account_self_check_concurrency: cfg?.account_self_check_concurrency ?? null,
|
||||
@@ -855,6 +831,7 @@ async function handleSave() {
|
||||
'probing_active_target_count',
|
||||
'active_probe_target_percent',
|
||||
'active_probe_target_count',
|
||||
'probing_interval_minutes',
|
||||
'account_self_check_method',
|
||||
'self_check_method',
|
||||
'account_self_check_request',
|
||||
@@ -879,9 +856,6 @@ async function handleSave() {
|
||||
score_fallback_scan_limit: form.value.score_fallback_scan_limit ?? undefined,
|
||||
score_rules: scoreRules,
|
||||
probing_enabled: form.value.probing_enabled,
|
||||
probing_interval_minutes: form.value.probing_enabled
|
||||
? (form.value.probing_interval_minutes ?? undefined)
|
||||
: undefined,
|
||||
account_self_check_enabled: form.value.account_self_check_enabled,
|
||||
account_self_check_interval_minutes: form.value.account_self_check_enabled
|
||||
? (form.value.account_self_check_interval_minutes ?? undefined)
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('poolAdvancedDialog', () => {
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
label: '自适应热池',
|
||||
description: '自动维护热池,缺口时异步补位。',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
label: '自适应热池',
|
||||
description: '自动维护热池,缺口时异步补位。',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user