301 lines
7.0 KiB
Vue
301 lines
7.0 KiB
Vue
<script setup>
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
|
||
const KEY = 'pomodoro-stats-v1'
|
||
const MODES = {
|
||
focus: { name: '专注', mins: 25, color: '#fb7185' },
|
||
short: { name: '小休', mins: 5, color: '#5eead4' },
|
||
long: { name: '大休', mins: 15, color: '#818cf8' },
|
||
}
|
||
|
||
const mode = ref('focus')
|
||
const left = ref(MODES.focus.mins * 60)
|
||
const running = ref(false)
|
||
const stats = ref({ done: 0, minutes: 0, days: {} })
|
||
let timer = null
|
||
|
||
function loadStats() {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(KEY))
|
||
if (s) stats.value = s
|
||
} catch { /* 使用默认 */ }
|
||
}
|
||
function saveStats() {
|
||
try {
|
||
localStorage.setItem(KEY, JSON.stringify(stats.value))
|
||
} catch { /* 忽略 */ }
|
||
}
|
||
|
||
function switchMode(m) {
|
||
mode.value = m
|
||
running.value = false
|
||
left.value = MODES[m].mins * 60
|
||
}
|
||
function toggle() {
|
||
running.value = !running.value
|
||
}
|
||
function reset() {
|
||
running.value = false
|
||
left.value = MODES[mode.value].mins * 60
|
||
}
|
||
|
||
function finish() {
|
||
running.value = false
|
||
if (mode.value === 'focus') {
|
||
stats.value.done++
|
||
stats.value.minutes += MODES.focus.mins
|
||
const day = new Date().toISOString().slice(0, 10)
|
||
stats.value.days[day] = (stats.value.days[day] || 0) + 1
|
||
saveStats()
|
||
switchMode(stats.value.done % 4 === 0 ? 'long' : 'short')
|
||
} else {
|
||
switchMode('focus')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadStats()
|
||
timer = setInterval(() => {
|
||
if (!running.value) return
|
||
left.value--
|
||
if (left.value <= 0) finish()
|
||
}, 1000)
|
||
})
|
||
onBeforeUnmount(() => clearInterval(timer))
|
||
|
||
const mm = computed(() => String(Math.floor(left.value / 60)).padStart(2, '0'))
|
||
const ss = computed(() => String(left.value % 60).padStart(2, '0'))
|
||
const total = computed(() => MODES[mode.value].mins * 60)
|
||
const pct = computed(() => 1 - left.value / total.value)
|
||
const today = computed(() => stats.value.days[new Date().toISOString().slice(0, 10)] || 0)
|
||
const ringStyle = computed(() => ({
|
||
background: `conic-gradient(${MODES[mode.value].color} ${pct.value * 360}deg, rgba(255,255,255,.07) 0)`,
|
||
}))
|
||
</script>
|
||
|
||
<template>
|
||
<div class="po">
|
||
<nav class="lab-nav">
|
||
<router-link to="/lab" class="lab-back">← 实验室</router-link>
|
||
<h1 class="lab-title">番茄钟</h1>
|
||
<span class="lab-badge">FOCUS</span>
|
||
</nav>
|
||
<p class="lab-desc">25 分钟专注 + 5 分钟小休,每四个番茄换一次大休;完成记录存在本地,关掉页面也不丢。</p>
|
||
|
||
<div class="po-stage">
|
||
<div class="po-modes">
|
||
<button
|
||
v-for="(m, k) in MODES"
|
||
:key="k"
|
||
:class="{ on: mode === k }"
|
||
:style="mode === k ? { background: m.color, borderColor: m.color } : {}"
|
||
@click="switchMode(k)"
|
||
>
|
||
{{ m.name }} {{ m.mins }}′
|
||
</button>
|
||
</div>
|
||
|
||
<div class="po-ring" :style="ringStyle">
|
||
<div class="po-face">
|
||
<p class="po-time">{{ mm }}:{{ ss }}</p>
|
||
<p class="po-state" :style="{ color: MODES[mode].color }">
|
||
{{ running ? '进行中 · ' + MODES[mode].name : '已暂停' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="po-actions">
|
||
<button class="po-main" :style="{ background: MODES[mode].color }" @click="toggle">
|
||
{{ running ? '⏸ 暂停' : '▶ 开始' }}
|
||
</button>
|
||
<button class="po-sub" @click="reset">↺ 重置</button>
|
||
</div>
|
||
|
||
<div class="po-stats">
|
||
<div class="po-stat"><b>{{ today }}</b><span>今日番茄</span></div>
|
||
<div class="po-stat"><b>{{ stats.done }}</b><span>累计完成</span></div>
|
||
<div class="po-stat"><b>{{ Math.round(stats.minutes / 6) / 10 }}</b><span>专注小时</span></div>
|
||
</div>
|
||
|
||
<div class="po-dots">
|
||
<i v-for="n in 8" :key="n" :class="{ on: n <= (stats.done % 8 || (stats.done ? 8 : 0)) }"></i>
|
||
<span>本轮进度(8 个一大轮)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.po {
|
||
min-height: 100vh;
|
||
background: #0b0e14;
|
||
color: #e8ecf4;
|
||
font-family: 'Space Grotesk', 'Noto Serif SC', sans-serif;
|
||
padding: 24px clamp(16px, 5vw, 64px) 60px;
|
||
}
|
||
.lab-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
max-width: 760px;
|
||
margin: 0 auto 10px;
|
||
}
|
||
.lab-back {
|
||
color: #f59e0b;
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||
border-radius: 8px;
|
||
padding: 7px 14px;
|
||
}
|
||
.lab-back:hover { background: rgba(245, 158, 11, 0.12); }
|
||
.lab-title { margin: 0; font-size: 22px; font-weight: 700; }
|
||
.lab-badge {
|
||
font-size: 10px;
|
||
letter-spacing: 0.22em;
|
||
color: #5eead4;
|
||
border: 1px solid rgba(94, 234, 212, 0.4);
|
||
border-radius: 99px;
|
||
padding: 4px 10px;
|
||
}
|
||
.lab-desc {
|
||
max-width: 760px;
|
||
margin: 0 auto 34px;
|
||
font-size: 13.5px;
|
||
color: rgba(232, 236, 244, 0.66);
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.po-stage {
|
||
max-width: 760px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
.po-modes {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 34px;
|
||
}
|
||
.po-modes button {
|
||
background: #13161f;
|
||
border: 1px solid #2a2f3d;
|
||
border-radius: 99px;
|
||
color: rgba(232, 236, 244, 0.72);
|
||
font-size: 13px;
|
||
padding: 9px 20px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.po-modes button.on {
|
||
color: #0b0e14;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.po-ring {
|
||
width: min(300px, 74vw);
|
||
aspect-ratio: 1;
|
||
border-radius: 50%;
|
||
padding: 12px;
|
||
display: grid;
|
||
place-items: center;
|
||
transition: background 0.4s ease;
|
||
}
|
||
.po-face {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
background: #10131c;
|
||
display: grid;
|
||
place-content: center;
|
||
text-align: center;
|
||
gap: 6px;
|
||
}
|
||
.po-time {
|
||
margin: 0;
|
||
font-size: clamp(52px, 12vw, 68px);
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.po-state {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
letter-spacing: 0.2em;
|
||
}
|
||
|
||
.po-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin: 30px 0 34px;
|
||
}
|
||
.po-main {
|
||
border: none;
|
||
border-radius: 999px;
|
||
color: #0b0e14;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
padding: 14px 40px;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, background 0.3s ease;
|
||
}
|
||
.po-main:hover { transform: scale(1.05); }
|
||
.po-sub {
|
||
background: #13161f;
|
||
border: 1px solid #2a2f3d;
|
||
border-radius: 999px;
|
||
color: rgba(232, 236, 244, 0.72);
|
||
font-size: 14px;
|
||
padding: 14px 24px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.po-stats {
|
||
display: flex;
|
||
gap: 12px;
|
||
width: 100%;
|
||
max-width: 480px;
|
||
}
|
||
.po-stat {
|
||
flex: 1;
|
||
background: #10131c;
|
||
border: 1px solid #222736;
|
||
border-radius: 14px;
|
||
padding: 16px 10px;
|
||
text-align: center;
|
||
}
|
||
.po-stat b {
|
||
display: block;
|
||
font-size: 26px;
|
||
color: #fb7185;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.po-stat span {
|
||
font-size: 11.5px;
|
||
color: rgba(232, 236, 244, 0.55);
|
||
}
|
||
|
||
.po-dots {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin-top: 24px;
|
||
}
|
||
.po-dots i {
|
||
width: 11px;
|
||
height: 11px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
}
|
||
.po-dots i.on {
|
||
background: #fb7185;
|
||
box-shadow: 0 0 10px rgba(251, 113, 133, 0.6);
|
||
}
|
||
.po-dots span {
|
||
margin-left: 8px;
|
||
font-size: 11px;
|
||
color: rgba(232, 236, 244, 0.4);
|
||
}
|
||
</style>
|