init
This commit is contained in:
128
src/components/basic-block/main.vue
Normal file
128
src/components/basic-block/main.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="basic-block" :style="styleName">
|
||||
<div class="box" :style="boxStyleName">
|
||||
<router-link :to="to">
|
||||
<span v-text="text"></span>
|
||||
<p v-text="dept"></p>
|
||||
<i :class="icon"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'basicBlock',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
},
|
||||
to: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
},
|
||||
dept: {
|
||||
type: String,
|
||||
},
|
||||
time: {
|
||||
type: [Number, String],
|
||||
},
|
||||
gutter: {
|
||||
type: [Number, String],
|
||||
default: 5,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
},
|
||||
width: {
|
||||
type: [Number, String],
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
styleName() {
|
||||
return {
|
||||
animationDelay: `${this.time / 25}s`,
|
||||
width: `${this.width}px`,
|
||||
height: `${this.height}px`,
|
||||
margin: `${this.gutter}px`,
|
||||
};
|
||||
},
|
||||
boxStyleName() {
|
||||
return {
|
||||
backgroundColor: this.color,
|
||||
backgroundImage: `url('${this.background}')`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.basic-block {
|
||||
opacity: 0;
|
||||
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
animation: mymove 1s;
|
||||
animation-fill-mode: forwards;
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: all 1s;
|
||||
background-size: cover;
|
||||
|
||||
&:hover {
|
||||
transform: rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
width: 80%;
|
||||
font-size: 10px;
|
||||
color: #eee;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
font-size: 50px !important;
|
||||
}
|
||||
|
||||
@keyframes mymove {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/components/basic-container/main.vue
Normal file
57
src/components/basic-container/main.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="basic-container" :style="styleName" :class="{ 'basic-container--block': block }">
|
||||
<el-card class="basic-container__card">
|
||||
<slot></slot>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'basicContainer',
|
||||
props: {
|
||||
radius: {
|
||||
type: [String, Number],
|
||||
default: 10,
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
styleName() {
|
||||
return {
|
||||
borderRadius: `${this.radius}px`,
|
||||
background: this.background,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.basic-container {
|
||||
padding: 10px 6px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&--block {
|
||||
height: 100%;
|
||||
|
||||
.basic-container__card {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
140
src/components/basic-video/main.vue
Normal file
140
src/components/basic-video/main.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div :style="styleName" class="basic-video">
|
||||
<div class="basic-video__border">
|
||||
<span :style="borderStyleName"></span>
|
||||
<span :style="borderStyleName"></span>
|
||||
<span :style="borderStyleName"></span>
|
||||
<span :style="borderStyleName"></span>
|
||||
</div>
|
||||
<img :style="imgStyleName" class="basic-video__img" :src="background" />
|
||||
<video class="basic-video__main" ref="main" autoplay muted></video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecordVideo from './plugin';
|
||||
|
||||
export default {
|
||||
name: 'basic-video',
|
||||
props: {
|
||||
background: {
|
||||
type: String,
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: 500,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
styleName() {
|
||||
return {
|
||||
width: `${this.width}px`,
|
||||
};
|
||||
},
|
||||
imgStyleName() {
|
||||
return {
|
||||
width: `${this.width / 2}px`,
|
||||
};
|
||||
},
|
||||
borderStyleName() {
|
||||
return {
|
||||
width: `${this.width / 15}px`,
|
||||
height: `${this.width / 15}px`,
|
||||
borderWidth: `${5}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
videoObj: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.videoObj = new RecordVideo(this.$refs.main);
|
||||
const videoPromise = this.videoObj.init();
|
||||
videoPromise.then(() => {
|
||||
this.videoObj.mediaRecorder.addEventListener('stop', this.getData, false);
|
||||
});
|
||||
},
|
||||
startRecord() {
|
||||
this.videoObj.startRecord();
|
||||
},
|
||||
stopRecord() {
|
||||
this.videoObj.stopRecord();
|
||||
},
|
||||
getData() {
|
||||
const blob = new Blob(this.videoObj.chunks, {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.addEventListener('loadend', () => {
|
||||
var video_base64 = reader.result;
|
||||
this.$emit('data-change', video_base64);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.basic-video {
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__border {
|
||||
span {
|
||||
position: absolute;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-width: 4px;
|
||||
color: #0073eb;
|
||||
border-style: solid;
|
||||
|
||||
&:nth-child(1) {
|
||||
left: 15px;
|
||||
top: 15px;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
right: 15px;
|
||||
top: 15px;
|
||||
border-left: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
bottom: 15px;
|
||||
left: 15px;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
&__main {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
88
src/components/basic-video/plugin.js
Normal file
88
src/components/basic-video/plugin.js
Normal file
@@ -0,0 +1,88 @@
|
||||
export default class RecordVideo {
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param {Object} videoObj 视频对象
|
||||
*/
|
||||
constructor(videoObj) {
|
||||
this.video = videoObj;
|
||||
this.mediaRecorder = null;
|
||||
this.chunks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*
|
||||
* @return {Object} promise
|
||||
*/
|
||||
init() {
|
||||
// 返回Promise对象
|
||||
// resolve 正常处理
|
||||
// reject 处理异常情况
|
||||
return new Promise((resovle, reject) => {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
audio: true,
|
||||
video: true,
|
||||
// video: {
|
||||
// width: this.videoWidth,
|
||||
// height: this.videoHeight
|
||||
// }
|
||||
})
|
||||
// 返回一个媒体内容的流
|
||||
.then(stream => {
|
||||
// 检测是否支持 srcObject,该属性在新的浏览器支持
|
||||
if ('srcObject' in this.video) {
|
||||
this.video.srcObject = stream;
|
||||
} else {
|
||||
// 兼容旧的浏览器
|
||||
this.video.src = window.URL.createObjectURL(stream);
|
||||
}
|
||||
|
||||
// 当视频的元数据已经加载时触发
|
||||
this.video.addEventListener('loadmetadata', () => {
|
||||
this.video.play();
|
||||
});
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
this.mediaRecorder.addEventListener('dataavailable', e => {
|
||||
this.chunks.push(e.data);
|
||||
});
|
||||
resovle();
|
||||
})
|
||||
// 异常抓取,包括用于禁用麦克风、摄像头
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频开始录制
|
||||
*/
|
||||
startRecord() {
|
||||
if (this.mediaRecorder.state === 'inactive') {
|
||||
this.mediaRecorder.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频结束录制
|
||||
*/
|
||||
stopRecord() {
|
||||
if (this.mediaRecorder.state === 'recording') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前浏览器对否支持
|
||||
*
|
||||
* @return {boolean} 当前浏览器是否支持
|
||||
*/
|
||||
isSupport() {
|
||||
const flag = navigator.mediaDevices && navigator.mediaDevices.getUserMedia;
|
||||
if (flag) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/components/behavior-captcha/concat-panel.vue
Normal file
101
src/components/behavior-captcha/concat-panel.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="concat-panel">
|
||||
<div class="concat-stage" :style="stageSize">
|
||||
<div class="concat-top" :style="topStyle"></div>
|
||||
<div class="concat-bottom" :style="bottomStyle"></div>
|
||||
</div>
|
||||
<track-slider
|
||||
ref="slider"
|
||||
hint="按住滑块,对齐上下图案"
|
||||
:locked="locked"
|
||||
@drag="handleDrag"
|
||||
@release="handleRelease"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackSlider from './track-slider.vue';
|
||||
|
||||
export default {
|
||||
name: 'concatPanel',
|
||||
components: { TrackSlider },
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['confirm'],
|
||||
data() {
|
||||
return {
|
||||
moveX: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 舞台尺寸以服务端下发的逻辑尺寸为准,避免前后端各存一份常量产生偏差
|
||||
stageSize() {
|
||||
return { width: `${this.data.width}px`, height: `${this.data.height}px` };
|
||||
},
|
||||
topStyle() {
|
||||
return {
|
||||
height: `${this.data.sliceY}px`,
|
||||
backgroundImage: `url(${this.data.image})`,
|
||||
backgroundSize: `${this.data.width}px ${this.data.height}px`,
|
||||
backgroundPosition: '0 0',
|
||||
};
|
||||
},
|
||||
// 下条以背景横向循环平移实现推回复位,纵向偏移对准切缝以下的内容
|
||||
bottomStyle() {
|
||||
return {
|
||||
height: `${this.data.height - this.data.sliceY}px`,
|
||||
backgroundImage: `url(${this.data.image})`,
|
||||
backgroundSize: `${this.data.width}px ${this.data.height}px`,
|
||||
backgroundPosition: `${this.moveX}px -${this.data.sliceY}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// 更换题目后下条回到初始错位状态
|
||||
data() {
|
||||
this.moveX = 0;
|
||||
this.$refs.slider?.reset();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 滑轨行程映射整幅画布宽度的循环位移
|
||||
handleDrag(progress) {
|
||||
this.moveX = Math.round(progress * this.data.width);
|
||||
},
|
||||
handleRelease() {
|
||||
this.$emit('confirm', String(this.moveX));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.concat-stage {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.concat-stage::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.concat-top,
|
||||
.concat-bottom {
|
||||
width: 100%;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
</style>
|
||||
528
src/components/behavior-captcha/index.vue
Normal file
528
src/components/behavior-captcha/index.vue
Normal file
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="behavior-fade">
|
||||
<div v-if="visible" class="behavior-overlay">
|
||||
<div class="behavior-panel" :class="{ 'is-shake': status === 'fail' }">
|
||||
<div class="behavior-header">
|
||||
<div class="behavior-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="behavior-title">
|
||||
<span class="behavior-title-main">安全验证</span>
|
||||
<span class="behavior-title-sub">{{ subtitle }}</span>
|
||||
</div>
|
||||
<div class="behavior-actions">
|
||||
<button class="behavior-icon-btn" :class="{ 'is-spinning': status === 'loading' }" title="换一题" @click="load">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="behavior-icon-btn" title="关闭" @click="close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="behavior-body">
|
||||
<transition name="behavior-swap" mode="out-in">
|
||||
<div v-if="status === 'loading'" key="skeleton" class="behavior-skeleton">
|
||||
<div class="behavior-skeleton-stage">
|
||||
<div class="behavior-skeleton-brand">
|
||||
<span class="behavior-skeleton-halo"></span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="behavior-skeleton-text">正在构建安全环境<i></i><i></i><i></i></span>
|
||||
</div>
|
||||
<div class="behavior-skeleton-bar"></div>
|
||||
</div>
|
||||
<div v-else key="panel" class="behavior-content">
|
||||
<word-panel v-if="type === 'word' || type === 'idiom'" :data="behaviorData" :locked="locked" @confirm="handleConfirm" />
|
||||
<puzzle-panel v-else-if="type === 'puzzle'" :data="behaviorData" :locked="locked" @confirm="handleConfirm" />
|
||||
<concat-panel v-else-if="type === 'concat'" :data="behaviorData" :locked="locked" @confirm="handleConfirm" />
|
||||
<rotate-panel v-else-if="type === 'rotate'" :data="behaviorData" :locked="locked" @confirm="handleConfirm" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="behavior-result">
|
||||
<div v-if="status === 'success'" class="behavior-success">
|
||||
<svg class="behavior-success-icon" viewBox="0 0 52 52">
|
||||
<circle class="behavior-success-circle" cx="26" cy="26" r="24" fill="none" />
|
||||
<path class="behavior-success-check" fill="none" d="M14 27l8 8 16-17" />
|
||||
</svg>
|
||||
<span class="behavior-success-text">验证通过</span>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<transition name="behavior-result">
|
||||
<div v-if="status === 'fail'" class="behavior-fail">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
<span>验证未通过,即将自动重试</span>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WordPanel from './word-panel.vue';
|
||||
import PuzzlePanel from './puzzle-panel.vue';
|
||||
import ConcatPanel from './concat-panel.vue';
|
||||
import RotatePanel from './rotate-panel.vue';
|
||||
import { getBehavior, checkBehavior } from '@/api/user';
|
||||
|
||||
const SUBTITLES = {
|
||||
word: '请按提示顺序点击图中文字',
|
||||
idiom: '请找出图中成语并按语序点击',
|
||||
puzzle: '请拖动滑块完成拼图',
|
||||
concat: '请拖动滑块对齐上下图案',
|
||||
rotate: '请拖动滑块将图案转至正确方向',
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'behaviorCaptcha',
|
||||
components: { WordPanel, PuzzlePanel, ConcatPanel, RotatePanel },
|
||||
emits: ['success'],
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
// 状态机: loading 出题中 / ready 待作答 / checking 核验中 / success 通过 / fail 未通过
|
||||
status: 'loading',
|
||||
key: '',
|
||||
type: '',
|
||||
behaviorData: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
subtitle() {
|
||||
return SUBTITLES[this.type] || '完成验证以继续登录';
|
||||
},
|
||||
locked() {
|
||||
return this.status !== 'ready';
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.clearTimer();
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.visible = true;
|
||||
this.load();
|
||||
},
|
||||
close() {
|
||||
this.clearTimer();
|
||||
this.visible = false;
|
||||
this.status = 'loading';
|
||||
this.key = '';
|
||||
this.type = '';
|
||||
this.behaviorData = {};
|
||||
},
|
||||
load() {
|
||||
this.clearTimer();
|
||||
this.status = 'loading';
|
||||
getBehavior()
|
||||
.then(res => {
|
||||
const data = res.data;
|
||||
// 题面图预解码完成后再切换视图:出题响应到达时图片尚未完成首帧绘制,
|
||||
// 立即切换会让舞台区域先渲染为空白,由骨架屏覆盖整个等待期
|
||||
return this.preloadImages([data.data.image, data.data.piece, data.data.ring]).then(() => {
|
||||
this.key = data.key;
|
||||
this.behaviorData = data.data;
|
||||
this.type = data.type;
|
||||
this.status = 'ready';
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.close();
|
||||
});
|
||||
},
|
||||
preloadImages(sources) {
|
||||
const tasks = sources.filter(Boolean).map(
|
||||
source =>
|
||||
new Promise(resolve => {
|
||||
const image = new Image();
|
||||
image.onload = resolve;
|
||||
// 解码失败不阻塞流程,交由面板正常渲染兜底
|
||||
image.onerror = resolve;
|
||||
image.src = source;
|
||||
if (image.decode) {
|
||||
image.decode().then(resolve, resolve);
|
||||
}
|
||||
})
|
||||
);
|
||||
return Promise.all(tasks);
|
||||
},
|
||||
handleConfirm(answer) {
|
||||
if (this.status !== 'ready') return;
|
||||
this.status = 'checking';
|
||||
checkBehavior(this.key, answer).then(
|
||||
res => {
|
||||
this.status = 'success';
|
||||
const ticket = res.data.ticket;
|
||||
this.timer = setTimeout(() => {
|
||||
const key = this.key;
|
||||
this.close();
|
||||
this.$emit('success', { key, ticket });
|
||||
}, 700);
|
||||
},
|
||||
() => {
|
||||
// 题目已被服务端一次性消费,短暂展示失败反馈后自动更换题目
|
||||
this.status = 'fail';
|
||||
this.timer = setTimeout(() => this.load(), 900);
|
||||
}
|
||||
);
|
||||
},
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.behavior-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(17, 24, 39, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.behavior-panel {
|
||||
position: relative;
|
||||
/* 宽度跟随舞台内容自适应,保证题面图与提示条、滑轨结构性等宽对称 */
|
||||
width: fit-content;
|
||||
padding: 18px 20px 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.behavior-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.behavior-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #6366f1, #3b82f6);
|
||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.3);
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.behavior-badge svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.behavior-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.behavior-title-main {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.behavior-title-sub {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.behavior-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.behavior-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.behavior-icon-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.behavior-icon-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.behavior-icon-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.behavior-icon-btn.is-spinning svg {
|
||||
animation: behavior-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes behavior-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.behavior-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 骨架加载态:品牌徽标呼吸 + 斜向扫光,尺寸与题面舞台一致避免交接跳动 */
|
||||
.behavior-skeleton-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
width: 340px;
|
||||
height: 220px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8f9fc, #f1f3f8);
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.behavior-skeleton-stage::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -60%;
|
||||
width: 50%;
|
||||
background: linear-gradient(105deg, transparent, rgba(255, 255, 255, 0.75), transparent);
|
||||
transform: skewX(-16deg);
|
||||
animation: behavior-sweep 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
|
||||
}
|
||||
@keyframes behavior-sweep {
|
||||
to {
|
||||
left: 120%;
|
||||
}
|
||||
}
|
||||
.behavior-skeleton-brand {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(135deg, #6366f1, #3b82f6);
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.28);
|
||||
color: #ffffff;
|
||||
}
|
||||
.behavior-skeleton-brand svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.behavior-skeleton-halo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 13px;
|
||||
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.35);
|
||||
animation: behavior-pulse 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
|
||||
}
|
||||
@keyframes behavior-pulse {
|
||||
to {
|
||||
box-shadow: 0 0 0 16px rgba(99, 102, 241, 0);
|
||||
}
|
||||
}
|
||||
.behavior-skeleton-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.behavior-skeleton-text i {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
margin-left: 3px;
|
||||
border-radius: 50%;
|
||||
background: #9ca3af;
|
||||
animation: behavior-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.behavior-skeleton-text i:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.behavior-skeleton-text i:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes behavior-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.behavior-skeleton-bar {
|
||||
height: 40px;
|
||||
margin-top: 12px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: behavior-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes behavior-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 骨架与题面的交接过渡 */
|
||||
.behavior-swap-enter-active {
|
||||
transition: opacity 0.25s cubic-bezier(0.2, 0.8, 0.2, 1), transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.behavior-swap-leave-active {
|
||||
transition: opacity 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.behavior-swap-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.behavior-swap-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 成功遮罩 */
|
||||
.behavior-success {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.behavior-success-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
stroke: #10b981;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.behavior-success-circle {
|
||||
stroke-dasharray: 151;
|
||||
stroke-dashoffset: 151;
|
||||
animation: behavior-stroke 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
.behavior-success-check {
|
||||
stroke-dasharray: 40;
|
||||
stroke-dashoffset: 40;
|
||||
animation: behavior-stroke 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) 0.35s forwards;
|
||||
}
|
||||
@keyframes behavior-stroke {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
.behavior-success-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
/* 失败提示条 */
|
||||
.behavior-fail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
}
|
||||
.behavior-fail svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 面板抖动反馈 */
|
||||
.is-shake {
|
||||
animation: behavior-shake 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
@keyframes behavior-shake {
|
||||
20% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗过渡 */
|
||||
.behavior-fade-enter-active,
|
||||
.behavior-fade-leave-active {
|
||||
transition: opacity 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.behavior-fade-enter-active .behavior-panel,
|
||||
.behavior-fade-leave-active .behavior-panel {
|
||||
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.behavior-fade-enter-from,
|
||||
.behavior-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.behavior-fade-enter-from .behavior-panel,
|
||||
.behavior-fade-leave-to .behavior-panel {
|
||||
transform: scale(0.96) translateY(8px);
|
||||
}
|
||||
.behavior-result-enter-active,
|
||||
.behavior-result-leave-active {
|
||||
transition: opacity 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.behavior-result-enter-from,
|
||||
.behavior-result-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
100
src/components/behavior-captcha/puzzle-panel.vue
Normal file
100
src/components/behavior-captcha/puzzle-panel.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="puzzle-panel">
|
||||
<div class="puzzle-stage" :style="stageSize">
|
||||
<img class="puzzle-image" :src="data.image" alt="" draggable="false" />
|
||||
<img
|
||||
class="puzzle-piece"
|
||||
:src="data.piece"
|
||||
alt=""
|
||||
draggable="false"
|
||||
:style="{
|
||||
left: `${pieceX}px`,
|
||||
top: `${data.pieceY}px`,
|
||||
width: `${data.pieceSize}px`,
|
||||
height: `${data.pieceSize}px`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<track-slider
|
||||
ref="slider"
|
||||
hint="按住滑块,拖动完成拼图"
|
||||
:locked="locked"
|
||||
@drag="handleDrag"
|
||||
@release="handleRelease"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackSlider from './track-slider.vue';
|
||||
|
||||
export default {
|
||||
name: 'puzzlePanel',
|
||||
components: { TrackSlider },
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['confirm'],
|
||||
data() {
|
||||
return {
|
||||
pieceX: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 舞台尺寸以服务端下发的逻辑尺寸为准,避免前后端各存一份常量产生偏差
|
||||
stageSize() {
|
||||
return { width: `${this.data.width}px`, height: `${this.data.height}px` };
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// 更换题目后拼图块回到起点
|
||||
data() {
|
||||
this.pieceX = 0;
|
||||
this.$refs.slider?.reset();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 滑轨行程与拼图块行程等比映射,保证滑到尽头时拼图块恰好到达画布右缘
|
||||
handleDrag(progress) {
|
||||
this.pieceX = Math.round(progress * (this.data.width - this.data.pieceSize));
|
||||
},
|
||||
handleRelease() {
|
||||
this.$emit('confirm', String(this.pieceX));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.puzzle-stage {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.puzzle-stage::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.puzzle-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.puzzle-piece {
|
||||
position: absolute;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35));
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
101
src/components/behavior-captcha/rotate-panel.vue
Normal file
101
src/components/behavior-captcha/rotate-panel.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="rotate-panel">
|
||||
<div class="rotate-stage" :style="stageSize">
|
||||
<img class="rotate-image" :src="data.image" alt="" draggable="false" />
|
||||
<img
|
||||
class="rotate-ring"
|
||||
:src="data.ring"
|
||||
alt=""
|
||||
draggable="false"
|
||||
:style="{
|
||||
width: `${data.ringSize}px`,
|
||||
height: `${data.ringSize}px`,
|
||||
transform: `translate(-50%, -50%) rotate(${angle}deg)`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<track-slider
|
||||
ref="slider"
|
||||
hint="按住滑块,转动图案至正确方向"
|
||||
:locked="locked"
|
||||
@drag="handleDrag"
|
||||
@release="handleRelease"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrackSlider from './track-slider.vue';
|
||||
|
||||
export default {
|
||||
name: 'rotatePanel',
|
||||
components: { TrackSlider },
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['confirm'],
|
||||
data() {
|
||||
return {
|
||||
angle: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 舞台尺寸以服务端下发的逻辑尺寸为准,避免前后端各存一份常量产生偏差
|
||||
stageSize() {
|
||||
return { width: `${this.data.width}px`, height: `${this.data.height}px` };
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// 更换题目后内圆回到初始方向
|
||||
data() {
|
||||
this.angle = 0;
|
||||
this.$refs.slider?.reset();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 滑轨行程映射整圆角度
|
||||
handleDrag(progress) {
|
||||
this.angle = Math.round(progress * 360);
|
||||
},
|
||||
handleRelease() {
|
||||
this.$emit('confirm', String(this.angle));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rotate-stage {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.rotate-stage::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rotate-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.rotate-ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
136
src/components/behavior-captcha/track-slider.vue
Normal file
136
src/components/behavior-captcha/track-slider.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div ref="track" class="track-slider" :class="{ 'is-dragging': dragging }">
|
||||
<div class="track-fill" :style="{ width: `${handleX + handleSize / 2}px` }"></div>
|
||||
<span v-show="!moved" class="track-hint">{{ hint }}</span>
|
||||
<div
|
||||
class="track-handle"
|
||||
:style="{ transform: `translateX(${handleX}px)` }"
|
||||
@pointerdown="handleDown"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 6 6 6-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const HANDLE_SIZE = 40;
|
||||
|
||||
export default {
|
||||
name: 'trackSlider',
|
||||
props: {
|
||||
hint: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['drag', 'release'],
|
||||
data() {
|
||||
return {
|
||||
handleSize: HANDLE_SIZE,
|
||||
handleX: 0,
|
||||
dragging: false,
|
||||
moved: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleDown(event) {
|
||||
if (this.locked) return;
|
||||
this.dragging = true;
|
||||
this.startX = event.clientX;
|
||||
this.baseX = this.handleX;
|
||||
this.handleEl = event.currentTarget;
|
||||
this.handleEl.setPointerCapture(event.pointerId);
|
||||
this.handleEl.addEventListener('pointermove', this.handleMove);
|
||||
this.handleEl.addEventListener('pointerup', this.handleUp, { once: true });
|
||||
},
|
||||
handleMove(event) {
|
||||
if (!this.dragging) return;
|
||||
const maxX = this.$refs.track.clientWidth - HANDLE_SIZE;
|
||||
this.handleX = Math.min(Math.max(this.baseX + event.clientX - this.startX, 0), maxX);
|
||||
this.moved = this.moved || this.handleX > 0;
|
||||
this.$emit('drag', maxX > 0 ? this.handleX / maxX : 0);
|
||||
},
|
||||
handleUp() {
|
||||
this.handleEl.removeEventListener('pointermove', this.handleMove);
|
||||
this.dragging = false;
|
||||
// 从未移动的误触不视为作答,避免白白消费一次题目
|
||||
if (!this.moved) return;
|
||||
const maxX = this.$refs.track.clientWidth - HANDLE_SIZE;
|
||||
this.$emit('release', maxX > 0 ? this.handleX / maxX : 0);
|
||||
},
|
||||
reset() {
|
||||
this.handleX = 0;
|
||||
this.dragging = false;
|
||||
this.moved = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.track-slider {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
margin-top: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.track-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, rgba(99, 102, 241, 0.12), rgba(59, 130, 246, 0.18));
|
||||
transition: none;
|
||||
}
|
||||
.track-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
}
|
||||
.track-handle {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
color: #6b7280;
|
||||
cursor: grab;
|
||||
transition: box-shadow 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), background 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.track-handle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.track-handle:hover {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15), 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.is-dragging .track-handle {
|
||||
background: linear-gradient(135deg, #6366f1, #3b82f6);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15), 0 4px 12px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
</style>
|
||||
235
src/components/behavior-captcha/word-panel.vue
Normal file
235
src/components/behavior-captcha/word-panel.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="word-panel">
|
||||
<div class="word-stage" :style="stageSize" @click="handleStageClick">
|
||||
<img class="word-image" :src="data.image" alt="" draggable="false" />
|
||||
<transition-group name="word-dot">
|
||||
<span
|
||||
v-for="(dot, index) in dots"
|
||||
:key="dot.id"
|
||||
class="word-dot"
|
||||
:style="{ left: `${dot.x}px`, top: `${dot.y}px` }"
|
||||
>{{ index + 1 }}</span
|
||||
>
|
||||
</transition-group>
|
||||
</div>
|
||||
<div class="word-bar">
|
||||
<span class="word-bar-label">{{ data.prompt ? '依次点击' : '按语序点击' }}</span>
|
||||
<div class="word-chips">
|
||||
<span
|
||||
v-for="(chip, index) in chips"
|
||||
:key="index"
|
||||
class="word-chip"
|
||||
:class="{ 'is-active': index < dots.length, 'is-ordinal': !data.prompt }"
|
||||
>{{ chip }}</span
|
||||
>
|
||||
</div>
|
||||
<button class="word-undo" title="撤销上一步" :disabled="!dots.length" @click="undo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'wordPanel',
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['confirm'],
|
||||
data() {
|
||||
return {
|
||||
dots: [],
|
||||
dotSeed: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 舞台尺寸以服务端下发的逻辑尺寸为准,避免前后端各存一份常量产生偏差
|
||||
stageSize() {
|
||||
return { width: `${this.data.width}px`, height: `${this.data.height}px` };
|
||||
},
|
||||
// 语序模式不下发目标字符,提示条降级为作答进度序号
|
||||
chips() {
|
||||
if (this.data.prompt) {
|
||||
return this.data.prompt;
|
||||
}
|
||||
return Array.from({ length: this.data.targetCount }, (item, index) => index + 1);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// 更换题目后清空作答痕迹
|
||||
data() {
|
||||
this.dots = [];
|
||||
this.clearTimer();
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.clearTimer();
|
||||
},
|
||||
methods: {
|
||||
handleStageClick(event) {
|
||||
if (this.locked || this.dots.length >= this.data.targetCount) return;
|
||||
// 以图片原始尺寸换算坐标,保证展示缩放时命中判定仍然准确
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = Math.round(((event.clientX - rect.left) / rect.width) * this.data.width);
|
||||
const y = Math.round(((event.clientY - rect.top) / rect.height) * this.data.height);
|
||||
this.dots.push({ x, y, id: ++this.dotSeed });
|
||||
if (this.dots.length === this.data.targetCount) {
|
||||
// 短暂停留让最后一个序号标记完成入场,再提交作答
|
||||
this.timer = setTimeout(() => {
|
||||
this.$emit('confirm', this.dots.map(dot => `${dot.x},${dot.y}`).join(';'));
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
undo() {
|
||||
if (this.locked) return;
|
||||
this.dots.pop();
|
||||
this.clearTimer();
|
||||
},
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.word-stage {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
}
|
||||
.word-stage::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.word-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.word-dot {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: -12px 0 0 -12px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #3b82f6);
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.35);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
.word-dot-enter-active {
|
||||
transition: all 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.word-dot-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
.word-dot-leave-active {
|
||||
transition: all 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.word-dot-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
|
||||
.word-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
margin-top: 12px;
|
||||
padding: 0 10px 0 12px;
|
||||
border-radius: 10px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.word-bar-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.word-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.word-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.word-chip.is-ordinal {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
.word-chip.is-active {
|
||||
background: linear-gradient(135deg, #6366f1, #3b82f6);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.word-undo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.word-undo svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.word-undo:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
.word-undo:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
135
src/components/code-editor/main.vue
Normal file
135
src/components/code-editor/main.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="code-editor">
|
||||
<textarea ref="textarea" v-show="false"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CodeMirror from 'codemirror';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
// 主题
|
||||
import 'codemirror/theme/idea.css';
|
||||
import 'codemirror/theme/nord.css';
|
||||
import 'codemirror/theme/xq-light.css';
|
||||
import 'codemirror/mode/clike/clike';
|
||||
import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/addon/display/autorefresh';
|
||||
// 搜索
|
||||
import 'codemirror/addon/scroll/annotatescrollbar.js';
|
||||
import 'codemirror/addon/search/matchesonscrollbar.js';
|
||||
import 'codemirror/addon/search/match-highlighter.js';
|
||||
import 'codemirror/addon/search/jump-to-line.js';
|
||||
import 'codemirror/addon/dialog/dialog.js';
|
||||
import 'codemirror/addon/dialog/dialog.css';
|
||||
import 'codemirror/addon/search/searchcursor.js';
|
||||
import 'codemirror/addon/search/search.js';
|
||||
// 折叠
|
||||
import 'codemirror/addon/fold/foldgutter.css';
|
||||
import 'codemirror/addon/fold/foldcode';
|
||||
import 'codemirror/addon/fold/foldgutter';
|
||||
import 'codemirror/addon/fold/brace-fold';
|
||||
import 'codemirror/addon/fold/comment-fold';
|
||||
// 格式化
|
||||
import formatter from '@/utils/formatter';
|
||||
import { validatejson, validatenull } from '@/utils/validate';
|
||||
|
||||
export default {
|
||||
name: 'CodeEditor',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '450px',
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'javascript',
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'idea',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
json: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editor = CodeMirror.fromTextArea(this.$refs.textarea, {
|
||||
mode: this.mode, // 默认设置为 'javascript' 或根据需要配置
|
||||
theme: this.theme, // 直接设置主题或根据需要配置
|
||||
readOnly: this.readonly,
|
||||
autoRefresh: true,
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
extraKeys: {
|
||||
// 绑定快捷键 Ctrl-F / Cmd-F 开启搜索
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-F': 'findPersistent',
|
||||
// 如果需要,您还可以添加 "Ctrl-R"/"Cmd-R" 绑定替换功能
|
||||
'Ctrl-R': 'replace',
|
||||
'Cmd-R': 'replace',
|
||||
},
|
||||
});
|
||||
|
||||
// 设置高度
|
||||
this.editor.setSize('auto', this.height);
|
||||
|
||||
// 设置文本
|
||||
const editorValue = this.json ? formatter.prettyCode(this.modelValue) : this.modelValue;
|
||||
this.editor.setValue(editorValue);
|
||||
|
||||
this.editor.on('change', () => {
|
||||
this.$emit('update:modelValue', this.editor.getValue()); // 更新 modelValue
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
if (newVal !== this.editor.getValue()) {
|
||||
const editorValue = this.json ? formatter.prettyCode(this.modelValue) : this.modelValue;
|
||||
this.editor.setValue(editorValue);
|
||||
}
|
||||
},
|
||||
height(newHeight) {
|
||||
this.editor.setSize('auto', newHeight);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
prettyCode() {
|
||||
if (this.json) {
|
||||
const val = this.editor.getValue();
|
||||
if (validatenull(val)) {
|
||||
this.$message.warning('请先填写数据');
|
||||
return;
|
||||
}
|
||||
if (!validatejson(val)) {
|
||||
this.$message.warning('数据 JSON 格式错误');
|
||||
return;
|
||||
}
|
||||
this.editor.setValue(formatter.prettyCode(val));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-editor {
|
||||
line-height: 1.2 !important;
|
||||
width: calc(100% - 4px);
|
||||
height: 100%;
|
||||
border: 1px solid #ccc; /* 添加边框样式 */
|
||||
}
|
||||
</style>
|
||||
597
src/components/cron-editor/main.vue
Normal file
597
src/components/cron-editor/main.vue
Normal file
@@ -0,0 +1,597 @@
|
||||
<template>
|
||||
<div class="cron-editor">
|
||||
<el-card shadow="never" class="cron-card">
|
||||
<div class="cron-mode-tabs">
|
||||
<el-radio-group v-model="cronMode" @change="handleModeChange" :disabled="disabled">
|
||||
<el-radio-button label="daily">每天</el-radio-button>
|
||||
<el-radio-button label="weekly">每周</el-radio-button>
|
||||
<el-radio-button label="monthly">每月</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="cron-config-area">
|
||||
<!-- 每天模式 -->
|
||||
<div v-if="cronMode === 'daily'" class="mode-content">
|
||||
<el-form :model="dailyConfig" label-width="100px" size="default">
|
||||
<el-form-item label="执行时间">
|
||||
<el-time-picker
|
||||
v-model="dailyConfig.time"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
:disabled="disabled"
|
||||
@change="generateCron"
|
||||
/>
|
||||
<span class="tip-text">每天在指定时间执行</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 每周模式 -->
|
||||
<div v-if="cronMode === 'weekly'" class="mode-content">
|
||||
<el-form :model="weeklyConfig" label-width="100px" size="default">
|
||||
<el-form-item label="星期选择">
|
||||
<el-checkbox-group v-model="weeklyConfig.weekDays" @change="generateCron" :disabled="disabled">
|
||||
<el-checkbox label="1">周一</el-checkbox>
|
||||
<el-checkbox label="2">周二</el-checkbox>
|
||||
<el-checkbox label="3">周三</el-checkbox>
|
||||
<el-checkbox label="4">周四</el-checkbox>
|
||||
<el-checkbox label="5">周五</el-checkbox>
|
||||
<el-checkbox label="6">周六</el-checkbox>
|
||||
<el-checkbox label="7">周日</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行时间">
|
||||
<el-time-picker
|
||||
v-model="weeklyConfig.time"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
:disabled="disabled"
|
||||
@change="generateCron"
|
||||
/>
|
||||
<span class="tip-text">在选定的星期几执行</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 每月模式 -->
|
||||
<div v-if="cronMode === 'monthly'" class="mode-content">
|
||||
<el-form :model="monthlyConfig" label-width="100px" size="default">
|
||||
<el-form-item label="日期选择">
|
||||
<el-select
|
||||
v-model="monthlyConfig.days"
|
||||
multiple
|
||||
placeholder="选择日期"
|
||||
:disabled="disabled"
|
||||
@change="generateCron"
|
||||
style="width: 400px"
|
||||
>
|
||||
<el-option
|
||||
v-for="day in 31"
|
||||
:key="day"
|
||||
:label="`${day} 号`"
|
||||
:value="day"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="tip-text">每月的哪几天</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行时间">
|
||||
<el-time-picker
|
||||
v-model="monthlyConfig.time"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
:disabled="disabled"
|
||||
@change="generateCron"
|
||||
/>
|
||||
<span class="tip-text">在选定的日期执行</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 自定义模式 -->
|
||||
<div v-if="cronMode === 'custom'" class="mode-content">
|
||||
<el-form :model="customConfig" label-width="100px" size="default">
|
||||
<el-form-item label="秒">
|
||||
<el-input
|
||||
v-model="customConfig.second"
|
||||
placeholder="0-59 或 * 或 */5"
|
||||
:disabled="disabled"
|
||||
@input="generateCron"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分">
|
||||
<el-input
|
||||
v-model="customConfig.minute"
|
||||
placeholder="0-59 或 * 或 */10"
|
||||
:disabled="disabled"
|
||||
@input="generateCron"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="时">
|
||||
<el-input
|
||||
v-model="customConfig.hour"
|
||||
placeholder="0-23 或 * 或 8-18"
|
||||
:disabled="disabled"
|
||||
@input="generateCron"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="日">
|
||||
<el-input
|
||||
v-model="customConfig.day"
|
||||
placeholder="1-31 或 * 或 ?"
|
||||
:disabled="disabled"
|
||||
@input="generateCron"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="月">
|
||||
<el-input
|
||||
v-model="customConfig.month"
|
||||
placeholder="1-12 或 * 或 JAN-DEC"
|
||||
:disabled="disabled"
|
||||
@input="generateCron"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="周">
|
||||
<el-input
|
||||
v-model="customConfig.week"
|
||||
placeholder="0-7 或 * 或 ? 或 MON-SUN"
|
||||
:disabled="disabled"
|
||||
@input="generateCron"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="cron-help">
|
||||
<div class="cron-help-title">
|
||||
<el-icon class="help-icon"><InfoFilled /></el-icon>
|
||||
<span>Cron 表达式说明</span>
|
||||
</div>
|
||||
<ul class="cron-help-list">
|
||||
<li><strong>*</strong> <span>表示所有值</span></li>
|
||||
<li><strong>?</strong> <span>表示不指定值(仅日和周可用)</span></li>
|
||||
<li><strong>-</strong> <span>表示范围,如:8-18</span></li>
|
||||
<li><strong>,</strong> <span>表示列举,如:1,3,5</span></li>
|
||||
<li><strong>/</strong> <span>表示增量,如:*/5 表示每5个单位</span></li>
|
||||
<li><strong>周</strong> <span>1-7 或 MON-SUN(1=周一,7=周日)</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cron-result">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="Cron 表达式">
|
||||
<el-input
|
||||
v-model="cronExpression"
|
||||
readonly
|
||||
placeholder="自动生成的 Cron 表达式"
|
||||
class="cron-expression-input"
|
||||
>
|
||||
<template #append>
|
||||
<el-button
|
||||
v-clipboard:copy="cronExpression"
|
||||
v-clipboard:success="onCopySuccess"
|
||||
v-clipboard:error="onCopyError">
|
||||
复制
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行说明" v-if="cronDescription" class="description-item">
|
||||
<el-tag type="primary" effect="plain" class="cron-description">{{ cronDescription }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
export default {
|
||||
name: 'CronEditor',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
InfoFilled,
|
||||
cronMode: 'daily',
|
||||
cronExpression: '',
|
||||
cronDescription: '',
|
||||
|
||||
// 每天配置
|
||||
dailyConfig: {
|
||||
time: '00:00:00'
|
||||
},
|
||||
|
||||
// 每周配置
|
||||
weeklyConfig: {
|
||||
weekDays: ['1'],
|
||||
time: '00:00:00'
|
||||
},
|
||||
|
||||
// 每月配置
|
||||
monthlyConfig: {
|
||||
days: [1],
|
||||
time: '00:00:00'
|
||||
},
|
||||
|
||||
// 自定义配置
|
||||
customConfig: {
|
||||
second: '0',
|
||||
minute: '0',
|
||||
hour: '0',
|
||||
day: '*',
|
||||
month: '*',
|
||||
week: '?'
|
||||
}
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
handler(newVal) {
|
||||
if (newVal && newVal !== this.cronExpression) {
|
||||
this.parseCron(newVal);
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
cronExpression(newVal) {
|
||||
this.$emit('update:modelValue', newVal);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.modelValue) {
|
||||
this.generateCron();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 切换模式时重置配置
|
||||
*/
|
||||
handleModeChange() {
|
||||
this.generateCron();
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成 Cron 表达式
|
||||
*/
|
||||
generateCron() {
|
||||
let cron = '';
|
||||
let description = '';
|
||||
|
||||
switch (this.cronMode) {
|
||||
case 'daily':
|
||||
cron = this.generateDailyCron();
|
||||
description = `每天 ${this.dailyConfig.time} 执行`;
|
||||
break;
|
||||
case 'weekly':
|
||||
cron = this.generateWeeklyCron();
|
||||
description = this.getWeeklyDescription();
|
||||
break;
|
||||
case 'monthly':
|
||||
cron = this.generateMonthlyCron();
|
||||
description = this.getMonthlyDescription();
|
||||
break;
|
||||
case 'custom':
|
||||
cron = this.generateCustomCron();
|
||||
description = '自定义表达式';
|
||||
break;
|
||||
}
|
||||
|
||||
this.cronExpression = cron;
|
||||
this.cronDescription = description;
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成每天的 Cron 表达式
|
||||
*/
|
||||
generateDailyCron() {
|
||||
const time = this.dailyConfig.time || '00:00:00';
|
||||
const [hour, minute, second] = time.split(':');
|
||||
return `${second} ${minute} ${hour} * * ?`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成每周的 Cron 表达式
|
||||
*/
|
||||
generateWeeklyCron() {
|
||||
if (!this.weeklyConfig.weekDays || this.weeklyConfig.weekDays.length === 0) {
|
||||
return '0 0 0 ? * 1';
|
||||
}
|
||||
|
||||
const time = this.weeklyConfig.time || '00:00:00';
|
||||
const [hour, minute, second] = time.split(':');
|
||||
const weekDays = this.weeklyConfig.weekDays.sort((a, b) => a - b).join(',');
|
||||
|
||||
return `${second} ${minute} ${hour} ? * ${weekDays}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成每月的 Cron 表达式
|
||||
*/
|
||||
generateMonthlyCron() {
|
||||
if (!this.monthlyConfig.days || this.monthlyConfig.days.length === 0) {
|
||||
return '0 0 0 1 * ?';
|
||||
}
|
||||
|
||||
const time = this.monthlyConfig.time || '00:00:00';
|
||||
const [hour, minute, second] = time.split(':');
|
||||
const days = this.monthlyConfig.days.sort((a, b) => a - b).join(',');
|
||||
|
||||
return `${second} ${minute} ${hour} ${days} * ?`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成自定义的 Cron 表达式
|
||||
*/
|
||||
generateCustomCron() {
|
||||
const { second, minute, hour, day, month, week } = this.customConfig;
|
||||
return `${second || '0'} ${minute || '0'} ${hour || '0'} ${day || '*'} ${month || '*'} ${week || '?'}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取每周执行说明
|
||||
*/
|
||||
getWeeklyDescription() {
|
||||
const weekMap = {
|
||||
'1': '周一',
|
||||
'2': '周二',
|
||||
'3': '周三',
|
||||
'4': '周四',
|
||||
'5': '周五',
|
||||
'6': '周六',
|
||||
'7': '周日'
|
||||
};
|
||||
|
||||
const weekDays = this.weeklyConfig.weekDays.map(day => weekMap[day]).join('、');
|
||||
return `每周 ${weekDays} ${this.weeklyConfig.time} 执行`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取每月执行说明
|
||||
*/
|
||||
getMonthlyDescription() {
|
||||
const days = this.monthlyConfig.days.sort((a, b) => a - b).join('、');
|
||||
return `每月 ${days} 号 ${this.monthlyConfig.time} 执行`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 解析 Cron 表达式(反向解析,用于回显)
|
||||
*/
|
||||
parseCron(cron) {
|
||||
if (!cron) return;
|
||||
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 6) {
|
||||
// 如果不是标准6位表达式,设置为自定义模式
|
||||
this.cronMode = 'custom';
|
||||
this.customConfig = {
|
||||
second: parts[0] || '0',
|
||||
minute: parts[1] || '0',
|
||||
hour: parts[2] || '0',
|
||||
day: parts[3] || '*',
|
||||
month: parts[4] || '*',
|
||||
week: parts[5] || '?'
|
||||
};
|
||||
this.cronExpression = cron;
|
||||
return;
|
||||
}
|
||||
|
||||
const [second, minute, hour, day, month, week] = parts;
|
||||
|
||||
// 尝试识别模式
|
||||
if (day === '*' && week === '?') {
|
||||
// 可能是每天
|
||||
this.cronMode = 'daily';
|
||||
this.dailyConfig.time = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}`;
|
||||
} else if (day === '?' && week !== '?' && week !== '*') {
|
||||
// 可能是每周
|
||||
this.cronMode = 'weekly';
|
||||
this.weeklyConfig.weekDays = week.split(',');
|
||||
this.weeklyConfig.time = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}`;
|
||||
} else if (day !== '*' && day !== '?' && week === '?') {
|
||||
// 可能是每月
|
||||
this.cronMode = 'monthly';
|
||||
this.monthlyConfig.days = day.split(',').map(d => parseInt(d));
|
||||
this.monthlyConfig.time = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}`;
|
||||
} else {
|
||||
// 自定义模式
|
||||
this.cronMode = 'custom';
|
||||
this.customConfig = { second, minute, hour, day, month, week };
|
||||
}
|
||||
|
||||
this.cronExpression = cron;
|
||||
this.generateCron();
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制成功回调
|
||||
*/
|
||||
onCopySuccess() {
|
||||
ElMessage.success('Cron 表达式已复制到剪贴板');
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制失败回调
|
||||
*/
|
||||
onCopyError() {
|
||||
ElMessage.error('复制失败,请手动复制');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前 Cron 表达式(供父组件调用)
|
||||
*/
|
||||
getCronExpression() {
|
||||
return this.cronExpression;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cron-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cron-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cron-mode-tabs {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.cron-mode-tabs :deep(.el-radio-button__inner) {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cron-config-area {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mode-content {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
margin-left: 12px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cron-help {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%);
|
||||
border: 1px solid #fde047;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
|
||||
.cron-help-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.cron-help-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cron-help-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
line-height: 26px;
|
||||
color: #713f12;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.cron-help-list li strong {
|
||||
display: inline-block;
|
||||
min-width: 36px;
|
||||
margin-right: 8px;
|
||||
padding: 2px 8px;
|
||||
background-color: #fef3c7;
|
||||
border: 1px solid #fde047;
|
||||
border-radius: 4px;
|
||||
color: #b45309;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cron-help-list li span {
|
||||
flex: 1;
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.cron-result {
|
||||
margin-top: 25px;
|
||||
padding: 18px 20px;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.cron-description {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
background-color: #f0f9ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cron-expression-input :deep(.el-input__inner) {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mode-content :deep(.el-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.mode-content :deep(.el-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-select .el-input__inner) {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
:deep(.el-time-picker) {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.description-item {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
23
src/components/error-page/403.vue
Normal file
23
src/components/error-page/403.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="img" style="background-image: url('/img/403.svg')"></div>
|
||||
<div class="content">
|
||||
<h1>403</h1>
|
||||
<div class="desc">抱歉,你无权访问该页面</div>
|
||||
<div class="actions">
|
||||
<router-link :to="{ path: '/' }">
|
||||
<el-button type="primary">返回首页</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'error-403',
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use './style.scss';
|
||||
</style>
|
||||
23
src/components/error-page/404.vue
Normal file
23
src/components/error-page/404.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="img" style="background-image: url('/img/404.svg')"></div>
|
||||
<div class="content">
|
||||
<h1>404</h1>
|
||||
<div class="desc">抱歉,你访问的页面不存在</div>
|
||||
<div class="actions">
|
||||
<router-link :to="{ path: '/' }">
|
||||
<el-button type="primary">返回首页</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'error-404',
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use './style.scss';
|
||||
</style>
|
||||
23
src/components/error-page/500.vue
Normal file
23
src/components/error-page/500.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="img" style="background-image: url('/img/500.svg')"></div>
|
||||
<div class="content">
|
||||
<h1>500</h1>
|
||||
<div class="desc">抱歉,服务器出错了</div>
|
||||
<div class="actions">
|
||||
<router-link :to="{ path: '/' }">
|
||||
<el-button type="primary">返回首页</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'error-500',
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use './style.scss';
|
||||
</style>
|
||||
35
src/components/error-page/style.scss
Normal file
35
src/components/error-page/style.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
.error-page {
|
||||
background: #f0f2f5;
|
||||
margin-top: -30px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.img {
|
||||
margin-right: 80px;
|
||||
height: 360px;
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 50%;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.content {
|
||||
h1 {
|
||||
color: #434e59;
|
||||
font-size: 72px;
|
||||
font-weight: 600;
|
||||
line-height: 72px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
194
src/components/flow-design-step/main.vue
Normal file
194
src/components/flow-design-step/main.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div v-if="componentLoaded">
|
||||
<el-dialog
|
||||
v-if="website.design.designMode"
|
||||
title="流程配置"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
v-model="visible"
|
||||
:close-on-press-escape="false"
|
||||
:fullscreen="true"
|
||||
:before-close="handleNutflowClose"
|
||||
class="nf-dialog"
|
||||
>
|
||||
<nf-design-base
|
||||
v-if="nutflowOption.step === 1"
|
||||
class="animated fadeIn"
|
||||
style="height: calc(100vh - 108px)"
|
||||
ref="nf-design"
|
||||
:options="nutflowOption.step1"
|
||||
></nf-design-base>
|
||||
<nf-design-base
|
||||
v-if="nutflowOption.step === 2"
|
||||
class="animated fadeIn"
|
||||
style="height: calc(100vh - 108px)"
|
||||
ref="nf-design-view"
|
||||
:options="nutflowOption.step2"
|
||||
></nf-design-base>
|
||||
<template #footer>
|
||||
<span class="avue-dialog__footer">
|
||||
<el-button @click="handleNutflowClose(() => {}, true)">取 消</el-button>
|
||||
<el-button v-if="nutflowOption.step === 1" type="success" @click="handleStep(1)"
|
||||
>下 一 步</el-button
|
||||
>
|
||||
<el-button v-if="nutflowOption.step === 2" type="success" @click="handleStep(-1)"
|
||||
>上 一 步</el-button
|
||||
>
|
||||
<el-button v-if="nutflowOption.step === 2" type="primary" @click="handleSubmitModel"
|
||||
>确 定</el-button
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { loadFlowModule } from '@/utils/module';
|
||||
import { submitModel } from '@/api/flow/flow';
|
||||
|
||||
export default {
|
||||
name: 'flowDesign',
|
||||
props: {
|
||||
isDisplay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
componentLoaded: false,
|
||||
nutflowOption: {
|
||||
process: {},
|
||||
step: 1,
|
||||
step1: {
|
||||
toolbar: [
|
||||
'open',
|
||||
'create',
|
||||
'fit',
|
||||
'zoom-in',
|
||||
'zoom-out',
|
||||
'undo',
|
||||
'redo',
|
||||
'import',
|
||||
'preview',
|
||||
],
|
||||
},
|
||||
step2: {
|
||||
mode: 'view',
|
||||
simulation: true,
|
||||
minimap: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 懒加载流程设计器模块
|
||||
loadFlowModule(this.$app).then(() => {
|
||||
this.componentLoaded = true;
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
isDisplay: {
|
||||
handler(val) {
|
||||
this.visible = val;
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
visible: {
|
||||
handler(val) {
|
||||
this.$emit('update:is-display', val);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleSubmitModel() {
|
||||
const registry = this.$refs['nf-design-view'].getElementRegistry().getAll();
|
||||
const { businessObject } = registry[0];
|
||||
const { id, name, documentation } = businessObject;
|
||||
const description = documentation && documentation.length > 0 ? documentation[0].text : null;
|
||||
const params = {
|
||||
...this.nutflowOption.process,
|
||||
modelKey: id,
|
||||
name,
|
||||
description,
|
||||
modelEditorXml: this.nutflowOption.process.xml,
|
||||
};
|
||||
submitModel(params).then(() => {
|
||||
this.$message.success('操作成功');
|
||||
this.handleNutflowClose();
|
||||
this.$emit('loadData');
|
||||
});
|
||||
},
|
||||
handleStep(step) {
|
||||
if (step === 1) {
|
||||
// 下一步
|
||||
this.$refs['nf-design'].getData('xml').then(data => {
|
||||
this.nutflowOption.step1.xml = data;
|
||||
this.nutflowOption.step2.xml = data;
|
||||
this.nutflowOption.process.xml = data;
|
||||
this.nutflowOption.step = 2;
|
||||
});
|
||||
} else this.nutflowOption.step = 1;
|
||||
},
|
||||
handleNutflowClose(done, flag) {
|
||||
const initOption = {
|
||||
process: {},
|
||||
step: 1,
|
||||
step1: {
|
||||
toolbar: [
|
||||
'open',
|
||||
'create',
|
||||
'fit',
|
||||
'zoom-in',
|
||||
'zoom-out',
|
||||
'undo',
|
||||
'redo',
|
||||
'import',
|
||||
'preview',
|
||||
],
|
||||
},
|
||||
step2: {
|
||||
mode: 'view',
|
||||
simulation: true,
|
||||
minimap: true,
|
||||
},
|
||||
};
|
||||
if (done || flag) {
|
||||
this.$confirm('确定要关闭吗?关闭未保存的修改都会丢失。', '警告', {
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => {
|
||||
this.nutflowOption = initOption;
|
||||
if (typeof done == 'function') done();
|
||||
this.visible = false;
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
this.nutflowOption = initOption;
|
||||
this.visible = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.flow-design-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 !important;
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -40%);
|
||||
max-height: calc(100% - 30px);
|
||||
max-width: calc(100% - 30px);
|
||||
|
||||
.el-dialog__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
133
src/components/flow-design/main.vue
Normal file
133
src/components/flow-design/main.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div v-if="componentLoaded">
|
||||
<el-dialog
|
||||
v-if="isDialog"
|
||||
v-model="visible"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
title="流程图展示"
|
||||
width="70%"
|
||||
class="flow-design-dialog"
|
||||
>
|
||||
<nf-design-base ref="bpmn" style="height: 60vh" :options="option"></nf-design-base>
|
||||
</el-dialog>
|
||||
<div v-else>
|
||||
<nf-design-base
|
||||
v-if="visible"
|
||||
ref="bpmn"
|
||||
style="height: 60vh"
|
||||
:options="option"
|
||||
></nf-design-base>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { loadFlowModule } from '@/utils/module';
|
||||
import { modelView } from '@/api/flow/flow';
|
||||
|
||||
export default {
|
||||
name: 'flowDesign',
|
||||
props: {
|
||||
isDialog: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDisplay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
processInstanceId: String,
|
||||
processDefinitionId: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
componentLoaded: false,
|
||||
option: {
|
||||
mode: 'view',
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// 懒加载流程设计器模块
|
||||
loadFlowModule(this.$app).then(() => {
|
||||
this.componentLoaded = true;
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
isDisplay: {
|
||||
handler(val) {
|
||||
this.visible = val;
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
visible: {
|
||||
handler(val) {
|
||||
this.$emit('update:is-display', val);
|
||||
},
|
||||
},
|
||||
processInstanceId: {
|
||||
handler(val) {
|
||||
if (!val) return;
|
||||
this.getDetail({ processInstanceId: this.processInstanceId });
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
processDefinitionId: {
|
||||
handler(val) {
|
||||
if (!val) return;
|
||||
this.getDetail({ processDefinitionId: this.processDefinitionId });
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDetail(params) {
|
||||
modelView(params).then(res => {
|
||||
const data = res.data.data;
|
||||
const { xml, flow } = data;
|
||||
this.option.xml = xml;
|
||||
if (flow) {
|
||||
const flows = [];
|
||||
flow.forEach(f => {
|
||||
let { endTime } = f;
|
||||
|
||||
const ff = {
|
||||
id: f.historyActivityId,
|
||||
class: !endTime && f.historyActivityType !== 'candidate' ? 'nodePrimary' : '',
|
||||
};
|
||||
|
||||
if (f.historyActivityType === 'sequenceFlow') ff.class = 'lineWarn';
|
||||
else if (!ff.class && f.historyActivityType !== 'candidate') ff.class = 'nodeSuccess';
|
||||
|
||||
const index = flows.findIndex(fl => fl.id === f.historyActivityId);
|
||||
if (index !== -1) flows.splice(index, 1, ff);
|
||||
else flows.push(ff);
|
||||
});
|
||||
this.option.flows = flows;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.flow-design-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 !important;
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -40%);
|
||||
max-height: calc(100% - 30px);
|
||||
max-width: calc(100% - 30px);
|
||||
|
||||
.el-dialog__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
90
src/components/highlight/main.vue
Normal file
90
src/components/highlight/main.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="code-block-container">
|
||||
<pre v-if="highlightedCode"><code v-html="highlightedCode" class="hljs"></code></pre>
|
||||
<button v-if="clipboard"
|
||||
v-clipboard:copy="this.code"
|
||||
v-clipboard:success="onCopySuccess"
|
||||
class="copy-btn">
|
||||
一键复制
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import hljs from 'highlight.js';
|
||||
import formatter from '@/utils/formatter';
|
||||
// 引入语言支持
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import java from 'highlight.js/lib/languages/java';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import 'highlight.js/styles/github.css';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
code: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'javascript'
|
||||
},
|
||||
clipboard: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
highlightedCode() {
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
let code = this.code;
|
||||
if (this.language === 'json') {
|
||||
code = formatter.prettyCode(this.code);
|
||||
}
|
||||
return hljs.highlight(code, {language: this.language, ignoreIllegals: true}).value;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCopySuccess() {
|
||||
this.$message.success('复制成功');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-block-container {
|
||||
position: relative;
|
||||
background-color: #f0f0f0;
|
||||
width: 100%;
|
||||
overflow-y: auto; /* 只显示纵向滚动条 */
|
||||
overflow-x: hidden; /* 隐藏横向滚动条 */
|
||||
white-space: pre-wrap; /* 防止内容溢出 */
|
||||
word-wrap: break-word; /* 自动换行 */
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0.5em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 0.75em;
|
||||
color: #fff;
|
||||
background-color: #606266;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background-color: #909399;
|
||||
}
|
||||
</style>
|
||||
82
src/components/iframe/main.vue
Normal file
82
src/components/iframe/main.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<basic-container>
|
||||
<iframe :src="src" class="iframe" ref="iframe" />
|
||||
</basic-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import 'nprogress/nprogress.css'; // progress bar style
|
||||
export default {
|
||||
name: 'AvueIframe',
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
created() {
|
||||
NProgress.configure({ showSpinner: false });
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
},
|
||||
watch: {
|
||||
$route: function () {
|
||||
this.load();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
src() {
|
||||
return this.$route.query.url.replace(/#/g, '&');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 显示等待框
|
||||
show() {
|
||||
NProgress.start();
|
||||
},
|
||||
// 隐藏等待狂
|
||||
hide() {
|
||||
NProgress.done();
|
||||
},
|
||||
// 加载组件
|
||||
load() {
|
||||
this.show();
|
||||
//超时3s自动隐藏等待狂,加强用户体验
|
||||
let time = 3;
|
||||
const timeFunc = setInterval(() => {
|
||||
time--;
|
||||
if (time == 0) {
|
||||
this.hide();
|
||||
clearInterval(timeFunc);
|
||||
}
|
||||
}, 1000);
|
||||
this.iframeInit();
|
||||
},
|
||||
//iframe窗口初始化
|
||||
iframeInit() {
|
||||
const iframe = this.$refs.iframe;
|
||||
const clientHeight = document.documentElement.clientHeight - 150;
|
||||
if (!iframe) return;
|
||||
iframe.style.height = `${clientHeight}px`;
|
||||
if (iframe.attachEvent) {
|
||||
iframe.attachEvent('onload', () => {
|
||||
this.hide();
|
||||
});
|
||||
} else {
|
||||
iframe.onload = () => {
|
||||
this.hide();
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
129
src/components/third-register/main.vue
Normal file
129
src/components/third-register/main.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
title="账号注册"
|
||||
append-to-body
|
||||
v-model="accountBox"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
width="20%"
|
||||
>
|
||||
<el-form :model="form" ref="form" label-width="80px">
|
||||
<el-form-item v-if="tenantMode" label="租户编号">
|
||||
<el-input v-model="form.tenantId" placeholder="请输入租户编号"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户姓名">
|
||||
<el-input v-model="form.name" placeholder="请输入用户姓名"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号名称">
|
||||
<el-input v-model="form.account" placeholder="请输入账号名称"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号密码">
|
||||
<el-input v-model="form.password" placeholder="请输入账号密码"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="form.password2" placeholder="请输入确认密码"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button type="primary" :loading="loading" @click="handleRegister">确 定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { validatenull } from 'utils/validate';
|
||||
import { registerGuest } from '@/api/user';
|
||||
import { getTopUrl } from 'utils/util';
|
||||
import { info } from '@/api/system/tenant';
|
||||
import { resetRouter } from '@/router/index';
|
||||
|
||||
export default {
|
||||
name: 'thirdRegister',
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
tenantId: '',
|
||||
name: '',
|
||||
account: '',
|
||||
password: '',
|
||||
password2: '',
|
||||
},
|
||||
loading: false,
|
||||
tenantMode: true,
|
||||
accountBox: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['userInfo']),
|
||||
},
|
||||
created() {
|
||||
this.getTenant();
|
||||
},
|
||||
mounted() {
|
||||
// 若未登录则弹出框进行绑定
|
||||
if (validatenull(this.userInfo.userId) || this.userInfo.userId < 0) {
|
||||
this.form.name = this.userInfo.userName;
|
||||
this.form.account = this.userInfo.account;
|
||||
this.accountBox = true;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleRegister() {
|
||||
if (this.form.tenantId === '') {
|
||||
this.$message.warning('请先输入租户编号');
|
||||
return;
|
||||
}
|
||||
if (this.form.account === '') {
|
||||
this.$message.warning('请先输入账号名称');
|
||||
return;
|
||||
}
|
||||
if (this.form.password === '' || this.form.password2 === '') {
|
||||
this.$message.warning('请先输入密码');
|
||||
return;
|
||||
}
|
||||
if (this.form.password !== this.form.password2) {
|
||||
this.$message.warning('两次密码输入不一致');
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
registerGuest(this.form, this.userInfo.oauthId).then(
|
||||
res => {
|
||||
this.loading = false;
|
||||
const data = res.data;
|
||||
if (data.success) {
|
||||
this.accountBox = false;
|
||||
this.$alert('注册申请已提交,请耐心等待管理员通过!', '注册提示').then(() => {
|
||||
this.$store.dispatch('LogOut').then(() => {
|
||||
resetRouter();
|
||||
this.$router.push({ path: '/login' });
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.$message.error(data.msg || '提交失败');
|
||||
}
|
||||
},
|
||||
error => {
|
||||
window.console.log(error);
|
||||
this.loading = false;
|
||||
}
|
||||
);
|
||||
},
|
||||
getTenant() {
|
||||
let domain = getTopUrl();
|
||||
// 临时指定域名,方便测试
|
||||
//domain = "https://bladex.cn";
|
||||
info(domain).then(res => {
|
||||
const data = res.data;
|
||||
if (data.success && data.data.tenantId) {
|
||||
this.form.tenantId = data.data.tenantId;
|
||||
this.tenantMode = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user