fix: 服务器数据纪元保护(空快照不清空本地,自动全量重推自救) / 详情页导出指定日 / 全局计时时钟面板
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { listRunningPlan, stopPlanTimer, type PlanItem } from '../db/repo'
|
||||
import { planTimerPulse } from '../lib/bus'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
// 全局计时时钟面板:任何页面都能看到正在计时的计划,实时走秒、一键停止、点击跳转计划页
|
||||
const plan = ref<PlanItem | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
let poll: number | null = null
|
||||
let tick: number | null = null
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
plan.value = await listRunningPlan()
|
||||
} catch { /* 库未就绪时静默,下轮轮询重试 */ }
|
||||
}
|
||||
|
||||
const elapsedSec = computed(() => {
|
||||
const p = plan.value
|
||||
if (!p || !p.timer_started_at) return 0
|
||||
const base = (p.actual_minutes || 0) * 60
|
||||
const extra = Math.max(0, Math.floor((nowMs.value - new Date(p.timer_started_at).getTime()) / 1000))
|
||||
return base + extra
|
||||
})
|
||||
const elapsedText = computed(() => {
|
||||
const s = elapsedSec.value
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const ss = s % 60
|
||||
const p2 = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? h + ':' + p2(m) + ':' + p2(ss) : m + ':' + p2(ss)
|
||||
})
|
||||
|
||||
async function stop() {
|
||||
const p = plan.value
|
||||
if (!p) return
|
||||
await stopPlanTimer(p.id as number)
|
||||
showToast('已停止计时:' + p.title)
|
||||
await refresh()
|
||||
}
|
||||
function goPlan() {
|
||||
location.hash = '#/plan'
|
||||
}
|
||||
|
||||
watch(planTimerPulse, () => { void refresh() })
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
poll = window.setInterval(() => { void refresh() }, 15000)
|
||||
tick = window.setInterval(() => { nowMs.value = Date.now() }, 1000)
|
||||
window.addEventListener('focus', refresh)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (poll !== null) window.clearInterval(poll)
|
||||
if (tick !== null) window.clearInterval(tick)
|
||||
window.removeEventListener('focus', refresh)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="tp">
|
||||
<div v-if="plan && plan.timer_started_at" class="tp" @click="goPlan" title="点击查看计划页">
|
||||
<span class="tp-pulse"></span>
|
||||
<span class="tp-main">
|
||||
<span class="tp-title">{{ plan.title }}</span>
|
||||
<span class="tp-meta">{{ plan.kind }} · 计时中</span>
|
||||
</span>
|
||||
<span class="tp-time num">{{ elapsedText }}</span>
|
||||
<span class="tp-stop" role="button" title="停止计时" @click.stop="stop">停止</span>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tp {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 340px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.tp:hover { border-color: var(--border-strong); }
|
||||
.tp-pulse {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
.tp-pulse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
animation: tp-ring 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes tp-ring {
|
||||
0% { transform: scale(0.5); opacity: 0.6; }
|
||||
100% { transform: scale(1.5); opacity: 0; }
|
||||
}
|
||||
.tp-main { display: flex; flex-direction: column; min-width: 0; }
|
||||
.tp-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tp-meta { font-size: 11px; color: var(--text-3); }
|
||||
.tp-time {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 2px;
|
||||
flex: none;
|
||||
}
|
||||
.tp-stop {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.tp-stop:hover { color: var(--danger); border-color: var(--danger); background: #FDF0F0; }
|
||||
.tp-enter-active, .tp-leave-active { transition: all 0.2s ease; }
|
||||
.tp-enter-from, .tp-leave-to { opacity: 0; transform: translateY(10px); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tp {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(66px + env(safe-area-inset-bottom));
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user