添加计时器小组件,支持倒计时和正计时功能,包含丰富的样式和配置选项

This commit is contained in:
hxuanyu 2025-06-27 16:07:37 +08:00
parent 5af0b3396e
commit f7ffeb54c2
6 changed files with 821 additions and 0 deletions

View File

@ -18,6 +18,7 @@
2. **日期小组件**:显示当前日期,可自定义格式、样式和特效
3. **文本小组件**:显示文本,支持渐变、阴影、字体等自定义样式
4. **图片小组件**:显示图片,可自定义大小、特效和位置
5. **计时器小组件**:支持倒计时和正计时功能,具有丰富的样式和提示功能
## 使用方法
@ -62,6 +63,34 @@
- **圆角**:调整图片圆角半径
- **阴影效果**:添加阴影及调整阴影颜色和模糊度
### 计时器小组件
- **计时器模式**
- **倒计时**:设定时长,从指定时间倒数至零
- **正计时**:从零开始向上计时(秒表功能)
- **时间设置**
- 倒计时模式:可设置分钟和秒数
- 时间格式:支持 mm:ss 和 hh:mm:ss 格式
- 毫秒显示:可选择是否显示毫秒
- **控制功能**
- 自动开始:组件加载后自动开始计时
- 手动控制:开始、暂停、重置按钮
- 点击切换:点击计时器进行状态切换
- **倒计时专属功能**
- **警告设置**:设置警告阈值和警告颜色
- **进度条**:显示剩余时间进度,可自定义颜色和高度
- **音效提示**:倒计时结束播放提示音,可调节音量
- **结束颜色**:倒计时结束时的特殊颜色显示
- **样式设置**
- 字体大小、字体类型、字重
- 文本颜色或渐变色
- 文字阴影及模糊度
- **预设样式**
- 游戏风格:适合游戏直播的醒目样式
- 会议计时:适合会议和演讲的专业样式
- 运动计时:适合健身和运动的动感样式
- 简约风格:简洁的极简主义设计
## 开发
```bash
@ -100,6 +129,11 @@ src/
│ ├── date/ # 日期小组件模块
│ ├── text/ # 文本小组件模块
│ ├── image/ # 图片小组件模块
│ ├── timer/ # 计时器小组件模块
│ │ ├── index.ts # 模块导出
│ │ ├── types.ts # 类型定义
│ │ ├── Widget.vue # 小组件组件
│ │ └── Config.vue # 配置组件
│ └── registry.ts # 小组件注册中心
```

View File

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

View File

