fix some bugs

This commit is contained in:
Julian Freeman
2026-03-26 18:13:32 -04:00
parent 8f8fe93f4e
commit 0e424c9afb
14 changed files with 434 additions and 13 deletions

View File

@@ -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>

View File

@@ -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
View File

@@ -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
View File

@@ -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"

View File

@@ -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"

View File

@@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"notification:default"
]
}

View File

@@ -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(())
}

View File

@@ -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);

View File

@@ -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| {

View File

@@ -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">

View 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>

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, 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 });
};

View File

@@ -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 });
};

View File

@@ -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;