CountdownTimer.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. <template>
  2. <MoveablePanel
  3. class="countdown-timer"
  4. :width="250"
  5. :height="170"
  6. :left="left"
  7. :top="top"
  8. >
  9. <div class="panel-body">
  10. <div class="row time-row">
  11. <div class="time-box">
  12. <input
  13. ref="hourInputRef"
  14. type="number"
  15. class="digit-input"
  16. v-model.number="hourInput"
  17. @blur="handleBlur"
  18. @click="focusedInput = 'hour'"
  19. @focus="handleFocus('hour')"
  20. @keyup.enter="handleEnter"
  21. min="0"
  22. />
  23. <span class="colon">:</span>
  24. <input
  25. ref="minuteInputRef"
  26. type="number"
  27. class="digit-input"
  28. v-model.number="minuteInput"
  29. @blur="handleBlur"
  30. @click="focusedInput = 'minute'"
  31. @focus="handleFocus('minute')"
  32. @keyup.enter="handleEnter"
  33. min="0"
  34. max="59"
  35. />
  36. <span class="colon">:</span>
  37. <input
  38. ref="secondInputRef"
  39. type="number"
  40. class="digit-input"
  41. v-model.number="secondInput"
  42. @blur="handleBlur"
  43. @click="focusedInput = 'second'"
  44. @focus="handleFocus('second')"
  45. @keyup.enter="handleEnter"
  46. min="0"
  47. max="59"
  48. />
  49. </div>
  50. <div class="side-controls">
  51. <button class="square-btn" @click="increment()">+</button>
  52. <button class="square-btn" @click="decrement()" :disabled="time <= 0">−</button>
  53. </div>
  54. </div>
  55. <div class="divider"></div>
  56. <button class="primary-btn" @click="toggle()">{{ inTiming ? lang.ssPause : lang.ssStartTimer }}</button>
  57. </div>
  58. <!-- <div class="close-btn" @click="emit('close')"><IconClose class="icon" /></div> -->
  59. </MoveablePanel>
  60. </template>
  61. <script lang="ts" setup>
  62. import { onUnmounted, ref, watch } from 'vue'
  63. import MoveablePanel from '@/components/MoveablePanel2.vue'
  64. import { lang } from '@/main'
  65. withDefaults(defineProps<{
  66. left?: number
  67. top?: number
  68. }>(), {
  69. left: -200,
  70. top: -100,
  71. })
  72. const emit = defineEmits<{
  73. (event: 'close'): void
  74. (event: 'timer-start', payload: { isCountdown: boolean; startAt: string; durationSec?: number }): void
  75. (event: 'timer-pause', payload: { pausedAt: string }): void
  76. (event: 'timer-reset'): void
  77. (event: 'timer-stop'): void
  78. (event: 'timer-finish'): void
  79. (event: 'timer-update', payload: { durationSec: number }): void
  80. }>()
  81. const timer = ref<number | null>(null)
  82. const inTiming = ref(false)
  83. // 仅倒计时模式,默认 03:00:00
  84. const time = ref(5 * 60)
  85. // 可编辑的时分秒输入值
  86. const hourInput = ref(3)
  87. const minuteInput = ref(0)
  88. const secondInput = ref(0)
  89. // 输入框引用
  90. const hourInputRef = ref<HTMLInputElement | null>(null)
  91. const minuteInputRef = ref<HTMLInputElement | null>(null)
  92. const secondInputRef = ref<HTMLInputElement | null>(null)
  93. // 当前聚焦的输入框类型:'hour' | 'minute' | 'second' | null
  94. const focusedInput = ref<'hour' | 'minute' | 'second' | null>(null)
  95. // 当前正在编辑的输入框(有焦点)
  96. const editingInput = ref<'hour' | 'minute' | 'second' | null>(null)
  97. // 从 time 同步到输入框(如果输入框没有被编辑)
  98. watch(time, (newTime) => {
  99. if (editingInput.value !== 'hour') {
  100. hourInput.value = Math.floor(newTime / 3600)
  101. }
  102. if (editingInput.value !== 'minute') {
  103. minuteInput.value = Math.floor((newTime % 3600) / 60)
  104. }
  105. if (editingInput.value !== 'second') {
  106. secondInput.value = newTime % 60
  107. }
  108. }, { immediate: true })
  109. // 处理输入框获得焦点
  110. const handleFocus = (type: 'hour' | 'minute' | 'second') => {
  111. focusedInput.value = type
  112. editingInput.value = type
  113. }
  114. // 处理输入框失焦
  115. const handleBlur = () => {
  116. editingInput.value = null
  117. updateTimeFromInputs()
  118. // 不清除选中状态,保持用户选择的输入框,直到用户点击其他输入框
  119. }
  120. // 处理回车键
  121. const handleEnter = () => {
  122. if (editingInput.value) {
  123. editingInput.value = null
  124. updateTimeFromInputs()
  125. // 失焦当前输入框
  126. if (hourInputRef.value && document.activeElement === hourInputRef.value) {
  127. hourInputRef.value.blur()
  128. }
  129. else if (minuteInputRef.value && document.activeElement === minuteInputRef.value) {
  130. minuteInputRef.value.blur()
  131. }
  132. else if (secondInputRef.value && document.activeElement === secondInputRef.value) {
  133. secondInputRef.value.blur()
  134. }
  135. }
  136. }
  137. // 从输入框更新 time 值
  138. const updateTimeFromInputs = () => {
  139. // 验证并限制输入范围
  140. const h = Math.max(0, hourInput.value || 0)
  141. const m = Math.max(0, Math.min(59, minuteInput.value || 0))
  142. const s = Math.max(0, Math.min(59, secondInput.value || 0))
  143. // 更新输入值(确保显示正确)
  144. hourInput.value = h
  145. minuteInput.value = m
  146. secondInput.value = s
  147. // 计算总秒数(不限制最大时间)
  148. const newTime = h * 3600 + m * 60 + s
  149. const oldTime = time.value
  150. time.value = newTime
  151. // 如果正在计时且时间发生变化,重新开始计时
  152. if (inTiming.value && newTime !== oldTime) {
  153. restart()
  154. }
  155. }
  156. const clearTimer = () => {
  157. if (timer.value !== null) {
  158. clearInterval(timer.value)
  159. }
  160. }
  161. onUnmounted(clearTimer)
  162. const pause = () => {
  163. clearTimer()
  164. inTiming.value = false
  165. emit('timer-pause', { pausedAt: new Date().toISOString() })
  166. }
  167. const start = () => {
  168. clearTimer()
  169. // 倒计时
  170. timer.value = window.setInterval(() => {
  171. time.value = time.value - 1
  172. if (time.value <= 0) {
  173. time.value = 0
  174. clearTimer()
  175. inTiming.value = false
  176. emit('timer-finish')
  177. }
  178. }, 1000)
  179. inTiming.value = true
  180. emit('timer-start', { isCountdown: true, startAt: new Date().toISOString(), durationSec: time.value })
  181. }
  182. // 重新开始计时(用于时间更新时重置)
  183. const restart = () => {
  184. if (!inTiming.value) return
  185. clearTimer()
  186. // 倒计时
  187. timer.value = window.setInterval(() => {
  188. time.value = time.value - 1
  189. if (time.value <= 0) {
  190. time.value = 0
  191. clearTimer()
  192. inTiming.value = false
  193. emit('timer-finish')
  194. }
  195. }, 1000)
  196. emit('timer-start', { isCountdown: true, startAt: new Date().toISOString(), durationSec: time.value })
  197. }
  198. const toggle = () => {
  199. if (inTiming.value) pause()
  200. else start()
  201. }
  202. onUnmounted(() => {
  203. emit('timer-stop')
  204. })
  205. // 根据当前聚焦的输入框增加时间(默认操作秒)
  206. const increment = () => {
  207. const currentFocus = focusedInput.value || 'second' // 默认操作秒
  208. if (currentFocus === 'hour') {
  209. hourInput.value = Math.max(0, (hourInput.value || 0) + 1)
  210. }
  211. else if (currentFocus === 'minute') {
  212. const newMinute = (minuteInput.value || 0) + 1
  213. if (newMinute > 59) {
  214. minuteInput.value = 0
  215. hourInput.value = Math.max(0, (hourInput.value || 0) + 1)
  216. }
  217. else {
  218. minuteInput.value = newMinute
  219. }
  220. }
  221. else {
  222. // 默认操作秒
  223. const newSecond = (secondInput.value || 0) + 1
  224. if (newSecond > 59) {
  225. secondInput.value = 0
  226. const newMinute = (minuteInput.value || 0) + 1
  227. if (newMinute > 59) {
  228. minuteInput.value = 0
  229. hourInput.value = Math.max(0, (hourInput.value || 0) + 1)
  230. }
  231. else {
  232. minuteInput.value = newMinute
  233. }
  234. }
  235. else {
  236. secondInput.value = newSecond
  237. }
  238. }
  239. // 更新总时间
  240. updateTimeFromInputs()
  241. }
  242. // 根据当前聚焦的输入框减少时间(默认操作秒)
  243. const decrement = () => {
  244. const currentFocus = focusedInput.value || 'second' // 默认操作秒
  245. if (currentFocus === 'hour') {
  246. hourInput.value = Math.max(0, (hourInput.value || 0) - 1)
  247. }
  248. else if (currentFocus === 'minute') {
  249. const newMinute = (minuteInput.value || 0) - 1
  250. if (newMinute < 0) {
  251. if (hourInput.value > 0) {
  252. minuteInput.value = 59
  253. hourInput.value = Math.max(0, (hourInput.value || 0) - 1)
  254. }
  255. else {
  256. minuteInput.value = 0
  257. }
  258. }
  259. else {
  260. minuteInput.value = newMinute
  261. }
  262. }
  263. else {
  264. // 默认操作秒
  265. const newSecond = (secondInput.value || 0) - 1
  266. if (newSecond < 0) {
  267. if (minuteInput.value > 0 || hourInput.value > 0) {
  268. secondInput.value = 59
  269. const newMinute = (minuteInput.value || 0) - 1
  270. if (newMinute < 0) {
  271. if (hourInput.value > 0) {
  272. minuteInput.value = 59
  273. hourInput.value = Math.max(0, (hourInput.value || 0) - 1)
  274. }
  275. else {
  276. minuteInput.value = 0
  277. }
  278. }
  279. else {
  280. minuteInput.value = newMinute
  281. }
  282. }
  283. else {
  284. secondInput.value = 0
  285. }
  286. }
  287. else {
  288. secondInput.value = newSecond
  289. }
  290. }
  291. // 更新总时间
  292. updateTimeFromInputs()
  293. }
  294. </script>
  295. <style lang="scss" scoped>
  296. .countdown-timer {
  297. user-select: none;
  298. background: rgba(0, 0, 0, 0.6);
  299. border-radius: 12px;
  300. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
  301. /* padding: 14px; */
  302. }
  303. .panel-body {
  304. display: flex;
  305. flex-direction: column;
  306. gap: 12px;
  307. }
  308. .time-row {
  309. display: flex;
  310. align-items: center;
  311. justify-content: space-between;
  312. }
  313. .colon {
  314. margin: 0 6px;
  315. color: #fff;
  316. }
  317. .time-box {
  318. display: flex;
  319. align-items: baseline;
  320. }
  321. .digit-input {
  322. width: 50px;
  323. color: #fff;
  324. font-size: 40px;
  325. font-weight: 700;
  326. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  327. background: transparent;
  328. border: none;
  329. outline: none;
  330. text-align: center;
  331. padding: 0;
  332. -moz-appearance: textfield;
  333. appearance: textfield;
  334. }
  335. .digit-input::-webkit-outer-spin-button,
  336. .digit-input::-webkit-inner-spin-button {
  337. -webkit-appearance: none;
  338. appearance: none;
  339. margin: 0;
  340. }
  341. .digit-input:disabled {
  342. opacity: 1;
  343. cursor: not-allowed;
  344. }
  345. .digit-input:focus {
  346. background: rgba(255, 255, 255, 0.1);
  347. border-radius: 4px;
  348. }
  349. .side-controls {
  350. display: flex;
  351. flex-direction: column;
  352. gap: 8px;
  353. }
  354. .square-btn {
  355. width: 36px;
  356. height: 32px;
  357. border: 0;
  358. border-radius: 6px;
  359. background: rgba(255, 255, 255, 0.18);
  360. color: #fff;
  361. font-size: 16px;
  362. cursor: pointer;
  363. }
  364. .square-btn:disabled {
  365. opacity: .5;
  366. cursor: not-allowed;
  367. }
  368. .divider {
  369. height: 1px;
  370. background: rgba(255, 255, 255, 0.35);
  371. margin: 2px 0;
  372. }
  373. .primary-btn {
  374. width: 100%;
  375. height: 44px;
  376. border: 0;
  377. border-radius: 8px;
  378. background: #ffcc00;
  379. color: #333;
  380. font-weight: 600;
  381. font-size: 16px;
  382. cursor: pointer;
  383. }
  384. .primary-btn:hover {
  385. filter: brightness(0.98);
  386. }
  387. .close-btn {
  388. position: absolute;
  389. top: 0;
  390. right: 0;
  391. padding: 10px;
  392. line-height: 1;
  393. cursor: pointer;
  394. color: #fff;
  395. }
  396. </style>