@ -0,0 +1,360 @@
<template>
<div class="timer-config">
<h2>计时器小组件设置</h2>
<el-form label-position="top">
<el-divider>预设样式</el-divider>
<el-form-item>
<el-button-group>
<el-button type="primary" @click="applyPreset('gaming')">游戏风格</el-button>
<el-button type="success" @click="applyPreset('meeting')">会议计时</el-button>
<el-button type="warning" @click="applyPreset('workout')">运动计时</el-button>
<el-button type="danger" @click="applyPreset('minimal')">简约风格</el-button>
</el-button-group>
</el-form-item>
<el-divider>基本设置</el-divider>
<el-form-item label="计时器模式">
<el-radio-group v-model="localConfig.mode">
<el-radio value="countdown">倒计时</el-radio>
<el-radio value="stopwatch">正计时</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="localConfig.mode === 'countdown'" label="倒计时时长">
<div style="display: flex; gap: 10px; align-items: center;">
<el-input-number
v-model="durationMinutes"
:min="0"
:max="999"
controls-position="right"
style="width: 100px"
/>
<span>分钟</span>
<el-input-number
v-model="durationSeconds"
:min="0"
:max="59"
controls-position="right"
style="width: 100px"
/>
<span></span>
</div>
</el-form-item>
<el-form-item>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span>自动开始</span>
<el-switch v-model="localConfig.autoStart" style="margin-left: 10px;" />
</div>
<div>
<span>显示毫秒</span>
<el-switch v-model="localConfig.showMilliseconds" style="margin-left: 10px;" />
</div>
</div>
</el-form-item>
<el-form-item label="时间格式">
<el-select v-model="localConfig.format" placeholder="选择格式">
<el-option label="mm:ss (分:秒)" value="mm:ss" />
<el-option label="hh:mm:ss (时:分:秒)" value="hh:mm:ss" />
</el-select>
</el-form-item>
<el-divider>文本样式</el-divider>
<el-form-item label="字体大小">
<el-slider v-model="localConfig.fontSize" :min="12" :max="120" show-input />
</el-form-item>
<el-form-item label="字体">
<el-select v-model="localConfig.fontFamily">
<el-option label="Arial" value="Arial" />
<el-option label="Helvetica" value="Helvetica" />
<el-option label="Times New Roman" value="'Times New Roman'" />
<el-option label="Courier New" value="'Courier New'" />
<el-option label="Georgia" value="Georgia" />
<el-option label="Verdana" value="Verdana" />
<el-option label="Impact" value="Impact" />
<el-option label="JetBrains Mono" value="'JetBrains Mono'" />
</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="lighter" />
</el-select>
</el-form-item>
<el-divider>颜色设置</el-divider>
<el-form-item label="使用渐变色">
<el-switch v-model="localConfig.useGradient" />
</el-form-item>
<template v-if="!localConfig.useGradient">
<el-form-item label="文本颜色">
<el-color-picker v-model="localConfig.color" show-alpha />
</el-form-item>
</template>
<template v-else>
<el-form-item label="渐变起始颜色">
<el-color-picker v-model="localConfig.gradientColors[0]" show-alpha />
</el-form-item>
<el-form-item label="渐变结束颜色">
<el-color-picker v-model="localConfig.gradientColors[1]" show-alpha />
</el-form-item>
</template>
<el-form-item v-if="localConfig.mode === 'countdown'" label="结束颜色">
<el-color-picker v-model="localConfig.finishedColor" show-alpha />
</el-form-item>
<template v-if="localConfig.mode === 'countdown'">
<el-divider>警告设置</el-divider>
<el-form-item label="警告阈值(秒)">
<el-input-number
v-model="localConfig.warningThreshold"
:min="0"
:max="3600"
controls-position="right"
/>
</el-form-item>
<el-form-item label="警告颜色">
<el-color-picker v-model="localConfig.warningColor" show-alpha />
</el-form-item>
<el-divider>进度条设置</el-divider>
<el-form-item label="显示进度条">
<el-switch v-model="localConfig.showProgress" />
</el-form-item>
<template v-if="localConfig.showProgress">
<el-form-item label="进度条颜色">
<el-color-picker v-model="localConfig.progressColor" show-alpha />
</el-form-item>
<el-form-item label="进度条高度">
<el-slider v-model="localConfig.progressHeight" :min="1" :max="20" show-input />
</el-form-item>
</template>
<el-divider>音效设置</el-divider>
<el-form-item label="播放提示音">
<el-switch v-model="localConfig.playSound" />
</el-form-item>
<el-form-item v-if="localConfig.playSound" label="音量">
<el-slider v-model="localConfig.soundVolume" :min="0" :max="100" show-input />
</el-form-item>
</template>
<el-divider>特效</el-divider>
<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="localConfig.shadowColor" show-alpha />
</el-form-item>
<el-form-item label="阴影模糊度">
<el-slider v-model="localConfig.shadowBlur" :min="0" :max="20" show-input />
</el-form-item>
</template>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import type { TimerConfig } from './types';
// Define props with default values
const props = withDefaults(defineProps<{
config: Partial<TimerConfig>;
}>(), {
config: () => ({
mode: 'countdown',
duration: 300,
autoStart: false,
showMilliseconds: false,
format: 'mm:ss',
color: '#ffffff',
fontSize: 48,
fontWeight: 'normal',
fontFamily: 'Arial',
textShadow: false,
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 4,
useGradient: false,
gradientColors: ['#ff0000', '#0000ff'],
finishedColor: '#ff0000',
playSound: true,
soundVolume: 50,
warningThreshold: 30,
warningColor: '#ff6b35',
showProgress: true,
progressColor: '#3498db',
progressHeight: 4
})
});
// Define emit
const emit = defineEmits<{
(event: 'update:config', config: TimerConfig): void;
}>();
// Local config for two-way binding
const localConfig = ref<TimerConfig>({
mode: 'countdown',
duration: 300,
autoStart: false,
showMilliseconds: false,
format: 'mm:ss',
color: '#ffffff',
fontSize: 48,
fontWeight: 'normal',
fontFamily: 'Arial',
textShadow: false,
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 4,
useGradient: false,
gradientColors: ['#ff0000', '#0000ff'],
finishedColor: '#ff0000',
playSound: true,
soundVolume: 50,
warningThreshold: 30,
warningColor: '#ff6b35',
showProgress: true,
progressColor: '#3498db',
progressHeight: 4
});
//
const durationMinutes = computed({
get: () => Math.floor((localConfig.value.duration || 0) / 60),
set: (value: number) => {
const seconds = (localConfig.value.duration || 0) % 60;
localConfig.value.duration = value * 60 + seconds;
}
});
const durationSeconds = computed({
get: () => (localConfig.value.duration || 0) % 60,
set: (value: number) => {
const minutes = Math.floor((localConfig.value.duration || 0) / 60);
localConfig.value.duration = minutes * 60 + value;
}
});
// Sync with parent config on mount
onMounted(() => {
// Merge default config with provided config
localConfig.value = { ...localConfig.value, ...props.config };
});
// Preset styles
const presets = {
gaming: {
mode: 'countdown',
duration: 1800, // 30
format: 'mm:ss',
fontSize: 64,
fontFamily: 'Impact',
fontWeight: 'bold',
useGradient: true,
gradientColors: ['#ff6b35', '#f7931e'],
textShadow: true,
shadowColor: 'rgba(0, 0, 0, 0.8)',
shadowBlur: 8,
showProgress: true,
progressColor: '#ff6b35',
progressHeight: 6,
warningThreshold: 60,
warningColor: '#ff0000',
playSound: true,
soundVolume: 80
},
meeting: {
mode: 'countdown',
duration: 3600, // 60
format: 'hh:mm:ss',
fontSize: 52,
fontFamily: 'Arial',
fontWeight: 'normal',
color: '#2ecc71',
useGradient: false,
textShadow: true,
shadowColor: 'rgba(0, 0, 0, 0.3)',
shadowBlur: 4,
showProgress: true,
progressColor: '#2ecc71',
progressHeight: 4,
warningThreshold: 300,
warningColor: '#f39c12',
finishedColor: '#e74c3c',
playSound: true,
soundVolume: 60
},
workout: {
mode: 'stopwatch',
format: 'mm:ss',
fontSize: 72,
fontFamily: 'JetBrains Mono',
fontWeight: 'bold',
useGradient: true,
gradientColors: ['#e74c3c', '#c0392b'],
textShadow: true,
shadowColor: 'rgba(0, 0, 0, 0.5)',
shadowBlur: 6,
showMilliseconds: true,
autoStart: false
},
minimal: {
mode: 'countdown',
duration: 300,
format: 'mm:ss',
fontSize: 48,
fontFamily: 'Helvetica',
fontWeight: 'lighter',
color: '#ffffff',
useGradient: false,
textShadow: false,
showProgress: false,
playSound: false,
autoStart: false
}
};
// Apply preset
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>
.timer-config {
padding: 10px;
}
</style>

