14 Commits
v0.2.0 ... main

Author SHA1 Message Date
Julian Freeman
0e39ad7ed8 upgrade cal 2026-03-27 12:48:14 -04:00
Julian Freeman
07faa81d21 opt 2026-03-27 12:25:46 -04:00
Julian Freeman
2d51467bb9 support badge 2026-03-27 12:09:13 -04:00
Julian Freeman
c4366e62e1 upgrade 2026-03-26 21:07:50 -04:00
Julian Freeman
42be9062b4 add export to json 2026-03-26 21:02:59 -04:00
Julian Freeman
b1b63a9949 custon scrollbar 2026-03-26 20:48:08 -04:00
Julian Freeman
3fe7f013dc support resize 2026-03-26 20:45:46 -04:00
Julian Freeman
5f14a0dd10 support copy col 2026-03-26 20:35:46 -04:00
Julian Freeman
b838ce7aa8 fix bug 2026-03-26 20:24:55 -04:00
Julian Freeman
97ba35c4c4 support export table 2026-03-26 20:15:24 -04:00
Julian Freeman
5b4abdeae7 upgrade 2026-03-26 19:31:26 -04:00
Julian Freeman
10583fd882 improve 2026-03-26 19:23:22 -04:00
Julian Freeman
0e424c9afb fix some bugs 2026-03-26 18:48:07 -04:00
Julian Freeman
8f8fe93f4e fix animi 2026-03-26 17:49:56 -04:00
17 changed files with 1012 additions and 47 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

@@ -1,7 +1,7 @@
{
"name": "chrono-snap",
"private": true,
"version": "0.2.0",
"version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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

69
src-tauri/Cargo.lock generated
View File

@@ -667,7 +667,7 @@ dependencies = [
[[package]]
name = "chrono-snap"
version = "0.2.0"
version = "0.2.3"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -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

@@ -1,6 +1,6 @@
[package]
name = "chrono-snap"
version = "0.2.0"
version = "0.2.3"
description = "An app to record screens and events"
authors = ["you"]
edition = "2021"
@@ -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,8 @@
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
"core:window:allow-set-overlay-icon",
"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,92 @@ 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(())
}
pub fn get_overdue_reminders_count(path: &str, date: &str, minute: i32) -> anyhow::Result<i32> {
let conn = Connection::open(path)?;
let mut stmt = conn.prepare("SELECT COUNT(*) FROM reminders WHERE is_completed = 0 AND (date < ?1 OR (date = ?1 AND minute < ?2))")?;
let count: i32 = stmt.query_row(params![date, minute], |row| row.get(0))?;
Ok(count)
}
#[derive(Serialize, Deserialize)]
pub struct DayStatus {
pub date: String,
pub has_overdue: bool,
pub has_upcoming: bool,
}
pub fn get_reminders_by_month(path: &str, year_month: &str, today: &str, now_minute: i32) -> anyhow::Result<Vec<DayStatus>> {
let conn = Connection::open(path)?;
// 查找该月份内有提醒的所有日期
let mut stmt = conn.prepare("
SELECT date,
MAX(CASE WHEN is_completed = 0 AND (date < ?2 OR (date = ?2 AND minute < ?3)) THEN 1 ELSE 0 END) as has_overdue,
MAX(CASE WHEN is_completed = 0 AND (date > ?2 OR (date = ?2 AND minute >= ?3)) THEN 1 ELSE 0 END) as has_upcoming
FROM reminders
WHERE date LIKE ?1
GROUP BY date
")?;
let rows = stmt.query_map(params![format!("{}%", year_month), today, now_minute], |row| {
Ok(DayStatus {
date: row.get(0)?,
has_overdue: row.get::<_, i32>(1)? == 1,
has_upcoming: row.get::<_, i32>(2)? == 1,
})
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}

View File

@@ -72,6 +72,48 @@ 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 get_overdue_reminders_count(state: tauri::State<'_, AppState>, date: String, minute: i32) -> Result<i32, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_overdue_reminders_count(path, &date, minute).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_reminders_by_month(state: tauri::State<'_, AppState>, year_month: String, today: String, now_minute: i32) -> Result<Vec<crate::db::DayStatus>, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_reminders_by_month(path, &year_month, &today, now_minute).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,12 @@ 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::get_overdue_reminders_count,
engine::get_reminders_by_month,
engine::write_file
])
.setup(|app| {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "chrono-snap",
"version": "0.2.0",
"productName": "ChronoSnap",
"version": "0.2.3",
"identifier": "top.volan.chrono-snap",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "瞬影 - 时间记录 v0.2.0",
"title": "瞬影 - 时间记录 v0.2.3",
"width": 1760,
"height": 1100
}

View File

@@ -3,17 +3,19 @@ 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, refreshBadgeCount,
calendarStatus, loadCalendarStatus, currentCalendarMonthStr
} from "./store";
import { DBEvent, TimelineItem } from "./types";
@@ -24,6 +26,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 +34,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 +46,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 +75,72 @@ 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) {
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;
}
// 无论是否看今天,每分钟都尝试刷新全局徽章和日历状态点
refreshBadgeCount();
};
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(calendarMonth, (newMonth) => {
const y = newMonth.getFullYear();
const m = String(newMonth.getMonth() + 1).padStart(2, '0');
currentCalendarMonthStr.value = `${y}-${m}`;
loadCalendarStatus(currentCalendarMonthStr.value);
}, { immediate: true });
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 +165,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 +174,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('mousedown', handleClickOutside);
if (currentMinuteTimeout) window.clearTimeout(currentMinuteTimeout);
if (currentMinuteInterval) window.clearInterval(currentMinuteInterval);
if (captureUnlisten) captureUnlisten();
});
@@ -284,6 +331,7 @@ const selectCalendarDate = (date: Date) => {
updateCurrentMinute();
loadTimeline(true);
loadEvents();
loadReminders();
};
const calendarDays = computed(() => {
@@ -329,7 +377,17 @@ 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 && (currentDate < getLogicDateStr() || (currentDate === getLogicDateStr() && r.minute < currentLogicalMinute)))" class="absolute top-1.5 right-1.5 w-2 h-2 bg-[#FF3B30] rounded-full border border-bg-sidebar"></span>
<span v-else-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 }}
@@ -342,8 +400,23 @@ const togglePause = async () => {
</div>
<div class="grid grid-cols-7 gap-1 text-center mb-2"><div v-for="d in ['一','二','三','四','五','六','日']" :key="d" class="text-[10px] font-bold text-text-sec">{{d}}</div></div>
<div class="grid grid-cols-7 gap-1">
<div v-for="(date, i) in calendarDays" :key="i" class="aspect-square flex items-center justify-center">
<button v-if="date" @click="selectCalendarDate(date)" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="date.toLocaleDateString('sv') === currentDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ date.getDate() }}</button>
<div v-for="(date, i) in calendarDays" :key="i" class="aspect-square flex flex-col items-center justify-center relative">
<button v-if="date" @click="selectCalendarDate(date)"
class="w-8 h-8 rounded-full text-xs font-medium transition-all flex items-center justify-center relative"
:class="[
date.toLocaleDateString('sv') === getLogicDateStr()
? 'bg-[#007AFF] text-white font-black shadow-[0_4px_12px_rgba(0,122,255,0.4)] ' + (date.toLocaleDateString('sv') === currentDate ? 'ring-2 ring-offset-2 ring-[#007AFF]' : '')
: (date.toLocaleDateString('sv') === currentDate
? 'bg-bg-input text-[#007AFF] font-black ring-1 ring-border-main shadow-sm'
: 'hover:bg-bg-input text-main')
]">
{{ date.getDate() }}
</button>
<!-- Status Dots -->
<div v-if="date && calendarStatus[date.toLocaleDateString('sv')]" class="absolute -bottom-1.5 flex gap-0.5">
<div v-if="calendarStatus[date.toLocaleDateString('sv')].has_overdue" class="w-1.5 h-1.5 rounded-full bg-[#FF3B30] border-[1.5px] border-bg-card shadow-sm"></div>
<div v-if="calendarStatus[date.toLocaleDateString('sv')].has_upcoming" class="w-1.5 h-1.5 rounded-full bg-[#007AFF] border-[1.5px] border-bg-card shadow-sm"></div>
</div>
</div>
</div>
</div>
@@ -355,7 +428,16 @@ 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>
<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-[6px] hover:z-50 hover:brightness-110 transition-all duration-100" :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>
<!-- Reminder Markers -->
<div v-for="r in reminders" :key="'rem-'+r.id" class="absolute left-0 w-12 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>
<div v-if="hoveredTime" class="absolute left-0 right-0 border-t-2 border-[#007AFF] z-40 pointer-events-none" :style="{ top: timeToLogicalMinutes(hoveredTime, hoveredTime < '03:00') * timelineZoom + 'px' }"><div class="absolute -left-12 -top-3 bg-[#007AFF] text-white text-[9px] px-1 py-0.5 rounded font-bold">{{ hoveredTime }}</div></div>
@@ -399,6 +481,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

