Files
chrono-snap/src/App.vue
Julian Freeman b9db650e25 ui lang to zh-cn
2026-03-22 16:49:30 -04:00

387 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { load } from "@tauri-apps/plugin-store";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { FolderOpen, Settings, Play, Pause, Maximize2, X, RefreshCw } from "lucide-vue-next";
// --- Types & Constants ---
interface TimelineItem {
time: string; // "HH:mm:ss"
path: string;
}
const PIXELS_PER_MINUTE = 1.5; // Controls the vertical stretch of the timeline
const TOTAL_MINUTES = 24 * 60;
const RULER_HEIGHT = TOTAL_MINUTES * PIXELS_PER_MINUTE;
// --- State ---
const isSetupComplete = ref(false);
const savePath = ref("");
const isPaused = ref(false);
const currentDate = ref(new Date().toISOString().split("T")[0]);
const timelineImages = ref<TimelineItem[]>([]);
const selectedImage = ref<TimelineItem | null>(null);
const lockedImage = ref<TimelineItem | null>(null); // State for the clicked/locked point
const previewSrc = ref("");
const isFullscreen = ref(false);
const isSettingsOpen = ref(false);
const mergeScreens = ref(false);
const retainDays = ref(30);
const captureInterval = ref(30);
const hoveredTime = ref<string | null>(null);
const timelineRef = ref<HTMLElement | null>(null);
let store: any = null;
let captureUnlisten: any = null;
// --- Logic ---
onMounted(async () => {
store = await load("config.json");
const path = await store.get("savePath");
if (path) {
savePath.value = path as string;
isSetupComplete.value = true;
mergeScreens.value = (await store.get("mergeScreens")) as boolean || false;
retainDays.value = (await store.get("retainDays")) as number || 30;
captureInterval.value = (await store.get("captureInterval")) as number || 30;
// Sync initial interval to Rust
await invoke("update_interval", { seconds: captureInterval.value });
isPaused.value = await invoke("get_pause_state");
await loadTimeline();
}
await listen<boolean>("pause-state-changed", (event) => {
isPaused.value = event.payload;
});
// Listen for refresh triggers if any
captureUnlisten = await listen("refresh-timeline", () => {
loadTimeline();
});
});
onUnmounted(() => {
if (captureUnlisten) captureUnlisten();
});
const selectFolder = async () => {
const selected = await open({ directory: true, multiple: false });
if (selected && typeof selected === "string") {
savePath.value = selected;
await store.set("savePath", selected);
await store.save();
isSetupComplete.value = true;
await loadTimeline();
}
};
const updateSettings = async () => {
await store.set("mergeScreens", mergeScreens.value);
await store.set("retainDays", retainDays.value);
await store.set("captureInterval", captureInterval.value);
await store.save();
// Sync interval to Rust
await invoke("update_interval", { seconds: captureInterval.value });
};
const togglePauseState = async () => {
isPaused.value = await invoke("toggle_pause");
};
const loadTimeline = async () => {
if (!savePath.value) return;
timelineImages.value = await invoke("get_timeline", {
date: currentDate.value,
baseDir: savePath.value
});
};
// Internal function to update preview with caching/optimization
const updatePreview = async (img: TimelineItem | null) => {
if (!img) {
selectedImage.value = null;
previewSrc.value = "";
return;
}
if (selectedImage.value?.path === img.path && previewSrc.value) return;
selectedImage.value = img;
try {
const base64 = await invoke("get_image_base64", { path: img.path });
previewSrc.value = `data:image/jpeg;base64,${base64}`;
} catch (e) {
console.error("Failed to load image:", e);
}
};
// --- Timeline Helper Functions ---
const timeToMinutes = (timeStr: string) => {
const [h, m] = timeStr.split(":").map(Number);
return h * 60 + m;
};
const minutesToTime = (totalMinutes: number) => {
const h = Math.floor(totalMinutes / 60);
const m = Math.floor(totalMinutes % 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
const findClosestImage = (minutes: number) => {
if (timelineImages.value.length === 0) return null;
return timelineImages.value.reduce((prev, curr) => {
const prevDiff = Math.abs(timeToMinutes(prev.time) - minutes);
const currDiff = Math.abs(timeToMinutes(curr.time) - minutes);
return currDiff < prevDiff ? curr : prev;
}, timelineImages.value[0]);
};
const handleTimelineMouseMove = (e: MouseEvent) => {
if (!timelineRef.value) return;
const rect = timelineRef.value.getBoundingClientRect();
const y = e.clientY - rect.top + timelineRef.value.scrollTop;
const minutes = Math.floor(y / PIXELS_PER_MINUTE);
if (minutes >= 0 && minutes < TOTAL_MINUTES) {
hoveredTime.value = minutesToTime(minutes);
const closest = findClosestImage(minutes);
if (closest) {
updatePreview(closest);
}
}
};
const handleTimelineMouseLeave = () => {
hoveredTime.value = null;
// Revert to locked image if exists, otherwise keep current
if (lockedImage.value) {
updatePreview(lockedImage.value);
}
};
const handleTimelineClick = (e: MouseEvent) => {
if (!timelineRef.value) return;
const rect = timelineRef.value.getBoundingClientRect();
const y = e.clientY - rect.top + timelineRef.value.scrollTop;
const minutes = Math.floor(y / PIXELS_PER_MINUTE);
const closest = findClosestImage(minutes);
if (closest) {
lockedImage.value = closest;
updatePreview(closest);
}
};
</script>
<template>
<div class="h-screen w-screen flex flex-col overflow-hidden text-[#1D1D1F] bg-[#FBFBFD]">
<!-- Setup Wizard -->
<div v-if="!isSetupComplete" class="flex-1 flex items-center justify-center p-10">
<div class="bg-white p-12 rounded-[32px] shadow-2xl max-w-md text-center border border-[#E5E5E7]">
<div class="w-20 h-20 bg-[#007AFF]/10 rounded-3xl flex items-center justify-center mx-auto mb-8">
<FolderOpen :size="40" class="text-[#007AFF]" />
</div>
<h1 class="text-3xl font-bold mb-4 tracking-tight">初始化 Chrono Snap</h1>
<p class="text-[#86868B] mb-10 leading-relaxed">请选择一个本地文件夹用于安全地存储您的视觉历史记录</p>
<button
@click="selectFolder"
class="bg-[#007AFF] text-white px-8 py-4 rounded-2xl font-semibold hover:bg-[#0063CC] transition-all transform active:scale-95 shadow-lg shadow-[#007AFF]/20 w-full flex items-center justify-center gap-3"
>
选择存储目录
</button>
</div>
</div>
<!-- Main Workspace -->
<div v-else class="flex flex-1 overflow-hidden">
<!-- Vertical Ruler Sidebar -->
<div class="w-72 bg-[#F8FAFD] border-r border-[#E5E5E7] flex flex-col select-none relative">
<!-- Date Picker Header -->
<div class="p-6 bg-[#F8FAFD]/80 backdrop-blur-md sticky top-0 z-20 border-b border-[#E5E5E7]/50">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold tracking-tight">历史活动</h2>
<button @click="loadTimeline" class="p-2 hover:bg-white rounded-xl transition-colors text-[#86868B] hover:text-[#007AFF]">
<RefreshCw :size="18" />
</button>
</div>
<input
type="date"
v-model="currentDate"
@change="loadTimeline"
class="w-full bg-white border border-[#E5E5E7] rounded-xl px-4 py-2.5 text-sm font-medium focus:ring-2 focus:ring-[#007AFF]/20 focus:border-[#007AFF] outline-none transition-all"
/>
</div>
<!-- Scrollable Ruler -->
<div
ref="timelineRef"
class="flex-1 overflow-y-auto relative no-scrollbar hover:cursor-crosshair group"
@mousemove="handleTimelineMouseMove"
@mouseleave="handleTimelineMouseLeave"
@click="handleTimelineClick"
>
<!-- Ruler Canvas -->
<div :style="{ height: RULER_HEIGHT + 'px' }" class="relative ml-16">
<!-- Hour Markers -->
<div v-for="h in 24" :key="h"
class="absolute left-0 w-full border-t border-[#E5E5E7]/60 flex items-start"
:style="{ top: (h-1) * 60 * PIXELS_PER_MINUTE + 'px' }"
>
<span class="absolute -left-12 -top-2.5 text-[10px] font-bold text-[#86868B] tracking-tighter">
{{ String(h-1).padStart(2, '0') }}:00
</span>
</div>
<!-- Activity Bars -->
<div
v-for="img in timelineImages"
:key="img.path"
class="absolute left-1 right-8 h-1 bg-[#007AFF]/30 rounded-full transition-all"
:class="[
selectedImage?.path === img.path ? 'bg-[#007AFF]/60 h-2 shadow-sm z-10' : '',
lockedImage?.path === img.path ? 'bg-[#007AFF] h-2.5 ring-2 ring-[#007AFF]/20 z-20' : ''
]"
:style="{ top: timeToMinutes(img.time) * PIXELS_PER_MINUTE + 'px' }"
></div>
<!-- Hover Cursor Line -->
<div
v-if="hoveredTime"
class="absolute left-0 right-0 border-t-2 border-[#007AFF] z-30 pointer-events-none"
:style="{ top: timeToMinutes(hoveredTime) * PIXELS_PER_MINUTE + 'px' }"
>
<div class="absolute -left-14 -top-3 bg-[#007AFF] text-white text-[10px] px-1.5 py-0.5 rounded font-bold shadow-sm">
{{ hoveredTime }}
</div>
</div>
</div>
</div>
<!-- Footer Actions -->
<div class="p-4 border-t border-[#E5E5E7] bg-white/50 backdrop-blur-sm flex justify-around">
<button @click="togglePauseState" class="p-3 rounded-2xl hover:bg-[#F2F2F7] transition-colors" :class="isPaused ? 'text-[#FF3B30]' : 'text-[#86868B]'">
<Play v-if="isPaused" :size="22" /> <Pause v-else :size="22" />
</button>
<button @click="isSettingsOpen = true" class="p-3 rounded-2xl hover:bg-[#F2F2F7] transition-colors text-[#86868B]">
<Settings :size="22" />
</button>
</div>
</div>
<!-- Main Preview Area -->
<div class="flex-1 bg-[#FBFBFD] flex flex-col relative overflow-hidden">
<div class="p-6 flex items-center justify-between border-b border-[#E5E5E7]/50 bg-white/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 v-if="lockedImage?.path === selectedImage.path" class="text-xs font-bold px-2 py-0.5 bg-[#007AFF] text-white rounded-full uppercase tracking-wider">已定格</span>
<span v-else class="text-xs font-medium px-2 py-0.5 bg-[#F2F2F7] text-[#86868B] rounded-full uppercase tracking-wider">预览中</span>
</div>
<div v-else class="text-[#86868B] font-medium">未选中活动</div>
<button v-if="selectedImage" @click="isFullscreen = true" class="p-2.5 rounded-xl hover:bg-[#F2F2F7] transition-all text-[#86868B]">
<Maximize2 :size="20" />
</button>
</div>
<div class="flex-1 flex items-center justify-center p-12 overflow-hidden bg-[#F2F2F7]/30">
<div v-if="previewSrc" class="relative group max-w-full max-h-full">
<img
:src="previewSrc"
class="max-w-full max-h-full object-contain rounded-3xl shadow-2xl border border-white/50"
/>
</div>
<div v-else class="flex flex-col items-center gap-4 text-[#86868B]">
<div class="w-16 h-16 bg-[#E5E5E7] rounded-full flex items-center justify-center opacity-50">
<Maximize2 :size="30" />
</div>
<p class="font-medium">在时间轴上滑动以预览</p>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<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-white rounded-[40px] shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
<div class="p-8 border-b border-[#E5E5E7] flex justify-between items-center bg-[#FBFBFD]">
<h2 class="text-2xl font-bold">设置</h2>
<button @click="isSettingsOpen = false" class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-[#E5E5E7] transition-colors">
<X :size="24" />
</button>
</div>
<div class="p-10 space-y-8">
<div class="space-y-3">
<label class="text-sm font-bold text-[#86868B] uppercase tracking-widest ml-1">存储位置</label>
<div class="flex gap-3">
<input type="text" readonly :value="savePath" class="flex-1 bg-[#F2F2F7] rounded-2xl px-5 py-3.5 text-sm font-medium text-[#1D1D1F] outline-none" />
<button @click="selectFolder" class="bg-white border-2 border-[#E5E5E7] p-3.5 rounded-2xl hover:border-[#007AFF] transition-all"><FolderOpen :size="20" /></button>
</div>
</div>
<div class="flex items-center justify-between p-4 bg-[#F2F2F7] rounded-3xl">
<div class="space-y-0.5">
<div class="font-bold text-[#1D1D1F]">多屏合并</div>
<div class="text-xs font-medium text-[#86868B]">将所有屏幕拼接成一张图片</div>
</div>
<button @click="mergeScreens = !mergeScreens; updateSettings()" class="w-14 h-8 rounded-full transition-all relative shadow-inner" :class="mergeScreens ? 'bg-[#34C759]' : 'bg-[#E5E5E7]'">
<div class="absolute top-1 left-1 w-6 h-6 bg-white rounded-full transition-transform shadow-md" :style="{ transform: mergeScreens ? 'translateX(24px)' : 'translateX(0)' }"></div>
</button>
</div>
<div class="space-y-4">
<div class="flex justify-between items-end">
<label class="text-sm font-bold text-[#86868B] uppercase tracking-widest ml-1">截图间隔</label>
<span class="text-lg font-black text-[#007AFF]">{{ captureInterval }} </span>
</div>
<input type="range" v-model.number="captureInterval" min="10" max="600" step="10" @change="updateSettings" class="w-full h-2 bg-[#E5E5E7] rounded-full appearance-none cursor-pointer accent-[#007AFF]" />
<p class="text-[11px] text-[#86868B] font-medium leading-normal text-center">自动截图的频率10秒至10分钟</p>
</div>
<div class="space-y-4">
<div class="flex justify-between items-end">
<label class="text-sm font-bold text-[#86868B] uppercase tracking-widest ml-1">清理策略</label>
<span class="text-lg font-black text-[#007AFF]">{{ retainDays }} </span>
</div>
<input type="range" v-model.number="retainDays" min="1" max="180" @change="updateSettings" class="w-full h-2 bg-[#E5E5E7] rounded-full appearance-none cursor-pointer accent-[#007AFF]" />
<p class="text-[11px] text-[#86868B] font-medium leading-normal text-center">超过此天数的截图将被自动永久删除</p>
</div>
</div>
</div>
</div>
<!-- Fullscreen Modal -->
<div v-if="isFullscreen && previewSrc" class="fixed inset-0 z-[200] bg-black/95 flex items-center justify-center p-6 backdrop-blur-xl animate-in fade-in duration-300">
<button @click="isFullscreen = false" class="absolute top-10 right-10 w-14 h-14 flex items-center justify-center bg-white/10 text-white rounded-full hover:bg-white/20 transition-all backdrop-blur-md">
<X :size="36" />
</button>
<img :src="previewSrc" class="max-w-full max-h-full object-contain rounded-lg shadow-2xl" />
</div>
</div>
</template>
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.1s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 24px;
width: 24px;
border-radius: 50%;
background: #ffffff;
border: 4px solid #007AFF;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
margin-top: -11px;
}
</style>