fix some bugs
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chrono Snap</title>
|
||||
</head>
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
67
src-tauri/Cargo.lock
generated
67
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<Vec<Reminder>> {
|
||||
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<i64> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<Vec<crate::db::Reminder>, 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<i64, String> {
|
||||
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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
78
src/App.vue
78
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<HTMLElement | null>(null);
|
||||
@@ -42,7 +45,6 @@ const dragStartMin = ref<number | null>(null);
|
||||
const dragEndMin = ref<number | null>(null);
|
||||
const hoveredTime = ref<string | null>(null);
|
||||
const hoveredEventDetails = ref<{ event: DBEvent; x: number; y: number } | null>(null);
|
||||
const currentLogicalMinute = ref(-1);
|
||||
|
||||
const editingEvent = ref<DBEvent>({ 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<string>("savePath");
|
||||
@@ -119,7 +156,7 @@ onMounted(async () => {
|
||||
await invoke("update_interval", { seconds: captureInterval.value });
|
||||
isPaused.value = await invoke<boolean>("get_pause_state");
|
||||
|
||||
await loadTimeline(true); await loadTags(); await loadEvents();
|
||||
await loadTimeline(true); await loadTags(); await loadEvents(); await loadReminders();
|
||||
}
|
||||
|
||||
await listen<boolean>("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 () => {
|
||||
<!-- Sidebar -->
|
||||
<div class="w-80 bg-bg-sidebar border-r border-border-main flex flex-col select-none relative">
|
||||
<div class="p-6 bg-bg-sidebar/80 backdrop-blur-md sticky top-0 z-100 border-b border-border-main/50">
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-lg font-bold">瞬影 - 时间记录</h2><button @click="loadTimeline(true); loadEvents()" class="p-2 hover:bg-bg-card rounded-xl text-text-sec"><RefreshCw :size="18" /></button></div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold">瞬影 - 时间记录</h2>
|
||||
<div class="flex gap-1">
|
||||
<button @click="isReminderManagerOpen = true" class="relative p-2 hover:bg-bg-card rounded-xl text-text-sec">
|
||||
<Bell :size="18" />
|
||||
<span v-if="reminders.some(r => !r.is_completed)" class="absolute top-1.5 right-1.5 w-2 h-2 bg-[#007AFF] rounded-full border border-bg-sidebar"></span>
|
||||
</button>
|
||||
<button @click="loadTimeline(true); loadEvents(); loadReminders()" class="p-2 hover:bg-bg-card rounded-xl text-text-sec"><RefreshCw :size="18" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="calendarRef" class="relative">
|
||||
<button @click="isCalendarOpen = !isCalendarOpen" class="w-full bg-bg-card border border-border-main rounded-xl pl-11 pr-4 py-2.5 text-sm font-bold text-left flex items-center hover:bg-bg-input">
|
||||
<Calendar :size="16" class="absolute left-4 text-text-sec" />{{ currentDate }}
|
||||
@@ -355,6 +403,15 @@ const togglePause = async () => {
|
||||
<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>
|
||||
|
||||
<!-- Reminder Markers -->
|
||||
<div v-for="r in reminders" :key="'rem-'+r.id" class="absolute left-0 w-2 h-0.5 rounded-r-full z-40 transition-all"
|
||||
:class="r.is_completed ? 'bg-text-sec/30' :
|
||||
(currentDate < getLogicDateStr() || (currentDate === getLogicDateStr() && r.minute < currentLogicalMinute)
|
||||
? 'bg-[#FF3B30] shadow-[0_0_8px_rgba(255,59,48,0.6)]'
|
||||
: 'bg-[#007AFF] shadow-[0_0_8px_rgba(0,122,255,0.6)]')"
|
||||
:style="{ top: r.minute * timelineZoom + 'px' }"></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-8 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" @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>
|
||||
@@ -399,6 +456,7 @@ const togglePause = async () => {
|
||||
<TagManager v-if="isTagManagerOpen" @close="isTagManagerOpen = false" />
|
||||
<ExportModal v-if="isExportModalOpen" @close="isExportModalOpen = false" />
|
||||
<SettingsModal v-if="isSettingsOpen" @close="isSettingsOpen = false" />
|
||||
<ReminderManager v-if="isReminderManagerOpen" @close="isReminderManagerOpen = false" />
|
||||
|
||||
<!-- Event Modal -->
|
||||
<div v-if="isEventModalOpen" class="fixed inset-0 z-110 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isEventModalOpen = false">
|
||||
|
||||
167
src/components/modals/ReminderManager.vue
Normal file
167
src/components/modals/ReminderManager.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { X, Plus, Trash2, Check, Clock, Calendar } from "lucide-vue-next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { reminders, loadReminders, showToast, currentDate, logicalMinutesToTime, logicalMinutesFromTime, currentLogicalMinute, getLogicDateStr, TIME_OFFSET_MINUTES } from "../../store";
|
||||
import { Reminder } from "../../types";
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const getInitialMinute = () => {
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 10 * 60000);
|
||||
const h = later.getHours();
|
||||
const m = later.getMinutes();
|
||||
return logicalMinutesFromTime(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
const newContent = ref("");
|
||||
const newMinute = ref(getInitialMinute());
|
||||
|
||||
const isTimePickerOpen = ref(false);
|
||||
const timePickerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const openTimePicker = async () => {
|
||||
isTimePickerOpen.value = !isTimePickerOpen.value;
|
||||
if (isTimePickerOpen.value) {
|
||||
await nextTick();
|
||||
const activeItems = timePickerRef.value?.querySelectorAll('.bg-\\[\\#007AFF\\].text-white');
|
||||
activeItems?.forEach(el => el.scrollIntoView({ block: 'center' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (isTimePickerOpen.value && timePickerRef.value && !timePickerRef.value.contains(target)) {
|
||||
isTimePickerOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener('mousedown', handleClickOutside));
|
||||
onUnmounted(() => window.removeEventListener('mousedown', handleClickOutside));
|
||||
|
||||
const addReminder = async () => {
|
||||
if (!newContent.value.trim()) {
|
||||
showToast("请输入提醒内容", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke("save_reminder", {
|
||||
reminder: { id: 0, date: currentDate.value, minute: newMinute.value, content: newContent.value, is_completed: false }
|
||||
});
|
||||
newContent.value = "";
|
||||
await loadReminders();
|
||||
showToast("提醒已添加");
|
||||
} catch (e) {
|
||||
showToast("添加失败: " + e, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (r: Reminder) => {
|
||||
try {
|
||||
await invoke("toggle_reminder", { id: r.id, isCompleted: !r.is_completed });
|
||||
await loadReminders();
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const deleteItem = async (id: number) => {
|
||||
try {
|
||||
await invoke("delete_reminder", { id });
|
||||
await loadReminders();
|
||||
showToast("提醒已删除");
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const safeReminders = computed(() => Array.isArray(reminders.value) ? reminders.value : []);
|
||||
|
||||
const isPast = computed(() => currentDate.value < getLogicDateStr());
|
||||
const isToday = computed(() => currentDate.value === getLogicDateStr());
|
||||
|
||||
const overdueReminders = computed(() => safeReminders.value.filter(r =>
|
||||
!r.is_completed && (isPast.value || (isToday.value && r.minute < currentLogicalMinute.value))
|
||||
));
|
||||
|
||||
const upcomingReminders = computed(() => safeReminders.value.filter(r =>
|
||||
!r.is_completed && (!isPast.value && (!isToday.value || r.minute >= currentLogicalMinute.value))
|
||||
));
|
||||
|
||||
const completedReminders = computed(() => safeReminders.value.filter(r => r.is_completed));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-110 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-w-md overflow-hidden flex flex-col h-[80vh] max-h-175">
|
||||
<div class="p-8 border-b border-border-main/50 flex justify-between items-center bg-bg-card/80 backdrop-blur-md sticky top-0 z-10">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-3"><Clock :size="24" class="text-[#007AFF]"/> 提醒事项</h2>
|
||||
<button @click="emit('close')" class="p-2 hover:bg-bg-input rounded-xl text-text-sec"><X :size="24" /></button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-b border-border-main/50 bg-bg-input/30">
|
||||
<form @submit.prevent="addReminder" class="flex items-center gap-3">
|
||||
<div ref="timePickerRef" class="relative">
|
||||
<button type="button" @click="openTimePicker" class="bg-bg-card border border-border-main rounded-xl px-4 py-3 text-sm font-bold flex items-center justify-center hover:bg-bg-hover transition-all focus:border-[#007AFF]/50 w-24 outline-none">
|
||||
{{ logicalMinutesToTime(newMinute) }}
|
||||
</button>
|
||||
<div v-if="isTimePickerOpen" class="absolute top-full left-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 w-48">
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1 text-center">
|
||||
<button type="button" v-for="h in 24" :key="h" @click="newMinute = logicalMinutesFromTime(`${String((h-1+3)%24).padStart(2,'0')}:${String((newMinute + TIME_OFFSET_MINUTES) % 60).padStart(2,'0')}`)" class="py-2 text-xs rounded-lg transition-colors w-full text-center" :class="Math.floor((newMinute + 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 text-center">
|
||||
<button type="button" v-for="m in 60" :key="m" @click="newMinute = Math.floor(newMinute / 60) * 60 + (m-1); isTimePickerOpen = false" class="py-2 text-xs rounded-lg transition-colors w-full text-center" :class="newMinute % 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>
|
||||
<input type="text" v-model="newContent" placeholder="添加新提醒..." class="flex-1 bg-bg-card border border-border-main rounded-xl px-4 py-3 text-sm font-bold outline-none focus:border-[#007AFF]/50" />
|
||||
<button type="submit" class="bg-[#007AFF] text-white p-3 rounded-xl hover:brightness-110 transition-all shadow-md shadow-[#007AFF]/20"><Plus :size="20" /></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6 no-scrollbar">
|
||||
<div v-if="overdueReminders.length > 0">
|
||||
<h3 class="text-xs font-bold text-[#FF3B30] mb-3 uppercase tracking-wider">已超时未办</h3>
|
||||
<div class="space-y-2 mb-6">
|
||||
<div v-for="r in overdueReminders" :key="r.id" class="flex items-center gap-3 bg-[#FF3B30]/10 p-3 rounded-2xl border border-[#FF3B30]/30 group transition-all hover:bg-[#FF3B30]/20 hover:shadow-sm">
|
||||
<button @click="toggleStatus(r)" class="w-6 h-6 rounded-full border-2 border-[#FF3B30]/50 flex items-center justify-center text-transparent hover:border-[#FF3B30] transition-all"><Check :size="14" class="opacity-0 group-hover:opacity-50 group-hover:text-[#FF3B30]"/></button>
|
||||
<div class="font-bold text-[#FF3B30] text-sm w-12">{{ logicalMinutesToTime(r.minute) }}</div>
|
||||
<div class="flex-1 font-medium text-sm text-[#FF3B30] truncate">{{ r.content }}</div>
|
||||
<button @click="deleteItem(r.id)" class="p-2 text-text-sec hover:text-[#FF3B30] opacity-0 group-hover:opacity-100 transition-all rounded-lg hover:bg-[#FF3B30]/20"><Trash2 :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="upcomingReminders.length > 0">
|
||||
<h3 class="text-xs font-bold text-text-sec mb-3 uppercase tracking-wider">即将到来</h3>
|
||||
<div class="space-y-2 mb-6">
|
||||
<div v-for="r in upcomingReminders" :key="r.id" class="flex items-center gap-3 bg-bg-input/50 p-3 rounded-2xl border border-border-main/30 group transition-all hover:bg-bg-input hover:shadow-sm">
|
||||
<button @click="toggleStatus(r)" class="w-6 h-6 rounded-full border-2 border-border-main flex items-center justify-center text-transparent hover:border-[#007AFF] transition-all"><Check :size="14" class="opacity-0 group-hover:opacity-30 group-hover:text-[#007AFF]"/></button>
|
||||
<div class="font-bold text-[#007AFF] text-sm w-12">{{ logicalMinutesToTime(r.minute) }}</div>
|
||||
<div class="flex-1 font-medium text-sm text-text-main truncate">{{ r.content }}</div>
|
||||
<button @click="deleteItem(r.id)" class="p-2 text-text-sec hover:text-[#FF3B30] opacity-0 group-hover:opacity-100 transition-all rounded-lg hover:bg-[#FF3B30]/10"><Trash2 :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="completedReminders.length > 0">
|
||||
<h3 class="text-xs font-bold text-text-sec mb-3 uppercase tracking-wider">已完成</h3>
|
||||
<div class="space-y-2 opacity-60">
|
||||
<div v-for="r in completedReminders" :key="r.id" class="flex items-center gap-3 bg-bg-input/30 p-3 rounded-2xl border border-transparent group transition-all">
|
||||
<button @click="toggleStatus(r)" class="w-6 h-6 rounded-full bg-[#007AFF] flex items-center justify-center text-white"><Check :size="14" /></button>
|
||||
<div class="font-bold text-text-sec text-sm w-12 line-through">{{ logicalMinutesToTime(r.minute) }}</div>
|
||||
<div class="flex-1 font-medium text-sm text-text-sec line-through truncate">{{ r.content }}</div>
|
||||
<button @click="deleteItem(r.id)" class="p-2 text-text-sec hover:text-[#FF3B30] opacity-0 group-hover:opacity-100 transition-all rounded-lg hover:bg-[#FF3B30]/10"><Trash2 :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="overdueReminders.length === 0 && upcomingReminders.length === 0 && completedReminders.length === 0" class="flex flex-col items-center justify-center h-full text-text-sec opacity-40 mt-10">
|
||||
<Calendar :size="48" class="mb-4" />
|
||||
<p class="font-bold">今天没有提醒事项</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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, viewMode, refreshSignal } from "./index";
|
||||
import { currentDate, getTagColor, getTagName, formatMinutes, parseISODate, toISODate, viewMode, refreshSignal, dbPath } from "./index";
|
||||
|
||||
// Re-export for easier access in Dashboard component
|
||||
export { getTagColor, getTagName, formatMinutes };
|
||||
@@ -20,6 +20,7 @@ export const dashboardEvents = ref<DBEvent[]>([]);
|
||||
export const dailyAverageMode = ref<'natural' | 'recorded'>('natural');
|
||||
|
||||
export const loadDashboardEvents = async () => {
|
||||
if (!dbPath.value) return; // Wait until DB path is initialized
|
||||
dashboardEvents.value = await invoke("get_events_range", { startDate: dashboardStartDate.value, endDate: dashboardEndDate.value });
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Tag, DBEvent, TimelineItem, Toast } from "../types";
|
||||
import { Tag, DBEvent, TimelineItem, Toast, Reminder } from "../types";
|
||||
|
||||
export const TIME_OFFSET_MINUTES = 180;
|
||||
export const TOTAL_MINUTES = 1440;
|
||||
@@ -39,6 +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 currentLogicalMinute = ref(-1); // Exported to keep track of current minute globally
|
||||
export const viewMode = ref<'preview' | 'dashboard'>('dashboard');
|
||||
export const isFullscreen = ref(false);
|
||||
export const previewSrc = ref("");
|
||||
@@ -48,6 +49,7 @@ export const lockedImage = ref<TimelineItem | null>(null);
|
||||
// --- Data State ---
|
||||
export const tags = ref<Tag[]>([]);
|
||||
export const dayEvents = ref<DBEvent[]>([]);
|
||||
export const reminders = ref<Reminder[]>([]);
|
||||
export const timelineImages = ref<TimelineItem[]>([]);
|
||||
export const refreshSignal = ref(0); // Counter to trigger dashboard refreshes
|
||||
|
||||
@@ -107,3 +109,6 @@ export const loadEvents = async () => {
|
||||
dayEvents.value = await invoke("get_events", { date: currentDate.value });
|
||||
refreshSignal.value++; // Increment to signal other stores to refresh
|
||||
};
|
||||
export const loadReminders = async () => {
|
||||
reminders.value = await invoke("get_reminders", { date: currentDate.value });
|
||||
};
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface DBEvent {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Reminder {
|
||||
id: number;
|
||||
date: string;
|
||||
minute: number;
|
||||
content: string;
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineItem {
|
||||
time: string;
|
||||
path: string;
|
||||
|
||||
Reference in New Issue
Block a user