2 changed files with 108 additions and 0 deletions
@ -0,0 +1,46 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
defineProps<{ |
||||
|
svg: string |
||||
|
loading: boolean |
||||
|
modelValue: string |
||||
|
}>() |
||||
|
|
||||
|
const emit = defineEmits<{ |
||||
|
refresh: [] |
||||
|
'update:modelValue': [value: string] |
||||
|
}>() |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<UFormField label="Captcha" required> |
||||
|
<div v-if="svg" class="flex gap-2 items-start"> |
||||
|
<!-- eslint-disable-next-line vue/no-v-html --> |
||||
|
<div |
||||
|
class="flex-1 h-10 border rounded-md overflow-hidden bg-[#f8f9fa]" |
||||
|
role="img" |
||||
|
aria-label="CAPTCHA verification code" |
||||
|
v-html="svg" |
||||
|
/> |
||||
|
<UButton |
||||
|
variant="ghost" |
||||
|
color="neutral" |
||||
|
:icon="'i-lucide-refresh-cw'" |
||||
|
:class="{ 'animate-spin': loading }" |
||||
|
:disabled="loading" |
||||
|
square |
||||
|
aria-label="刷新验证码" |
||||
|
@click="emit('refresh')" |
||||
|
/> |
||||
|
</div> |
||||
|
<div v-else class="text-sm text-red-500"> |
||||
|
验证码加载失败, |
||||
|
<button class="underline" @click="emit('refresh')">点此重试</button> |
||||
|
</div> |
||||
|
<UInput |
||||
|
:model-value="modelValue" |
||||
|
placeholder="Enter the code above" |
||||
|
class="mt-2" |
||||
|
@update:model-value="emit('update:modelValue', $event)" |
||||
|
/> |
||||
|
</UFormField> |
||||
|
</template> |
||||
@ -0,0 +1,62 @@ |
|||||
|
import { describe, it, expect } from 'vitest' |
||||
|
import { mount } from '@vue/test-utils' |
||||
|
import CaptchaField from '../CaptchaField.vue' |
||||
|
|
||||
|
const stubs = { |
||||
|
UInput: { |
||||
|
template: '<input :type="type" :disabled="disabled" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />', |
||||
|
props: ['type', 'disabled', 'modelValue', 'placeholder'], |
||||
|
}, |
||||
|
UButton: { |
||||
|
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>', |
||||
|
props: ['disabled', 'icon', 'variant', 'color', 'square'], |
||||
|
emits: ['click'], |
||||
|
}, |
||||
|
UFormField: { |
||||
|
template: '<div><span>{{ label }}</span><slot /></div>', |
||||
|
props: ['label', 'required'], |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
const defaultProps = { svg: '<svg></svg>', loading: false, modelValue: '' } |
||||
|
|
||||
|
function mountComponent(overrides = {}) { |
||||
|
return mount(CaptchaField, { |
||||
|
props: { ...defaultProps, ...overrides }, |
||||
|
global: { stubs }, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
describe('CaptchaField', () => { |
||||
|
it('renders SVG via v-html', () => { |
||||
|
const svg = '<svg><circle cx="10" cy="10" r="5"/></svg>' |
||||
|
const wrapper = mountComponent({ svg }) |
||||
|
expect(wrapper.html()).toContain('<circle') |
||||
|
}) |
||||
|
|
||||
|
it('emits refresh when button clicked', async () => { |
||||
|
const wrapper = mountComponent() |
||||
|
const btn = wrapper.find('button') |
||||
|
await btn.trigger('click') |
||||
|
expect(wrapper.emitted('refresh')).toBeTruthy() |
||||
|
}) |
||||
|
|
||||
|
it('disables refresh button when loading', () => { |
||||
|
const wrapper = mountComponent({ loading: true }) |
||||
|
const btn = wrapper.find('button') |
||||
|
expect(btn.attributes('disabled')).toBeDefined() |
||||
|
}) |
||||
|
|
||||
|
it('renders error state when svg is empty', () => { |
||||
|
const wrapper = mountComponent({ svg: '' }) |
||||
|
expect(wrapper.text()).toContain('加载失败') |
||||
|
}) |
||||
|
|
||||
|
it('emits update:modelValue when input changes', async () => { |
||||
|
const wrapper = mountComponent() |
||||
|
const input = wrapper.find('input') |
||||
|
await input.setValue('abc') |
||||
|
expect(wrapper.emitted('update:modelValue')).toBeTruthy() |
||||
|
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['abc']) |
||||
|
}) |
||||
|
}) |
||||
Loading…
Reference in new issue