Commit 0876cde6 authored by 郑磊's avatar 郑磊

init

parents
Pipeline #829 canceled with stages
node_modules/
# 行为验证码H5库
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
.captcha--slider-bar[data-v-fcfe5ea2]{height:55px;position:relative;margin-top:15px}.captcha--slider-bar--bar[data-v-fcfe5ea2]{height:35px;position:relative;top:5px;background-color:#eee;border-radius:3px;overflow:hidden}.captcha--slider-bar--bar .captcha--slider-bar--active[data-v-fcfe5ea2]{position:absolute;left:0;top:0;height:100%;background-color:#6495ed}.captcha--slider-bar--track[data-v-fcfe5ea2]{background-color:#fff;position:absolute;left:0;top:0;width:55px;height:45px;border-radius:3px;box-shadow:0 0 5px #999;display:flex;justify-content:center;align-items:center}.captcha--slider-bar--track .captcha--slider-bar--track-icon[data-v-fcfe5ea2]{fill:#333;display:block;width:25px;height:25px}.captcha--status-tip[data-v-4b782e10]{position:absolute;left:0;bottom:0;width:100%;display:flex;justify-content:center;align-items:center;color:#fff;font-size:14px;height:30px}.captcha--status-tip .captcha--status-tip--icon[data-v-4b782e10]{fill:#fff;display:block;width:18px;height:18px;margin-right:5px}.captcha--status-tip.captcha--status-tip--success[data-v-4b782e10]{background-color:#39c522}.captcha--status-tip.captcha--status-tip--fail[data-v-4b782e10]{background-color:#ff5d39}.captcha--view[data-v-ef209593]{padding:15px 10px}.captcha--view--content[data-v-ef209593]{position:relative;height:210px;margin-top:15px;overflow:hidden}.captcha--view--tip[data-v-ef209593]{font-size:14px;color:#333;line-height:24px;text-align:center}.captcha--status-tip-enter-active[data-v-ef209593],.captcha--status-tip-appear-active[data-v-ef209593]{transition:transform ease-out .4s}.captcha--status-tip-enter-from[data-v-ef209593],.captcha--status-tip-appear-from[data-v-ef209593]{transform:translateY(100%)}.captcha--status-tip-enter-to[data-v-ef209593],.captcha--status-tip-appear-to[data-v-ef209593]{transform:translateY(0)}.captcha--view--concat--bg[data-v-fd603259]{position:absolute;display:block;width:100%;height:100%}.captcha--view--concat--track[data-v-fd603259]{position:absolute;display:block;left:0;top:0;width:100%;overflow:hidden;background-size:100% auto;background-position:0px 0px}.captcha--view--rotate--bg[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%}.captcha--view--rotate--track[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%;object-fit:contain}.captcha--view--slider--bg[data-v-8fb0f971]{position:absolute;display:block;width:100%;height:100%}.captcha--view--slider--track[data-v-8fb0f971]{position:absolute;display:block;width:auto;height:100%;left:0;top:0}.captcha--error[data-v-6ba70e16]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:rgba(255,255,255,.7)}.captcha--error .captcha--error--icon[data-v-6ba70e16]{fill:#1c4c5b;width:80px;height:80px}.captcha--error .captcha--error--text[data-v-6ba70e16]{color:#1c4c5b;font-size:14px;margin-top:12px}.captcha--loading[data-v-edb749e2]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background-color:rgba(255,255,255,.8)}.captcha--loading .lds-ellipsis[data-v-edb749e2],.captcha--loading .lds-ellipsis div[data-v-edb749e2]{box-sizing:border-box}.captcha--loading .lds-ellipsis[data-v-edb749e2]{color:#1c4c5b;display:inline-block;position:relative;width:80px;height:80px}.captcha--loading .lds-ellipsis div[data-v-edb749e2]{position:absolute;top:33.33333px;width:13.33333px;height:13.33333px;border-radius:50%;background:currentColor;animation-timing-function:cubic-bezier(0,1,1,0)}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(1){left:8px;animation:lds-ellipsis1-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(2){left:8px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(3){left:32px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(4){left:56px;animation:lds-ellipsis3-edb749e2 .6s infinite}@keyframes lds-ellipsis1-edb749e2{0%{transform:scale(0)}to{transform:scale(1)}}@keyframes lds-ellipsis3-edb749e2{0%{transform:scale(1)}to{transform:scale(0)}}@keyframes lds-ellipsis2-edb749e2{0%{transform:translate(0)}to{transform:translate(24px)}}@keyframes captcha--refreshing-3b07deff{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.captcha--container[data-v-3b07deff]{position:relative;width:300px;height:400px;background:#fff;display:flex;flex-direction:column;overflow:hidden}.captcha--container.captcha--container--radius[data-v-3b07deff]{border-radius:6px}.captcha--container.captcha--container--shadow[data-v-3b07deff]{box-shadow:0 0 11px #999}.captcha--box[data-v-3b07deff]{position:relative;flex:1;overflow:hidden}.captcha--content[data-v-3b07deff]{position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.captcha--bottom[data-v-3b07deff]{display:flex;justify-content:space-between;align-items:center;flex-shrink:0;padding:15px 10px}.captcha--bottom .captcha--svg-icon[data-v-3b07deff]{width:28px;height:28px;display:block;cursor:pointer;fill:#1c4c5b}.captcha--bottom .captcha--svg-icon+.captcha--svg-icon[data-v-3b07deff]{margin-left:12px}.captcha--bottom .captcha--btn-refresh.captcha--btn-refresh--active[data-v-3b07deff]{animation:captcha--refreshing-3b07deff linear .8s infinite}
This diff is collapsed.
.captcha--slider-bar[data-v-fcfe5ea2]{height:55px;position:relative;margin-top:15px}.captcha--slider-bar--bar[data-v-fcfe5ea2]{height:35px;position:relative;top:5px;background-color:#eee;border-radius:3px;overflow:hidden}.captcha--slider-bar--bar .captcha--slider-bar--active[data-v-fcfe5ea2]{position:absolute;left:0;top:0;height:100%;background-color:#6495ed}.captcha--slider-bar--track[data-v-fcfe5ea2]{background-color:#fff;position:absolute;left:0;top:0;width:55px;height:45px;border-radius:3px;box-shadow:0 0 5px #999;display:flex;justify-content:center;align-items:center}.captcha--slider-bar--track .captcha--slider-bar--track-icon[data-v-fcfe5ea2]{fill:#333;display:block;width:25px;height:25px}.captcha--status-tip[data-v-4b782e10]{position:absolute;left:0;bottom:0;width:100%;display:flex;justify-content:center;align-items:center;color:#fff;font-size:14px;height:30px}.captcha--status-tip .captcha--status-tip--icon[data-v-4b782e10]{fill:#fff;display:block;width:18px;height:18px;margin-right:5px}.captcha--status-tip.captcha--status-tip--success[data-v-4b782e10]{background-color:#39c522}.captcha--status-tip.captcha--status-tip--fail[data-v-4b782e10]{background-color:#ff5d39}.captcha--view[data-v-ef209593]{padding:15px 10px}.captcha--view--content[data-v-ef209593]{position:relative;height:210px;margin-top:15px;overflow:hidden}.captcha--view--tip[data-v-ef209593]{font-size:14px;color:#333;line-height:24px;text-align:center}.captcha--status-tip-enter-active[data-v-ef209593],.captcha--status-tip-appear-active[data-v-ef209593]{transition:transform ease-out .4s}.captcha--status-tip-enter-from[data-v-ef209593],.captcha--status-tip-appear-from[data-v-ef209593]{transform:translateY(100%)}.captcha--status-tip-enter-to[data-v-ef209593],.captcha--status-tip-appear-to[data-v-ef209593]{transform:translateY(0)}.captcha--view--concat--bg[data-v-fd603259]{position:absolute;display:block;width:100%;height:100%}.captcha--view--concat--track[data-v-fd603259]{position:absolute;display:block;left:0;top:0;width:100%;overflow:hidden;background-size:100% auto;background-position:0px 0px}.captcha--view--rotate--bg[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%}.captcha--view--rotate--track[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%;object-fit:contain}.captcha--view--slider--bg[data-v-8fb0f971]{position:absolute;display:block;width:100%;height:100%}.captcha--view--slider--track[data-v-8fb0f971]{position:absolute;display:block;width:auto;height:100%;left:0;top:0}.captcha--error[data-v-6ba70e16]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:rgba(255,255,255,.7)}.captcha--error .captcha--error--icon[data-v-6ba70e16]{fill:#1c4c5b;width:80px;height:80px}.captcha--error .captcha--error--text[data-v-6ba70e16]{color:#1c4c5b;font-size:14px;margin-top:12px}.captcha--loading[data-v-edb749e2]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background-color:rgba(255,255,255,.8)}.captcha--loading .lds-ellipsis[data-v-edb749e2],.captcha--loading .lds-ellipsis div[data-v-edb749e2]{box-sizing:border-box}.captcha--loading .lds-ellipsis[data-v-edb749e2]{color:#1c4c5b;display:inline-block;position:relative;width:80px;height:80px}.captcha--loading .lds-ellipsis div[data-v-edb749e2]{position:absolute;top:33.33333px;width:13.33333px;height:13.33333px;border-radius:50%;background:currentColor;animation-timing-function:cubic-bezier(0,1,1,0)}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(1){left:8px;animation:lds-ellipsis1-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(2){left:8px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(3){left:32px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(4){left:56px;animation:lds-ellipsis3-edb749e2 .6s infinite}@keyframes lds-ellipsis1-edb749e2{0%{transform:scale(0)}to{transform:scale(1)}}@keyframes lds-ellipsis3-edb749e2{0%{transform:scale(1)}to{transform:scale(0)}}@keyframes lds-ellipsis2-edb749e2{0%{transform:translate(0)}to{transform:translate(24px)}}@keyframes captcha--refreshing-0a2d342a{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.captcha--container[data-v-0a2d342a]{position:relative;width:300px;height:400px;background:#fff;display:flex;flex-direction:column;overflow:hidden}.captcha--container.captcha--container--radius[data-v-0a2d342a]{border-radius:6px}.captcha--container.captcha--container--shadow[data-v-0a2d342a]{box-shadow:0 0 11px #999}.captcha--box[data-v-0a2d342a]{position:relative;flex:1;overflow:hidden}.captcha--content[data-v-0a2d342a]{position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.captcha--bottom[data-v-0a2d342a]{display:flex;justify-content:space-between;align-items:center;flex-shrink:0;padding:15px 10px}.captcha--bottom .captcha--svg-icon[data-v-0a2d342a]{width:28px;height:28px;display:block;cursor:pointer;fill:#1c4c5b}.captcha--bottom .captcha--svg-icon+.captcha--svg-icon[data-v-0a2d342a]{margin-left:12px}.captcha--bottom .captcha--btn-refresh.captcha--btn-refresh--active[data-v-0a2d342a]{animation:captcha--refreshing-0a2d342a linear .8s infinite}
This diff is collapsed.
{
"name": "fj-captcha-vue",
"version": "1.0.0",
"type": "module",
"main": "./esm/index.js",
"typings": "./esm/index.d.ts",
"files": [
"browser",
"esm"
],
"scripts": {
"build": "npm run build:esm && npm run build:declaration && npm run build:browser",
"build:esm": "vite build -c vite.esm.config.ts",
"build:browser": "vite build -c vite.browser.config.ts",
"build:declaration": "vue-tsc -p tsconfig.dts.json"
},
"peerDependencies": {
"vue": "^3.0.0"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@vitejs/plugin-vue": "^5.0.4",
"less": "^4.2.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vue": "^3.4.27",
"vue-tsc": "^2.0.17"
}
}
<script setup lang="ts">
import { PropType, computed, onMounted, ref, toRef } from 'vue'
import ConcatCaptcha from './captcha-views/ConcatCaptcha.vue'
import RotateCaptcha from './captcha-views/RotateCaptcha.vue'
import SliderCaptcha from './captcha-views/SliderCaptcha.vue'
import ErrorView from './components/ErrorView.vue'
import Loading from './components/Loading.vue'
import { CaptchaData, loadCaptcha, validCaptcha } from './core'
import { provideLocale } from './locales'
import { CurrentCaptchaData } from './types'
const captchaViews = {
CONCAT: ConcatCaptcha,
SLIDER: SliderCaptcha,
ROTATE: RotateCaptcha,
}
const emit = defineEmits<{
close: []
success: [id: string]
}>()
const props = defineProps({
requestCaptchaDataUrl: {
type: String,
required: true,
},
validCaptchaUrl: {
type: String,
required: true,
},
showClose: {
type: Boolean,
default: true,
},
radius: {
type: Number,
},
shadow: {
type: Boolean,
default: true,
},
locale: {
type: String,
},
extra: {
type: Object as PropType<Record<string, any>>,
default: () => ({}),
},
})
const radiusStyle = computed(() =>
typeof props.radius === 'number' &&
!isNaN(props.radius) &&
props.radius >= 0
? { borderRadius: `${props.radius}px` }
: {},
)
const locale = toRef(props, 'locale')
provideLocale(locale)
const loading = ref(false)
const refreshing = ref(false)
const verifying = ref(false)
const currentCaptcha = ref<CurrentCaptchaData>()
const error = ref(false)
const containerRef = ref<HTMLDivElement>()
const loadId = ref(0)
const _loadCaptcha = () => {
loading.value = true
const id = ++loadId.value
return new Promise<CaptchaData>((resolve, reject) => {
loadCaptcha({
url: props.requestCaptchaDataUrl,
data: {
extra: props.extra,
},
})
.then((resp) => {
if (id !== loadId.value) return
if (resp.code === 0) {
resolve(resp.data)
} else {
reject(resp)
}
loading.value = false
})
.catch((err) => {
if (id !== loadId.value) return
reject(err)
loading.value = false
})
})
}
/**
* 执行加载操作
*/
const load = async () => {
try {
currentCaptcha.value = await _loadCaptcha()
} catch {
//加载失败
error.value = true
}
}
const onRefresh = async () => {
if (refreshing.value || verifying.value) return
error.value = false
refreshing.value = true
await load()
refreshing.value = false
}
//初始化时自动刷新
onMounted(onRefresh)
/**
* 子组件触发的校验回调
*/
const onVerify = async (data: object) => {
if (!currentCaptcha.value) return
const captcha = currentCaptcha.value
verifying.value = true
const message = await validCaptcha({
url: props.validCaptchaUrl,
data: {
key: captcha.key,
data,
},
})
if (!message) {
//没有错误信息就表示验证成功
captcha.verifyStatus = { status: true }
//1.5秒后触发成功回调
setTimeout(() => emit('success', captcha.key), 1500)
return
}
//设置校验失败数据
captcha.verifyStatus = {
status: false,
message,
}
//1.5秒后重新加载
setTimeout(() => {
verifying.value = false
load()
}, 1500)
}
defineExpose({
containerRef,
refresh: () => {
onRefresh()
},
})
</script>
<template>
<div
ref="containerRef"
class="captcha--container"
:class="{
'captcha--container--radius': radius,
'captcha--container--shadow': shadow,
}"
:style="radiusStyle"
@touchmove.prevent
@mousemove.prevent
>
<div class="captcha--box">
<component
v-if="currentCaptcha"
:is="captchaViews[currentCaptcha.captcha.type]"
:key="currentCaptcha.key"
:captcha="currentCaptcha.captcha"
:verifyStatus="currentCaptcha.verifyStatus"
@verify="onVerify"
/>
<template v-if="currentCaptcha"> </template>
<Loading v-if="loading" />
<ErrorView v-if="error" />
</div>
<div className="captcha--bottom">
<svg
class="captcha--svg-icon captcha--btn-refresh"
:class="{
'captcha--btn-refresh--active': refreshing,
}"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4399"
width="64"
height="64"
@click="onRefresh"
>
<path
d="M715.7 794.7c-1.9-4.7-7.4-6.5-11.8-4-54.3 31.1-115.8 50.3-177.2 50.3-192.2 0-345.2-148.3-345.2-335.7 0-37.7 5.5-72.6 18.3-107.1l65.2 17.5c6.3 1.7 11.9-4.1 10-10.5l-55.5-183.1c-1.8-5.9-9.1-7.9-13.5-3.6l-139.4 131c-4.8 4.5-2.8 12.3 3.5 14.1l58.2 15.6c-15.7 42.6-25.4 86.5-25.4 135 0 226.6 187.9 409.8 419.5 409.8 76.7 0 149.6-18.6 215.5-55.9 3.6-2.1 5.2-6.5 3.6-10.4l-25.8-63zM308.2 229.2c1.9 4.7 7.4 6.5 11.8 4 54.3-31.2 115.8-50.3 177.2-50.3 192.2 0 345.2 148.3 345.2 335.7 0 37.7-5.5 72.6-18.3 107.1l-65.2-17.5c-6.3-1.7-11.9 4.1-10 10.5l55.5 183.1c1.8 5.9 9.1 7.9 13.5 3.6l139.5-130.8c4.8-4.5 2.8-12.3-3.5-14.1l-58.2-15.6c15.7-42.6 25.4-86.5 25.4-135 0-226.6-187.9-409.8-419.5-409.8-76.7 0-149.6 18.6-215.5 55.9-3.6 2.1-5.2 6.5-3.6 10.4l25.7 62.8z"
p-id="4400"
></path>
</svg>
<svg
v-if="showClose"
class="captcha--svg-icon captcha--btn-close"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="5377"
width="64"
height="64"
@click="
() => {
if (verifying) return
emit('close')
}
"
>
<path
d="M509.866667 32C245.333333 32 32 247.466667 32 512s213.333333 480 477.866667 480S987.733333 776.533333 987.733333 512 774.4 32 509.866667 32z m0 896C281.6 928 96 742.4 96 512S281.6 96 509.866667 96 923.733333 281.6 923.733333 512s-185.6 416-413.866666 416z"
p-id="5378"
></path>
<path
d="M693.333333 330.666667c-12.8-12.8-32-12.8-44.8 0L512 467.2l-136.533333-136.533333c-12.8-12.8-32-12.8-44.8 0-12.8 12.8-12.8 32 0 44.8l136.533333 136.533333-136.533333 136.533333c-12.8 12.8-12.8 32 0 44.8 6.4 6.4 14.933333 8.533333 23.466666 8.533334s17.066667-2.133333 23.466667-8.533334l136.533333-136.533333 136.533334 136.533333c6.4 6.4 14.933333 8.533333 23.466666 8.533334s17.066667-2.133333 23.466667-8.533334c12.8-12.8 12.8-32 0-44.8L556.8 512l136.533333-136.533333c12.8-12.8 12.8-32 0-44.8z"
p-id="5379"
></path>
</svg>
</div>
</div>
</template>
<style lang="less" scoped>
@keyframes captcha--refreshing {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.captcha--container {
position: relative;
width: 300px;
height: 400px;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
&.captcha--container--radius {
border-radius: 6px;
}
&.captcha--container--shadow {
box-shadow: 0 0 11px 0 #999999;
}
}
.captcha--box {
position: relative;
flex: 1;
overflow: hidden;
}
.captcha--content {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
.captcha--bottom {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
padding: 15px 10px;
.captcha--svg-icon {
width: 28px;
height: 28px;
display: block;
cursor: pointer;
fill: #1c4c5b;
& + .captcha--svg-icon {
margin-left: 12px;
}
}
.captcha--btn-refresh {
&.captcha--btn-refresh--active {
animation: captcha--refreshing linear 800ms infinite;
}
}
}
</style>
import { createApp } from 'vue'
import Captcha from './Captcha.vue'
export interface CaptchaProps {
/**
* 获取验证码数据的接口地址
*/
requestCaptchaDataUrl: string
/**
* 校验验证码数据的接口地址
*/
validCaptchaUrl: string
/**
* 是否显示关闭按钮
*/
showClose?: boolean
/**
* 是否显示为圆角
*/
radius?: boolean
/**
* 是否显示阴影
*/
shadow?: boolean
/**
* 显示语言设置,支持zh-CN,zh-TW,en
*/
locale?: string
/**
* 额外的CSS类名
*/
className?: string
/**
* 额外的样式
*/
style?: Record<string, string | number> | string
/**
* 当验证码校验成功时触发的回调
* @param id 验证码id
*/
onSuccess?: (id: string) => void
/**
* 当用户点击关闭按钮时触发的回调
*/
onClose?: () => void
}
export interface CaptchaInstance {
refresh(): void
}
export function createCaptcha(
container: string | Element,
options: CaptchaProps,
) {
const app = createApp(Captcha, {
...options,
})
const refresh = () => {
console.log(app)
}
app.mount(container)
return {
refresh,
}
}
<script setup lang="ts">
import { PropType, computed, onBeforeUnmount, ref } from 'vue'
import CaptchaViewContainer from '../components/CaptchaViewContainer.vue'
import {
CaptchaDisplayData,
createConcatCaptcha,
getConcatTrackHeight,
} from '../core'
import { VerifyStatus } from '../types'
const emit = defineEmits<{
verify: [data: object]
}>()
const props = defineProps({
captcha: {
type: Object as PropType<CaptchaDisplayData>,
required: true,
},
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
})
const ratio = 280 / props.captcha.backgroundImageWidth
const x = ref(0)
const height = computed(() => getConcatTrackHeight(props.captcha, ratio))
const instance = createConcatCaptcha({
captcha: props.captcha,
ratio: ratio,
max: 280 - 55,
onDrag: (v) => (x.value = v),
onVerify: (data) => emit('verify', data),
})
onBeforeUnmount(() => instance.destroy())
</script>
<template>
<CaptchaViewContainer
class="captcha--view--concat"
:verifyStatus="verifyStatus"
:x="x"
@touchstart="instance.onDragStart"
@mousedown="instance.onDragStart"
>
<img :src="captcha.backgroundImage" class="captcha--view--concat--bg" />
<div
class="captcha--view--concat--track"
:style="{
height: `${height}px`,
backgroundImage: `url('${captcha.backgroundImage}')`,
backgroundPositionX: `${x}px`,
}"
></div>
</CaptchaViewContainer>
</template>
<style lang="less" scoped>
.captcha--view--concat--bg {
position: absolute;
display: block;
width: 100%;
height: 100%;
}
.captcha--view--concat--track {
position: absolute;
display: block;
left: 0;
top: 0;
width: 100%;
overflow: hidden;
background-size: 100% auto;
background-position: 0px 0px;
}
</style>
<script setup lang="ts">
import { PropType, onBeforeUnmount, ref } from 'vue'
import CaptchaViewContainer from '../components/CaptchaViewContainer.vue'
import { CaptchaDisplayData, createRotateCaptcha } from '../core'
import { VerifyStatus } from '../types'
const emit = defineEmits<{
verify: [data: object]
}>()
const props = defineProps({
captcha: {
type: Object as PropType<CaptchaDisplayData>,
required: true,
},
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
})
const x = ref(0)
const rotate = ref(0)
const ratio = 280 / props.captcha.backgroundImageWidth
const instance = createRotateCaptcha({
captcha: props.captcha,
ratio,
max: 280 - 55,
onDrag: (dx, drotate) => {
x.value = dx
rotate.value = drotate
},
onVerify: (data) => emit('verify', data),
})
onBeforeUnmount(() => instance.destroy())
</script>
<template>
<CaptchaViewContainer
class="captcha--view--rotate"
:verifyStatus="verifyStatus"
:x="x"
@touchstart="instance.onDragStart"
@mousedown="instance.onDragStart"
>
<img :src="captcha.backgroundImage" class="captcha--view--rotate--bg" />
<img
:src="captcha.templateImage"
class="captcha--view--rotate--track"
:style="{ transform: `rotate(${rotate}deg)` }"
/>
</CaptchaViewContainer>
</template>
<style lang="less" scoped>
.captcha--view--rotate--bg {
position: absolute;
display: block;
width: 100%;
height: 100%;
}
.captcha--view--rotate--track {
position: absolute;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
<script setup lang="ts">
import { PropType, onBeforeUnmount, ref } from 'vue'
import CaptchaViewContainer from '../components/CaptchaViewContainer.vue'
import { CaptchaDisplayData, createSliderCaptcha } from '../core'
import { VerifyStatus } from '../types'
const emit = defineEmits<{
verify: [data: object]
}>()
const props = defineProps({
captcha: {
type: Object as PropType<CaptchaDisplayData>,
required: true,
},
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
})
const ratio = 280 / props.captcha.backgroundImageWidth
const x = ref(0)
const instance = createSliderCaptcha({
captcha: props.captcha,
ratio,
onDrag: (dx) => {
x.value = dx
},
onVerify: (data) => emit('verify', data),
})
onBeforeUnmount(() => instance.destroy())
</script>
<template>
<CaptchaViewContainer
class="captcha--view--slider"
:verifyStatus="verifyStatus"
:x="x"
@touchstart="instance.onDragStart"
@mousedown="instance.onDragStart"
>
<img :src="captcha.backgroundImage" class="captcha--view--slider--bg" />
<img
:src="captcha.templateImage"
class="captcha--view--slider--track"
:style="{ transform: `translateX(${x}px)` }"
/>
</CaptchaViewContainer>
</template>
<style lang="less" scoped>
.captcha--view--slider--bg {
position: absolute;
display: block;
width: 100%;
height: 100%;
}
.captcha--view--slider--track {
position: absolute;
display: block;
width: auto;
height: 100%;
left: 0;
top: 0;
}
</style>
<script setup lang="ts">
import { PropType } from 'vue'
import SliderBar from '../components/SliderBar.vue'
import StatusTip from '../components/StatusTip.vue'
import { useTranslator } from '../locales'
import { VerifyStatus } from '../types'
defineProps({
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
x: {
type: Number,
default: 0,
},
})
const t = useTranslator()
</script>
<template>
<div class="captcha--view">
<div class="captcha--view--tip">{{ t('拖动下方滑块完成拼图') }}</div>
<div class="captcha--view--content">
<slot></slot>
<Transition name="captcha--status-tip">
<StatusTip v-if="verifyStatus" v-bind="verifyStatus" />
</Transition>
</div>
<SliderBar :x="x" />
</div>
</template>
<style lang="less" scoped>
.captcha--view {
padding: 15px 10px;
}
.captcha--view--content {
position: relative;
height: 210px;
margin-top: 15px;
overflow: hidden;
}
.captcha--view--tip {
font-size: 14px;
color: #333;
line-height: 24px;
text-align: center;
}
.captcha--status-tip-enter-active,
.captcha--status-tip-appear-active {
transition: transform ease-out 400ms;
}
.captcha--status-tip-enter-from,
.captcha--status-tip-appear-from {
transform: translateY(100%);
}
.captcha--status-tip-enter-to,
.captcha--status-tip-appear-to {
transform: translateY(0%);
}
</style>
<script setup lang="ts">
import { useTranslator } from '../locales'
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const t = useTranslator()
</script>
<template>
<div className="captcha--error" @click="(evt) => emit('click', evt)">
<svg
class="captcha--error--icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="6383"
width="64"
height="64"
>
<path
d="M575.986694 832.039919C575.986694 867.356032 547.316113 896.026613 512 896.026613 476.683887 896.026613 448.013306 867.356032 448.013306 832.039919 448.013306 796.723806 476.683887 768.048156 512 768.048156 547.316113 768.048156 575.986694 796.64777 575.986694 832.039919ZM512 255.997465C476.683887 255.997465 448.013306 284.668046 448.013306 319.984159L448.013306 639.998733C448.013306 675.314846 476.683887 703.990496 512 703.990496 547.316113 703.990496 575.986694 675.314846 575.986694 639.998733L575.986694 319.984159C575.986694 284.668046 547.316113 255.997465 512 255.997465ZM1023.979724 896.026613C1023.979724 966.729805 966.709529 1024.005069 896.006336 1024.005069L127.993664 1024.005069C57.290471 1024.005069 0.020276 966.658839 0.020276 896.026613 0.020276 874.868373 5.216059 854.931776 14.39105 837.311737L14.320083 837.24077 398.250384 69.304133 398.392317 69.304133C419.626593 28.133261 462.455047 0.040552 512 0.040552 561.544953 0.040552 604.373407 28.209297 625.67865 69.3751L1008.311272 834.711311C1018.348003 852.838256 1023.979724 873.783595 1023.979724 896.026613ZM959.99303 896.026613C959.99303 885.123073 957.392604 874.868373 952.191753 865.404445L951.613881 864.319667 951.112044 863.239958 568.621355 98.405584C557.499847 77.171308 535.834701 64.027246 512 64.027246 488.023365 64.027246 466.282184 77.318311 455.160675 98.765487L452.12938 104.544211 92.175714 824.527578 92.677551 825.034483 71.154339 866.778159C66.389426 875.948082 64.00697 885.842879 64.00697 896.097579 64.00697 931.413692 92.748517 960.089342 127.993664 960.089342L896.006336 960.089342C931.322449 960.013306 959.99303 931.342726 959.99303 896.026613Z"
p-id="6384"
></path>
</svg>
<div class="captcha--error--text">{{ t('网络异常,请点击重试') }}</div>
</div>
</template>
<style lang="less" scoped>
.captcha--error {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(#fff, 0.7);
.captcha--error--icon {
fill: #1c4c5b;
width: 80px;
height: 80px;
}
.captcha--error--text {
color: #1c4c5b;
font-size: 14px;
margin-top: 12px;
}
}
</style>
<template>
<div class="captcha--loading">
<div class="lds-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</template>
<style lang="less" scoped>
.captcha--loading {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(#fff, 0.8);
.lds-ellipsis,
.lds-ellipsis div {
box-sizing: border-box;
}
.lds-ellipsis {
color: #1c4c5b;
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33.33333px;
width: 13.33333px;
height: 13.33333px;
border-radius: 50%;
background: currentColor;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
</style>
<script setup lang="ts">
defineProps({
x: {
type: Number,
default: 0,
},
})
</script>
<template>
<div class="captcha--slider-bar">
<div class="captcha--slider-bar--bar">
<div
class="captcha--slider-bar--active"
:style="{ width: `${x}px` }"
></div>
</div>
<div
class="captcha--slider-bar--track"
:style="{
transform: `translateX(${x}px)`,
}"
>
<svg
class="captcha--slider-bar--track-icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4239"
width="64"
height="64"
>
<path
d="M576 192v256H0v128h576v256l448-320z"
p-id="4240"
></path>
</svg>
</div>
</div>
</template>
<style lang="less" scoped>
.captcha--slider-bar {
height: 55px;
position: relative;
margin-top: 15px;
}
.captcha--slider-bar--bar {
height: 35px;
position: relative;
top: 5px;
background-color: #eee;
border-radius: 3px;
overflow: hidden;
.captcha--slider-bar--active {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: cornflowerblue;
}
}
.captcha--slider-bar--track {
background-color: #fff;
position: absolute;
left: 0;
top: 0;
width: 55px;
height: 45px;
border-radius: 3px;
box-shadow: 0 0 5px 0 #999999;
display: flex;
justify-content: center;
align-items: center;
.captcha--slider-bar--track-icon {
fill: #333;
display: block;
width: 25px;
height: 25px;
}
}
</style>
<script setup lang="ts">
import { useTranslator } from '../locales'
defineProps({
status: {
type: Boolean,
required: true,
},
message: {
type: String,
},
})
const t = useTranslator()
</script>
<template>
<div
:class="`captcha--status-tip captcha--status-tip--${
status ? 'success' : 'fail'
}`"
>
<template v-if="status">
<svg
class="captcha--status-tip--icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="5407"
width="64"
height="64"
>
<path
d="M896 512a384 384 0 1 0-768 0 384 384 0 0 0 768 0zM42.666667 512C42.666667 252.8 252.8 42.666667 512 42.666667s469.333333 210.133333 469.333333 469.333333-210.133333 469.333333-469.333333 469.333333S42.666667 771.2 42.666667 512z m652.501333-158.165333l60.330667 60.330666L469.333333 700.330667l-200.832-200.832 60.330667-60.330667L469.333333 579.669333l225.834667-225.834666z"
p-id="5408"
></path>
</svg>
<div>{{ t('验证成功') }}</div>
</template>
<template v-else>
<svg
class="captcha--status-tip--icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="6383"
width="64"
height="64"
>
<path
d="M575.986694 832.039919C575.986694 867.356032 547.316113 896.026613 512 896.026613 476.683887 896.026613 448.013306 867.356032 448.013306 832.039919 448.013306 796.723806 476.683887 768.048156 512 768.048156 547.316113 768.048156 575.986694 796.64777 575.986694 832.039919ZM512 255.997465C476.683887 255.997465 448.013306 284.668046 448.013306 319.984159L448.013306 639.998733C448.013306 675.314846 476.683887 703.990496 512 703.990496 547.316113 703.990496 575.986694 675.314846 575.986694 639.998733L575.986694 319.984159C575.986694 284.668046 547.316113 255.997465 512 255.997465ZM1023.979724 896.026613C1023.979724 966.729805 966.709529 1024.005069 896.006336 1024.005069L127.993664 1024.005069C57.290471 1024.005069 0.020276 966.658839 0.020276 896.026613 0.020276 874.868373 5.216059 854.931776 14.39105 837.311737L14.320083 837.24077 398.250384 69.304133 398.392317 69.304133C419.626593 28.133261 462.455047 0.040552 512 0.040552 561.544953 0.040552 604.373407 28.209297 625.67865 69.3751L1008.311272 834.711311C1018.348003 852.838256 1023.979724 873.783595 1023.979724 896.026613ZM959.99303 896.026613C959.99303 885.123073 957.392604 874.868373 952.191753 865.404445L951.613881 864.319667 951.112044 863.239958 568.621355 98.405584C557.499847 77.171308 535.834701 64.027246 512 64.027246 488.023365 64.027246 466.282184 77.318311 455.160675 98.765487L452.12938 104.544211 92.175714 824.527578 92.677551 825.034483 71.154339 866.778159C66.389426 875.948082 64.00697 885.842879 64.00697 896.097579 64.00697 931.413692 92.748517 960.089342 127.993664 960.089342L896.006336 960.089342C931.322449 960.013306 959.99303 931.342726 959.99303 896.026613Z"
p-id="6384"
></path>
</svg>
<div>{{ t(message ?? '验证失败,请再试一次') }}</div>
</template>
</div>
</template>
<style lang="less" scoped>
.captcha--status-tip {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-size: 14px;
height: 30px;
.captcha--status-tip--icon {
fill: #fff;
display: block;
width: 18px;
height: 18px;
margin-right: 5px;
}
&.captcha--status-tip--success {
background-color: #39c522;
}
&.captcha--status-tip--fail {
background-color: #ff5d39;
}
}
</style>
import { CaptchaDisplayData } from './types'
/**
* 位置数据
*/
interface Position {
x: number
y: number
}
/**
* 创建验证码实例的配置项
*/
interface CreateCaptchaOptions {
/**
* 自动绑定开始拖动事件的元素
*/
triggers?: HTMLElement[]
/**
* 验证码显示数据
*/
captcha: CaptchaDisplayData
/**
* 允许拖动的最大距离
*/
max: number
/**
* 当进行拖动时触发的回调
* @param x 拖动的距离
*/
onDrag: (x: number) => void
/**
* 当拖动完成需要校验数据时触发的回调
* @param data 待校验的数据
* @returns
*/
onVerify: (data: object) => void
}
/**
* 验证码实例
*/
export interface CaptchaInstance {
/**
* 当开始拖动时调用的方法
* @param event
* @returns
*/
onDragStart: (event: MouseEvent | TouchEvent) => void
/**
* 销毁验证码实例,解除所有的事件绑定
*/
destroy(): void
}
/**
* 拖动事件追踪数据
*/
interface TrackData extends Position {
type: 'up' | 'move' | 'down'
t: number
}
/**
* 拖动中的数据
*/
interface TouchingData extends Position {
startTime: number
}
/**
* 从原生事件中获取坐标
* @param event
* @returns
*/
function getCurrentCoordinate(event: any): Position {
if (event.pageX !== null && event.pageX !== undefined) {
return {
x: Math.round(event.pageX),
y: Math.round(event.pageY),
}
}
let targetTouches
if (event.changedTouches) {
// 抬起事件
targetTouches = event.changedTouches
} else if (event.targetTouches) {
// pc 按下事件
targetTouches = event.targetTouches
} else if (event.originalEvent && event.originalEvent.targetTouches) {
// 鼠标触摸事件
targetTouches = event.originalEvent.targetTouches
}
if (
targetTouches[0].pageX !== null &&
targetTouches[0].pageX !== undefined
) {
return {
x: Math.round(targetTouches[0].pageX),
y: Math.round(targetTouches[0].pageY),
}
}
return {
x: Math.round(targetTouches[0].clientX),
y: Math.round(targetTouches[0].clientY),
}
}
/**
* 创建验证码实例
* @param options
*/
function createCaptcha(options: CreateCaptchaOptions): CaptchaInstance {
const { triggers = [], max, onDrag, onVerify } = options
let touching: TouchingData | null = null
let trackList: TrackData[] = []
const onDragStart = (event: MouseEvent | TouchEvent) => {
const coords = getCurrentCoordinate(event)
touching = {
...coords,
startTime: Date.now(),
}
trackList.push({
x: 0,
y: 0,
type: 'down',
t: 0,
})
//绑定全局事件
window.addEventListener('touchmove', onDragMove, { passive: true })
window.addEventListener('mousemove', onDragMove, { passive: true })
window.addEventListener('touchend', onDragEnd, { passive: true })
window.addEventListener('mouseup', onDragEnd, { passive: true })
}
const onDragMove = (event: MouseEvent | TouchEvent) => {
if (!touching) return
const coords = getCurrentCoordinate(event)
trackList.push({
x: coords.x - touching.x,
y: coords.y - touching.y,
type: 'move',
t: Date.now() - touching.startTime,
})
const x = Math.max(0, Math.min(max, coords.x - touching.x))
onDrag(x)
}
const onDragEnd = (event: MouseEvent | TouchEvent) => {
if (!touching) return
unbindGlobalEvents()
//如果追踪列表中没有任何移动事件,那么不会触发后续动作,只是重置数据
if (!trackList.some((t) => t.type === 'move')) {
trackList = []
} else {
const coords = getCurrentCoordinate(event)
trackList.push({
x: coords.x - touching.x,
y: coords.y - touching.y,
type: 'up',
t: Date.now() - touching.startTime,
})
unbindTriggerEvents()
onVerify({
startSlidingTime: new Date(touching.startTime).toISOString(),
endSlidingTime: new Date().toISOString(),
trackList,
})
}
touching = null
}
/**
* 解除触发元素上的事件
*/
const unbindTriggerEvents = () => {
triggers.forEach((el) => {
el.removeEventListener('touchstart', onDragStart)
el.removeEventListener('mousedown', onDragStart)
})
}
/**
* 解除全局元素上的事件
*/
const unbindGlobalEvents = () => {
window.removeEventListener('touchmove', onDragMove)
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('touchend', onDragEnd)
window.removeEventListener('mouseup', onDragEnd)
}
//如果传入了触发元素则自动绑定事件
triggers.forEach((el) => {
el.addEventListener('touchstart', onDragStart, { passive: true })
el.addEventListener('mousedown', onDragStart, { passive: true })
})
return {
destroy: () => {
unbindTriggerEvents()
unbindGlobalEvents()
},
onDragStart,
}
}
/**
* 创建拖动填充型验证码的配置项
*/
export interface CreateSliderCaptchaOptions
extends Omit<CreateCaptchaOptions, 'max'> {
/**
* 实际显示尺寸与原始图片的比例
*/
ratio: number
}
/**
* 创建拖动填充型验证码实例
* @param options
*/
export function createSliderCaptcha(
options: CreateSliderCaptchaOptions,
): CaptchaInstance {
const { captcha, ratio, onVerify, ...rest } = options
const max = Math.round(
(captcha.backgroundImageWidth - captcha.templateImageWidth) * ratio,
)
return createCaptcha({
...rest,
captcha,
max,
onVerify: (data) => {
onVerify({
...data,
bgImageWidth: Math.round(captcha.backgroundImageWidth * ratio),
bgImageHeight: Math.round(
captcha.backgroundImageHeight * ratio,
),
sliderImageWidth: Math.round(
captcha.templateImageWidth * ratio,
),
sliderImageHeight: Math.round(
captcha.templateImageHeight * ratio,
),
})
},
})
}
/**
* 创建旋转型验证码的配置项
*/
export interface CreateRotateCaptchaOptions
extends Omit<CreateCaptchaOptions, 'onDrag'> {
/**
* 实际显示尺寸与原始图片的比例
*/
ratio: number
/**
* 当进行拖动时触发的回调
* @param x 拖动的距离
* @param deg 旋转的角度
*/
onDrag: (x: number, deg: number) => void
}
/**
* 创建旋转型验证码实例
*/
export function createRotateCaptcha(
options: CreateRotateCaptchaOptions,
): CaptchaInstance {
const { captcha, ratio, onVerify, onDrag, max, ...rest } = options
return createCaptcha({
...rest,
captcha,
max,
onDrag: (x) => onDrag(x, (x / max) * 360),
onVerify: (data) => {
onVerify({
...data,
bgImageWidth: max,
bgImageHeight: Math.round(
captcha.backgroundImageHeight * ratio,
),
sliderImageWidth: Math.round(
captcha.templateImageWidth * ratio,
),
sliderImageHeight: Math.round(
captcha.templateImageHeight * ratio,
),
})
},
})
}
/**
* 创建上下拼接型验证码的配置项
*/
export interface CreateConcatCaptchaOptions extends CreateCaptchaOptions {
/**
* 实际显示尺寸与原始图片的比例
*/
ratio: number
}
/**
* 获取上下拼接型验证码的滑块高度
*/
export function getConcatTrackHeight(
captcha: CaptchaDisplayData,
ratio: number,
) {
return Math.round(
(captcha.backgroundImageHeight - captcha.data.randomY) * ratio,
)
}
/**
* 创建上下拼接型验证码实例
*/
export function createConcatCaptcha(
options: CreateConcatCaptchaOptions,
): CaptchaInstance {
const { captcha, ratio, onVerify, max, ...rest } = options
return createCaptcha({
...rest,
captcha,
max,
onVerify: (data) => {
onVerify({
...data,
bgImageWidth: Math.round(captcha.backgroundImageWidth * ratio),
bgImageHeight: Math.round(
captcha.backgroundImageHeight * ratio,
),
sliderImageWidth: Math.round(
captcha.backgroundImageWidth * ratio,
),
sliderImageHeight: getConcatTrackHeight(captcha, ratio),
})
},
})
}
import { ApiResp, CaptchaData } from './types'
/**
* HTTP请求配置项
*/
export interface HttpRequestOptions {
/**
* 请求地址
*/
url: string
/**
* 基地址
*/
baseURL?: string
/**
* 请求方法
*/
method?: string
/**
* 请求参数
*/
params?: Record<string, string>
/**
* 请求头
*/
headers?: Record<string, string>
/**
* 请求体
*/
data?: any
/**
* 请求超时时间
*/
timeout?: number
/**
* 跨域时是否包含身份信息
*/
withCredentials?: boolean
}
export interface HttpResponse<T = any> {
/**
* 响应码
*/
status: number
/**
* 响应头
*/
headers: Record<string, string>
/**
* 响应体
*/
data: T
}
function getURL(
url: string,
baseURL?: string,
params?: Record<string, string>,
): string {
if (!baseURL && !params) return url
const uri = new URL(url, baseURL)
if (params) {
Object.entries(params).forEach(([name, value]) =>
uri.searchParams.append(name, value),
)
}
return uri.href
}
function parseResponseHeaders(xhr: XMLHttpRequest): Record<string, string> {
const headers: Record<string, string> = {}
xhr.getAllResponseHeaders()
.split('\n')
.forEach((line) => {
line = line.trim()
if (line === '') return
let [name] = line.split(':')
name = name.trim()
if (name === '') return
const value = xhr.getResponseHeader(name)
if (value === null) return
headers[name] = value
})
return headers
}
/**
* 执行HTTP请求
* @param options 请求配置项
* @returns
*/
function request<T = any>(
options: HttpRequestOptions,
): Promise<HttpResponse<T>> {
const {
url,
baseURL,
method,
params,
headers,
data,
timeout,
withCredentials = false,
} = options
const xhr = new XMLHttpRequest()
xhr.responseType = 'json'
xhr.withCredentials = withCredentials
if (typeof timeout === 'number') {
xhr.timeout = timeout
}
return new Promise((resolve, reject) => {
xhr.addEventListener('abort', reject)
xhr.addEventListener('error', reject)
xhr.addEventListener('timeout', reject)
xhr.addEventListener('load', function () {
resolve({
status: xhr.status,
headers: parseResponseHeaders(xhr),
data: xhr.response,
})
})
xhr.addEventListener('readystatechange', function () {
if (xhr.readyState !== XMLHttpRequest.OPENED) return
if (typeof headers === 'object' && headers) {
Object.entries(headers).forEach(([name, value]) =>
xhr.setRequestHeader(name, value),
)
}
if (typeof data !== 'undefined' && data !== null) {
if (typeof data === 'string') {
xhr.send(data)
return
} else if (typeof data === 'object') {
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(JSON.stringify(data))
return
}
}
xhr.send()
})
xhr.open(method ?? 'GET', getURL(url, baseURL, params))
})
}
/**
* 加载验证码数据
* @param options 获取验证码数据的配置项
*/
export async function loadCaptcha(options: HttpRequestOptions) {
while (true) {
const resp = await request<ApiResp<CaptchaData>>({
method: 'POST',
data: {},
...options,
})
if (
['SLIDER', 'ROTATE', 'CONCAT'].includes(resp.data.data.captcha.type)
) {
return resp.data
}
}
}
export interface ValidCaptchaOptions
extends Omit<HttpRequestOptions, 'method' | 'data'> {
data: {
key: string
data: any
}
}
/**
* 校验验证码数据,校验成功返回undefined,校验失败返回失败文字
* @param options
*/
export async function validCaptcha(
options: ValidCaptchaOptions,
): Promise<string | void> {
const { data, ...rest } = options
let result: ApiResp<{
key: string
}>
try {
const resp = await request({
...rest,
method: 'POST',
data,
})
result = resp.data
} catch {
//发生了请求异常
return '验证失败,请再试一次'
}
if (result.code !== 0) {
return result.code === 4000 ? '验证码已过期' : '验证失败,请再试一次'
}
}
export * from './locales'
export * from './http'
export * from './types'
export * from './captcha'
{
"验证成功": "Success",
"验证失败,请再试一次": "Verification failed",
"验证码已过期": "Expired",
"拖动下方滑块完成拼图": "Drag the slider to complete the puzzle",
"网络异常,请点击重试": "Network error, click to retry"
}
import en from './en.json'
import zhCn from './zh-cn.json'
import zhTw from './zh-tw.json'
/**
* 可用的翻译数据
*/
const translations: Record<string, Record<string, string>> = {
en,
'zh-cn': zhCn,
'zh-tw': zhTw,
}
/**
* 可用的语言标识
*/
const availableLanguages = Object.keys(translations)
/**
* 默认的语言标识
*/
const DEFAULT_LANGUAGE = 'zh-cn'
/**
* 标准化语言标识
* @param language
*/
function normalizeLanguage(language: string): string {
const parts = language.split(/_|-/)
if (parts.length > 2) {
parts.splice(1, parts.length - 2)
}
return parts.map((t) => t.toLowerCase()).join('-')
}
/**
* 根据输入值,返回可用的语言
* @param language
*/
export function getLanguage(language?: string): string {
if (typeof language === 'string') {
language = normalizeLanguage(language)
if (availableLanguages.includes(language)) {
return language
}
}
//尝试通过navigator.languages获取
if (navigator && navigator.languages) {
for (let language of navigator.languages) {
language = normalizeLanguage(language)
if (availableLanguages.includes(language)) {
return language
}
}
}
return DEFAULT_LANGUAGE
}
/**
* 获取翻译字典
* @param language
*/
export function getTranslations(language?: string): Record<string, string> {
return translations[getLanguage(language)] ?? {}
}
/**
* 获取翻译函数
* @param language
*/
export function getTranslator(language?: string): (id: string) => string {
const translation = getTranslations(language)
return (id: string) => translation[id] ?? id
}
{
"验证成功": "验证成功",
"验证失败,请再试一次": "验证失败,请再试一次",
"验证码已过期": "验证码已过期",
"拖动下方滑块完成拼图": "拖动下方滑块完成拼图",
"网络异常,请点击重试": "网络异常,请点击重试"
}
{
"验证成功": "驗證成功",
"验证失败,请再试一次": "驗證失敗,請再試一次",
"验证码已过期": "驗證碼已過期",
"拖动下方滑块完成拼图": "拖動下方滑塊完成拼圖",
"网络异常,请点击重试": "網路異常,請點擊重試"
}
/**
* 验证码类型
*/
export type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT'
/**
* 接口响应数据格式
*/
export interface ApiResp<T = any> {
code: number
msg: string
data: T
}
/**
* 验证码显示数据
*/
export interface CaptchaDisplayData {
/**
* 验证码类型
*/
type: CaptchaType
templateImage: string
templateImageHeight: number
templateImageTag: string
templateImageWidth: number
backgroundImage: string
backgroundImageHeight: number
backgroundImageTag: string
backgroundImageWidth: number
data: {
randomY: number
}
}
/**
* 验证码数据
*/
export interface CaptchaData {
key: string
captcha: CaptchaDisplayData
}
export { default as Captcha } from './Captcha.vue'
import { InjectionKey, Ref, computed, inject, provide } from 'vue'
import { getTranslator } from './core'
const KEY_LOCALE: InjectionKey<Ref<string | undefined>> = Symbol()
export function provideLocale(ref: Ref<string | undefined>) {
provide(KEY_LOCALE, ref)
}
export function useTranslator(locale?: Ref<string | undefined>) {
if (!locale) {
locale = inject(KEY_LOCALE, () => computed(() => undefined), true)
}
return computed(() => getTranslator(locale.value))
}
import { CaptchaData } from './core'
/**
* 验证码的校验状态
*/
export interface VerifyStatus {
/**
* 是否校验成功
*/
status: boolean
/**
* 失败的消息
*/
message?: string
}
/**
* 包含校验状态数据的验证码数据
*/
export interface CurrentCaptchaData extends CaptchaData {
/**
* 校验状态
*/
verifyStatus?: VerifyStatus
}
/// <reference types="vite/client" />
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"declarationDir": "esm"
},
"exclude": ["src/browser.ts"]
}
{
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"lib": ["esnext", "DOM", "DOM.Iterable"],
"module": "esnext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
build: {
outDir: './browser',
cssTarget: 'chrome61',
rollupOptions: {},
lib: {
entry: './src/browser.ts',
formats: ['iife'],
fileName: () => 'fj-captcha-web.js',
name: 'FAC',
},
},
plugins: [vue()],
})
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
build: {
outDir: './esm',
emptyOutDir: true,
cssTarget: 'chrome61',
rollupOptions: {
external: ['vue'],
},
lib: {
entry: './src/index.ts',
formats: ['es'],
fileName: 'index',
},
},
plugins: [vue()],
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment