fix: 服务器数据纪元保护(空快照不清空本地,自动全量重推自救) / 详情页导出指定日 / 全局计时时钟面板

This commit is contained in:
root
2026-10-26 17:32:34 +08:00
parent 7a36fd4e04
commit dc5ab15ae1
18 changed files with 252 additions and 43 deletions
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-BbcS1QA8.js","./index-CtHE6m7N.js","./index-C8-KlPfD.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-CtHE6m7N.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-BbcS1QA8.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-BuQ7VOet.js","./index-B3bd8iWX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-B3bd8iWX.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-BuQ7VOet.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-CtHE6m7N.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C8-KlPfD.css">
<script type="module" crossorigin src="./assets/index-B3bd8iWX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.6.0",
"version": "0.6.1",
"type": "module",
"scripts": {
"dev": "vite",
+3
View File
@@ -10,6 +10,7 @@ import LedgerView from './views/LedgerView.vue'
import MemoView from './views/MemoView.vue'
import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
import TimerPanel from './components/TimerPanel.vue'
import UserAdminView from './views/UserAdminView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
@@ -379,6 +380,8 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- 全局计时时钟面板:有计划在计时时悬浮显示 -->
<TimerPanel v-if="session" />
<div class="toast-wrap">
<transition-group name="toast">
<div v-for="t in toasts" :key="t.id" class="toast">{{ t.text }}</div>
+154
View File
@@ -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>
+6
View File
@@ -379,6 +379,12 @@ export async function listPlansByDate(date: string): Promise<PlanItem[]> {
return rows.sort((a, b) => (a.start_time < b.start_time ? -1 : 1))
}
// 全局计时时钟面板:查找正在进行中的计时任务(跨日期,任意一天开始的都算)
export async function listRunningPlan(): Promise<PlanItem | null> {
const rows = await db.plan.filter(p => !!p.timer_started_at).toArray()
return rows.length ? rows[0] : null
}
export async function addPlan(input: PlanInput): Promise<void> {
const now = nowLocalISO()
await db.plan.add({
+6
View File
@@ -9,3 +9,9 @@ export const attendanceChanged = ref(0)
export function notifyAttendanceChanged(): void {
attendanceChanged.value++
}
// 计划计时启停信号:全局计时时钟面板监听并即时刷新
export const planTimerPulse = ref(0)
export function notifyPlanTimer(): void {
planTimerPulse.value++
}
+14 -1
View File
@@ -300,7 +300,7 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
}
}
async function doRunSync(config: SyncConfig): Promise<SyncResult> {
async function doRunSync(config: SyncConfig, epochRetry = false): Promise<SyncResult> {
const base = config.baseUrl.trim().replace(/\/+$/, '')
if (!base) throw new Error('请先配置同步服务地址')
await ensureSyncFields()
@@ -358,6 +358,18 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
}
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
const data = await resp.json()
// 服务器数据纪元检测:纪元变化说明服务端数据存储被重建(如误删 data 目录后重启)。
// 此时本地镜像不可信(镜像认为「早已同步」而不推送,随后会拉到空快照把本地缓存清空)。
// 处置:作废镜像并以本地全量数据重推一次,让服务器从本设备恢复。
const epoch = String(data.dataEpoch || '')
const epochKey = 'dsh_sync_epoch_' + (getCloudCreds()?.username || 'anon')
const prevEpoch = epoch ? localStorage.getItem(epochKey) : null
if (!epochRetry && prevEpoch && epoch && prevEpoch !== epoch) {
await db.sync_mirror.clear()
localStorage.setItem(epochKey, epoch)
return doRunSync(config, true)
}
const serverTables: Record<string, AnyRow[]> = data.tables || {}
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
@@ -368,6 +380,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
ensureSSE(base, getToken() || token)
const at = nowLocalISO()
await putSyncLastTime(at)
if (epoch) localStorage.setItem(epochKey, epoch)
if (applyResult.changed > 0 || applyResult.removed > 0 || pushed > 0) notifyAttendanceChanged()
return { pushed, pulled: applyResult.changed, removed: applyResult.removed, errors: 0, at }
}
+2 -1
View File
@@ -8,7 +8,7 @@ import {
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
} from '../db/repo'
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
import { attendanceChanged, notifyPlanTimer } from '../lib/bus'
import { showToast } from '../lib/toast'
// ---- 长期计划 ----
@@ -144,6 +144,7 @@ async function toggleTimer(p: PlanItem) {
await startPlanTimer(p.id as number)
showToast('开始计时:' + p.title)
}
notifyPlanTimer()
await load()
}
+7 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import DayPanel from '../components/log/DayPanel.vue'
import { listLogItemsBetween, listAttendanceBetween, listProjects, getWorkRule } from '../db/repo'
@@ -23,7 +23,8 @@ const expStart = ref(todayStr())
const expEnd = ref(todayStr())
const exporting = ref(false)
function rangeStartEnd(): { start: string; end: string; label: string } {
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
// 「指定日」导出:导的是详情页当前浏览的那一天,而不是今天
if (expRange.value === 'day') return { start: date.value, end: date.value, label: date.value }
if (expRange.value === 'week') {
const ws = weekStart(expStart.value)
return { start: ws, end: addDays(ws, 6), label: ws + '_' + addDays(ws, 6) }
@@ -87,6 +88,9 @@ function onDateInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (v) date.value = v
}
// 浏览日期变化时,导出区间锚点跟随(周/月/自定义默认围绕当前指定日)
watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.value = d })
</script>
<template>
<div class="detail-page">
@@ -102,7 +106,7 @@ function onDateInput(e: Event) {
<div class="exp-row">
<span class="muted exp-label">导出</span>
<div class="seg">
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">当日</button>
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">指定日</button>
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>