This commit is contained in:
kk
2026-07-08 18:00:33 +08:00
commit e66b4a3680
312 changed files with 54718 additions and 0 deletions

View 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>

View 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>

View 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>

View 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;
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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-SUN1=周一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>

View 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>

View 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>

View 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>

View 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;
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>