添加滚动公告小组件及其配置,支持水平和垂直滚动效果

This commit is contained in:
hxuanyu 2025-07-21 18:15:21 +08:00
parent d5cc3eb084
commit 84b31e5523
5 changed files with 866 additions and 0 deletions

View File

@ -0,0 +1,431 @@
<template>
<div class="marquee-config">
<h2>滚动公告小组件设置</h2>
<el-form label-position="top">
<el-divider>预设样式</el-divider>
<el-form-item>
<el-button-group>
<el-button type="primary" @click="applyPreset('classic')">经典滚动</el-button>
<el-button type="suc modern: {
conten gaming: {
conten vertical: {
content: '正在直播中\n精彩内容不要错过\n欢迎弹幕互动\n点个关注吧',
direction: 'vertical' as const,'击败BOSS! 连击! AMAZING! LEGENDARY!',
direction: 'horizontal' as const,'正在直播游戏 | LIVE | 欢迎互动聊天!',
direction: 'horizontal' as const,s" @click="applyPreset('modern')">现代风格</el-button>
<el-button type="warning" @click="applyPreset('gaming')">游戏主题</el-button>
<el-button type="danger" @click="applyPreset('vertical')">垂直轮播</el-button>
</el-button-group>
</el-form-item>
<el-divider>基本设置</el-divider>
<el-form-item label="公告内容">
<el-input
v-model="localConfig.content"
type="textarea"
:rows="4"
placeholder="请输入公告内容,支持多行(垂直滚动模式下每行单独显示)"
/>
</el-form-item>
<el-form-item label="滚动方向">
<el-select v-model="localConfig.direction" placeholder="选择滚动方向">
<el-option label="水平滚动" value="horizontal" />
<el-option label="垂直滚动" value="vertical" />
</el-select>
</el-form-item>
<!-- 水平滚动配置 -->
<template v-if="localConfig.direction === 'horizontal'">
<el-form-item label="滚动方向">
<el-select v-model="localConfig.horizontalDirection">
<el-option label="向左滚动" value="left" />
<el-option label="向右滚动" value="right" />
</el-select>
</el-form-item>
<el-form-item label="滚动速度">
<el-slider
v-model="localConfig.speed"
:min="10"
:max="200"
:step="5"
show-input
:format-tooltip="(val: number) => `${val} px/s`"
/>
</el-form-item>
</template>
<!-- 垂直滚动配置 -->
<template v-if="localConfig.direction === 'vertical'">
<el-form-item label="行间隔时间">
<el-slider
v-model="localConfig.verticalInterval"
:min="1"
:max="10"
:step="0.5"
show-input
:format-tooltip="(val: number) => `${val} 秒`"
/>
</el-form-item>
<el-form-item label="行高倍数(调节行间距)">
<el-slider
v-model="localConfig.lineHeight"
:min="1"
:max="3"
:step="0.1"
show-input
/>
</el-form-item>
<el-form-item label="每次显示行数">
<el-slider
v-model="localConfig.displayLines"
:min="1"
:max="5"
:step="1"
show-input
:format-tooltip="(val: number) => `${val} 行`"
/>
</el-form-item>
</template>
<el-divider>文本样式</el-divider>
<el-form-item label="使用渐变色">
<el-switch v-model="localConfig.useGradient" />
</el-form-item>
<el-form-item v-if="!localConfig.useGradient" label="文本颜色">
<el-color-picker v-model="localConfig.color" />
</el-form-item>
<template v-if="localConfig.useGradient">
<el-form-item label="渐变颜色1">
<el-color-picker v-model="localConfig.gradientColors[0]" />
</el-form-item>
<el-form-item label="渐变颜色2">
<el-color-picker v-model="localConfig.gradientColors[1]" />
</el-form-item>
</template>
<el-form-item label="字体大小">
<el-slider v-model="localConfig.fontSize" :min="12" :max="72" show-input />
</el-form-item>
<el-form-item label="字体">
<el-select v-model="localConfig.fontFamily">
<el-option label="Arial" value="Arial" />
<el-option label="微软雅黑" value="Microsoft YaHei" />
<el-option label="黑体" value="SimHei" />
<el-option label="宋体" value="SimSun" />
<el-option label="Times New Roman" value="Times New Roman" />
<el-option label="Courier New" value="Courier New" />
<el-option label="Impact" value="Impact" />
</el-select>
</el-form-item>
<el-form-item label="字重">
<el-select v-model="localConfig.fontWeight">
<el-option label="普通" value="normal" />
<el-option label="粗体" value="bold" />
<el-option label="细体" value="100" />
<el-option label="轻体" value="300" />
<el-option label="中等" value="500" />
<el-option label="加粗" value="700" />
<el-option label="特粗" value="900" />
</el-select>
</el-form-item>
<el-form-item label="文本阴影">
<el-switch v-model="localConfig.textShadow" />
</el-form-item>
<template v-if="localConfig.textShadow">
<el-form-item label="阴影颜色">
<el-color-picker v-model="shadowColor" />
</el-form-item>
<el-form-item label="阴影模糊度">
<el-slider v-model="localConfig.shadowBlur" :min="0" :max="20" show-input />
</el-form-item>
</template>
<el-divider>容器样式</el-divider>
<el-form-item label="宽度">
<el-slider
v-model="localConfig.width"
:min="100"
:max="800"
:step="10"
show-input
:format-tooltip="(val: number) => `${val}px`"
/>
</el-form-item>
<el-form-item label="高度">
<el-slider
v-model="localConfig.height"
:min="30"
:max="200"
:step="5"
show-input
:format-tooltip="(val: number) => `${val}px`"
/>
</el-form-item>
<el-form-item label="背景颜色">
<el-color-picker v-model="backgroundColor" show-alpha />
</el-form-item>
<el-form-item label="圆角">
<el-slider
v-model="localConfig.borderRadius"
:min="0"
:max="30"
show-input
:format-tooltip="(val: number) => `${val}px`"
/>
</el-form-item>
<el-form-item label="内边距">
<el-slider
v-model="localConfig.padding"
:min="0"
:max="50"
:step="2"
show-input
:format-tooltip="(val: number) => `${val}px`"
/>
</el-form-item>
<el-divider>其他设置</el-divider>
<el-form-item>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span>循环播放</span>
<el-switch v-model="localConfig.loop" style="margin-left: 10px;" />
</div>
<div>
<span>鼠标悬停暂停</span>
<el-switch v-model="localConfig.pauseOnHover" style="margin-left: 10px;" />
</div>
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue';
import type { MarqueeConfig } from './types';
// Define props with default values
const props = withDefaults(defineProps<{
config: Partial<MarqueeConfig>;
}>(), {
config: () => ({
content: '这是一条滚动公告\n支持多行内容\n可以自定义样式和滚动效果',
color: '#ffffff',
fontSize: 24,
fontWeight: 'normal',
fontFamily: 'Arial',
textShadow: true,
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 2,
useGradient: false,
gradientColors: ['#ff6b6b', '#4ecdc4'],
direction: 'horizontal',
horizontalDirection: 'left',
speed: 50,
verticalInterval: 3,
lineHeight: 1.5,
displayLines: 1,
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 8,
padding: 12,
width: 400,
height: 60,
loop: true,
pauseOnHover: true
})
});
// Define emit
const emit = defineEmits<{
(event: 'update:config', config: MarqueeConfig): void;
}>();
// Local config for two-way binding
const localConfig = ref<MarqueeConfig>({
content: '这是一条滚动公告\n支持多行内容\n可以自定义样式和滚动效果',
color: '#ffffff',
fontSize: 24,
fontWeight: 'normal',
fontFamily: 'Arial',
textShadow: true,
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 2,
useGradient: false,
gradientColors: ['#ff6b6b', '#4ecdc4'],
direction: 'horizontal',
horizontalDirection: 'left',
speed: 50,
verticalInterval: 3,
lineHeight: 1.5,
displayLines: 1,
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 8,
padding: 12,
width: 400,
height: 60,
loop: true,
pauseOnHover: true
});
// Sync with parent config on mount
onMounted(() => {
// Merge default config with provided config
localConfig.value = { ...localConfig.value, ...props.config };
});
//
const backgroundColor = computed({
get: () => {
return localConfig.value.backgroundColor;
},
set: (value) => {
localConfig.value.backgroundColor = value;
}
});
//
const shadowColor = computed({
get: () => {
return localConfig.value.shadowColor;
},
set: (value) => {
localConfig.value.shadowColor = value;
}
});
//
const presets = {
classic: {
content: '欢迎关注我的直播间 ★ 记得点赞订阅哦 ♥ 感谢大家的支持!',
direction: 'horizontal' as const,
horizontalDirection: 'left' as const,
speed: 80,
verticalInterval: 3,
lineHeight: 1.4,
displayLines: 1,
fontSize: 24,
fontFamily: 'Microsoft YaHei',
fontWeight: 'normal',
color: '#ffffff',
useGradient: false,
textShadow: true,
shadowColor: 'rgba(0,0,0,0.7)',
shadowBlur: 3,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 25,
padding: 12,
width: 600,
height: 50,
loop: true,
pauseOnHover: true
},
modern: {
content: '🎮 正在直播游戏 | ● LIVE | 欢迎互动聊天!',
direction: 'horizontal' as const,
horizontalDirection: 'left' as const,
speed: 60,
verticalInterval: 3,
lineHeight: 1.4,
displayLines: 1,
fontSize: 26,
fontFamily: 'Arial',
fontWeight: '500',
useGradient: true,
gradientColors: ['#667eea', '#764ba2'],
textShadow: true,
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 2,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 12,
padding: 16,
width: 500,
height: 60,
loop: true,
pauseOnHover: true
},
gaming: {
content: '🎮 正在直播游戏 | ● LIVE | 欢迎互动聊天!',
direction: 'horizontal' as const,
horizontalDirection: 'right' as const,
speed: 100,
verticalInterval: 3,
lineHeight: 1.4,
displayLines: 1,
fontSize: 32,
fontFamily: 'Impact',
fontWeight: 'bold',
useGradient: true,
gradientColors: ['#ff6b35', '#f7931e'],
textShadow: true,
shadowColor: 'rgba(0,0,0,0.8)',
shadowBlur: 6,
backgroundColor: 'rgba(0,0,0,0.8)',
borderRadius: 8,
padding: 20,
width: 550,
height: 70,
loop: true,
pauseOnHover: false
},
vertical: {
content: '正在直播中\n🎬 精彩内容不要错过\n 欢迎弹幕互动\n⭐ 点个关注吧',
direction: 'vertical' as const,
verticalInterval: 2.5,
lineHeight: 1.4,
displayLines: 2,
fontSize: 22,
fontFamily: 'Microsoft YaHei',
fontWeight: 'normal',
color: '#ffffff',
useGradient: false,
textShadow: true,
shadowColor: 'rgba(0,0,0,0.6)',
shadowBlur: 2,
backgroundColor: 'rgba(30,30,30,0.85)',
borderRadius: 10,
padding: 18,
width: 280,
height: 90,
loop: true,
pauseOnHover: true
}
};
//
const applyPreset = (presetName: keyof typeof presets) => {
const preset = presets[presetName];
localConfig.value = { ...localConfig.value, ...preset };
};
// Watch for local changes and emit to parent
watch(localConfig, (newConfig) => {
emit('update:config', { ...newConfig });
}, { deep: true });
</script>
<style scoped>
.marquee-config {
padding: 10px;
}
</style>

View File

@ -0,0 +1,352 @@
<template>
<div
class="marquee-container"
:style="containerStyle"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div
v-if="config.direction === 'horizontal'"
class="marquee-horizontal"
:style="horizontalStyle"
ref="horizontalRef"
>
<span class="marquee-text" :style="textStyle">{{ config.content }}</span>
<span class="marquee-text marquee-duplicate" :style="textStyle">{{ config.content }}</span>
</div>
<div
v-else
class="marquee-vertical"
:style="verticalStyle"
>
<div
class="marquee-vertical-content"
:style="verticalContentStyle"
>
<div
v-for="(line, index) in contentLines"
:key="index"
class="marquee-line"
:style="lineStyle"
>
{{ line }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import type { MarqueeConfig } from './types';
interface Props {
config: MarqueeConfig;
}
const props = defineProps<Props>();
const horizontalRef = ref<HTMLElement>();
const currentLineIndex = ref(0);
const horizontalOffset = ref(0);
const isPaused = ref(false);
let animationId: number | null = null;
let verticalTimer: number | null = null;
let lastTime = 0;
//
const containerStyle = computed(() => ({
width: `${props.config.width}px`,
height: `${props.config.height}px`,
backgroundColor: props.config.backgroundColor,
borderRadius: `${props.config.borderRadius}px`,
padding: `${props.config.padding}px`,
overflow: 'hidden',
position: 'relative' as const,
boxSizing: 'border-box' as const
}));
const textStyle = computed(() => {
const style: any = {
fontSize: `${props.config.fontSize}px`,
fontWeight: props.config.fontWeight,
fontFamily: props.config.fontFamily,
whiteSpace: 'nowrap',
lineHeight: '1.2', //
margin: 0,
padding: 0
};
if (props.config.useGradient && props.config.gradientColors.length >= 2) {
style.background = `linear-gradient(45deg, ${props.config.gradientColors.join(', ')})`;
style.WebkitBackgroundClip = 'text';
style.WebkitTextFillColor = 'transparent';
style.backgroundClip = 'text';
} else {
style.color = props.config.color;
}
if (props.config.textShadow) {
style.textShadow = `0 0 ${props.config.shadowBlur}px ${props.config.shadowColor}`;
}
return style;
});
const horizontalStyle = computed(() => ({
transform: `translateX(${horizontalOffset.value}px)`,
transition: isPaused.value ? 'none' : 'transform 0.1s linear',
height: '100%',
display: 'flex',
alignItems: 'center'
}));
const verticalStyle = computed(() => {
//
const lineHeight = props.config.fontSize * props.config.lineHeight;
const containerHeight = lineHeight * props.config.displayLines;
return {
height: `${containerHeight}px`,
display: 'flex' as const,
flexDirection: 'column' as const,
justifyContent: 'flex-start' as const,
overflow: 'hidden'
};
});
const verticalContentStyle = computed(() => {
const lineHeight = props.config.fontSize * props.config.lineHeight;
const offset = currentLineIndex.value * lineHeight;
return {
transform: `translateY(${-offset}px)`,
transition: 'transform 0.5s ease-in-out'
};
});
const lineStyle = computed(() => {
const lineHeight = props.config.fontSize * props.config.lineHeight;
const style: any = {
fontSize: `${props.config.fontSize}px`,
fontWeight: props.config.fontWeight,
fontFamily: props.config.fontFamily,
margin: 0,
padding: 0,
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: `${lineHeight}px`,
lineHeight: props.config.lineHeight,
whiteSpace: 'nowrap'
};
if (props.config.useGradient && props.config.gradientColors.length >= 2) {
style.background = `linear-gradient(45deg, ${props.config.gradientColors.join(', ')})`;
style.WebkitBackgroundClip = 'text';
style.WebkitTextFillColor = 'transparent';
style.backgroundClip = 'text';
} else {
style.color = props.config.color;
}
if (props.config.textShadow) {
style.textShadow = `0 0 ${props.config.shadowBlur}px ${props.config.shadowColor}`;
}
return style;
});
//
const contentLines = computed(() => {
return props.config.content.split('\n').filter(line => line.trim() !== '');
});
//
const startHorizontalAnimation = () => {
if (!horizontalRef.value || props.config.direction !== 'horizontal') return;
const container = horizontalRef.value.parentElement;
if (!container) return;
// DOM
requestAnimationFrame(() => {
const textElement = horizontalRef.value?.querySelector('.marquee-text') as HTMLElement;
if (!textElement) return;
const containerWidth = container.offsetWidth;
const textWidth = textElement.offsetWidth;
const spacing = 50; //
const totalWidth = textWidth + spacing;
const animate = (currentTime: number) => {
if (lastTime === 0) lastTime = currentTime;
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
if (!isPaused.value) {
const direction = props.config.horizontalDirection === 'left' ? -1 : 1;
const pixelsPerSecond = props.config.speed;
const deltaPixels = (pixelsPerSecond * deltaTime) / 1000;
horizontalOffset.value += direction * deltaPixels;
//
if (props.config.horizontalDirection === 'left') {
if (horizontalOffset.value <= -totalWidth) {
horizontalOffset.value = 0;
}
} else {
if (horizontalOffset.value >= totalWidth) {
horizontalOffset.value = 0;
}
}
}
if (props.config.loop) {
animationId = requestAnimationFrame(animate);
}
};
//
if (props.config.horizontalDirection === 'left') {
horizontalOffset.value = containerWidth;
} else {
horizontalOffset.value = -totalWidth;
}
animationId = requestAnimationFrame(animate);
});
};
//
const startVerticalAnimation = () => {
if (props.config.direction !== 'vertical' || contentLines.value.length <= props.config.displayLines) return;
const showNextLines = () => {
const totalLines = contentLines.value.length;
const displayLines = props.config.displayLines;
//
let nextIndex = currentLineIndex.value + displayLines;
//
if (nextIndex >= totalLines) {
nextIndex = 0;
}
currentLineIndex.value = nextIndex;
if (props.config.loop || nextIndex !== 0) {
verticalTimer = window.setTimeout(showNextLines, props.config.verticalInterval * 1000);
}
};
verticalTimer = window.setTimeout(showNextLines, props.config.verticalInterval * 1000);
};
//
const handleMouseEnter = () => {
if (props.config.pauseOnHover) {
isPaused.value = true;
}
};
const handleMouseLeave = () => {
if (props.config.pauseOnHover) {
isPaused.value = false;
}
};
//
const stopAnimations = () => {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
if (verticalTimer) {
clearTimeout(verticalTimer);
verticalTimer = null;
}
lastTime = 0;
};
//
const startAnimation = () => {
stopAnimations();
currentLineIndex.value = 0;
if (props.config.direction === 'horizontal') {
startHorizontalAnimation();
} else {
startVerticalAnimation();
}
};
//
watch(() => props.config, () => {
startAnimation();
}, { deep: true });
onMounted(() => {
startAnimation();
});
onUnmounted(() => {
stopAnimations();
});
</script>
<style scoped>
.marquee-container {
display: flex;
align-items: center;
justify-content: flex-start;
position: relative;
overflow: hidden;
}
.marquee-horizontal {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
width: auto;
display: flex;
align-items: center;
}
.marquee-vertical {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.marquee-vertical-content {
display: flex;
flex-direction: column;
}
.marquee-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.marquee-line.active {
opacity: 1 !important;
}
.marquee-text {
display: inline-block;
white-space: nowrap;
}
.marquee-duplicate {
margin-left: 50px; /* 两个文本之间的间距 */
}
</style>

View File

@ -0,0 +1,14 @@
import Widget from './Widget.vue';
import Config from './Config.vue';
import { defaultConfig } from './types';
import { createWidget } from '../createWidget';
export default createWidget({
label: '滚动公告',
value: 'marquee',
icon: '📢',
description: '可定制内容、样式和滚动效果的公告滚动条,支持水平和垂直滚动',
component: Widget,
configComponent: Config,
defaultConfig
});

View File

@ -0,0 +1,67 @@
// 滚动公告小组件配置接口
export interface MarqueeConfig {
// 公告内容
content: string;
// 文本样式
color: string;
fontSize: number;
fontWeight: string;
fontFamily: string;
textShadow: boolean;
shadowColor: string;
shadowBlur: number;
useGradient: boolean;
gradientColors: string[];
// 滚动配置
direction: 'horizontal' | 'vertical';
horizontalDirection: 'left' | 'right';
speed: number; // 水平滚动速度 (px/s)
// 垂直滚动特有配置
verticalInterval: number; // 垂直滚动时行间隔时间 (秒)
lineHeight: number; // 行高倍数
displayLines: number; // 每次显示的行数
// 容器样式
backgroundColor: string;
borderRadius: number;
padding: number;
width: number;
height: number;
// 其他配置
loop: boolean; // 是否循环播放
pauseOnHover: boolean; // 鼠标悬停时是否暂停
}
// 滚动公告小组件默认配置
export const defaultConfig: MarqueeConfig = {
content: '欢迎关注我的直播间 ★ 记得点赞订阅哦 ♥ 感谢大家的支持!',
color: '#ffffff',
fontSize: 24,
fontWeight: 'normal',
fontFamily: 'Microsoft YaHei',
textShadow: true,
shadowColor: 'rgba(0,0,0,0.7)',
shadowBlur: 3,
useGradient: false,
gradientColors: ['#ff6b6b', '#4ecdc4'],
direction: 'horizontal',
horizontalDirection: 'left',
speed: 80,
verticalInterval: 3,
lineHeight: 1.4,
displayLines: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 25,
padding: 12,
width: 600,
height: 50,
loop: true,
pauseOnHover: true
};

View File

@ -4,6 +4,7 @@ import DateWidget from './date';
import TextWidget from './text'; import TextWidget from './text';
import ImageWidget from './image'; import ImageWidget from './image';
import TimerWidget from './timer'; import TimerWidget from './timer';
import MarqueeWidget from './marquee';
// 导入类型定义 // 导入类型定义
@ -16,6 +17,7 @@ export const widgets: WidgetRegistration[] = [
TextWidget, TextWidget,
ImageWidget, ImageWidget,
TimerWidget, TimerWidget,
MarqueeWidget,
]; ];
// 获取小组件显示信息列表(用于 UI 展示) // 获取小组件显示信息列表(用于 UI 展示)