View File

@ -0,0 +1,354 @@
<template>
<div class="timer-widget" :style="containerStyle">
<div class="timer-display" :style="timerStyle" @click="toggleTimer">
{{ displayTime }}
</div>
<div
v-if="props.config.showProgress && props.config.mode === 'countdown'"
class="progress-bar"
:style="progressBarStyle"
>
<div class="progress-fill" :style="progressFillStyle"></div>
</div>
<div class="timer-controls" v-if="!props.config.autoStart">
<button @click="startTimer" :disabled="status === 'running'" class="control-btn start">
{{ status === 'paused' ? '继续' : '开始' }}
</button>
<button @click="pauseTimer" :disabled="status !== 'running'" class="control-btn pause">
暂停
</button>
<button @click="resetTimer" class="control-btn reset">
重置
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import type { TimerConfig, TimerStatus, TimerMode } from './types';
// Define props with default values
const props = withDefaults(defineProps<{
config: Partial<TimerConfig>;
}>(), {
config: () => ({
mode: 'countdown',
duration: 300,
autoStart: false,
showMilliseconds: false,
format: 'mm:ss',
color: '#ffffff',
fontSize: 48,
fontWeight: 'normal',
fontFamily: 'Arial',
textShadow: false,
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 4,
useGradient: false,
gradientColors: ['#ff0000', '#0000ff'],
finishedColor: '#ff0000',
playSound: true,
soundVolume: 50,
warningThreshold: 30,
warningColor: '#ff6b35',
showProgress: true,
progressColor: '#3498db',
progressHeight: 4
})
});
//
const status = ref<TimerStatus>('stopped');
const currentTime = ref(0); //
const startTime = ref(0);
const pausedTime = ref(0);
//
let animationFrame: number | null = null;
//
const displayTime = computed(() => {
const time = Math.max(0, currentTime.value);
const totalSeconds = Math.floor(time);
const milliseconds = Math.floor((time % 1) * 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const format = props.config.format || 'mm:ss';
if (format.includes('hh')) {
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
return props.config.showMilliseconds ? `${timeStr}.${milliseconds.toString().padStart(3, '0')}` : timeStr;
} else {
const timeStr = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
return props.config.showMilliseconds ? `${timeStr}.${milliseconds.toString().padStart(3, '0')}` : timeStr;
}
});
//
const currentColor = computed(() => {
if (status.value === 'finished') {
return props.config.finishedColor || '#ff0000';
}
if (props.config.mode === 'countdown' && props.config.warningThreshold) {
if (currentTime.value <= props.config.warningThreshold && currentTime.value > 0) {
return props.config.warningColor || '#ff6b35';
}
}
return props.config.color || '#ffffff';
});
//
const progressBarStyle = computed(() => ({
width: '100%',
height: `${props.config.progressHeight || 4}px`,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: '2px',
marginTop: '8px',
overflow: 'hidden'
}));
const progressFillStyle = computed(() => {
const progress = props.config.mode === 'countdown'
? (currentTime.value / (props.config.duration || 300)) * 100
: 0;
return {
width: `${Math.max(0, Math.min(100, progress))}%`,
height: '100%',
backgroundColor: props.config.progressColor || '#3498db',
transition: 'width 0.1s ease'
};
});
//
const containerStyle = computed(() => ({
textAlign: 'center',
userSelect: 'none'
}));
//
const timerStyle = computed(() => {
const style: Record<string, string> = {
fontSize: `${props.config.fontSize || 48}px`,
fontFamily: props.config.fontFamily || 'Arial',
fontWeight: props.config.fontWeight || 'normal',
cursor: props.config.autoStart ? 'default' : 'pointer',
marginBottom: '10px'
};
//
if (props.config.useGradient && (props.config.gradientColors || []).length >= 2) {
const colors = props.config.gradientColors || ['#ff0000', '#0000ff'];
style.backgroundImage = `linear-gradient(to right, ${colors.join(', ')})`;
style.webkitBackgroundClip = 'text';
style.backgroundClip = 'text';
style.color = 'transparent';
} else {
style.color = currentColor.value;
}
//
if (props.config.textShadow) {
style.textShadow = `0 0 ${props.config.shadowBlur || 4}px ${props.config.shadowColor || 'rgba(0,0,0,0.5)'}`;
}
return style;
});
//
const updateTimer = () => {
if (status.value !== 'running') return;
const now = Date.now();
const elapsed = (now - startTime.value + pausedTime.value) / 1000;
if (props.config.mode === 'countdown') {
currentTime.value = (props.config.duration || 300) - elapsed;
if (currentTime.value <= 0) {
currentTime.value = 0;
status.value = 'finished';
playFinishSound();
return;
}
} else {
currentTime.value = elapsed;
}
animationFrame = requestAnimationFrame(updateTimer);
};
//
const playFinishSound = () => {
if (!props.config.playSound) return;
//
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
gainNode.gain.setValueAtTime((props.config.soundVolume || 50) / 100, audioContext.currentTime);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.2);
} catch (error) {
console.warn('无法播放提示音:', error);
}
};
//
const startTimer = () => {
if (status.value === 'stopped') {
//
if (props.config.mode === 'countdown') {
currentTime.value = props.config.duration || 300;
} else {
currentTime.value = 0;
}
pausedTime.value = 0;
}
startTime.value = Date.now();
status.value = 'running';
updateTimer();
};
const pauseTimer = () => {
if (status.value === 'running') {
status.value = 'paused';
pausedTime.value += Date.now() - startTime.value;
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
}
};
const resetTimer = () => {
status.value = 'stopped';
pausedTime.value = 0;
if (props.config.mode === 'countdown') {
currentTime.value = props.config.duration || 300;
} else {
currentTime.value = 0;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
const toggleTimer = () => {
if (props.config.autoStart) return;
if (status.value === 'running') {
pauseTimer();
} else if (status.value === 'paused' || status.value === 'stopped') {
startTimer();
} else if (status.value === 'finished') {
resetTimer();
}
};
//
watch(() => props.config.duration, (newDuration) => {
if (status.value === 'stopped' && props.config.mode === 'countdown') {
currentTime.value = newDuration || 300;
}
});
watch(() => props.config.mode, (newMode) => {
resetTimer();
});
//
onMounted(() => {
if (props.config.mode === 'countdown') {
currentTime.value = props.config.duration || 300;
} else {
currentTime.value = 0;
}
if (props.config.autoStart) {
startTimer();
}
});
//
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
});
</script>
<style scoped>
.timer-widget {
display: inline-block;
padding: 15px;
font-family: Arial, sans-serif;
}
.timer-display {
margin-bottom: 10px;
}
.timer-controls {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 10px;
}
.control-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-btn.start {
background: rgba(76, 175, 80, 0.8);
}
.control-btn.pause {
background: rgba(255, 193, 7, 0.8);
}
.control-btn.reset {
background: rgba(244, 67, 54, 0.8);
}
.progress-bar {
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
border-radius: 2px;
}
</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: 'timer',
icon: '⏱️',
description: '支持倒计时和正计时功能,可自定义样式、进度条和提示音',
component: Widget,
configComponent: Config,
defaultConfig
});

