572 lines
12 KiB
XML
572 lines
12 KiB
XML
<template>
|
|
<swiper class="timer-swiper" vertical="true" indicator="false" loop="false">
|
|
<div class="timer-page">
|
|
<text class="clock-label">{{ time }}</text>
|
|
<text class="page-title">{{isResting ? "休息中" : "专注中"}}</text>
|
|
|
|
<text class="countdown">{{ countdown }}</text>
|
|
<text class="focus-label">{{isResting ? "休息中" : "专注中"}}</text>
|
|
|
|
<div class="stop-button" onclick="stopTimer">
|
|
<image class="stop-background" src="/common/icons/button_grey.png"></image>
|
|
<image class="stop-icon" src="/common/icons/stop.png"></image>
|
|
</div>
|
|
<div class="pause-button" onclick="togglePause">
|
|
<image
|
|
class="pause-background"
|
|
src="{{isResting ? '/common/icons/button_yellow.png' : '/common/icons/button2.png'}}"
|
|
></image>
|
|
<image
|
|
class="pause-icon"
|
|
src="{{paused ? '/common/icons/MdiPlay.png' : '/common/icons/pause.png'}}"
|
|
></image>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-page">
|
|
<text class="stats-clock-label">{{ time }}</text>
|
|
<text class="stats-page-title">专注统计</text>
|
|
|
|
<text class="focus-total-title">累计专注</text>
|
|
<text class="focus-total-value">{{ focusTotalText }}</text>
|
|
<text class="rest-total-title">累计休息</text>
|
|
<text class="rest-total-value">{{ restTotalText }}</text>
|
|
</div>
|
|
|
|
<div class="settings-page">
|
|
<text class="settings-title">设置</text>
|
|
<div class="setting-row">
|
|
<text class="setting-label">屏幕常亮</text>
|
|
<div class="toggle-track {{screenAlwaysOn ? 'toggle-on' : 'toggle-off'}}" onclick="toggleScreenAlwaysOn">
|
|
<div class="toggle-knob {{screenAlwaysOn ? 'knob-on' : 'knob-off'}}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</swiper>
|
|
</template>
|
|
|
|
<script>
|
|
import router from "@system.router"
|
|
import file from "@system.file"
|
|
import vibrator from "@system.vibrator"
|
|
import brightness from "@system.brightness"
|
|
import storage from "@system.storage"
|
|
|
|
export default {
|
|
protected: {
|
|
duration: "m:20"
|
|
},
|
|
|
|
private: {
|
|
time: "00:00",
|
|
countdown: "20:00",
|
|
clockTimer: null,
|
|
countdownTimer: null,
|
|
remaining: 1200,
|
|
paused: false,
|
|
isResting: false,
|
|
focusDurationSeconds: 1200,
|
|
restDurationSeconds: 300,
|
|
screenAlwaysOn: false,
|
|
// 息屏后 setInterval 会被系统挂起,所有计时以时间戳为准
|
|
phaseEndAt: 0,
|
|
lastCountedAt: 0,
|
|
lastPersistAt: 0,
|
|
focusTotalMs: 0,
|
|
restTotalMs: 0,
|
|
focusTotalText: "00:00:00",
|
|
restTotalText: "00:00:00"
|
|
},
|
|
|
|
onInit() {
|
|
this.loadStats()
|
|
this.loadScreenAlwaysOn()
|
|
const duration = this.durationSeconds(this.duration)
|
|
if (duration > 0) {
|
|
this.startCountdown(duration)
|
|
} else {
|
|
this.loadDuration()
|
|
}
|
|
this.updateTime()
|
|
this.clockTimer = setInterval(() => this.updateTime(), 1000)
|
|
},
|
|
|
|
onShow() {
|
|
// 亮屏后立刻按时间戳补齐息屏期间的进度
|
|
this.updateTime()
|
|
this.sync()
|
|
},
|
|
|
|
onDestroy() {
|
|
clearInterval(this.clockTimer)
|
|
clearInterval(this.countdownTimer)
|
|
this.countdownTimer = null
|
|
if (!this.paused) {
|
|
this.accumulate(Date.now())
|
|
this.updateStats()
|
|
}
|
|
this.persistStats(true)
|
|
},
|
|
|
|
loadDuration() {
|
|
file.readText({
|
|
uri: "internal://files/focus-duration.txt",
|
|
success: (data) => this.startCountdown(this.durationSeconds(data.text)),
|
|
fail: () => this.startCountdown(1200)
|
|
})
|
|
},
|
|
|
|
startCountdown(seconds) {
|
|
this.focusDurationSeconds = seconds > 0 ? seconds : 1200
|
|
this.beginPhase(false, this.focusDurationSeconds, Date.now())
|
|
this.ensureTicking()
|
|
},
|
|
|
|
startRest(startAt) {
|
|
this.beginPhase(true, this.restDurationSeconds, startAt)
|
|
this.paused = true
|
|
},
|
|
|
|
beginPhase(isResting, durationSeconds, startAt) {
|
|
this.isResting = isResting
|
|
this.paused = false
|
|
this.phaseEndAt = startAt + durationSeconds * 1000
|
|
this.lastCountedAt = startAt
|
|
this.remaining = durationSeconds
|
|
this.updateCountdown()
|
|
},
|
|
|
|
ensureTicking() {
|
|
if (this.countdownTimer === null) {
|
|
this.countdownTimer = setInterval(() => this.sync(), 1000)
|
|
}
|
|
},
|
|
|
|
updateTime() {
|
|
const now = new Date()
|
|
const pad = (value) => (value < 10 ? `0${value}` : `${value}`)
|
|
this.time = `${pad(now.getHours())}:${pad(now.getMinutes())}`
|
|
},
|
|
|
|
// 把 lastCountedAt 到 untilAt 之间的时间计入当前阶段的累计值
|
|
accumulate(untilAt) {
|
|
const delta = untilAt - this.lastCountedAt
|
|
if (delta <= 0) {
|
|
return
|
|
}
|
|
if (this.isResting) {
|
|
this.restTotalMs += delta
|
|
} else {
|
|
this.focusTotalMs += delta
|
|
}
|
|
this.lastCountedAt = untilAt
|
|
},
|
|
|
|
sync() {
|
|
// phaseEndAt 为 0 表示时长还没读出来,尚未开始计时
|
|
if (this.paused || this.phaseEndAt === 0) {
|
|
return
|
|
}
|
|
const now = Date.now()
|
|
// 息屏期间可能已跨过一个或多个阶段,逐段结算
|
|
while (now >= this.phaseEndAt) {
|
|
this.accumulate(this.phaseEndAt)
|
|
const endedAt = this.phaseEndAt
|
|
if (this.isResting) {
|
|
vibrator.vibrate({
|
|
mode: "short"
|
|
})
|
|
this.beginPhase(false, this.focusDurationSeconds, endedAt)
|
|
} else {
|
|
vibrator.vibrate({
|
|
mode: "long"
|
|
})
|
|
this.startRest(endedAt)
|
|
break
|
|
}
|
|
}
|
|
if (!this.paused) {
|
|
this.accumulate(now)
|
|
this.remaining = Math.max(0, Math.round((this.phaseEndAt - now) / 1000))
|
|
this.updateCountdown()
|
|
}
|
|
this.updateStats()
|
|
this.persistStats(false)
|
|
},
|
|
|
|
updateCountdown() {
|
|
const minutes = Math.floor(this.remaining / 60)
|
|
const seconds = this.remaining % 60
|
|
const pad = (value) => (value < 10 ? `0${value}` : `${value}`)
|
|
this.countdown = `${pad(minutes)}:${pad(seconds)}`
|
|
},
|
|
|
|
durationSeconds(config) {
|
|
if (config && config.indexOf("s:") === 0) {
|
|
return parseInt(config.substring(2), 10) || 5
|
|
}
|
|
if (config && config.indexOf("m:") === 0) {
|
|
return (parseInt(config.substring(2), 10) || 20) * 60
|
|
}
|
|
return (parseInt(config, 10) || 20) * 60
|
|
},
|
|
|
|
updateStats() {
|
|
this.focusTotalText = this.formatDuration(Math.floor(this.focusTotalMs / 1000))
|
|
this.restTotalText = this.formatDuration(Math.floor(this.restTotalMs / 1000))
|
|
},
|
|
|
|
formatDuration(totalSeconds) {
|
|
const hours = Math.floor(totalSeconds / 3600)
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
const seconds = totalSeconds % 60
|
|
const pad = (value) => (value < 10 ? `0${value}` : `${value}`)
|
|
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
|
},
|
|
|
|
loadStats() {
|
|
file.readText({
|
|
uri: "internal://files/focus-stats.json",
|
|
success: (data) => {
|
|
try {
|
|
const stats = JSON.parse(data.text)
|
|
if (stats.date === this.today()) {
|
|
// 读取是异步的,期间可能已经累计了一点时间,用加法避免被覆盖
|
|
this.focusTotalMs += (parseInt(stats.focus, 10) || 0) * 1000
|
|
this.restTotalMs += (parseInt(stats.rest, 10) || 0) * 1000
|
|
}
|
|
this.updateStats()
|
|
} catch (error) {
|
|
this.updateStats()
|
|
}
|
|
},
|
|
fail: () => this.updateStats()
|
|
})
|
|
},
|
|
|
|
persistStats(force) {
|
|
const now = Date.now()
|
|
// 每秒写盘没有必要,节流到 5 秒;退出/切阶段时强制落盘
|
|
if (!force && now - this.lastPersistAt < 5000) {
|
|
return
|
|
}
|
|
this.lastPersistAt = now
|
|
file.writeText({
|
|
uri: "internal://files/focus-stats.json",
|
|
text: JSON.stringify({
|
|
date: this.today(),
|
|
focus: Math.floor(this.focusTotalMs / 1000),
|
|
rest: Math.floor(this.restTotalMs / 1000)
|
|
})
|
|
})
|
|
},
|
|
|
|
today() {
|
|
const now = new Date()
|
|
const pad = (value) => (value < 10 ? `0${value}` : `${value}`)
|
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
|
},
|
|
|
|
togglePause() {
|
|
if (this.paused) {
|
|
// 恢复:把剩余秒数换算成新的结束时间戳
|
|
const now = Date.now()
|
|
this.phaseEndAt = now + this.remaining * 1000
|
|
this.lastCountedAt = now
|
|
this.paused = false
|
|
this.ensureTicking()
|
|
} else {
|
|
this.accumulate(Date.now())
|
|
this.paused = true
|
|
this.updateStats()
|
|
this.persistStats(true)
|
|
}
|
|
},
|
|
|
|
loadScreenAlwaysOn() {
|
|
storage.get({
|
|
key: "screenAlwaysOn",
|
|
success: (data) => {
|
|
const on = data === "true"
|
|
this.screenAlwaysOn = on
|
|
if (on) this.applyScreenOn(true)
|
|
}
|
|
})
|
|
},
|
|
|
|
toggleScreenAlwaysOn() {
|
|
const on = !this.screenAlwaysOn
|
|
this.screenAlwaysOn = on
|
|
this.applyScreenOn(on)
|
|
storage.set({
|
|
key: "screenAlwaysOn",
|
|
value: "" + on
|
|
})
|
|
},
|
|
|
|
applyScreenOn(on) {
|
|
brightness.setKeepScreenOn({
|
|
keepScreenOn: on,
|
|
success: () => {},
|
|
fail: () => {}
|
|
})
|
|
},
|
|
|
|
stopTimer() {
|
|
router.back()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.timer-swiper {
|
|
width: 466px;
|
|
height: 466px;
|
|
background-color: #000000;
|
|
}
|
|
|
|
.timer-page {
|
|
position: relative;
|
|
width: 466px;
|
|
height: 466px;
|
|
background-color: #000000;
|
|
}
|
|
|
|
.clock-label {
|
|
position: absolute;
|
|
left: 169px;
|
|
top: 0px;
|
|
width: 130px;
|
|
height: 45px;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
line-height: 33.6px;
|
|
text-align: center;
|
|
}
|
|
|
|
.page-title {
|
|
position: absolute;
|
|
left: 107px;
|
|
top: 34px;
|
|
width: 255px;
|
|
height: 45px;
|
|
color: #ffffff;
|
|
font-size: 30px;
|
|
font-weight: 500;
|
|
line-height: 44.8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.countdown {
|
|
position: absolute;
|
|
left: 83px;
|
|
top: 165px;
|
|
width: 300px;
|
|
height: 97px;
|
|
color: #ffffff;
|
|
font-size: 64px;
|
|
font-weight: 500;
|
|
line-height: 84.86px;
|
|
text-align: center;
|
|
}
|
|
|
|
.focus-label {
|
|
position: absolute;
|
|
left: 83px;
|
|
top: 263px;
|
|
width: 300px;
|
|
height: 38px;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
font-size: 32px;
|
|
font-weight: 500;
|
|
line-height: 42.43px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stop-button,
|
|
.pause-button {
|
|
position: absolute;
|
|
top: 358px;
|
|
width: 140px;
|
|
height: 69px;
|
|
}
|
|
|
|
.stop-button {
|
|
left: 83px;
|
|
}
|
|
|
|
.pause-button {
|
|
left: 243px;
|
|
}
|
|
|
|
.stop-background,
|
|
.pause-background {
|
|
position: absolute;
|
|
left: 0px;
|
|
top: 0px;
|
|
width: 140px;
|
|
height: 69px;
|
|
}
|
|
|
|
.stop-icon {
|
|
position: absolute;
|
|
left: 59px;
|
|
top: 23px;
|
|
width: 22px;
|
|
height: 24px;
|
|
}
|
|
|
|
.pause-icon {
|
|
position: absolute;
|
|
left: 53px;
|
|
top: 16px;
|
|
width: 35px;
|
|
height: 38px;
|
|
}
|
|
|
|
.stats-page {
|
|
position: relative;
|
|
width: 466px;
|
|
height: 466px;
|
|
background-color: #000000;
|
|
}
|
|
|
|
.settings-page {
|
|
position: relative;
|
|
width: 466px;
|
|
height: 466px;
|
|
background-color: #000000;
|
|
}
|
|
|
|
.stats-clock-label {
|
|
position: absolute;
|
|
left: 169px;
|
|
top: 0px;
|
|
width: 130px;
|
|
height: 45px;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
line-height: 33.6px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stats-page-title {
|
|
position: absolute;
|
|
left: 107px;
|
|
top: 34px;
|
|
width: 255px;
|
|
height: 45px;
|
|
color: #ffffff;
|
|
font-size: 30px;
|
|
font-weight: 500;
|
|
line-height: 44.8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.focus-total-title,
|
|
.rest-total-title,
|
|
.focus-total-value,
|
|
.rest-total-value {
|
|
position: absolute;
|
|
left: 83px;
|
|
width: 300px;
|
|
text-align: center;
|
|
}
|
|
|
|
.focus-total-title {
|
|
top: 134px;
|
|
height: 43px;
|
|
color: rgba(112, 119, 255, 1);
|
|
font-size: 32px;
|
|
font-weight: 500;
|
|
line-height: 42.43px;
|
|
}
|
|
|
|
.focus-total-value {
|
|
top: 177px;
|
|
height: 50px;
|
|
color: #ffffff;
|
|
font-size: 40px;
|
|
font-weight: 500;
|
|
line-height: 53.04px;
|
|
}
|
|
|
|
.rest-total-title {
|
|
top: 233px;
|
|
height: 43px;
|
|
color: rgba(149, 168, 0, 1);
|
|
font-size: 32px;
|
|
font-weight: 500;
|
|
line-height: 42.43px;
|
|
}
|
|
|
|
.rest-total-value {
|
|
top: 276px;
|
|
height: 50px;
|
|
color: #ffffff;
|
|
font-size: 40px;
|
|
font-weight: 500;
|
|
line-height: 53.04px;
|
|
}
|
|
|
|
.settings-title {
|
|
position: absolute;
|
|
left: 83px;
|
|
top: 34px;
|
|
width: 300px;
|
|
height: 45px;
|
|
color: #ffffff;
|
|
font-size: 30px;
|
|
font-weight: 500;
|
|
line-height: 44.8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.setting-row {
|
|
position: absolute;
|
|
left: 83px;
|
|
top: 134px;
|
|
width: 300px;
|
|
height: 45px;
|
|
}
|
|
|
|
.setting-label {
|
|
position: absolute;
|
|
left: 0px;
|
|
top: 0px;
|
|
width: 220px;
|
|
height: 45px;
|
|
color: #ffffff;
|
|
font-size: 30px;
|
|
font-weight: 500;
|
|
line-height: 44.8px;
|
|
text-align: left;
|
|
}
|
|
|
|
.toggle-track {
|
|
position: absolute;
|
|
left: 232px;
|
|
top: 5px;
|
|
width: 60px;
|
|
height: 34px;
|
|
border-radius: 17px;
|
|
}
|
|
|
|
.toggle-on { background-color: #1cb45c; }
|
|
.toggle-off { background-color: #3f3f3f; }
|
|
|
|
.toggle-knob {
|
|
position: absolute;
|
|
top: 4px;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 13px;
|
|
background-color: #ffffff;
|
|
}
|
|
|
|
.knob-on { left: 32px; }
|
|
.knob-off { left: 2px; }
|
|
</style>
|