Feat: add verification capthca and form adjustment
This commit is contained in:
No files matched your search
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, defineEmits, defineProps, onMounted, nextTick, defineExpose } from 'vue'
|
||||
import Input from '~/components/pub/ui/input/Input.vue';
|
||||
import Button from '~/components/pub/ui/button/Button.vue';
|
||||
import waveyFingerprint from '~/assets/svg/wavey-fingerprint.svg'
|
||||
|
||||
/**
|
||||
* TextCaptcha props:
|
||||
* - length: number of characters in the core captcha
|
||||
* - caseSensitive: whether validation is case sensitive
|
||||
* - useSpacing: show spaced-out characters (visual obfuscation only)
|
||||
* - noiseChars: include random noise characters visually (not required to type)
|
||||
*/
|
||||
const props = defineProps({
|
||||
length: { type: Number, default: 6 },
|
||||
caseSensitive: { type: Boolean, default: false },
|
||||
useSpacing: { type: Boolean, default: true },
|
||||
noiseChars: { type: Boolean, default: false }, // adds random noise characters to display
|
||||
refreshCooldownMs: { type: Number, default: 500 }, // guard repeated refresh
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:valid', valid: boolean): void
|
||||
(e: 'validated', valid: boolean): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
// Internal state
|
||||
const raw = ref('') // the canonical captcha value (what user must match, ignoring visual noise)
|
||||
const display = ref('') // randomized visual representation (may include spacing/noise)
|
||||
const input = ref('') // user typed value
|
||||
const lastRefresh = ref(0)
|
||||
const valid = inject('isCaptchaValid') as Ref<boolean>
|
||||
const errorMessage = ref('')
|
||||
|
||||
/** Characters excluding ambiguous ones: 0/O, 1/l/I etc. */
|
||||
const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
||||
|
||||
function randomChar() {
|
||||
return CHARS.charAt(Math.floor(Math.random() * CHARS.length))
|
||||
}
|
||||
|
||||
/** Generate the canonical captcha string */
|
||||
function genRaw(len = props.length) {
|
||||
let s = ''
|
||||
for (let i = 0; i < len; i++) s += randomChar()
|
||||
return s
|
||||
}
|
||||
|
||||
/** Create a visually obfuscated display string (spacing, noise, random case) */
|
||||
function genDisplay(base: string) {
|
||||
const arr: string[] = []
|
||||
for (const ch of base) {
|
||||
// toggle case randomly (only for letters)
|
||||
const c = /[A-Za-z]/.test(ch) && Math.random() > 0.5 ? (Math.random() > 0.5 ? ch.toLowerCase() : ch.toUpperCase()) : ch
|
||||
arr.push(c)
|
||||
if (props.useSpacing && Math.random() > 0.3) arr.push(' ') // random space
|
||||
}
|
||||
return arr.join('')
|
||||
}
|
||||
|
||||
/** Refresh captcha */
|
||||
function refresh() {
|
||||
const now = Date.now()
|
||||
if (now - lastRefresh.value < props.refreshCooldownMs) return
|
||||
lastRefresh.value = now
|
||||
|
||||
raw.value = genRaw(props.length)
|
||||
display.value = genDisplay(raw.value)
|
||||
input.value = ''
|
||||
valid.value = false
|
||||
errorMessage.value = ''
|
||||
// emit change so parent knows new value (but we don't send the raw canonical in production)
|
||||
emit('change', display.value)
|
||||
}
|
||||
|
||||
/** Normalize input and canonical for comparison */
|
||||
function normalizeForCompare(s: string) {
|
||||
const normalized = s.replace(/\s+/g, '') // strip spaces
|
||||
return props.caseSensitive ? normalized : normalized.toLowerCase()
|
||||
}
|
||||
|
||||
/** Validate the current input */
|
||||
function validate() {
|
||||
const left = normalizeForCompare(input.value)
|
||||
const right = normalizeForCompare(raw.value)
|
||||
if (!input.value) {
|
||||
valid.value = false
|
||||
errorMessage.value = 'Please enter the captcha text.'
|
||||
} else if (left === right) {
|
||||
valid.value = true
|
||||
errorMessage.value = ''
|
||||
} else {
|
||||
valid.value = false
|
||||
errorMessage.value = 'Captcha does not match.'
|
||||
}
|
||||
emit('update:valid', valid.value)
|
||||
emit('validated', valid.value)
|
||||
return valid.value
|
||||
}
|
||||
|
||||
// expose a refresh method to parent via ref
|
||||
defineExpose({ refresh, validate, isValid: computed(() => valid.value) })
|
||||
|
||||
// generate on mount
|
||||
onMounted(() => refresh())
|
||||
|
||||
// // re-validate whenever input changes (lightweight)
|
||||
// watch(input, () => {
|
||||
// // we don't auto-pass until the user explicitly validate (but we can optionally live-validate)
|
||||
// // Here we perform live feedback but still emit validated only when called
|
||||
// const left = normalizeForCompare(input.value)
|
||||
// const right = normalizeForCompare(raw.value)
|
||||
// valid.value = !!input.value && left === right
|
||||
// // emit a live update so the parent can disable submit accordingly
|
||||
// emit('update:valid', valid.value)
|
||||
// })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 w-full max-w-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- Captcha visual box -->
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Text captcha, type the characters shown"
|
||||
tabindex="0"
|
||||
class="select-none p-3 rounded-md border border-gray-200 text-white text-xl font-mono tracking-wider text-center w-full"
|
||||
>
|
||||
<span class="inline-block" v-html="display"></span>
|
||||
</div>
|
||||
|
||||
<!-- Refresh -->
|
||||
<div class="flex-shrink-0">
|
||||
<Button variant="ghost" type="button" @click="refresh" title="Refresh captcha">
|
||||
<Icon name="i-lucide-refresh-cw" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="flex-grow">
|
||||
<Input
|
||||
v-model="input"
|
||||
:aria-invalid="valid ? 'false' : 'true'"
|
||||
inputmode="text"
|
||||
placeholder="Type the captcha text"
|
||||
@keyup.enter="validate"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-xs text-red-500 mt-1">{{ errorMessage }}</p>
|
||||
<p v-else-if="valid" class="text-xs text-green-500 mt-1">Correct</p>
|
||||
<p v-else class="text-xs text-gray-500 mt-1">Not case-sensitive</p>
|
||||
</div>
|
||||
<Button variant="outline" type="button" @click="validate" title="Validate"
|
||||
class="border-orange-400">
|
||||
<Icon name="i-lucide-check" class="text-orange-400" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* small nicety: make noise/spaced display look irregular */
|
||||
div[role="img"] {
|
||||
background: url('~/assets/svg/wavey-fingerprint.svg') repeat center;
|
||||
}
|
||||
|
||||
div[role="img"] span {
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user