@@ -55,3 +55,43 @@ body {
.text-main { color: var(--text-main); }
.text-sec { color: var(--text-sec); }
.border-main { border-color: var(--border-color); }
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border: 2px solid transparent;
background-clip: padding-box;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
border: 2px solid transparent;
background-clip: padding-box;
}

View File

@@ -2,8 +2,8 @@
import { ref, computed, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog";
import { X, Calendar, ChevronLeft, ChevronRight } from "lucide-vue-next";
import { currentDate, showToast, getTagName, logicalMinutesToTime, toISODate } from "../../store";
import { X, Calendar, ChevronLeft, ChevronRight, Copy, ArrowDown, ArrowUp, Download } from "lucide-vue-next";
import { currentDate, showToast, getTagName, logicalMinutesToTime, toISODate, mainTags } from "../../store";
import { DBEvent } from "../../types";
defineEmits(['close']);
@@ -18,6 +18,75 @@ const exportEndMonth = ref(new Date(exportEndDate.value));
const isExportStartCalendarOpen = ref(false);
const isExportEndCalendarOpen = ref(false);
// 选择的主标签,默认全选
const selectedTags = ref<number[]>(mainTags.value.map(t => t.id));
// 表格数据
const dateList = ref<string[]>([]);
const previewData = ref<Record<string, Record<number, string>>>({});
const isDesc = ref(true);
const sortedDateList = computed(() => isDesc.value ? [...dateList.value].reverse() : dateList.value);
// 拖拽调整宽高状态
const colWidths = ref<Record<string | number, number>>({});
const rowHeights = ref<Record<string, number>>({});
const isDragging = ref(false);
let dragType: 'col' | 'row' | null = null;
let dragKey: string | number | null = null;
let startPos = 0;
let startSize = 0;
const MIN_COL_WIDTH = 120;
const MIN_ROW_HEIGHT = 48;
const startResizeCol = (e: MouseEvent, key: string | number) => {
isDragging.value = true;
dragType = 'col';
dragKey = key;
startPos = e.clientX;
startSize = colWidths.value[key] || MIN_COL_WIDTH;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const startResizeRow = (e: MouseEvent, key: string) => {
isDragging.value = true;
dragType = 'row';
dragKey = key;
startPos = e.clientY;
startSize = rowHeights.value[key] || (e.target as HTMLElement).closest('td')?.offsetHeight || MIN_ROW_HEIGHT;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value || dragKey === null) return;
if (dragType === 'col') {
const delta = e.clientX - startPos;
colWidths.value[dragKey] = Math.max(MIN_COL_WIDTH, startSize + delta);
} else if (dragType === 'row') {
const delta = e.clientY - startPos;
rowHeights.value[dragKey as string] = Math.max(MIN_ROW_HEIGHT, startSize + delta);
}
};
const onMouseUp = () => {
isDragging.value = false;
dragType = null;
dragKey = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
const toggleTag = (id: number) => {
if (selectedTags.value.includes(id)) {
selectedTags.value = selectedTags.value.filter(tId => tId !== id);
} else {
selectedTags.value.push(id);
}
};
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (isExportStartCalendarOpen.value && startCalendarRef.value && !startCalendarRef.value.contains(target)) {
@@ -34,6 +103,8 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
const exportStartCalendarDays = computed(() => {
@@ -60,43 +131,183 @@ const exportEndCalendarDays = computed(() => {
return days;
});
const handleExport = async () => {
const parseDateStr = (str: string) => {
const [y, m, d] = str.split('-').map(Number);
return new Date(y, m - 1, d);
};
const handlePreview = async () => {
if (selectedTags.value.length === 0) {
showToast("请至少选择一个主标签", "error");
return;
}
const curDate = parseDateStr(exportStartDate.value);
const endDate = parseDateStr(exportEndDate.value);
if (curDate > endDate) {
showToast("开始日期不能晚于结束日期", "error");
return;
}
try {
const events: DBEvent[] = await invoke("get_events_range", { startDate: exportStartDate.value, endDate: exportEndDate.value });
if (events.length === 0) {
showToast("所选范围内没有找到记录", "error");
return;
// 生成日期范围列表
const dates: string[] = [];
let tempDate = new Date(curDate);
while (tempDate <= endDate) {
dates.push(toISODate(tempDate));
tempDate.setDate(tempDate.getDate() + 1);
}
const savePath = await save({
filters: [{ name: "JSON 文件", extensions: ["json"] }],
defaultPath: `ChronoSnap_Export_${exportStartDate.value}_${exportEndDate.value}.json`
dateList.value = dates;
// 初始化列宽
colWidths.value = { date: 150 };
selectedTags.value.forEach(tagId => {
colWidths.value[tagId] = 200;
});
if (savePath) {
const exportData = events.map(e => ({
date: e.date,
start_time: logicalMinutesToTime(e.start_minute),
end_time: logicalMinutesToTime(e.end_minute),
main_tag: getTagName(e.main_tag_id),
sub_tag: getTagName(e.sub_tag_id),
content: e.content
}));
await invoke("write_file", { path: savePath, content: JSON.stringify(exportData, null, 2) });
// 初始化统一行高
rowHeights.value = {};
dates.forEach(d => {
rowHeights.value[d] = 100;
});
// 过滤选中的主标签并构建矩阵
const matrix: Record<string, Record<number, string>> = {};
dates.forEach(d => matrix[d] = {});
const filteredEvents = events.filter(e => selectedTags.value.includes(e.main_tag_id));
filteredEvents.forEach(e => {
if (!matrix[e.date]) matrix[e.date] = {}; // 容错
const cellArr = matrix[e.date][e.main_tag_id] ? matrix[e.date][e.main_tag_id].split('\n') : [];
const startTime = logicalMinutesToTime(e.start_minute);
const endTime = logicalMinutesToTime(e.end_minute);
const subTag = getTagName(e.sub_tag_id);
// 替换换行符为空格
const content = e.content ? e.content.replace(/\n/g, ' ') : '';
let line = `${startTime}-${endTime}`;
if (subTag) line += ` ${subTag}`;
if (content) line += ` ${content}`;
cellArr.push(line);
matrix[e.date][e.main_tag_id] = cellArr.join('\n');
});
previewData.value = matrix;
} catch (e) {
showToast("获取数据失败: " + e, "error");
}
};
const exportToJson = async () => {
if (selectedTags.value.length === 0) {
showToast("请至少选择一个主标签", "error");
return;
}
const curDate = parseDateStr(exportStartDate.value);
const endDate = parseDateStr(exportEndDate.value);
if (curDate > endDate) {
showToast("开始日期不能晚于结束日期", "error");
return;
}
try {
const events: DBEvent[] = await invoke("get_events_range", { startDate: exportStartDate.value, endDate: exportEndDate.value });
const filteredEvents = events.filter(e => selectedTags.value.includes(e.main_tag_id));
// Add tag names to the exported data for better readability
const exportData = filteredEvents.map(e => ({
...e,
main_tag_name: getTagName(e.main_tag_id),
sub_tag_name: getTagName(e.sub_tag_id),
start_time_str: logicalMinutesToTime(e.start_minute),
end_time_str: logicalMinutesToTime(e.end_minute),
}));
const jsonStr = JSON.stringify(exportData, null, 2);
const filePath = await save({
filters: [{ name: 'JSON', extensions: ['json'] }],
defaultPath: `chrono-snap-export-${exportStartDate.value}-to-${exportEndDate.value}.json`
});
if (filePath) {
await invoke("write_file", { path: filePath, content: jsonStr });
showToast("导出成功");
}
} catch (e) {
showToast("导出失败: " + e, "error");
}
};
const copyColumn = async (tagId: number | 'date') => {
let lines: string[] = [];
for (const date of sortedDateList.value) {
if (tagId === 'date') {
lines.push(date);
} else {
let cellStr = previewData.value[date][tagId] || "";
if (cellStr.includes('\n') || cellStr.includes('\t') || cellStr.includes('"')) {
cellStr = `"${cellStr.replace(/"/g, '""')}"`;
}
lines.push(cellStr);
}
}
try {
await navigator.clipboard.writeText(lines.join("\n"));
showToast(`列数据已复制`);
} catch(err) {
showToast("复制失败", "error");
}
};
const copyToClipboard = async () => {
const header = ["日期", ...selectedTags.value.map(id => getTagName(id))];
let tsv = header.join("\t") + "\n";
for (const date of sortedDateList.value) {
let row = [date];
for (const tagId of selectedTags.value) {
let cellStr = previewData.value[date][tagId] || "";
if (cellStr.includes('\n') || cellStr.includes('\t') || cellStr.includes('"')) {
cellStr = `"${cellStr.replace(/"/g, '""')}"`;
}
row.push(cellStr);
}
tsv += row.join("\t") + "\n";
}
try {
await navigator.clipboard.writeText(tsv);
showToast("表格已复制,可直接粘贴到 Excel");
} catch(err) {
showToast("复制失败", "error");
}
};
</script>
<template>
<div class="fixed inset-0 z-100 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-2xl overflow-visible flex flex-col animate-in fade-in zoom-in duration-200">
<div class="p-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">导出记录</h2><button @click="$emit('close')"><X :size="24" /></button></div>
<div class="p-10 space-y-8">
<div class="space-y-4">
<div class="bg-bg-card rounded-[40px] shadow-2xl w-full flex flex-col animate-in fade-in zoom-in duration-300 transition-all"
:class="dateList.length > 0 ? 'max-w-[90vw] md:max-w-6xl h-[90vh] overflow-hidden' : 'max-w-4xl min-h-125'">
<div class="p-8 border-b flex justify-between items-center shrink-0">
<h2 class="text-2xl font-bold">导出记录</h2>
<button @click="$emit('close')" class="hover:bg-bg-input p-2 rounded-full transition-colors"><X :size="24" /></button>
</div>
<div class="p-8 flex-1 flex flex-col gap-8 no-scrollbar" :class="dateList.length > 0 ? 'overflow-y-auto' : 'overflow-visible'">
<!-- 配置区域 -->
<div class="space-y-6 shrink-0">
<!-- 日期选择 -->
<div class="space-y-2">
<label class="text-[10px] font-bold text-text-sec">日期范围</label>
<label class="text-[11px] font-bold text-text-sec">日期范围</label>
<div class="flex gap-4 items-center">
<div class="relative flex-1" ref="startCalendarRef">
<button @click="isExportStartCalendarOpen = !isExportStartCalendarOpen; isExportEndCalendarOpen = false" class="w-full bg-bg-input rounded-xl pl-10 pr-4 py-3 text-sm outline-none border border-transparent focus:border-[#007AFF] transition-all text-left flex items-center">
@@ -139,8 +350,87 @@ const handleExport = async () => {
</div>
</div>
</div>
<!-- 主标签选择 -->
<div class="space-y-2">
<label class="text-[11px] font-bold text-text-sec">选择要导出的主标签</label>
<div class="flex flex-wrap gap-2">
<button v-for="tag in mainTags" :key="tag.id"
@click="toggleTag(tag.id)"
class="px-3 py-1.5 rounded-xl text-[12px] font-bold border-2 transition-all active:scale-95"
:style="selectedTags.includes(tag.id) ? { backgroundColor: tag.color, borderColor: tag.color, color: 'white' } : { borderColor: tag.color, color: tag.color }"
>
{{ tag.name }}
</button>
</div>
</div>
<div class="flex gap-4">
<button @click="exportToJson" :disabled="!exportStartDate || !exportEndDate" class="flex-1 bg-bg-input border border-border-main text-text-sec hover:bg-border-main hover:text-text-main py-3.5 rounded-2xl font-bold shadow-sm transition-all disabled:opacity-50 flex items-center justify-center gap-2">
<Download :size="16" /> 导出为 JSON
</button>
<button @click="handlePreview" :disabled="!exportStartDate || !exportEndDate" class="flex-1 bg-[#007AFF] text-white py-3.5 rounded-2xl font-bold shadow-lg shadow-[#007AFF]/20 active:scale-95 transition-all disabled:opacity-50 disabled:active:scale-100">
生成预览表格
</button>
</div>
</div>
<button @click="handleExport" :disabled="!exportStartDate || !exportEndDate" class="w-full bg-[#007AFF] text-white py-4 rounded-2xl font-bold shadow-lg shadow-[#007AFF]/20 active:scale-95 transition-all disabled:opacity-50 disabled:active:scale-100">导出为 JSON</button>
<!-- 表格预览区域 -->
<div v-if="dateList.length > 0" class="flex-1 flex flex-col border-t border-border-main pt-6">
<div class="flex justify-between items-center mb-4 shrink-0">
<div class="flex items-center gap-4">
<h3 class="text-sm font-bold text-text-sec flex items-center gap-2">数据预览</h3>
<button @click="isDesc = !isDesc" class="flex items-center gap-1.5 px-3 py-1.5 bg-bg-input hover:bg-border-main text-text-sec hover:text-text-main rounded-lg text-xs font-bold transition-all active:scale-95">
<ArrowDown v-if="!isDesc" :size="14" />
<ArrowUp v-else :size="14" />
{{ isDesc ? '倒序 (由近及远)' : '正序 (由远及近)' }}
</button>
</div>
<button @click="copyToClipboard" class="flex items-center gap-1.5 px-4 py-2 bg-[#007AFF] hover:bg-[#007AFF]/90 text-white rounded-xl text-xs font-bold transition-all shadow-lg shadow-[#007AFF]/20 active:scale-95">
<Copy :size="14" /> 一键复制完整表格
</button>
</div>
<div class="flex-1 overflow-auto border border-border-main rounded-2xl bg-bg-input/30 relative" :class="isDragging ? 'select-none' : 'select-text'">
<table class="w-max min-w-full text-left border-collapse text-xs table-fixed">
<thead class="bg-bg-card sticky top-0 z-30 shadow-sm">
<tr>
<th class="relative p-4 border-b border-border-main font-bold whitespace-nowrap text-text-sec bg-bg-card group" :style="{ width: colWidths['date'] + 'px' }">
<div class="flex items-center gap-2 overflow-hidden w-full">
日期
<button @click="copyColumn('date')" title="复制此列" class="opacity-0 group-hover:opacity-100 p-1 hover:bg-bg-input rounded transition-all text-text-sec shrink-0"><Copy :size="12"/></button>
</div>
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-[#007AFF] z-40 group-hover:bg-border-main" @mousedown.prevent="startResizeCol($event, 'date')"></div>
</th>
<th v-for="tagId in selectedTags" :key="tagId" class="relative p-4 border-b border-l border-border-main/50 font-bold whitespace-nowrap bg-bg-card group" :style="{ color: mainTags.find(t => t.id === tagId)?.color || 'inherit', width: colWidths[tagId] + 'px' }">
<div class="flex items-center gap-2 overflow-hidden w-full">
{{ getTagName(tagId) }}
<button @click="copyColumn(tagId)" title="复制此列" class="opacity-0 group-hover:opacity-100 p-1 hover:bg-bg-input rounded transition-all text-text-sec shrink-0"><Copy :size="12"/></button>
</div>
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-[#007AFF] z-40 group-hover:bg-border-main" @mousedown.prevent="startResizeCol($event, tagId)"></div>
</th>
</tr>
</thead>
<tbody class="bg-bg-card">
<tr v-for="date in sortedDateList" :key="date" class="border-b border-border-main/30 hover:bg-bg-input/50 transition-colors">
<td class="relative p-4 font-bold whitespace-nowrap text-text-main align-top group">
<div class="flex items-start overflow-hidden w-full" :style="{ height: rowHeights[date] ? Math.max(0, rowHeights[date] - 32) + 'px' : 'auto', minHeight: '16px' }">
{{ date }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-1.5 cursor-row-resize hover:bg-[#007AFF] z-20 group-hover:bg-border-main" @mousedown.prevent="startResizeRow($event, date)"></div>
</td>
<td v-for="tagId in selectedTags" :key="tagId" class="relative p-4 border-l border-border-main/30 align-top whitespace-pre-wrap leading-relaxed text-text-sec group">
<div class="w-full overflow-y-auto no-scrollbar wrap-break-words" :style="{ height: rowHeights[date] ? Math.max(0, rowHeights[date] - 32) + 'px' : 'auto', minHeight: '16px' }">
{{ previewData[date]?.[tagId] || '' }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-1.5 cursor-row-resize hover:bg-[#007AFF] z-20 group-hover:bg-border-main" @mousedown.prevent="startResizeRow($event, date)"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import { X, Plus, Trash2, Check, Clock, Calendar, Edit2 } 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 editingId = ref<number | null>(null);
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;
}
const isEditing = editingId.value !== null;
try {
await invoke("save_reminder", {
reminder: { id: editingId.value || 0, date: currentDate.value, minute: newMinute.value, content: newContent.value, is_completed: false }
});
newContent.value = "";
newMinute.value = getInitialMinute();
editingId.value = null;
await loadReminders();
showToast(isEditing ? "提醒已更新" : "提醒已添加");
} catch (e) {
showToast("保存失败: " + e, "error");
}
};
const editReminder = (r: Reminder) => {
editingId.value = r.id;
newContent.value = r.content;
newMinute.value = r.minute;
};
const cancelEdit = () => {
editingId.value = null;
newContent.value = "";
newMinute.value = getInitialMinute();
};
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 relative">
<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" :class="{ 'ring-2 ring-[#007AFF]/50 border-transparent': editingId }">
{{ 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>
<div class="flex-1 relative">
<input type="text" v-model="newContent" :placeholder="editingId ? '编辑提醒...' : '添加新提醒...'" class="w-full bg-bg-card border border-border-main rounded-xl px-4 py-3 text-sm font-bold outline-none focus:border-[#007AFF]/50 transition-all pr-10" :class="{ 'ring-2 ring-[#007AFF]/50 border-transparent': editingId }" />
<button v-if="editingId" type="button" @click="cancelEdit" class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-text-sec hover:bg-bg-input rounded-lg"><X :size="16" /></button>
</div>
<button type="submit" class="bg-[#007AFF] text-white p-3 rounded-xl hover:brightness-110 transition-all shadow-md shadow-[#007AFF]/20"><Check v-if="editingId" :size="20" /><Plus v-else :size="20" /></button>
</form>
</div>
<div class="flex-1 overflow-y-auto p-6 space-y-6 no-scrollbar">
<!-- 已超时未办 -->
<div v-if="overdueReminders && 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" :class="{ 'ring-2 ring-[#FF3B30]/50': editingId === r.id }">
<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" :class="{ 'opacity-50': editingId === r.id }">{{ r.content }}</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button @click="editReminder(r)" class="p-2 text-text-sec hover:text-[#007AFF] rounded-lg hover:bg-[#007AFF]/10"><Edit2 :size="16" /></button>
<button @click="deleteItem(r.id)" class="p-2 text-text-sec hover:text-[#FF3B30] rounded-lg hover:bg-[#FF3B30]/20"><Trash2 :size="16" /></button>
</div>
</div>
</div>
</div>
<!-- 即将到来 -->
<div v-if="upcomingReminders && 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" :class="{ 'ring-2 ring-[#007AFF]/50 border-transparent': editingId === r.id }">
<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" :class="{ 'opacity-50': editingId === r.id }">{{ r.content }}</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button @click="editReminder(r)" class="p-2 text-text-sec hover:text-[#007AFF] rounded-lg hover:bg-[#007AFF]/10"><Edit2 :size="16" /></button>
<button @click="deleteItem(r.id)" class="p-2 text-text-sec hover:text-[#FF3B30] rounded-lg hover:bg-[#FF3B30]/10"><Trash2 :size="16" /></button>
</div>
</div>
</div>
</div>
<!-- 已完成 -->
<div v-if="completedReminders && 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" :class="{ 'ring-2 ring-border-main': editingId === r.id }">
<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" :class="{ 'opacity-50': editingId === r.id }">{{ r.content }}</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button @click="editReminder(r)" class="p-2 text-text-sec hover:text-[#007AFF] rounded-lg hover:bg-[#007AFF]/10"><Edit2 :size="16" /></button>
<button @click="deleteItem(r.id)" class="p-2 text-text-sec hover:text-[#FF3B30] rounded-lg hover:bg-[#FF3B30]/10"><Trash2 :size="16" /></button>
</div>
</div>
</div>
</div>
<!-- 无提醒事项 -->
<div v-if="(!overdueReminders || overdueReminders.length === 0) && (!upcomingReminders || upcomingReminders.length === 0) && (!completedReminders || 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,8 @@
import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { Tag, DBEvent, TimelineItem, Toast } from "../types";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Image } from "@tauri-apps/api/image";
import { Tag, DBEvent, TimelineItem, Toast, Reminder } from "../types";
export const TIME_OFFSET_MINUTES = 180;
export const TOTAL_MINUTES = 1440;
@@ -39,6 +41,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 +51,8 @@ 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 calendarStatus = ref<Record<string, { has_overdue: boolean, has_upcoming: boolean }>>({});
export const timelineImages = ref<TimelineItem[]>([]);
export const refreshSignal = ref(0); // Counter to trigger dashboard refreshes
@@ -107,3 +112,102 @@ 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 });
refreshBadgeCount();
refreshCalendarStatus(); // 提醒更新时也刷新日历状态
};
export const loadCalendarStatus = async (yearMonth: string) => {
if (!dbPath.value) return;
try {
const { date, minute } = getRealNowLogicalTime();
const statuses: any[] = await invoke("get_reminders_by_month", {
yearMonth,
today: date,
nowMinute: minute
});
const map: Record<string, any> = {};
statuses.forEach(s => { map[s.date] = s; });
calendarStatus.value = map;
} catch (e) {
console.error("Failed to load calendar status:", e);
}
};
// 当前显示的日历月份,用于自动刷新状态
export const currentCalendarMonthStr = ref("");
export const refreshCalendarStatus = () => {
if (currentCalendarMonthStr.value) {
loadCalendarStatus(currentCalendarMonthStr.value);
}
};
export const overdueCount = ref(0);
// 获取当前真实的逻辑时间和日期(不依赖 UI 状态)
export const getRealNowLogicalTime = () => {
const now = new Date();
const d = new Date(now.getTime() - TIME_OFFSET_MINUTES * 60000);
const date = toISODate(d);
const m = now.getHours() * 60 + now.getMinutes();
const minute = (m < TIME_OFFSET_MINUTES ? m + 1440 : m) - TIME_OFFSET_MINUTES;
return { date, minute };
};
export const refreshBadgeCount = async () => {
if (!dbPath.value) return;
try {
const { date, minute } = getRealNowLogicalTime();
const count: number = await invoke("get_overdue_reminders_count", {
date: date,
minute: minute
});
overdueCount.value = count;
await updateTaskbarBadge(count);
} catch (e) {
console.error("Failed to refresh badge count:", e);
}
};
const updateTaskbarBadge = async (count: number) => {
try {
const win = getCurrentWindow();
if (count <= 0) {
await win.setOverlayIcon(undefined);
return;
}
const canvas = document.createElement('canvas');
const size = 64;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
// Draw red circle
ctx.fillStyle = '#FF3B30';
ctx.beginPath();
ctx.arc(size/2, size/2, size/2 - 2, 0, Math.PI * 2);
ctx.fill();
// Draw white text
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 38px Arial';
const displayCount = count > 99 ? '99+' : count.toString();
if (displayCount.length > 2) ctx.font = 'bold 28px Arial';
ctx.fillText(displayCount, size/2, size/2 + 2);
// Get raw RGBA pixels and wrap in Tauri Image object
const imageData = ctx.getImageData(0, 0, size, size);
const img = await Image.new(new Uint8Array(imageData.data), size, size);
await win.setOverlayIcon(img);
} catch (e) {
console.error("Failed to set overlay icon:", e);
}
};

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;