View File

@ -0,0 +1,57 @@
// 计时器模式
export type TimerMode = 'countdown' | 'stopwatch';
// 计时器状态
export type TimerStatus = 'stopped' | 'running' | 'paused' | 'finished';
// 计时器小组件配置接口
export interface TimerConfig {
mode: TimerMode;
duration: number; //
autoStart: boolean; // 是否自动开始
showMilliseconds: boolean; // 是否显示毫秒
format: string; // 时间显示格式
color: string;
fontSize: number;
fontWeight: string;
fontFamily: string;
textShadow: boolean;
shadowColor: string;
shadowBlur: number;
useGradient: boolean;
gradientColors: string[];
finishedColor: string; // 倒计时结束时的颜色
playSound: boolean; // 是否播放提示音
soundVolume: number; // 音量 (0-100)
warningThreshold: number; // 警告阈值(秒)
warningColor: string; // 警告颜色
showProgress: boolean; // 是否显示进度条
progressColor: string; // 进度条颜色
progressHeight: number; // 进度条高度
}
// 计时器小组件默认配置
export const defaultConfig: TimerConfig = {
mode: 'countdown',
duration: 300, // 5分钟
autoStart: false,
showMilliseconds: false,
format: 'mm:ss',
color: '#ffffff',
fontSize: 48,
fontWeight: 'normal',
fontFamily: 'Arial',
textShadow: false,
shadowColor: 'rgba(0,0,0,0.5)',
shadowBlur: 4,
useGradient: false,
gradientColors: ['#ff0000', '#0000ff'],
finishedColor: '#ff0000',
playSound: true,
soundVolume: 50,
warningThreshold: 30,
warningColor: '#ff6b35',
showProgress: true,
progressColor: '#3498db',
progressHeight: 4
};