验证码组件封装
This commit is contained in:
parent
78bed71168
commit
e581ced7e8
1
components.d.ts
vendored
1
components.d.ts
vendored
@ -7,6 +7,7 @@ export {}
|
|||||||
|
|
||||||
declare module '@vue/runtime-core' {
|
declare module '@vue/runtime-core' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
CaptchaPanel: typeof import('./src/components/CaptchaPanel.vue')['default']
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
|
|||||||
126
src/components/CaptchaPanel.vue
Normal file
126
src/components/CaptchaPanel.vue
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="captchaBoxRef" class="captcha-panel"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
createTianAiCaptcha,
|
||||||
|
type TianAiCaptchaConfig,
|
||||||
|
type TianAiCaptchaInstance,
|
||||||
|
type TianAiCaptchaResult,
|
||||||
|
type TianAiCaptchaStyle
|
||||||
|
} from '@/vendor/tianai-captcha'
|
||||||
|
|
||||||
|
type CaptchaMode = 'data' | 'validate'
|
||||||
|
type CaptchaType = 'RANDOM' | 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
mode?: CaptchaMode
|
||||||
|
type?: CaptchaType
|
||||||
|
validCaptchaUrl?: string
|
||||||
|
requestHeaders?: Record<string, string>
|
||||||
|
styleConfig?: TianAiCaptchaStyle
|
||||||
|
}>(), {
|
||||||
|
mode: 'data',
|
||||||
|
type: 'RANDOM'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'data-ready', result: TianAiCaptchaResult): void
|
||||||
|
(e: 'init-error', error: Error): void
|
||||||
|
(e: 'state-change', active: boolean): void
|
||||||
|
(e: 'valid-fail', res: any): void
|
||||||
|
(e: 'valid-success', res: any): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const captchaBoxRef = ref<HTMLElement | null>(null)
|
||||||
|
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
|
||||||
|
const active = ref(false)
|
||||||
|
const requestCaptchaBaseUrl = buildCaptchaApiUrl('/captcha/gen')
|
||||||
|
const resolvedValidCaptchaUrl = computed(() => {
|
||||||
|
return props.validCaptchaUrl || buildCaptchaApiUrl('/captcha/check')
|
||||||
|
})
|
||||||
|
const requestCaptchaDataUrl = computed(() => {
|
||||||
|
if (props.type === 'RANDOM') {
|
||||||
|
return requestCaptchaBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${requestCaptchaBaseUrl}?type=${encodeURIComponent(props.type)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
if (!captchaBoxRef.value) {
|
||||||
|
emit('init-error', new Error('验证码容器初始化失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy()
|
||||||
|
|
||||||
|
const config: TianAiCaptchaConfig = {
|
||||||
|
bindEl: captchaBoxRef.value,
|
||||||
|
requestCaptchaDataUrl: requestCaptchaDataUrl.value,
|
||||||
|
requestHeaders: props.requestHeaders,
|
||||||
|
btnRefreshFun: (_el, tac) => {
|
||||||
|
tac.reloadCaptcha()
|
||||||
|
},
|
||||||
|
btnCloseFun: () => {
|
||||||
|
destroy()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (props.mode) {
|
||||||
|
case 'validate':
|
||||||
|
config.validCaptchaUrl = resolvedValidCaptchaUrl.value
|
||||||
|
config.validSuccess = res => emit('valid-success', res)
|
||||||
|
config.validFail = res => emit('valid-fail', res)
|
||||||
|
break
|
||||||
|
case 'data':
|
||||||
|
config.onDataReady = result => emit('data-ready', result)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
emit('init-error', new Error(`未知的验证码模式: ${props.mode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaInstance.value = createTianAiCaptcha(config, props.styleConfig)
|
||||||
|
captchaInstance.value.init()
|
||||||
|
setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
captchaInstance.value?.reloadCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (captchaInstance.value) {
|
||||||
|
captchaInstance.value.destroyWindow()
|
||||||
|
captchaInstance.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(nextValue: boolean) {
|
||||||
|
if (active.value === nextValue) return
|
||||||
|
active.value = nextValue
|
||||||
|
emit('state-change', nextValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
destroy,
|
||||||
|
init,
|
||||||
|
reload,
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildCaptchaApiUrl(path: string) {
|
||||||
|
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${normalizedBase}${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => destroy())
|
||||||
|
</script>
|
||||||
@ -3,7 +3,6 @@
|
|||||||
background-size: 100% 180px;
|
background-size: 100% 180px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(0px, 0px);
|
transform: translate(0px, 0px);
|
||||||
/* border-bottom: 1px solid blue; */
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,13 @@
|
|||||||
@closed="handleCaptchaDialogClosed"
|
@closed="handleCaptchaDialogClosed"
|
||||||
>
|
>
|
||||||
<p class="captcha-dialog-subtitle">请先完成验证码校验,再继续登录。</p>
|
<p class="captcha-dialog-subtitle">请先完成验证码校验,再继续登录。</p>
|
||||||
<div ref="captchaBoxRef" class="captcha-box"></div>
|
<CaptchaPanel
|
||||||
|
ref="captchaRef"
|
||||||
|
class="captcha-box"
|
||||||
|
@close="closeCaptchaDialog"
|
||||||
|
@data-ready="handleCaptchaSolved"
|
||||||
|
@init-error="handleCaptchaInitError"
|
||||||
|
/>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -63,13 +69,9 @@ import { useStore } from 'vuex'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { VForm } from '../types'
|
import type { VForm } from '../types'
|
||||||
|
import CaptchaPanel from '@/components/CaptchaPanel.vue'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
import {
|
import type { TianAiCaptchaResult } from '@/vendor/tianai-captcha'
|
||||||
createTianAiCaptcha,
|
|
||||||
type TianAiCaptchaConfig,
|
|
||||||
type TianAiCaptchaInstance,
|
|
||||||
type TianAiCaptchaResult
|
|
||||||
} from '@/vendor/tianai-captcha'
|
|
||||||
|
|
||||||
type LoginFormState = {
|
type LoginFormState = {
|
||||||
username: string | null,
|
username: string | null,
|
||||||
@ -87,13 +89,17 @@ type LoginConfigResponse = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CaptchaPanelRef = {
|
||||||
|
destroy: () => void
|
||||||
|
init: () => Promise<void>
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loginForm = ref<VForm>()
|
const loginForm = ref<VForm>()
|
||||||
const captchaBoxRef = ref<HTMLElement | null>(null)
|
const captchaRef = ref<CaptchaPanelRef | null>(null)
|
||||||
const captchaDialogVisible = ref(false)
|
const captchaDialogVisible = ref(false)
|
||||||
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
|
|
||||||
const requestCaptchaDataUrl = buildApiUrl('/captcha/gen')
|
|
||||||
const captchaErrorPattern = /验证码|captcha/i
|
const captchaErrorPattern = /验证码|captcha/i
|
||||||
|
|
||||||
const userInfo: LoginFormState = reactive({
|
const userInfo: LoginFormState = reactive({
|
||||||
@ -119,7 +125,7 @@ localStorage.clear()
|
|||||||
async function openCaptchaDialog() {
|
async function openCaptchaDialog() {
|
||||||
captchaDialogVisible.value = true
|
captchaDialogVisible.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
initCaptcha()
|
await captchaRef.value?.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
@ -148,31 +154,6 @@ async function handleLogin() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCaptcha() {
|
|
||||||
if (!captchaBoxRef.value) {
|
|
||||||
captchaDialogVisible.value = false
|
|
||||||
ElMessage.error('验证码容器初始化失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
destroyCaptcha(false)
|
|
||||||
|
|
||||||
const config: TianAiCaptchaConfig = {
|
|
||||||
bindEl: captchaBoxRef.value,
|
|
||||||
requestCaptchaDataUrl,
|
|
||||||
onDataReady: handleCaptchaSolved,
|
|
||||||
btnRefreshFun: (_el, tac) => {
|
|
||||||
tac.reloadCaptcha()
|
|
||||||
},
|
|
||||||
btnCloseFun: () => {
|
|
||||||
closeCaptchaDialog()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
captchaInstance.value = createTianAiCaptcha(config)
|
|
||||||
captchaInstance.value.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCaptchaSolved(result: TianAiCaptchaResult) {
|
async function handleCaptchaSolved(result: TianAiCaptchaResult) {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
|
|
||||||
@ -186,7 +167,7 @@ async function handleCaptchaSolved(result: TianAiCaptchaResult) {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCaptchaError(error)) {
|
if (isCaptchaError(error)) {
|
||||||
captchaInstance.value?.reloadCaptcha()
|
captchaRef.value?.reload()
|
||||||
} else {
|
} else {
|
||||||
closeCaptchaDialog()
|
closeCaptchaDialog()
|
||||||
}
|
}
|
||||||
@ -207,29 +188,19 @@ async function submitLogin(payload: LoginRequest) {
|
|||||||
|
|
||||||
function closeCaptchaDialog(resetLoading = true) {
|
function closeCaptchaDialog(resetLoading = true) {
|
||||||
captchaDialogVisible.value = false
|
captchaDialogVisible.value = false
|
||||||
destroyCaptcha(false)
|
captchaRef.value?.destroy()
|
||||||
if (resetLoading) {
|
if (resetLoading) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCaptchaDialogClosed() {
|
function handleCaptchaDialogClosed() {
|
||||||
destroyCaptcha(false)
|
captchaRef.value?.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyCaptcha(showMessage = false) {
|
function handleCaptchaInitError() {
|
||||||
if (!captchaInstance.value) return
|
captchaDialogVisible.value = false
|
||||||
captchaInstance.value.destroyWindow()
|
ElMessage.error('验证码初始化失败')
|
||||||
captchaInstance.value = null
|
|
||||||
if (showMessage) {
|
|
||||||
console.info('[login captcha] destroy captcha instance')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApiUrl(path: string) {
|
|
||||||
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
|
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
||||||
return `${normalizedBase}${normalizedPath}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCaptchaError(error: unknown) {
|
function isCaptchaError(error: unknown) {
|
||||||
@ -239,7 +210,7 @@ function isCaptchaError(error: unknown) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => destroyCaptcha())
|
onBeforeUnmount(() => captchaRef.value?.destroy())
|
||||||
</script>
|
</script>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.login-page {
|
.login-page {
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<el-button type="primary" @click="startCaptcha" plain>开始验证</el-button>
|
<el-button type="primary" @click="startCaptcha" plain>开始验证</el-button>
|
||||||
<el-button @click="reloadCaptcha" :disabled="!captchaInstance">刷新</el-button>
|
<el-button @click="reloadCaptcha" :disabled="!captchaActive">刷新</el-button>
|
||||||
<el-button @click="destroyCaptcha" :disabled="!captchaInstance" plain>销毁</el-button>
|
<el-button @click="destroyCaptcha" :disabled="!captchaActive" plain>销毁</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sandbox-grid">
|
<div class="sandbox-grid">
|
||||||
@ -19,24 +19,33 @@
|
|||||||
|
|
||||||
<el-card class="preview-card" shadow="never">
|
<el-card class="preview-card" shadow="never">
|
||||||
<template #header>验证码预览</template>
|
<template #header>验证码预览</template>
|
||||||
<div ref="captchaBoxRef" class="captcha-box"></div>
|
<CaptchaPanel
|
||||||
|
ref="captchaRef"
|
||||||
|
class="captcha-box"
|
||||||
|
mode="validate"
|
||||||
|
:type="selectedType"
|
||||||
|
@close="handleCaptchaClose"
|
||||||
|
@state-change="handleCaptchaStateChange"
|
||||||
|
@valid-fail="handleValidFail"
|
||||||
|
@valid-success="handleValidSuccess"
|
||||||
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
import {
|
import CaptchaPanel from '@/components/CaptchaPanel.vue'
|
||||||
createTianAiCaptcha,
|
|
||||||
type TianAiCaptchaConfig,
|
|
||||||
type TianAiCaptchaInstance
|
|
||||||
} from '@/vendor/tianai-captcha'
|
|
||||||
|
|
||||||
type CaptchaType = 'RANDOM' | 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
|
type CaptchaType = 'RANDOM' | 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
|
||||||
|
|
||||||
const requestCaptchaBaseUrl = buildApiUrl('/captcha/gen')
|
type CaptchaPanelRef = {
|
||||||
const validCaptchaUrl = buildApiUrl('/captcha/check')
|
destroy: () => void
|
||||||
|
init: () => Promise<void>
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
|
const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
|
||||||
{ label: '随机', value: 'RANDOM' },
|
{ label: '随机', value: 'RANDOM' },
|
||||||
{ label: '滑块拼图', value: 'SLIDER' },
|
{ label: '滑块拼图', value: 'SLIDER' },
|
||||||
@ -45,91 +54,66 @@ const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
|
|||||||
{ label: '汉字点选', value: 'WORD_IMAGE_CLICK' },
|
{ label: '汉字点选', value: 'WORD_IMAGE_CLICK' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
|
const captchaRef = ref<CaptchaPanelRef | null>(null)
|
||||||
const captchaBoxRef = ref<HTMLElement | null>(null)
|
const captchaActive = ref(false)
|
||||||
const selectedType = ref<CaptchaType>('RANDOM')
|
const selectedType = ref<CaptchaType>('RANDOM')
|
||||||
|
|
||||||
const requestCaptchaDataUrl = computed(() => {
|
async function startCaptcha() {
|
||||||
if (selectedType.value === 'RANDOM') {
|
|
||||||
return requestCaptchaBaseUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${requestCaptchaBaseUrl}?type=${encodeURIComponent(selectedType.value)}`
|
|
||||||
})
|
|
||||||
|
|
||||||
function startCaptcha() {
|
|
||||||
if (!captchaBoxRef.value) {
|
|
||||||
console.warn('[captcha sandbox] captcha container is not ready')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
destroyCaptcha(false)
|
|
||||||
|
|
||||||
const config: TianAiCaptchaConfig = {
|
|
||||||
bindEl: captchaBoxRef.value,
|
|
||||||
requestCaptchaDataUrl: requestCaptchaDataUrl.value,
|
|
||||||
validCaptchaUrl,
|
|
||||||
validSuccess: (res, _captcha, tac) => {
|
|
||||||
console.log('[captcha sandbox] valid success', res)
|
|
||||||
tac.destroyWindow()
|
|
||||||
},
|
|
||||||
validFail: (res, _captcha, tac) => {
|
|
||||||
console.warn('[captcha sandbox] valid fail', res)
|
|
||||||
tac.reloadCaptcha()
|
|
||||||
},
|
|
||||||
btnRefreshFun: (_el, tac) => {
|
|
||||||
console.info('[captcha sandbox] refresh captcha')
|
|
||||||
tac.reloadCaptcha()
|
|
||||||
},
|
|
||||||
btnCloseFun: (_el, tac) => {
|
|
||||||
console.info('[captcha sandbox] close captcha window')
|
|
||||||
tac.destroyWindow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info('[captcha sandbox] init captcha', {
|
console.info('[captcha sandbox] init captcha', {
|
||||||
type: selectedType.value,
|
type: selectedType.value,
|
||||||
requestCaptchaDataUrl: requestCaptchaDataUrl.value,
|
mode: 'validate'
|
||||||
validCaptchaUrl
|
|
||||||
})
|
})
|
||||||
|
|
||||||
captchaInstance.value = createTianAiCaptcha(config)
|
await captchaRef.value?.init()
|
||||||
captchaInstance.value.init()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadCaptcha() {
|
function reloadCaptcha() {
|
||||||
if (!captchaInstance.value) return
|
if (!captchaActive.value) return
|
||||||
console.info('[captcha sandbox] manual reload')
|
console.info('[captcha sandbox] manual reload')
|
||||||
captchaInstance.value.reloadCaptcha()
|
captchaRef.value?.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyCaptcha(showMessage = true) {
|
function destroyCaptcha(showMessage = true) {
|
||||||
if (!captchaInstance.value) return
|
if (!captchaActive.value) return
|
||||||
captchaInstance.value.destroyWindow()
|
captchaRef.value?.destroy()
|
||||||
captchaInstance.value = null
|
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
console.info('[captcha sandbox] destroy captcha instance')
|
console.info('[captcha sandbox] destroy captcha instance')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApiUrl(path: string) {
|
function handleCaptchaClose() {
|
||||||
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
|
console.info('[captcha sandbox] close captcha window')
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
||||||
return `${normalizedBase}${normalizedPath}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => destroyCaptcha(false))
|
function handleCaptchaStateChange(active: boolean) {
|
||||||
|
captchaActive.value = active
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleValidSuccess(res: any) {
|
||||||
|
console.log('[captcha sandbox] valid success', res)
|
||||||
|
captchaRef.value?.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleValidFail(res: any) {
|
||||||
|
console.warn('[captcha sandbox] valid fail', res)
|
||||||
|
captchaRef.value?.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => captchaRef.value?.destroy())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sandbox-grid {
|
.sandbox-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
.el-card {
|
.el-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
&.preview-card {
|
&.preview-card {
|
||||||
flex: 0 0 420px;
|
flex: 0 0 420px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user