feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="slider-captcha">
|
||||
<!-- 触发按钮(未验证时显示) -->
|
||||
<el-button
|
||||
v-if="!verified"
|
||||
type="warning"
|
||||
size="large"
|
||||
plain
|
||||
style="width:100%; margin-bottom:12px"
|
||||
:loading="loading"
|
||||
@click="openDialog"
|
||||
>
|
||||
⚠ 请完成安全验证
|
||||
</el-button>
|
||||
|
||||
<!-- 已验证 -->
|
||||
<div v-else class="verified-badge">
|
||||
✅ 安全验证通过
|
||||
</div>
|
||||
|
||||
<!-- 验证对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="安全验证"
|
||||
width="380px"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
top="20vh"
|
||||
>
|
||||
<div class="captcha-body">
|
||||
<!-- 图形验证码模式 -->
|
||||
<template v-if="captchaType === 'image'">
|
||||
<div class="image-captcha-wrapper">
|
||||
<img :src="captchaImage" class="captcha-img" alt="验证码" />
|
||||
<el-input
|
||||
v-model="imageAnswer"
|
||||
placeholder="输入验证码"
|
||||
size="large"
|
||||
maxlength="4"
|
||||
style="width:120px"
|
||||
/>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||||
<el-button type="primary" size="small" @click="verifyImageCaptcha">确认</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 滑块拼图模式 -->
|
||||
<template v-else>
|
||||
<div class="slider-wrapper">
|
||||
<div class="slider-bg">
|
||||
<img :src="sliderBg" alt="滑块背景" />
|
||||
<div
|
||||
class="slider-gap-marker"
|
||||
:style="{ left: sliderGapX + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="slider-piece-track">
|
||||
<div
|
||||
class="slider-piece"
|
||||
:style="{ left: sliderPos + 'px', background: dragging ? '#409eff' : '#67c23a' }"
|
||||
></div>
|
||||
<div
|
||||
class="slider-rail"
|
||||
ref="railRef"
|
||||
@mousedown.prevent="startDrag"
|
||||
@touchstart.prevent="startDrag"
|
||||
>
|
||||
<div class="slider-thumb" :style="{ left: sliderPos + 'px' }">
|
||||
{{ dragging ? '→' : '→' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="slider-hint">{{ dragging ? '松开完成验证' : '拖动滑块完成拼图' }}</p>
|
||||
</div>
|
||||
<div class="captcha-actions">
|
||||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { securityApi } from '../api/index'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'verified', token: string): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const verified = ref(false)
|
||||
const captchaToken = ref('')
|
||||
const captchaType = ref<'image' | 'slider'>('slider')
|
||||
|
||||
// 图形验证码
|
||||
const captchaImage = ref('')
|
||||
const imageAnswer = ref('')
|
||||
const captchaId = ref('')
|
||||
|
||||
// 滑块拼图
|
||||
const sliderBg = ref('')
|
||||
const sliderGapX = ref(0)
|
||||
const sliderPos = ref(0)
|
||||
const dragging = ref(false)
|
||||
const railRef = ref<HTMLElement | null>(null)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartPos = ref(0)
|
||||
|
||||
// 容差像素
|
||||
const TOLERANCE = 6
|
||||
|
||||
async function fetchCaptcha() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.requestCaptcha(captchaType.value)
|
||||
captchaId.value = res.captcha_id
|
||||
if (captchaType.value === 'image') {
|
||||
captchaImage.value = res.image
|
||||
imageAnswer.value = ''
|
||||
} else {
|
||||
sliderBg.value = res.bg
|
||||
sliderGapX.value = res.gap_x
|
||||
sliderPos.value = 0
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取验证码失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function openDialog() {
|
||||
captchaType.value = Math.random() > 0.3 ? 'slider' : 'image'
|
||||
dialogVisible.value = true
|
||||
await fetchCaptcha()
|
||||
}
|
||||
|
||||
async function verifyImageCaptcha() {
|
||||
if (!imageAnswer.value || imageAnswer.value.length !== 4) {
|
||||
ElMessage.warning('请输入4位验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.verifyCaptcha({
|
||||
captcha_id: captchaId.value,
|
||||
answer: imageAnswer.value.toUpperCase(),
|
||||
captcha_type: 'image',
|
||||
})
|
||||
captchaToken.value = res.token
|
||||
verified.value = true
|
||||
dialogVisible.value = false
|
||||
emit('verified', res.token)
|
||||
ElMessage.success('验证通过')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||||
await fetchCaptcha()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent | TouchEvent) {
|
||||
dragging.value = true
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
dragStartX.value = clientX
|
||||
dragStartPos.value = sliderPos.value
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', endDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', endDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent | TouchEvent) {
|
||||
if (!dragging.value) return
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const rail = railRef.value
|
||||
if (!rail) return
|
||||
const railRect = rail.getBoundingClientRect()
|
||||
const max = railRect.width - 40
|
||||
let newPos = dragStartPos.value + (clientX - dragStartX.value)
|
||||
newPos = Math.max(0, Math.min(newPos, max))
|
||||
sliderPos.value = newPos
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!dragging.value) return
|
||||
dragging.value = false
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', endDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', endDrag)
|
||||
|
||||
// 验证滑块位置是否匹配缺口位置
|
||||
const rail = railRef.value
|
||||
if (!rail) return
|
||||
const railWidth = rail.getBoundingClientRect().width - 40
|
||||
const piecePos = (sliderPos.value / railWidth) * 280 // 映射到280px背景图宽度
|
||||
const gapX = sliderGapX.value
|
||||
if (Math.abs(piecePos - gapX) <= TOLERANCE) {
|
||||
// 验证通过 → 调用后端确认
|
||||
confirmSlider()
|
||||
} else {
|
||||
ElMessage.warning('位置不匹配,请再试一次')
|
||||
sliderPos.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSlider() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await securityApi.verifyCaptcha({
|
||||
captcha_id: captchaId.value,
|
||||
answer: 'verified',
|
||||
captcha_type: 'slider',
|
||||
})
|
||||
captchaToken.value = res.token
|
||||
verified.value = true
|
||||
dialogVisible.value = false
|
||||
emit('verified', res.token)
|
||||
ElMessage.success('验证通过')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||||
sliderPos.value = 0
|
||||
await fetchCaptcha()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 对外暴露重置方法
|
||||
function reset() {
|
||||
verified.value = false
|
||||
captchaToken.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-body {
|
||||
text-align: center;
|
||||
}
|
||||
.image-captcha-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.captcha-img {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
height: 50px;
|
||||
}
|
||||
.captcha-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.slider-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.slider-bg {
|
||||
position: relative;
|
||||
margin: 0 auto 12px;
|
||||
width: 280px;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.slider-bg img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slider-gap-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid rgba(255, 0, 0, 0.5);
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.slider-piece-track {
|
||||
position: relative;
|
||||
margin: 0 auto 8px;
|
||||
width: 280px;
|
||||
}
|
||||
.slider-piece {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
z-index: 2;
|
||||
transition: none;
|
||||
}
|
||||
.slider-rail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
background: #ebeef5;
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||
}
|
||||
.slider-thumb:active { cursor: grabbing; }
|
||||
.slider-hint { color: #909399; font-size: 12px; margin: 0; }
|
||||
.verified-badge {
|
||||
color: #67c23a;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user