feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+335
View File
@@ -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>