diff --git a/index.html b/index.html index cd7848f..db740cc 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + Chrono Snap diff --git a/package.json b/package.json index 690f0d4..b0d1031 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tauri-apps/plugin-autostart": "~2.5.1", "@tauri-apps/plugin-dialog": "~2.6.0", "@tauri-apps/plugin-fs": "~2.4.5", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-store": "~2.4.2", "lucide-vue-next": "^0.577.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 022b861..d8682ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@tauri-apps/plugin-fs': specifier: ~2.4.5 version: 2.4.5 + '@tauri-apps/plugin-notification': + specifier: ^2.3.3 + version: 2.3.3 '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 @@ -572,6 +575,9 @@ packages: '@tauri-apps/plugin-fs@2.4.5': resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==} + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -1245,6 +1251,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b870c31..e47e11e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -681,6 +681,7 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-store", "tokio", @@ -2738,6 +2739,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2957,6 +2970,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3901,6 +3928,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -5151,6 +5187,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -5289,6 +5344,18 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a86051c..2907a19 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "time"] } anyhow = "1.0.102" base64 = "0.22.1" rusqlite = { version = "0.39.0", features = ["bundled"] } +tauri-plugin-notification = "2.3.3" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..4625545 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "opener:default", + "notification:default" ] } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b4e7c0b..6525164 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -20,6 +20,15 @@ pub struct Event { pub content: String, } +#[derive(Serialize, Deserialize, Clone)] +pub struct Reminder { + pub id: i64, + pub date: String, + pub minute: i32, + pub content: String, + pub is_completed: bool, +} + pub fn init_db(path: &str) -> anyhow::Result<()> { let conn = Connection::open(path)?; @@ -49,6 +58,17 @@ pub fn init_db(path: &str) -> anyhow::Result<()> { [], )?; + conn.execute( + "CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + minute INTEGER NOT NULL, + content TEXT NOT NULL, + is_completed BOOLEAN NOT NULL DEFAULT 0 + )", + [], + )?; + Ok(()) } @@ -152,3 +172,51 @@ pub fn delete_event(path: &str, id: i64) -> anyhow::Result<()> { conn.execute("DELETE FROM events WHERE id = ?1", params![id])?; Ok(()) } + +pub fn get_reminders(path: &str, date: &str) -> anyhow::Result> { + let conn = Connection::open(path)?; + let mut stmt = conn.prepare("SELECT id, date, minute, content, is_completed FROM reminders WHERE date = ?1 ORDER BY minute")?; + let iter = stmt.query_map(params![date], |row| { + Ok(Reminder { + id: row.get(0)?, + date: row.get(1)?, + minute: row.get(2)?, + content: row.get(3)?, + is_completed: row.get(4)?, + }) + })?; + let mut reminders = Vec::new(); + for r in iter { + reminders.push(r?); + } + Ok(reminders) +} + +pub fn save_reminder(path: &str, reminder: Reminder) -> anyhow::Result { + let conn = Connection::open(path)?; + if reminder.id > 0 { + conn.execute( + "UPDATE reminders SET date=?1, minute=?2, content=?3, is_completed=?4 WHERE id=?5", + params![reminder.date, reminder.minute, reminder.content, reminder.is_completed, reminder.id], + )?; + Ok(reminder.id) + } else { + conn.execute( + "INSERT INTO reminders (date, minute, content, is_completed) VALUES (?1, ?2, ?3, ?4)", + params![reminder.date, reminder.minute, reminder.content, reminder.is_completed], + )?; + Ok(conn.last_insert_rowid()) + } +} + +pub fn delete_reminder(path: &str, id: i64) -> anyhow::Result<()> { + let conn = Connection::open(path)?; + conn.execute("DELETE FROM reminders WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn toggle_reminder(path: &str, id: i64, is_completed: bool) -> anyhow::Result<()> { + let conn = Connection::open(path)?; + conn.execute("UPDATE reminders SET is_completed=?1 WHERE id=?2", params![is_completed, id])?; + Ok(()) +} diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 700d54f..415dfa1 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -72,6 +72,34 @@ pub fn delete_event(state: tauri::State<'_, AppState>, id: i64) -> Result<(), St crate::db::delete_event(path, id).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_reminders(state: tauri::State<'_, AppState>, date: String) -> Result, String> { + let path = state.db_path.lock().unwrap(); + let path = path.as_ref().ok_or("Database path not set")?; + crate::db::get_reminders(path, &date).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn save_reminder(state: tauri::State<'_, AppState>, reminder: crate::db::Reminder) -> Result { + let path = state.db_path.lock().unwrap(); + let path = path.as_ref().ok_or("Database path not set")?; + crate::db::save_reminder(path, reminder).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_reminder(state: tauri::State<'_, AppState>, id: i64) -> Result<(), String> { + let path = state.db_path.lock().unwrap(); + let path = path.as_ref().ok_or("Database path not set")?; + crate::db::delete_reminder(path, id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn toggle_reminder(state: tauri::State<'_, AppState>, id: i64, is_completed: bool) -> Result<(), String> { + let path = state.db_path.lock().unwrap(); + let path = path.as_ref().ok_or("Database path not set")?; + crate::db::toggle_reminder(path, id, is_completed).map_err(|e| e.to_string()) +} + #[tauri::command] pub fn update_interval(state: tauri::State<'_, AppState>, seconds: u64) { state.capture_interval_secs.store(seconds, Ordering::SeqCst); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4305e31..9fead5c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, @@ -31,6 +32,10 @@ pub fn run() { engine::get_events_range, engine::save_event, engine::delete_event, + engine::get_reminders, + engine::save_reminder, + engine::delete_reminder, + engine::toggle_reminder, engine::write_file ]) .setup(|app| { diff --git a/src/App.vue b/src/App.vue index 9eec411..2a9c052 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,17 +3,18 @@ import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue"; import { load as loadStore } from "@tauri-apps/plugin-store"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification"; import { - RefreshCw, Calendar, ChevronLeft, ChevronRight, Play, Pause, SquarePlus, Tag as TagIcon, Download, Settings, Maximize2, X, Image as ImageIcon, BarChart2, Trash2 + RefreshCw, Calendar, ChevronLeft, ChevronRight, Play, Pause, SquarePlus, Tag as TagIcon, Download, Settings, Maximize2, X, Image as ImageIcon, BarChart2, Trash2, Bell } from "lucide-vue-next"; import { - isSetupComplete, savePath, dbPath, isPaused, currentDate, timelineImages, selectedImage, lockedImage, - previewSrc, isFullscreen, dayEvents, toast, showToast, timelineZoom, theme, viewMode, + isSetupComplete, savePath, dbPath, isPaused, currentDate, currentLogicalMinute, getLogicDateStr, timelineImages, selectedImage, lockedImage, + previewSrc, isFullscreen, dayEvents, reminders, toast, showToast, timelineZoom, theme, viewMode, retainDays, captureInterval, TIME_OFFSET_MINUTES, TOTAL_MINUTES, getTagColor, getTagName, mainTags, getSubTags, logicalMinutesToTime, logicalMinutesFromTime, formatDuration, - loadTags, loadEvents, toISODate + loadTags, loadEvents, loadReminders, toISODate } from "./store"; import { DBEvent, TimelineItem } from "./types"; @@ -24,6 +25,7 @@ import Dashboard from "./components/views/Dashboard.vue"; import TagManager from "./components/modals/TagManager.vue"; import ExportModal from "./components/modals/ExportModal.vue"; import SettingsModal from "./components/modals/SettingsModal.vue"; +import ReminderManager from "./components/modals/ReminderManager.vue"; // --- Local State (Timeline UI) --- const isSettingsOpen = ref(false); @@ -31,6 +33,7 @@ const isTagManagerOpen = ref(false); const isExportModalOpen = ref(false); const isEventModalOpen = ref(false); const isCalendarOpen = ref(false); +const isReminderManagerOpen = ref(false); const isMouseOverTimeline = ref(false); // Track mouse state to prevent race conditions const calendarRef = ref(null); @@ -42,7 +45,6 @@ const dragStartMin = ref(null); const dragEndMin = ref(null); const hoveredTime = ref(null); const hoveredEventDetails = ref<{ event: DBEvent; x: number; y: number } | null>(null); -const currentLogicalMinute = ref(-1); const editingEvent = ref({ id: 0, date: "", start_minute: 0, end_minute: 0, main_tag_id: 0, sub_tag_id: null, content: "" }); @@ -72,29 +74,64 @@ const openEndTimePicker = async () => { } }; +// --- Reminders Check Logic --- +let lastCheckedMinute = -1; +const checkReminders = async (currentMin: number) => { + if (lastCheckedMinute === currentMin) return; + lastCheckedMinute = currentMin; + + const dueReminders = reminders.value.filter(r => !r.is_completed && r.minute === currentMin); + if (dueReminders.length > 0) { + let hasPermission = await isPermissionGranted(); + if (!hasPermission) { + const permission = await requestPermission(); + hasPermission = permission === 'granted'; + } + + for (const r of dueReminders) { + showToast(`⏰ 提醒: ${r.content}`); + if (hasPermission) { + sendNotification({ title: '瞬影 提醒', body: r.content }); + } + } + } +}; + // --- Logic --- const updateCurrentMinute = () => { const now = new Date(); const logicalDateStr = new Date(now.getTime() - TIME_OFFSET_MINUTES * 60000).toLocaleDateString('sv'); if (logicalDateStr === currentDate.value) { const m = now.getHours() * 60 + now.getMinutes(); - currentLogicalMinute.value = (m < TIME_OFFSET_MINUTES ? m + 1440 : m) - TIME_OFFSET_MINUTES; + const min = (m < TIME_OFFSET_MINUTES ? m + 1440 : m) - TIME_OFFSET_MINUTES; + currentLogicalMinute.value = min; + checkReminders(min); } else { currentLogicalMinute.value = -1; } }; +let currentMinuteTimeout: number | null = null; let currentMinuteInterval: number | null = null; let captureUnlisten: any = null; +const startMinuteTimer = () => { + updateCurrentMinute(); + const now = new Date(); + const msUntilNextMinute = 60000 - (now.getSeconds() * 1000 + now.getMilliseconds()); + currentMinuteTimeout = window.setTimeout(() => { + updateCurrentMinute(); + currentMinuteInterval = window.setInterval(updateCurrentMinute, 60000); + }, msUntilNextMinute); +}; + watch(currentDate, () => { updateCurrentMinute(); }); onMounted(async () => { window.addEventListener('mousedown', handleClickOutside); - updateCurrentMinute(); - currentMinuteInterval = window.setInterval(updateCurrentMinute, 60000); + startMinuteTimer(); const store = await loadStore("config.json"); const path = await store.get("savePath"); @@ -119,7 +156,7 @@ onMounted(async () => { await invoke("update_interval", { seconds: captureInterval.value }); isPaused.value = await invoke("get_pause_state"); - await loadTimeline(true); await loadTags(); await loadEvents(); + await loadTimeline(true); await loadTags(); await loadEvents(); await loadReminders(); } await listen("pause-state-changed", (event) => { isPaused.value = event.payload; }); @@ -128,6 +165,7 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('mousedown', handleClickOutside); + if (currentMinuteTimeout) window.clearTimeout(currentMinuteTimeout); if (currentMinuteInterval) window.clearInterval(currentMinuteInterval); if (captureUnlisten) captureUnlisten(); }); @@ -284,6 +322,7 @@ const selectCalendarDate = (date: Date) => { updateCurrentMinute(); loadTimeline(true); loadEvents(); + loadReminders(); }; const calendarDays = computed(() => { @@ -329,7 +368,16 @@ const togglePause = async () => {
-

瞬影 - 时间记录

+
+

瞬影 - 时间记录

+
+ + +
+