support stats

This commit is contained in:
Julian Freeman
2026-03-26 11:36:04 -04:00
parent d6cd969990
commit 6793a2070e

View File

@@ -4,7 +4,7 @@ import { load } from "@tauri-apps/plugin-store";
import { open, save } from "@tauri-apps/plugin-dialog"; import { open, save } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { Tag as TagIcon, FolderOpen, Settings, Play, Pause, Maximize2, X, RefreshCw, Plus, Trash2, ChevronDown, ChevronLeft, ChevronRight, Calendar, Download, SquarePlus } from "lucide-vue-next"; import { Tag as TagIcon, FolderOpen, Settings, Play, Pause, Maximize2, X, RefreshCw, Plus, Trash2, ChevronDown, ChevronLeft, ChevronRight, Calendar, Download, SquarePlus, BarChart2, Image as ImageIcon } from "lucide-vue-next";
// --- Types --- // --- Types ---
interface Tag { id: number; name: string; parent_id: number | null; color: string; } interface Tag { id: number; name: string; parent_id: number | null; color: string; }
@@ -46,6 +46,94 @@ const captureInterval = ref(60);
const timelineZoom = ref(1.5); const timelineZoom = ref(1.5);
const theme = ref("system"); const theme = ref("system");
const viewMode = ref<'preview' | 'dashboard'>('preview');
const dashboardRange = ref<'today' | '7days' | '30days'>('today');
const dashboardStartDate = ref(currentDate.value);
const dashboardEndDate = ref(currentDate.value);
const dashboardEvents = ref<DBEvent[]>([]);
const dailyAverageMode = ref<'natural' | 'recorded'>('natural');
const loadDashboardEvents = async () => {
dashboardEvents.value = await invoke("get_events_range", { startDate: dashboardStartDate.value, endDate: dashboardEndDate.value });
};
watch([dashboardRange, currentDate], async () => {
const end = new Date(currentDate.value);
let start = new Date(currentDate.value);
if (dashboardRange.value === '7days') start.setDate(end.getDate() - 6);
else if (dashboardRange.value === '30days') start.setDate(end.getDate() - 29);
dashboardStartDate.value = start.toLocaleDateString('sv');
dashboardEndDate.value = end.toLocaleDateString('sv');
if (viewMode.value === 'dashboard') await loadDashboardEvents();
});
watch(viewMode, async (mode) => {
if (mode === 'dashboard') {
const end = new Date(currentDate.value);
let start = new Date(currentDate.value);
if (dashboardRange.value === '7days') start.setDate(end.getDate() - 6);
else if (dashboardRange.value === '30days') start.setDate(end.getDate() - 29);
dashboardStartDate.value = start.toLocaleDateString('sv');
dashboardEndDate.value = end.toLocaleDateString('sv');
await loadDashboardEvents();
}
});
const dashboardStats = computed(() => {
let totalMinutes = 0;
const mainTagMap = new Map<number, { total: number, subTags: Map<number, number> }>();
const uniqueDays = new Set<string>();
dashboardEvents.value.forEach(ev => {
let diff = ev.end_minute - ev.start_minute;
if (diff < 0) diff += 1440;
totalMinutes += diff;
uniqueDays.add(ev.date);
if (!mainTagMap.has(ev.main_tag_id)) {
mainTagMap.set(ev.main_tag_id, { total: 0, subTags: new Map() });
}
const mainStat = mainTagMap.get(ev.main_tag_id)!;
mainStat.total += diff;
if (ev.sub_tag_id) {
const subTotal = mainStat.subTags.get(ev.sub_tag_id) || 0;
mainStat.subTags.set(ev.sub_tag_id, subTotal + diff);
}
});
const start = new Date(dashboardStartDate.value);
const end = new Date(dashboardEndDate.value);
const naturalDays = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 86400000) + 1);
const recordedDays = Math.max(1, uniqueDays.size);
const daysCount = dailyAverageMode.value === 'natural' ? naturalDays : recordedDays;
const mainTagsList = Array.from(mainTagMap.entries()).map(([id, stat]) => {
const subTagsList = Array.from(stat.subTags.entries()).map(([subId, subTotal]) => ({
id: subId,
total: subTotal,
percentage: stat.total > 0 ? (subTotal / stat.total) * 100 : 0
})).sort((a, b) => b.total - a.total);
return {
id,
total: stat.total,
dailyAverage: stat.total / daysCount,
percentage: totalMinutes > 0 ? (stat.total / totalMinutes) * 100 : 0,
subTags: subTagsList
};
}).sort((a, b) => b.total - a.total);
return { totalMinutes, dailyAverage: totalMinutes / daysCount, mainTags: mainTagsList, naturalDays, recordedDays, daysCount };
});
const formatMinutes = (mins: number) => {
const h = Math.floor(mins / 60);
const m = Math.floor(mins % 60);
if (h === 0) return `${m}m`;
return `${h}h ${m}m`;
};
const applyTheme = (val: string) => { const applyTheme = (val: string) => {
if (val === 'dark') { if (val === 'dark') {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
@@ -251,7 +339,10 @@ const completeSetup = async () => {
}; };
const loadTags = async () => { tags.value = await invoke("get_tags"); }; const loadTags = async () => { tags.value = await invoke("get_tags"); };
const loadEvents = async () => { dayEvents.value = await invoke("get_events", { date: currentDate.value }); }; const loadEvents = async () => {
dayEvents.value = await invoke("get_events", { date: currentDate.value });
if (viewMode.value === 'dashboard') await loadDashboardEvents();
};
const handleAddTag = async (name: string, parentId: number | null, color: string) => { const handleAddTag = async (name: string, parentId: number | null, color: string) => {
try { try {
@@ -363,6 +454,7 @@ const handleTimelineMouseMove = (e: MouseEvent) => {
if (min >= 0 && min < 1440) { if (min >= 0 && min < 1440) {
hoveredTime.value = logicalMinutesToTime(min); hoveredTime.value = logicalMinutesToTime(min);
if (isDragging.value) dragEndMin.value = min; if (isDragging.value) dragEndMin.value = min;
if (viewMode.value === 'dashboard') return;
const closest = timelineImages.value.reduce((p, c) => { const closest = timelineImages.value.reduce((p, c) => {
const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - min); const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - min);
const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - min); const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - min);
@@ -386,13 +478,18 @@ const handleTimelineMouseUp = () => {
const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - start); const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - start);
return cd < pd ? c : p; return cd < pd ? c : p;
}, timelineImages.value[0]); }, timelineImages.value[0]);
if (closest) { lockedImage.value = closest; updatePreview(closest); } if (closest) {
lockedImage.value = closest;
viewMode.value = 'preview';
updatePreview(closest);
}
} }
} }
}; };
const handleTimelineMouseLeave = () => { const handleTimelineMouseLeave = () => {
hoveredTime.value = null; hoveredTime.value = null;
if (viewMode.value === 'dashboard') return;
if (!isDragging.value && lockedImage.value) updatePreview(lockedImage.value); if (!isDragging.value && lockedImage.value) updatePreview(lockedImage.value);
}; };
@@ -636,14 +733,107 @@ const handleExport = async () => {
<div class="flex-1 bg-bg-main flex flex-col relative overflow-hidden"> <div class="flex-1 bg-bg-main flex flex-col relative overflow-hidden">
<div class="p-6 flex items-center justify-between border-b bg-bg-card/80 backdrop-blur-md z-10"> <div class="p-6 flex items-center justify-between border-b bg-bg-card/80 backdrop-blur-md z-10">
<div v-if="selectedImage" class="flex items-center gap-3"><span class="text-lg font-bold">{{ selectedImage.time }}</span><span class="text-xs font-bold px-2 py-0.5 rounded-md" :class="lockedImage?.path === selectedImage.path ? 'bg-[#007AFF] text-white' : 'bg-bg-input text-text-sec'">{{ lockedImage?.path === selectedImage.path ? '已定格' : '预览中' }}</span></div> <div v-if="viewMode === 'preview'" class="flex items-center gap-3">
<div v-else class="text-text-sec font-medium">未选中活动</div> <template v-if="selectedImage">
<button v-if="selectedImage" @click="isFullscreen = true" class="p-2.5 text-text-sec"><Maximize2 :size="20" /></button> <span class="text-lg font-bold">{{ selectedImage.time }}</span><span class="text-xs font-bold px-2 py-0.5 rounded-md" :class="lockedImage?.path === selectedImage.path ? 'bg-[#007AFF] text-white' : 'bg-bg-input text-text-sec'">{{ lockedImage?.path === selectedImage.path ? '已定格' : '预览中' }}</span>
</template>
<span v-else class="text-text-sec font-medium">未选中活动</span>
</div> </div>
<div class="flex-1 flex items-center justify-center p-12 bg-bg-input/30"> <div v-else class="flex items-center gap-3">
<img v-if="previewSrc" :src="previewSrc" class="max-w-full max-h-full object-contain rounded-3xl shadow-2xl border" /> <span class="text-lg font-bold">时间分析</span>
</div>
<div class="flex items-center gap-4">
<button v-if="viewMode === 'preview' && selectedImage" @click="isFullscreen = true" class="p-2.5 text-text-sec hover:text-text-main"><Maximize2 :size="20" /></button>
<div class="flex bg-bg-input rounded-xl p-1 gap-1 border border-border-main/50 shadow-inner">
<button @click="viewMode = 'preview'" class="px-3 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center gap-1.5" :class="viewMode === 'preview' ? 'bg-bg-card shadow-md text-text-main' : 'text-text-sec hover:text-text-main'"><ImageIcon :size="14"/> 预览</button>
<button @click="viewMode = 'dashboard'" class="px-3 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center gap-1.5" :class="viewMode === 'dashboard' ? 'bg-bg-card shadow-md text-text-main' : 'text-text-sec hover:text-text-main'"><BarChart2 :size="14"/> 统计</button>
</div>
</div>
</div>
<div class="flex-1 relative overflow-hidden">
<div v-show="viewMode === 'preview'" class="absolute inset-0 flex items-center justify-center p-12 bg-bg-input/30">
<img v-if="previewSrc" :src="previewSrc" class="max-w-full max-h-full object-contain rounded-3xl shadow-2xl border border-border-main/50" />
<div v-else class="text-text-sec text-center"><Maximize2 :size="48" class="mx-auto mb-4 opacity-20" /><p>在时间轴上滑动或拖拽以记录</p></div> <div v-else class="text-text-sec text-center"><Maximize2 :size="48" class="mx-auto mb-4 opacity-20" /><p>在时间轴上滑动或拖拽以记录</p></div>
</div> </div>
<div v-if="viewMode === 'dashboard'" class="absolute inset-0 overflow-y-auto no-scrollbar p-10 bg-bg-main animate-in fade-in zoom-in-95 duration-200">
<div class="max-w-3xl mx-auto">
<div class="flex justify-between items-center mb-10">
<div class="flex bg-bg-input rounded-xl p-1.5 gap-1 border border-border-main/50 shadow-inner">
<button @click="dashboardRange = 'today'" class="px-5 py-2 text-xs font-bold rounded-lg transition-all" :class="dashboardRange === 'today' ? 'bg-[#007AFF] shadow-lg text-white' : 'text-text-sec hover:text-text-main hover:bg-bg-card'">今日</button>
<button @click="dashboardRange = '7days'" class="px-5 py-2 text-xs font-bold rounded-lg transition-all" :class="dashboardRange === '7days' ? 'bg-[#007AFF] shadow-lg text-white' : 'text-text-sec hover:text-text-main hover:bg-bg-card'"> 7 </button>
<button @click="dashboardRange = '30days'" class="px-5 py-2 text-xs font-bold rounded-lg transition-all" :class="dashboardRange === '30days' ? 'bg-[#007AFF] shadow-lg text-white' : 'text-text-sec hover:text-text-main hover:bg-bg-card'"> 30 </button>
</div>
<div v-if="dashboardRange !== 'today'" class="flex bg-bg-input rounded-xl p-1 gap-1 border border-border-main/50 shadow-inner">
<button @click="dailyAverageMode = 'natural'" class="px-3 py-1.5 text-[10px] font-bold rounded-lg transition-all" :class="dailyAverageMode === 'natural' ? 'bg-bg-card shadow-sm text-text-main' : 'text-text-sec hover:text-text-main'">自然天数 ({{dashboardStats.naturalDays}})</button>
<button @click="dailyAverageMode = 'recorded'" class="px-3 py-1.5 text-[10px] font-bold rounded-lg transition-all" :class="dailyAverageMode === 'recorded' ? 'bg-bg-card shadow-sm text-text-main' : 'text-text-sec hover:text-text-main'">记录天数 ({{dashboardStats.recordedDays}})</button>
</div>
</div>
<div class="grid grid-cols-2 gap-6 mb-10">
<div class="bg-bg-card border border-border-main rounded-3xl p-6 shadow-sm flex flex-col justify-center">
<div class="text-xs font-bold text-text-sec mb-2">总记录时长</div>
<div class="text-4xl font-black text-text-main">{{ formatMinutes(dashboardStats.totalMinutes) }}</div>
</div>
<div class="bg-bg-card border border-border-main rounded-3xl p-6 shadow-sm flex flex-col justify-center">
<div class="text-xs font-bold text-text-sec mb-2">日均时长</div>
<div class="text-4xl font-black text-[#007AFF]">{{ formatMinutes(dashboardStats.dailyAverage) }}</div>
</div>
</div>
<div class="mb-12 bg-bg-card border border-border-main p-6 rounded-3xl shadow-sm">
<h3 class="text-sm font-bold text-text-sec mb-4 uppercase tracking-wider">占比总览</h3>
<div class="h-6 w-full bg-bg-input rounded-full overflow-hidden flex shadow-inner mb-4 border border-border-main/30">
<div v-for="tag in dashboardStats.mainTags" :key="tag.id" :style="{ width: tag.percentage + '%', backgroundColor: getTagColor(tag.id) }" class="h-full first:rounded-l-full last:rounded-r-full border-r border-bg-main/20 last:border-0 transition-all duration-500 hover:brightness-110 cursor-pointer" :title="getTagName(tag.id) + ' ' + tag.percentage.toFixed(1) + '%'"></div>
</div>
<div class="flex flex-wrap gap-4 px-2">
<div v-for="tag in dashboardStats.mainTags" :key="tag.id" class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full shadow-inner" :style="{ backgroundColor: getTagColor(tag.id) }"></div>
<span class="text-xs font-bold text-text-sec">{{ getTagName(tag.id) }} <span class="text-text-main ml-1">{{ tag.percentage.toFixed(0) }}%</span></span>
</div>
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-bold text-text-sec mb-4 uppercase tracking-wider">时间分布明细</h3>
<div v-for="tag in dashboardStats.mainTags" :key="tag.id" class="bg-bg-card border border-border-main rounded-3xl p-5 shadow-sm transition-all hover:border-border-main/80 hover:shadow-md">
<div class="flex justify-between items-center mb-1">
<div class="flex items-center gap-3">
<div class="w-4 h-4 rounded-full shadow-inner" :style="{ backgroundColor: getTagColor(tag.id) }"></div>
<span class="font-bold text-lg">{{ getTagName(tag.id) }}</span>
</div>
<div class="text-right">
<div class="font-black text-xl">{{ formatMinutes(tag.total) }}</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4 pl-7 text-[11px] font-bold text-text-sec">
<span class="bg-bg-input/50 px-2 py-1 rounded-md border border-border-main/30">占比 {{ tag.percentage.toFixed(1) }}%</span>
<span class="bg-bg-input/50 px-2 py-1 rounded-md border border-border-main/30 text-[#007AFF]">日均 {{ formatMinutes(tag.dailyAverage) }}</span>
</div>
<div v-if="tag.subTags.length > 0" class="pl-7 space-y-3 pt-4 border-t border-border-main/50">
<div v-for="sub in tag.subTags" :key="sub.id" class="flex justify-between items-center group gap-4">
<span class="text-xs text-text-main font-bold w-16 truncate">{{ getTagName(sub.id) }}</span>
<div class="flex items-center gap-3 flex-1 justify-end">
<div class="w-full max-w-50 h-1.5 bg-bg-input rounded-full overflow-hidden shadow-inner">
<div class="h-full rounded-full opacity-80" :style="{ width: sub.percentage + '%', backgroundColor: getTagColor(tag.id) }"></div>
</div>
<span class="text-[10px] font-bold text-text-sec whitespace-nowrap min-w-10 text-right">{{ formatMinutes(sub.total) }}</span>
</div>
</div>
</div>
</div>
<div v-if="dashboardStats.mainTags.length === 0" class="text-center py-20">
<BarChart2 :size="48" class="mx-auto mb-4 text-text-sec opacity-20" />
<div class="text-text-sec font-bold">该时间范围内没有记录数据</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -658,7 +848,6 @@ const handleExport = async () => {
<label class="text-[10px] font-bold text-text-sec block mb-1">开始时间</label> <label class="text-[10px] font-bold text-text-sec block mb-1">开始时间</label>
<button @click="openStartTimePicker" <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"> 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">
<!-- <Clock :size="14" class="text-text-sec block" /> -->
<span class="text-sm font-bold leading-none">{{ startTimeInput }}</span> <span class="text-sm font-bold leading-none">{{ startTimeInput }}</span>
</button> </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 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">
@@ -690,7 +879,6 @@ const handleExport = async () => {
<label class="text-[10px] font-bold text-text-sec block mb-1">结束时间</label> <label class="text-[10px] font-bold text-text-sec block mb-1">结束时间</label>
<button @click="openEndTimePicker" <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"> 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">
<!-- <Clock :size="14" class="text-text-sec block" /> -->
<span class="text-sm font-bold leading-none">{{ endTimeInput }}</span> <span class="text-sm font-bold leading-none">{{ endTimeInput }}</span>
</button> </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 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">
@@ -841,7 +1029,7 @@ const handleExport = async () => {
</div> </div>
<div v-if="isSettingsOpen" class="fixed inset-0 z-100 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isSettingsOpen = false"> <div v-if="isSettingsOpen" class="fixed inset-0 z-100 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isSettingsOpen = false">
<div class="bg-bg-card rounded-[40px] shadow-2xl w-full max-w-md overflow-hidden flex flex-col"> <div class="bg-bg-card rounded-[40px] shadow-2xl w-full max-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="isSettingsOpen = false"><X :size="24" /></button></div> <div class="p-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">设置</h2><button @click="isSettingsOpen = false"><X :size="24" /></button></div>
<div class="p-10 space-y-8"> <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(); updateSettings()" class="bg-bg-card border p-2 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button></div></div> <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(); updateSettings()" class="bg-bg-card border p-2 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button></div></div>