This commit is contained in:
Julian Freeman
2026-03-26 13:19:43 -04:00
parent 05a6ee59ff
commit 06fb78edab
5 changed files with 146 additions and 38 deletions

View File

@@ -13,7 +13,7 @@ import {
retainDays, captureInterval,
TIME_OFFSET_MINUTES, TOTAL_MINUTES, getTagColor, getTagName, mainTags, getSubTags,
logicalMinutesToTime, logicalMinutesFromTime, formatDuration,
loadTags, loadEvents
loadTags, loadEvents, toISODate
} from "./store";
import { DBEvent, TimelineItem } from "./types";
@@ -31,6 +31,7 @@ const isTagManagerOpen = ref(false);
const isExportModalOpen = ref(false);
const isEventModalOpen = ref(false);
const isCalendarOpen = ref(false);
const isMouseOverTimeline = ref(false); // Track mouse state to prevent race conditions
const calendarRef = ref<HTMLElement | null>(null);
const calendarMonth = ref(new Date());
@@ -51,6 +52,26 @@ const endTimePickerRef = ref<HTMLElement | null>(null);
const isStartTimeOpen = ref(false);
const isEndTimeOpen = ref(false);
const openStartTimePicker = async () => {
isStartTimeOpen.value = !isStartTimeOpen.value;
isEndTimeOpen.value = false;
if (isStartTimeOpen.value) {
await nextTick();
const activeItems = document.querySelectorAll('.z-120 .bg-\\[\\#007AFF\\].text-white');
activeItems.forEach(el => el.scrollIntoView({ block: 'center' }));
}
};
const openEndTimePicker = async () => {
isEndTimeOpen.value = !isEndTimeOpen.value;
isStartTimeOpen.value = false;
if (isEndTimeOpen.value) {
await nextTick();
const activeItems = document.querySelectorAll('.z-120 .bg-\\[\\#007AFF\\].text-white');
activeItems.forEach(el => el.scrollIntoView({ block: 'center' }));
}
};
// --- Logic ---
const updateCurrentMinute = () => {
const now = new Date();
@@ -125,7 +146,13 @@ const applyTheme = (val: string) => {
const loadTimeline = async (autoScroll = false) => {
if (savePath.value) {
timelineImages.value = await invoke<TimelineItem[]>("get_timeline", { date: currentDate.value, baseDir: savePath.value });
const rawImages = await invoke<TimelineItem[]>("get_timeline", { date: currentDate.value, baseDir: savePath.value });
// Pre-calculate logical minutes to prevent high-cost recalculation on every mouse move
timelineImages.value = rawImages.map(img => ({
...img,
logical_minute: timeToLogicalMinutes(img.time, img.isNextDay)
}));
if (autoScroll && timelineRef.value) {
await nextTick();
const now = new Date();
@@ -155,20 +182,56 @@ const timeToLogicalMinutes = (timeStr: string, isNextDay = false) => {
let t = h * 60 + m + (s / 60); if (isNextDay) t += 1440; return t - TIME_OFFSET_MINUTES;
};
// Request Animation Frame lock for mouse move
let isMouseMovePending = false;
const handleTimelineMouseMove = (e: MouseEvent) => {
if (!timelineRef.value) return;
const rect = timelineRef.value.getBoundingClientRect();
const min = Math.floor((e.clientY - rect.top + timelineRef.value.scrollTop) / timelineZoom.value);
if (min >= 0 && min < 1440) {
hoveredTime.value = logicalMinutesToTime(min);
if (isDragging.value) dragEndMin.value = min;
if (viewMode.value === 'dashboard') return;
const closest = timelineImages.value.reduce((p, c) => {
const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - min);
const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - min);
return cd < pd ? c : p;
}, timelineImages.value[0]);
if (closest) updatePreview(closest);
isMouseOverTimeline.value = true;
if (isMouseMovePending) return;
isMouseMovePending = true;
const clientY = e.clientY; // Capture coordinate outside RAF
requestAnimationFrame(() => {
isMouseMovePending = false;
// Safety check: If mouse already left before this frame executed, DO NOT update preview
if (!isMouseOverTimeline.value || !timelineRef.value) return;
const rect = timelineRef.value.getBoundingClientRect();
const min = Math.floor((clientY - rect.top + timelineRef.value.scrollTop) / timelineZoom.value);
if (min >= 0 && min < 1440) {
hoveredTime.value = logicalMinutesToTime(min);
if (isDragging.value) dragEndMin.value = min;
if (viewMode.value === 'dashboard') return;
if (timelineImages.value.length === 0) return;
let closest = timelineImages.value[0];
let minDiff = Math.abs(closest.logical_minute! - min);
for (let i = 1; i < timelineImages.value.length; i++) {
const img = timelineImages.value[i];
const diff = Math.abs(img.logical_minute! - min);
if (diff < minDiff) {
minDiff = diff;
closest = img;
}
}
if (closest) updatePreview(closest);
}
});
};
const handleTimelineMouseLeave = () => {
isMouseOverTimeline.value = false; // Immediately flag as left
hoveredTime.value = null;
if (viewMode.value === 'dashboard') return;
if (!isDragging.value && lockedImage.value) {
updatePreview(lockedImage.value);
}
};
@@ -181,11 +244,17 @@ const handleTimelineMouseUp = () => {
editingEvent.value = { id: 0, date: currentDate.value, start_minute: start, end_minute: end, main_tag_id: mainTags.value[0]?.id || 0, sub_tag_id: null, content: "" };
isEventModalOpen.value = true;
} else if (timelineImages.value.length) {
const closest = timelineImages.value.reduce((p, c) => {
const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - start);
const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - start);
return cd < pd ? c : p;
}, timelineImages.value[0]);
// Optimized linear search on click
let closest = timelineImages.value[0];
let minDiff = Math.abs(closest.logical_minute! - start);
for (let i = 1; i < timelineImages.value.length; i++) {
const img = timelineImages.value[i];
const diff = Math.abs(img.logical_minute! - start);
if (diff < minDiff) {
minDiff = diff;
closest = img;
}
}
if (closest) { lockedImage.value = closest; viewMode.value = 'preview'; updatePreview(closest); }
}
}
@@ -203,10 +272,18 @@ const handleTimelineWheel = (e: WheelEvent) => {
};
const selectCalendarDate = (date: Date) => {
currentDate.value = date.toLocaleDateString('sv');
currentDate.value = toISODate(date);
isCalendarOpen.value = false;
selectedImage.value = null; lockedImage.value = null; previewSrc.value = "";
loadTimeline(true); loadEvents();
selectedImage.value = null;
lockedImage.value = null;
previewSrc.value = "";
hoveredTime.value = null;
hoveredEventDetails.value = null;
dayEvents.value = []
timelineImages.value = [];
updateCurrentMinute();
loadTimeline(true);
loadEvents();
};
const calendarDays = computed(() => {
@@ -273,12 +350,12 @@ const togglePause = async () => {
</div>
</div>
<div ref="timelineRef" class="flex-1 overflow-y-auto no-scrollbar hover:cursor-crosshair relative" @mousedown="handleTimelineMouseDown" @mousemove="handleTimelineMouseMove" @mouseup="handleTimelineMouseUp" @mouseleave="hoveredTime = null" @wheel="handleTimelineWheel">
<div ref="timelineRef" class="flex-1 overflow-y-auto no-scrollbar hover:cursor-crosshair relative" @mousedown="handleTimelineMouseDown" @mousemove="handleTimelineMouseMove" @mouseup="handleTimelineMouseUp" @mouseleave="handleTimelineMouseLeave" @wheel="handleTimelineWheel">
<div :style="{ height: TOTAL_MINUTES * timelineZoom + 'px' }" class="relative ml-14 mr-4">
<div v-for="h in 24" :key="h" class="absolute left-0 w-full border-t border-border-main/60" :style="{ top: (h-1) * 60 * timelineZoom + 'px' }">
<span v-if="h > 1" class="absolute -left-11 -top-2.5 text-[10px] font-bold text-text-sec">{{ String((h - 1 + 3) % 24).padStart(2, '0') }}:00</span>
</div>
<div v-for="ev in dayEvents" :key="ev.id" class="absolute left-0 w-[45%] opacity-80 border-l-4 cursor-pointer hover:z-50 hover:brightness-110" :style="{ top: ev.start_minute * timelineZoom + 'px', height: (ev.end_minute - ev.start_minute) * timelineZoom + 'px', backgroundColor: getTagColor(ev.main_tag_id) + '50', borderColor: getTagColor(ev.main_tag_id) }" @click.stop="editingEvent = { ...ev }; isEventModalOpen = true" @mouseenter="hoveredEventDetails = { event: ev, x: $event.clientX, y: $event.clientY }" @mousemove="hoveredEventDetails ? (hoveredEventDetails.x = $event.clientX, hoveredEventDetails.y = $event.clientY) : null" @mouseleave="hoveredEventDetails = null"></div>
<div v-for="ev in dayEvents" :key="ev.id" class="absolute left-0 w-[45%] opacity-80 border-l-4 cursor-pointer hover:opacity-100 hover:border-l-[6px] hover:z-50 hover:brightness-110 transition-all duration-100" :style="{ top: ev.start_minute * timelineZoom + 'px', height: (ev.end_minute - ev.start_minute) * timelineZoom + 'px', backgroundColor: getTagColor(ev.main_tag_id) + '50', borderColor: getTagColor(ev.main_tag_id) }" @click.stop="editingEvent = { ...ev }; isEventModalOpen = true" @mousedown.stop @mouseenter="hoveredEventDetails = { event: ev, x: $event.clientX, y: $event.clientY }" @mousemove="hoveredEventDetails ? (hoveredEventDetails.x = $event.clientX, hoveredEventDetails.y = $event.clientY) : null" @mouseleave="hoveredEventDetails = null"></div>
<div v-for="img in timelineImages" :key="img.path" class="absolute left-[50%] right-2 h-0.5 bg-[#007AFF]/20 rounded-full" :class="[selectedImage?.path === img.path ? 'bg-[#007AFF]/60 h-1 z-10' : '', lockedImage?.path === img.path ? 'bg-[#007AFF] h-1.5 ring-2 ring-[#007AFF]/20 z-20' : '']" :style="{ top: timeToLogicalMinutes(img.time, img.isNextDay) * timelineZoom + 'px' }"></div>
<div v-if="isDragging && dragStartMin !== null && dragEndMin !== null" class="absolute left-0 w-full bg-[#007AFF]/10 border-y-2 border-[#007AFF] pointer-events-none z-30" :style="{ top: Math.min(dragStartMin, dragEndMin) * timelineZoom + 'px', height: Math.abs(dragEndMin - dragStartMin) * timelineZoom + 'px' }"></div>
<div v-if="hoveredTime" class="absolute left-0 right-0 border-t-2 border-[#007AFF] z-40 pointer-events-none" :style="{ top: timeToLogicalMinutes(hoveredTime, hoveredTime < '03:00') * timelineZoom + 'px' }"><div class="absolute -left-12 -top-3 bg-[#007AFF] text-white text-[9px] px-1 py-0.5 rounded font-bold">{{ hoveredTime }}</div></div>
@@ -331,20 +408,20 @@ const togglePause = async () => {
<div class="flex gap-3 items-end">
<div ref="startTimePickerRef" class="flex-1 relative">
<label class="text-[10px] font-bold text-text-sec block mb-1">开始时间</label>
<button @click="isStartTimeOpen = !isStartTimeOpen; isEndTimeOpen = false" class="w-full h-12 bg-bg-input rounded-xl px-4 flex items-center justify-center gap-2 hover:bg-bg-hover transition-all border border-transparent focus:border-[#007AFF]/30"><span class="text-sm font-bold leading-none">{{ logicalMinutesToTime(editingEvent.start_minute) }}</span></button>
<button @click="openStartTimePicker" class="w-full h-12 bg-bg-input rounded-xl px-4 flex items-center justify-center gap-2 hover:bg-bg-hover transition-all border border-transparent focus:border-[#007AFF]/30"><span class="text-sm font-bold leading-none">{{ logicalMinutesToTime(editingEvent.start_minute) }}</span></button>
<div v-if="isStartTimeOpen" class="absolute top-full left-0 right-0 mt-2 bg-bg-card rounded-3xl shadow-2xl border border-border-main z-120 p-4 animate-in fade-in slide-in-from-top-2 flex gap-4 h-64">
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1">
<button v-for="h in 24" :key="h" @click="editingEvent.start_minute = logicalMinutesFromTime(`${String((h-1+3)%24).padStart(2,'0')}:${String(editingEvent.start_minute % 60).padStart(2,'0')}`)" class="py-2 text-[11px] rounded-lg transition-colors w-full text-center" :class="Math.floor((editingEvent.start_minute + TIME_OFFSET_MINUTES) / 60) % 24 === (h-1+3)%24 ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ String((h-1+3)%24).padStart(2,'0') }}</button>
<button v-for="h in 24" :key="h" @click="editingEvent.start_minute = logicalMinutesFromTime(`${String((h-1+3)%24).padStart(2,'0')}:${String(editingEvent.start_minute % 60).padStart(2,'0')}`)" class="py-2 text-xs rounded-lg transition-colors w-full text-center" :class="Math.floor((editingEvent.start_minute + TIME_OFFSET_MINUTES) / 60) % 24 === (h-1+3)%24 ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ String((h-1+3)%24).padStart(2,'0') }}</button>
</div>
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1">
<button v-for="m in 60" :key="m" @click="editingEvent.start_minute = Math.floor(editingEvent.start_minute / 60) * 60 + (m-1); isStartTimeOpen = false" class="py-2 text-[11px] rounded-lg transition-colors w-full text-center" :class="editingEvent.start_minute % 60 === (m-1) ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ String(m-1).padStart(2,'0') }}</button>
<button v-for="m in 60" :key="m" @click="editingEvent.start_minute = Math.floor(editingEvent.start_minute / 60) * 60 + (m-1); isStartTimeOpen = false" class="py-2 text-xs rounded-lg transition-colors w-full text-center" :class="editingEvent.start_minute % 60 === (m-1) ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ String(m-1).padStart(2,'0') }}</button>
</div>
</div>
</div>
<div class="h-12 flex items-center justify-center"><span class="text-[11px] font-bold text-text-sec bg-bg-input px-3 py-1.5 rounded-xl border border-border-main/50">{{ formatDuration(editingEvent.start_minute, editingEvent.end_minute) }}</span></div>
<div ref="endTimePickerRef" class="flex-1 relative">
<label class="text-[10px] font-bold text-text-sec block mb-1">结束时间</label>
<button @click="isEndTimeOpen = !isEndTimeOpen; isStartTimeOpen = false" class="w-full h-12 bg-bg-input rounded-xl px-4 flex items-center justify-center gap-2 hover:bg-bg-hover transition-all border border-transparent focus:border-[#007AFF]/30"><span class="text-sm font-bold leading-none">{{ logicalMinutesToTime(editingEvent.end_minute) }}</span></button>
<button @click="openEndTimePicker" class="w-full h-12 bg-bg-input rounded-xl px-4 flex items-center justify-center gap-2 hover:bg-bg-hover transition-all border border-transparent focus:border-[#007AFF]/30"><span class="text-sm font-bold leading-none">{{ logicalMinutesToTime(editingEvent.end_minute) }}</span></button>
<div v-if="isEndTimeOpen" class="absolute top-full left-0 right-0 mt-2 bg-bg-card rounded-3xl shadow-2xl border border-border-main z-120 p-4 animate-in fade-in slide-in-from-top-2 flex gap-4 h-64">
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1 text-center">
<button v-for="h in 24" :key="h" @click="editingEvent.end_minute = logicalMinutesFromTime(`${String((h-1+3)%24).padStart(2,'0')}:${String(editingEvent.end_minute % 60).padStart(2,'0')}`)" class="py-2 text-xs rounded-lg transition-colors w-full text-center" :class="Math.floor((editingEvent.end_minute + TIME_OFFSET_MINUTES) / 60) % 24 === (h-1+3)%24 ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ String((h-1+3)%24).padStart(2,'0') }}</button>
@@ -362,7 +439,7 @@ const togglePause = async () => {
<div v-if="editingEvent.main_tag_id && getSubTags(editingEvent.main_tag_id).length" class="space-y-2"><label class="text-[10px] font-bold text-text-sec">副标签</label>
<div class="flex flex-wrap gap-2"><button v-for="sub in getSubTags(editingEvent.main_tag_id)" :key="sub.id" @click="editingEvent.sub_tag_id = sub.id" class="px-3 py-1.5 rounded-lg text-[12px] font-medium" :class="editingEvent.sub_tag_id === sub.id ? 'bg-[#1D1D1F] text-white' : 'bg-bg-input text-text-sec'">{{ sub.name }}</button></div>
</div>
<textarea v-model="editingEvent.content" placeholder="记录具体内容..." class="w-full bg-bg-input rounded-2xl p-4 text-sm min-h-25 outline-none border border-transparent focus:bg-bg-card focus:border-border-main transition-all"></textarea>
<textarea v-model="editingEvent.content" placeholder="记录具体内容..." @keydown.ctrl.enter="saveEvent" class="w-full bg-bg-input rounded-2xl p-4 text-sm min-h-25 outline-none border border-transparent focus:bg-bg-card focus:border-border-main transition-all"></textarea>
</div>
<div class="flex gap-4 pt-4">
<button v-if="editingEvent.id" @click="deleteEvent(editingEvent.id); isEventModalOpen = false" class="text-[#FF3B30] font-bold px-4 py-2 flex items-center gap-2"><Trash2 :size="18" /> 删除</button>
@@ -374,8 +451,12 @@ const togglePause = async () => {
<!-- Fullscreen & Tooltip & Toast -->
<div v-if="isFullscreen && previewSrc" class="fixed inset-0 z-200 bg-black/95 flex items-center justify-center p-6 backdrop-blur-xl"><button @click="isFullscreen = false" class="absolute top-10 right-10 w-12 h-12 bg-white/10 rounded-full flex items-center justify-center"><X :size="32" class="text-white" /></button><img :src="previewSrc" class="max-w-full max-h-full object-contain shadow-2xl" /></div>
<div v-if="hoveredEventDetails" class="fixed z-300 pointer-events-none bg-bg-card/90 backdrop-blur-xl border border-white/20 shadow-2xl rounded-2xl p-4 w-64" :style="{ left: hoveredEventDetails.x + 15 + 'px', top: hoveredEventDetails.y + 15 + 'px' }">
<div class="flex items-center gap-2 mb-2"><div class="w-3 h-3 rounded-full" :style="{ backgroundColor: getTagColor(hoveredEventDetails.event.main_tag_id) }"></div><span class="font-bold text-sm text-text-main">{{ getTagName(hoveredEventDetails.event.main_tag_id) }}</span></div>
<div v-if="hoveredEventDetails && hoveredEventDetails.event" class="fixed z-300 pointer-events-none bg-bg-card/90 backdrop-blur-xl border border-white/20 shadow-2xl rounded-2xl p-4 w-64" :style="{ left: hoveredEventDetails.x + 15 + 'px', top: hoveredEventDetails.y + 15 + 'px' }">
<div class="flex items-center gap-2 mb-2">
<div class="w-3 h-3 rounded-full" :style="{ backgroundColor: getTagColor(hoveredEventDetails.event.main_tag_id) }"></div>
<span class="font-bold text-sm text-text-main">{{ getTagName(hoveredEventDetails.event.main_tag_id) }}</span>
<span v-if="hoveredEventDetails.event.sub_tag_id" class="text-[10px] font-bold text-text-sec bg-bg-input px-2 py-0.5 rounded-md border border-border-main/50">{{ getTagName(hoveredEventDetails.event.sub_tag_id) }}</span>
</div>
<div class="flex items-center justify-between text-[11px] font-bold text-text-sec mb-2.5"><span>{{ logicalMinutesToTime(hoveredEventDetails.event.start_minute) }} - {{ logicalMinutesToTime(hoveredEventDetails.event.end_minute) }}</span><span class="bg-bg-input px-1.5 py-0.5 rounded-lg text-[#007AFF]">{{ formatDuration(hoveredEventDetails.event.start_minute, hoveredEventDetails.event.end_minute) }}</span></div>
<div v-if="hoveredEventDetails.event.content" class="text-xs text-text-main leading-relaxed wrap-break-words whitespace-pre-wrap">{{ hoveredEventDetails.event.content }}</div>
</div>

View File

@@ -44,7 +44,7 @@ const createDBFile = async () => {
<template>
<div class="fixed inset-0 z-100 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="$emit('close')">
<div class="bg-bg-card rounded-[40px] shadow-2xl w-full max-md overflow-hidden flex flex-col">
<div class="bg-bg-card rounded-[40px] shadow-2xl w-full max-w-md overflow-hidden flex flex-col">
<div class="p-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">设置</h2><button @click="$emit('close')"><X :size="24" /></button></div>
<div class="p-10 space-y-8">
<div class="space-y-3"><label class="text-xs font-bold text-text-sec">截图保存位置</label><div class="flex gap-2"><input type="text" readonly :value="savePath" class="flex-1 bg-bg-input rounded-xl px-4 py-2 text-sm outline-none" /><button @click="selectFolder" class="bg-bg-card border p-2 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button></div></div>

View File

@@ -1,7 +1,7 @@
import { ref, computed, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { DBEvent } from "../types";
import { currentDate, getTagColor, getTagName, formatMinutes, parseISODate, toISODate } from "./index";
import { currentDate, getTagColor, getTagName, formatMinutes, parseISODate, toISODate, viewMode, refreshSignal } from "./index";
// Re-export for easier access in Dashboard component
export { getTagColor, getTagName, formatMinutes };
@@ -31,6 +31,20 @@ watch([dashboardRange, currentDate], () => {
loadDashboardEvents();
}, { immediate: true });
// Auto-refresh dashboard when a refresh signal is received (event added/deleted)
watch(refreshSignal, () => {
if (viewMode.value === 'dashboard') {
loadDashboardEvents();
}
});
// Refresh when switching to dashboard mode to catch updates that happened in preview mode
watch(viewMode, (mode) => {
if (mode === 'dashboard') {
loadDashboardEvents();
}
});
export const dashboardStats = computed(() => {
let totalMinutes = 0;
const mainTagMap = new Map<number, { total: number, subTags: Map<number, number> }>();

View File

@@ -39,7 +39,7 @@ export const theme = ref("system");
// --- Global UI State ---
export const isPaused = ref(false);
export const currentDate = ref(getLogicDateStr()); // Initialize with logic date
export const viewMode = ref<'preview' | 'dashboard'>('preview');
export const viewMode = ref<'preview' | 'dashboard'>('dashboard');
export const isFullscreen = ref(false);
export const previewSrc = ref("");
export const selectedImage = ref<TimelineItem | null>(null);
@@ -49,6 +49,7 @@ export const lockedImage = ref<TimelineItem | null>(null);
export const tags = ref<Tag[]>([]);
export const dayEvents = ref<DBEvent[]>([]);
export const timelineImages = ref<TimelineItem[]>([]);
export const refreshSignal = ref(0); // Counter to trigger dashboard refreshes
// --- Global Toast ---
export const toast = ref<Toast>({ message: "", type: "success", visible: false });
@@ -60,8 +61,16 @@ export const showToast = (message: string, type: "success" | "error" = "success"
// --- Computed Helpers ---
export const mainTags = computed(() => tags.value.filter(t => t.parent_id === null));
export const getSubTags = (parentId: number) => tags.value.filter(t => t.parent_id === parentId);
export const getTagColor = (tagId: number) => tags.value.find(t => t.id === tagId)?.color || "#007AFF";
export const getTagName = (tagId: number | null) => tags.value.find(t => t.id === tagId)?.name || "-- 无 --";
export const getTagColor = (tagId: number | null | undefined) => {
if (tagId == null) return "#007AFF";
const tag = tags.value.find(t => t.id === tagId);
return tag?.color || "#007AFF";
};
export const getTagName = (tagId: number | null | undefined) => {
if (tagId == null) return "";
const tag = tags.value.find(t => t.id === tagId);
return tag?.name || "";
};
// --- Time Helper Functions ---
export const logicalMinutesToTime = (min: number) => {
@@ -94,4 +103,7 @@ export const formatMinutes = (mins: number) => {
// --- Shared Actions ---
export const loadTags = async () => { tags.value = await invoke("get_tags"); };
export const loadEvents = async () => { dayEvents.value = await invoke("get_events", { date: currentDate.value }); };
export const loadEvents = async () => {
dayEvents.value = await invoke("get_events", { date: currentDate.value });
refreshSignal.value++; // Increment to signal other stores to refresh
};

View File

@@ -19,6 +19,7 @@ export interface TimelineItem {
time: string;
path: string;
isNextDay?: boolean;
logical_minute?: number;
}
export interface Toast {