1
0

ForumTimer.vue 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. <template>
  2. <div
  3. v-show="isVisible"
  4. class="forum-timer"
  5. :class="{ minimized: isMinimized, dragging: isDragging }"
  6. :style="style"
  7. @mousedown="startDrag"
  8. @touchstart="startDrag"
  9. >
  10. <!-- Maximized View -->
  11. <div v-if="!isMinimized" class="timer-content">
  12. <div class="timer-header">
  13. <span class="timer-title">剩余时间</span>
  14. <button class="minimize-btn" @click.stop="toggleMinimize">
  15. <minus-outlined />
  16. </button>
  17. </div>
  18. <div class="timer-display">
  19. {{ formattedTime }}
  20. </div>
  21. <div class="progress-bar">
  22. <div class="progress-fill" :style="{ width: progress + '%' }"></div>
  23. </div>
  24. </div>
  25. <!-- Minimized View -->
  26. <div v-else class="timer-minimized" @click.stop="handleClickMinimized">
  27. <div class="timer-circle">
  28. <clock-circle-outlined />
  29. </div>
  30. </div>
  31. </div>
  32. </template>
  33. <script setup lang="ts">
  34. import { ref, computed, onMounted, onUnmounted, watchEffect } from 'vue'
  35. import { MinusOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
  36. const props = defineProps<{
  37. startTime: string
  38. durationMinutes: number
  39. status: string
  40. }>()
  41. const isMinimized = ref(true)
  42. const isDragging = ref(false)
  43. const position = ref({ x: window.innerWidth - 60, y: 100 }) // Default initial position
  44. const remainingSeconds = ref(0)
  45. const progress = ref(0)
  46. const snapToEdge = () => {
  47. // Only snap horizontally
  48. const width = isMinimized.value ? 50 : 200
  49. const threshold = window.innerWidth / 2
  50. if (position.value.x + width / 2 > threshold) {
  51. // Snap to right
  52. position.value.x = window.innerWidth - width - 20 // 20px margin
  53. } else {
  54. // Snap to left
  55. position.value.x = 20
  56. }
  57. }
  58. // Dragging Logic
  59. const offset = { x: 0, y: 0 }
  60. const stopDrag = () => {
  61. if (!isDragging.value) return
  62. isDragging.value = false
  63. window.removeEventListener('mousemove', onDrag)
  64. window.removeEventListener('touchmove', onDrag)
  65. window.removeEventListener('mouseup', stopDrag)
  66. window.removeEventListener('touchend', stopDrag)
  67. snapToEdge()
  68. }
  69. const onDrag = (e: MouseEvent | TouchEvent) => {
  70. if (!isDragging.value) return
  71. const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
  72. const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
  73. let newX = clientX - offset.x
  74. let newY = clientY - offset.y
  75. // Boundary constraints
  76. const maxX = window.innerWidth - (isMinimized.value ? 50 : 200)
  77. const maxY = window.innerHeight - (isMinimized.value ? 50 : 120)
  78. position.value.x = Math.max(0, Math.min(newX, maxX))
  79. position.value.y = Math.max(0, Math.min(newY, maxY))
  80. }
  81. const startDrag = (e: MouseEvent | TouchEvent) => {
  82. // Only allow drag on minimized state or header of maximized state?
  83. // Let's allow dragging anywhere for simplicity, but maybe restrict to minimized for better UX?
  84. // Actually, dragging the whole thing is fine.
  85. // Prevent drag if clicking minimize button
  86. if ((e.target as HTMLElement).closest('.minimize-btn')) return
  87. isDragging.value = true
  88. const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
  89. const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
  90. offset.x = clientX - position.value.x
  91. offset.y = clientY - position.value.y
  92. window.addEventListener('mousemove', onDrag)
  93. window.addEventListener('touchmove', onDrag)
  94. window.addEventListener('mouseup', stopDrag)
  95. window.addEventListener('touchend', stopDrag)
  96. }
  97. // Timer Logic
  98. const updateTimer = () => {
  99. if (!props.startTime || props.status !== 'running') {
  100. remainingSeconds.value = 0
  101. progress.value = 0
  102. return
  103. }
  104. const start = new Date(props.startTime).getTime()
  105. const durationMs = props.durationMinutes * 60 * 1000
  106. const end = start + durationMs
  107. const now = Date.now()
  108. const remaining = Math.max(0, Math.floor((end - now) / 1000))
  109. remainingSeconds.value = remaining
  110. const totalSeconds = props.durationMinutes * 60
  111. progress.value = Math.min(100, Math.max(0, ((totalSeconds - remaining) / totalSeconds) * 100))
  112. }
  113. // Watch props to react to changes (especially start time update)
  114. watchEffect(() => {
  115. if (props.status === 'running') {
  116. updateTimer()
  117. }
  118. })
  119. let timerInterval: number | null = null
  120. onMounted(() => {
  121. updateTimer()
  122. timerInterval = window.setInterval(updateTimer, 1000)
  123. // Initial snap
  124. snapToEdge()
  125. })
  126. onUnmounted(() => {
  127. if (timerInterval) clearInterval(timerInterval)
  128. })
  129. const formattedTime = computed(() => {
  130. const m = Math.floor(remainingSeconds.value / 60)
  131. const s = remainingSeconds.value % 60
  132. return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
  133. })
  134. const style = computed(() => ({
  135. left: `${position.value.x}px`,
  136. top: `${position.value.y}px`
  137. }))
  138. const toggleMinimize = () => {
  139. isMinimized.value = !isMinimized.value
  140. // Re-snap after resize
  141. setTimeout(snapToEdge, 0)
  142. }
  143. const handleClickMinimized = () => {
  144. // If dragging happened, don't toggle
  145. // But we handle drag via separate listeners.
  146. // click event fires after mouseup.
  147. // If we want to distinguish drag vs click, we can check displacement?
  148. // For simplicity, let's just toggle.
  149. // But dragging might trigger click.
  150. // Usually click is fine.
  151. if (!isDragging.value) {
  152. toggleMinimize()
  153. }
  154. }
  155. </script>
  156. <style scoped>
  157. .forum-timer {
  158. position: fixed;
  159. z-index: 1000;
  160. background: white;
  161. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  162. border-radius: 8px;
  163. user-select: none;
  164. transition: width 0.3s, height 0.3s, border-radius 0.3s;
  165. /* Don't transition left/top during drag for smoothness */
  166. }
  167. .forum-timer:not(.dragging) {
  168. transition: left 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
  169. }
  170. /* Maximized State */
  171. .timer-content {
  172. width: 200px;
  173. padding: 16px;
  174. }
  175. .timer-header {
  176. display: flex;
  177. justify-content: space-between;
  178. align-items: center;
  179. margin-bottom: 8px;
  180. }
  181. .timer-title {
  182. font-weight: 500;
  183. color: #666;
  184. }
  185. .minimize-btn {
  186. border: none;
  187. background: none;
  188. cursor: pointer;
  189. padding: 4px;
  190. color: #999;
  191. }
  192. .minimize-btn:hover {
  193. color: #1890ff;
  194. }
  195. .timer-display {
  196. font-size: 32px;
  197. font-weight: bold;
  198. color: #1890ff;
  199. text-align: center;
  200. font-family: monospace;
  201. margin-bottom: 8px;
  202. }
  203. .progress-bar {
  204. height: 4px;
  205. background: #f0f0f0;
  206. border-radius: 2px;
  207. overflow: hidden;
  208. }
  209. .progress-fill {
  210. height: 100%;
  211. background: #1890ff;
  212. transition: width 1s linear;
  213. }
  214. /* Minimized State */
  215. .timer-minimized {
  216. width: 48px;
  217. height: 48px;
  218. border-radius: 50%;
  219. display: flex;
  220. align-items: center;
  221. justify-content: center;
  222. cursor: pointer;
  223. background: #1890ff;
  224. color: white;
  225. }
  226. .timer-circle {
  227. font-size: 24px;
  228. }
  229. /* Hover effect for minimized */
  230. .timer-minimized:hover {
  231. transform: scale(1.1);
  232. }
  233. </style>