Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e39ad7ed8 | ||
|
|
07faa81d21 | ||
|
|
2d51467bb9 | ||
|
|
c4366e62e1 | ||
|
|
42be9062b4 | ||
|
|
b1b63a9949 | ||
|
|
3fe7f013dc | ||
|
|
5f14a0dd10 | ||
|
|
b838ce7aa8 | ||
|
|
97ba35c4c4 | ||
|
|
5b4abdeae7 | ||
|
|
10583fd882 | ||
|
|
0e424c9afb | ||
|
|
8f8fe93f4e | ||
|
|
a41e06a6e1 | ||
|
|
ba4084170e | ||
|
|
bf435d4986 | ||
|
|
06fb78edab | ||
|
|
05a6ee59ff | ||
|
|
1e1307b8d4 | ||
|
|
6793a2070e |
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chrono-snap",
|
||||
"private": true,
|
||||
"version": "0.1.2",
|
||||
"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
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
|
||||
|
||||
69
src-tauri/Cargo.lock
generated
69
src-tauri/Cargo.lock
generated
@@ -667,7 +667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono-snap"
|
||||
version = "0.1.2"
|
||||
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrono-snap"
|
||||
version = "0.1.2"
|
||||
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"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"core:window:allow-set-overlay-icon",
|
||||
"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,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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "chrono-snap",
|
||||
"version": "0.1.2",
|
||||
"productName": "ChronoSnap",
|
||||
"version": "0.2.3",
|
||||
"identifier": "top.volan.chrono-snap",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "瞬影 - 时间记录 v0.1.2",
|
||||
"title": "瞬影 - 时间记录 v0.2.3",
|
||||
"width": 1760,
|
||||
"height": 1100
|
||||
}
|
||||
|
||||
1015
src/App.vue
1015
src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
52
src/components/Setup.vue
Normal file
52
src/components/Setup.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { Settings, FolderOpen, Plus } from "lucide-vue-next";
|
||||
import { savePath, dbPath, isSetupComplete, loadTags, loadEvents } from "../store";
|
||||
import { load as loadStore } from "@tauri-apps/plugin-store";
|
||||
|
||||
const selectFolder = async () => { const s = await open({ directory: true }); if (s) savePath.value = s as string; };
|
||||
const selectDBFile = async () => { const s = await open({ filters: [{ name: "SQLite", extensions: ["db"] }] }); if (s) dbPath.value = s as string; };
|
||||
const createDBFile = async () => { const s = await save({ filters: [{ name: "SQLite", extensions: ["db"] }] }); if (s) dbPath.value = s as string; };
|
||||
|
||||
const completeSetup = async () => {
|
||||
if (savePath.value && dbPath.value) {
|
||||
const store = await loadStore("config.json");
|
||||
await store.set("savePath", savePath.value);
|
||||
await store.set("dbPath", dbPath.value);
|
||||
await store.save();
|
||||
await invoke("update_db_path", { path: dbPath.value });
|
||||
isSetupComplete.value = true;
|
||||
await loadTags(); await loadEvents();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 flex items-center justify-center p-10 bg-bg-main h-screen">
|
||||
<div class="bg-bg-card p-12 rounded-4xl shadow-2xl max-w-lg text-center border border-border-main">
|
||||
<Settings :size="40" class="text-[#007AFF] mx-auto mb-8" />
|
||||
<h1 class="text-3xl font-bold mb-4">初始化设置</h1>
|
||||
<div class="space-y-6 text-left mb-10">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-bold text-text-sec">截图保存目录</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" readonly :value="savePath" placeholder="请选择目录..." class="flex-1 bg-bg-input rounded-xl px-4 py-2.5 text-sm outline-none" />
|
||||
<button @click="selectFolder" class="bg-bg-card border border-border-main p-2.5 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-bold text-text-sec">数据库文件</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" readonly :value="dbPath" placeholder="请选择或创建数据库文件..." class="flex-1 bg-bg-input rounded-xl px-4 py-2.5 text-sm outline-none" />
|
||||
<div class="flex gap-1">
|
||||
<button @click="selectDBFile" class="bg-bg-card border border-border-main p-2.5 rounded-xl hover:border-[#007AFF]"><FolderOpen :size="18" /></button>
|
||||
<button @click="createDBFile" class="bg-bg-card border border-border-main p-2.5 rounded-xl hover:border-[#007AFF]"><Plus :size="18" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="completeSetup" :disabled="!savePath || !dbPath" class="bg-[#007AFF] text-white px-8 py-4 rounded-2xl font-semibold w-full disabled:opacity-50 transition-all active:scale-95">开始使用</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
437
src/components/modals/ExportModal.vue
Normal file
437
src/components/modals/ExportModal.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<script setup lang="ts">
|
||||
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, Copy, ArrowDown, ArrowUp, Download } from "lucide-vue-next";
|
||||
import { currentDate, showToast, getTagName, logicalMinutesToTime, toISODate, mainTags } from "../../store";
|
||||
import { DBEvent } from "../../types";
|
||||
|
||||
defineEmits(['close']);
|
||||
|
||||
const startCalendarRef = ref<HTMLElement | null>(null);
|
||||
const endCalendarRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const exportStartDate = ref(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toLocaleDateString('sv'));
|
||||
const exportEndDate = ref(currentDate.value);
|
||||
const exportStartMonth = ref(new Date(exportStartDate.value));
|
||||
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)) {
|
||||
isExportStartCalendarOpen.value = false;
|
||||
}
|
||||
if (isExportEndCalendarOpen.value && endCalendarRef.value && !endCalendarRef.value.contains(target)) {
|
||||
isExportEndCalendarOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
const exportStartCalendarDays = computed(() => {
|
||||
const y = exportStartMonth.value.getFullYear();
|
||||
const m = exportStartMonth.value.getMonth();
|
||||
const firstDay = new Date(y, m, 1).getDay();
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||
const days = [];
|
||||
const padding = (firstDay + 6) % 7;
|
||||
for (let i = 0; i < padding; i++) days.push(null);
|
||||
for (let i = 1; i <= daysInMonth; i++) days.push(new Date(y, m, i));
|
||||
return days;
|
||||
});
|
||||
|
||||
const exportEndCalendarDays = computed(() => {
|
||||
const y = exportEndMonth.value.getFullYear();
|
||||
const m = exportEndMonth.value.getMonth();
|
||||
const firstDay = new Date(y, m, 1).getDay();
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||
const days = [];
|
||||
const padding = (firstDay + 6) % 7;
|
||||
for (let i = 0; i < padding; i++) days.push(null);
|
||||
for (let i = 1; i <= daysInMonth; i++) days.push(new Date(y, m, i));
|
||||
return days;
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
// 生成日期范围列表
|
||||
const dates: string[] = [];
|
||||
let tempDate = new Date(curDate);
|
||||
while (tempDate <= endDate) {
|
||||
dates.push(toISODate(tempDate));
|
||||
tempDate.setDate(tempDate.getDate() + 1);
|
||||
}
|
||||
dateList.value = dates;
|
||||
|
||||
// 初始化列宽
|
||||
colWidths.value = { date: 150 };
|
||||
selectedTags.value.forEach(tagId => {
|
||||
colWidths.value[tagId] = 200;
|
||||
});
|
||||
// 初始化统一行高
|
||||
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 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-[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">
|
||||
<Calendar :size="16" class="absolute left-4 text-text-sec" />
|
||||
{{ exportStartDate }}
|
||||
</button>
|
||||
<div v-if="isExportStartCalendarOpen" class="absolute top-full left-0 mt-2 bg-bg-card rounded-3xl shadow-2xl border border-border-main z-120 p-5 animate-in fade-in zoom-in-95 duration-200 w-72">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="exportStartMonth = new Date(exportStartMonth.getFullYear(), exportStartMonth.getMonth()-1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronLeft :size="18"/></button>
|
||||
<span class="text-sm font-black">{{ exportStartMonth.getFullYear() }}年 {{ exportStartMonth.getMonth()+1 }}月</span>
|
||||
<button @click="exportStartMonth = new Date(exportStartMonth.getFullYear(), exportStartMonth.getMonth()+1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronRight :size="18"/></button>
|
||||
</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 exportStartCalendarDays" :key="i" class="aspect-square flex items-center justify-center">
|
||||
<button v-if="date" @click="exportStartDate = toISODate(date); isExportStartCalendarOpen = false" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="toISODate(date) === exportStartDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ date.getDate() }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-text-sec font-black text-xs">至</span>
|
||||
<div class="relative flex-1" ref="endCalendarRef">
|
||||
<button @click="isExportEndCalendarOpen = !isExportEndCalendarOpen; isExportStartCalendarOpen = 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">
|
||||
<Calendar :size="16" class="absolute left-4 text-text-sec" />
|
||||
{{ exportEndDate }}
|
||||
</button>
|
||||
<div v-if="isExportEndCalendarOpen" class="absolute top-full right-0 mt-2 bg-bg-card rounded-3xl shadow-2xl border border-border-main z-120 p-5 animate-in fade-in zoom-in-95 duration-200 w-72">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="exportEndMonth = new Date(exportEndMonth.getFullYear(), exportEndMonth.getMonth()-1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronLeft :size="18"/></button>
|
||||
<span class="text-sm font-black">{{ exportEndMonth.getFullYear() }}年 {{ exportEndMonth.getMonth()+1 }}月</span>
|
||||
<button @click="exportEndMonth = new Date(exportEndMonth.getFullYear(), exportEndMonth.getMonth()+1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronRight :size="18"/></button>
|
||||
</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 exportEndCalendarDays" :key="i" class="aspect-square flex items-center justify-center">
|
||||
<button v-if="date" @click="exportEndDate = toISODate(date); isExportEndCalendarOpen = false" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="toISODate(date) === exportEndDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ date.getDate() }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 表格预览区域 -->
|
||||
<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>
|
||||
</template>
|
||||
199
src/components/modals/ReminderManager.vue
Normal file
199
src/components/modals/ReminderManager.vue
Normal 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>
|
||||
66
src/components/modals/SettingsModal.vue
Normal file
66
src/components/modals/SettingsModal.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { X, FolderOpen, Plus } from "lucide-vue-next";
|
||||
import { savePath, dbPath, theme, captureInterval, retainDays } from "../../store";
|
||||
import { load as loadStore } from "@tauri-apps/plugin-store";
|
||||
|
||||
defineEmits(['close']);
|
||||
|
||||
const updateSettings = async () => {
|
||||
const store = await loadStore("config.json");
|
||||
await store.set("savePath", savePath.value);
|
||||
await store.set("dbPath", dbPath.value);
|
||||
await store.set("retainDays", retainDays.value);
|
||||
await store.set("captureInterval", captureInterval.value);
|
||||
await store.set("theme", theme.value);
|
||||
await store.save();
|
||||
await invoke("update_db_path", { path: dbPath.value });
|
||||
await invoke("update_interval", { seconds: captureInterval.value });
|
||||
|
||||
if (theme.value === 'dark') document.documentElement.classList.add('dark');
|
||||
else if (theme.value === 'light') document.documentElement.classList.remove('dark');
|
||||
else {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
const selectFolder = async () => {
|
||||
const s = await open({ directory: true });
|
||||
if (s) { savePath.value = s as string; await updateSettings(); }
|
||||
};
|
||||
|
||||
const selectDBFile = async () => {
|
||||
const s = await open({ filters: [{ name: "SQLite", extensions: ["db"] }] });
|
||||
if (s) { dbPath.value = s as string; await updateSettings(); }
|
||||
};
|
||||
|
||||
const createDBFile = async () => {
|
||||
const s = await save({ filters: [{ name: "SQLite", extensions: ["db"] }] });
|
||||
if (s) { dbPath.value = s as string; await updateSettings(); }
|
||||
};
|
||||
</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-md overflow-hidden flex flex-col">
|
||||
<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-3"><label class="text-xs font-bold text-text-sec">截图保存位置</label><div class="flex gap-2"><input type="text" readonly :value="savePath" class="flex-1 bg-bg-input rounded-xl px-4 py-2 text-sm outline-none" /><button @click="selectFolder" class="bg-bg-card border p-2 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button></div></div>
|
||||
<div class="space-y-3"><label class="text-xs font-bold text-text-sec">数据库文件</label><div class="flex gap-2"><input type="text" readonly :value="dbPath" class="flex-1 bg-bg-input rounded-xl px-4 py-2 text-sm outline-none" /><div class="flex gap-1"><button @click="selectDBFile" title="打开现有" class="bg-bg-card border p-2 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button><button @click="createDBFile" title="新建" class="bg-bg-card border p-2 rounded-xl hover:border-[#007AFF] transition-all"><Plus :size="18" /></button></div></div></div>
|
||||
|
||||
<div class="space-y-2"><div class="flex justify-between"><label class="text-xs font-bold">主题设置</label></div>
|
||||
<div class="flex bg-bg-input rounded-xl p-1 gap-1">
|
||||
<button @click="theme = 'light'; updateSettings()" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition-all" :class="theme === 'light' ? 'bg-bg-card shadow-sm text-text-main' : 'text-text-sec hover:text-text-main'">浅色</button>
|
||||
<button @click="theme = 'dark'; updateSettings()" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition-all" :class="theme === 'dark' ? 'bg-bg-card shadow-sm text-text-main' : 'text-text-sec hover:text-text-main'">深色</button>
|
||||
<button @click="theme = 'system'; updateSettings()" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition-all" :class="theme === 'system' ? 'bg-bg-card shadow-sm text-text-main' : 'text-text-sec hover:text-text-main'">跟随系统</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2"><div class="flex justify-between"><label class="text-xs font-bold">截图间隔</label><span class="text-xs font-bold text-[#007AFF]">{{ captureInterval }}s</span></div><input type="range" v-model.number="captureInterval" min="60" max="600" step="10" @change="updateSettings" class="w-full h-1 bg-bg-hover accent-[#007AFF]" /></div>
|
||||
<div class="space-y-2"><div class="flex justify-between"><label class="text-xs font-bold">清理策略</label><span class="text-xs font-bold text-[#007AFF]">{{ retainDays }}天</span></div><input type="range" v-model.number="retainDays" min="1" max="180" @change="updateSettings" class="w-full h-1 bg-bg-hover accent-[#007AFF]" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
96
src/components/modals/TagManager.vue
Normal file
96
src/components/modals/TagManager.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { X, Trash2, ChevronRight, ChevronDown } from "lucide-vue-next";
|
||||
import { mainTags, getSubTags, getTagName, showToast, loadTags } from "../../store";
|
||||
|
||||
defineEmits(['close']);
|
||||
|
||||
const expandedMainTags = ref<number[]>([]);
|
||||
const toggleMainTag = (id: number) => {
|
||||
const index = expandedMainTags.value.indexOf(id);
|
||||
if (index > -1) expandedMainTags.value.splice(index, 1);
|
||||
else expandedMainTags.value.push(id);
|
||||
};
|
||||
|
||||
const isTagSelectOpen = ref(false);
|
||||
const newTagName = ref("");
|
||||
const newTagParent = ref<number | null>(null);
|
||||
const newTagColor = ref("#007AFF");
|
||||
|
||||
const handleAddTag = async () => {
|
||||
try {
|
||||
await invoke("add_tag", { name: newTagName.value, parentId: newTagParent.value, color: newTagColor.value });
|
||||
await loadTags();
|
||||
showToast("标签添加成功");
|
||||
newTagName.value = ""; newTagParent.value = null; newTagColor.value = "#007AFF";
|
||||
} catch (e) {
|
||||
showToast("添加失败: " + e, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (id: number) => {
|
||||
try {
|
||||
await invoke("delete_tag", { id });
|
||||
await loadTags();
|
||||
showToast("标签已删除");
|
||||
} catch (e: any) {
|
||||
if (e.toString().includes("FOREIGN KEY")) {
|
||||
showToast("该标签正在被使用,无法删除", "error");
|
||||
} else {
|
||||
showToast("删除失败: " + e, "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 h-[80vh] overflow-hidden 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="flex-1 overflow-hidden p-10 flex gap-10">
|
||||
<div class="flex-1 overflow-y-auto space-y-2 pr-4 no-scrollbar">
|
||||
<div v-for="mt in mainTags" :key="mt.id" class="space-y-2">
|
||||
<div @click="toggleMainTag(mt.id)" class="flex items-center justify-between group p-2 hover:bg-bg-input rounded-xl cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<ChevronRight :size="14" class="text-text-sec transition-transform" :class="{ 'rotate-90': expandedMainTags.includes(mt.id) }" />
|
||||
<div class="w-4 h-4 rounded-full" :style="{ backgroundColor: mt.color }"></div>
|
||||
<span class="font-bold">{{ mt.name }}</span>
|
||||
</div>
|
||||
<button @click.stop="handleDeleteTag(mt.id)" class="text-[#FF3B30] opacity-0 group-hover:opacity-100"><Trash2 :size="16" /></button>
|
||||
</div>
|
||||
<div v-if="expandedMainTags.includes(mt.id)" class="ml-7 space-y-1 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div v-for="st in getSubTags(mt.id)" :key="st.id" class="flex items-center justify-between group p-1.5 hover:bg-bg-input rounded-lg">
|
||||
<span class="text-sm">{{ st.name }}</span>
|
||||
<button @click="handleDeleteTag(st.id)" class="text-[#FF3B30] opacity-0 group-hover:opacity-100"><Trash2 :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-64 bg-bg-input p-6 rounded-3xl space-y-4 h-fit">
|
||||
<h3 class="font-bold text-xs text-text-sec uppercase">添加标签</h3>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] font-bold text-text-sec ml-1">标签名称</label>
|
||||
<input v-model="newTagName" placeholder="输入名称..." class="w-full bg-bg-card rounded-xl px-4 py-2.5 text-sm outline-none border border-transparent focus:border-[#007AFF] transition-all" />
|
||||
</div>
|
||||
<div class="space-y-1 relative">
|
||||
<label class="text-[10px] font-bold text-text-sec ml-1">父级标签</label>
|
||||
<button @click="isTagSelectOpen = !isTagSelectOpen" class="w-full bg-bg-card rounded-xl px-4 py-2.5 text-sm text-left flex justify-between items-center border border-transparent focus:border-[#007AFF] transition-all">
|
||||
<span>{{ getTagName(newTagParent) }}</span>
|
||||
<ChevronDown :size="14" class="text-text-sec transition-transform" :class="{ 'rotate-180': isTagSelectOpen }" />
|
||||
</button>
|
||||
<div v-if="isTagSelectOpen" class="absolute top-full left-0 right-0 mt-2 bg-bg-card rounded-2xl shadow-xl border border-border-main z-60 overflow-hidden py-2 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div @click="newTagParent = null; isTagSelectOpen = false" class="px-4 py-2 text-sm hover:bg-bg-input cursor-pointer" :class="{ 'text-[#007AFF] font-bold': newTagParent === null }">-- 无 --</div>
|
||||
<div v-for="t in mainTags" :key="t.id" @click="newTagParent = t.id; isTagSelectOpen = false" class="px-4 py-2 text-sm hover:bg-bg-input cursor-pointer" :class="{ 'text-[#007AFF] font-bold': newTagParent === t.id }">{{ t.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="newTagParent === null" class="space-y-1">
|
||||
<label class="text-[10px] font-bold text-text-sec ml-1">主题颜色</label>
|
||||
<div class="flex flex-wrap gap-2"><button v-for="c in ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#5856D6']" :key="c" @click="newTagColor = c" class="w-5 h-5 rounded-full border-2 transition-all" :style="{ backgroundColor: c, borderColor: newTagColor === c ? '#1D1D1F' : 'transparent' }"></button></div>
|
||||
</div>
|
||||
<button @click="handleAddTag" :disabled="!newTagName" class="w-full bg-[#007AFF] text-white py-3 rounded-xl font-bold shadow-lg shadow-[#007AFF]/20 active:scale-95 transition-all">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
179
src/components/views/Dashboard.vue
Normal file
179
src/components/views/Dashboard.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { BarChart2, Calendar, ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
dashboardStats, dashboardRange, dailyAverageMode,
|
||||
customStartDate, customEndDate,
|
||||
isStartCalendarOpen, isEndCalendarOpen,
|
||||
formatMinutes, getTagColor, getTagName
|
||||
} from "../../store/dashboardStore";
|
||||
import { toISODate } from "../../store";
|
||||
|
||||
const startCalendarRef = ref<HTMLElement | null>(null);
|
||||
const endCalendarRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const startCalendarMonth = ref(new Date(customStartDate.value));
|
||||
const endCalendarMonth = ref(new Date(customEndDate.value));
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (isStartCalendarOpen.value && startCalendarRef.value && !startCalendarRef.value.contains(target)) {
|
||||
isStartCalendarOpen.value = false;
|
||||
}
|
||||
if (isEndCalendarOpen.value && endCalendarRef.value && !endCalendarRef.value.contains(target)) {
|
||||
isEndCalendarOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
const getCalendarDays = (month: Date) => {
|
||||
const y = month.getFullYear();
|
||||
const m = month.getMonth();
|
||||
const firstDay = new Date(y, m, 1).getDay();
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||
const days = [];
|
||||
const padding = (firstDay + 6) % 7;
|
||||
for (let i = 0; i < padding; i++) days.push(null);
|
||||
for (let i = 1; i <= daysInMonth; i++) days.push(new Date(y, m, i));
|
||||
return days;
|
||||
};
|
||||
|
||||
const startCalendarDays = computed(() => getCalendarDays(startCalendarMonth.value));
|
||||
const endCalendarDays = computed(() => getCalendarDays(endCalendarMonth.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 overflow-y-auto no-scrollbar p-10 bg-bg-main animate-in fade-in zoom-in-95 duration-200">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Top Filters -->
|
||||
<div class="flex flex-col gap-6 mb-10">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex bg-bg-input rounded-xl p-1.5 gap-1 border border-border-main/50 shadow-inner">
|
||||
<button @click="dashboardRange = 'today'" class="px-5 py-2 text-xs font-bold rounded-lg transition-all" :class="dashboardRange === 'today' ? 'bg-[#007AFF] shadow-lg text-white' : 'text-text-sec hover:text-text-main hover:bg-bg-card'">今日</button>
|
||||
<button @click="dashboardRange = '7days'" class="px-5 py-2 text-xs font-bold rounded-lg transition-all" :class="dashboardRange === '7days' ? 'bg-[#007AFF] shadow-lg text-white' : 'text-text-sec hover:text-text-main hover:bg-bg-card'">近 7 天</button>
|
||||
<button @click="dashboardRange = '30days'" class="px-5 py-2 text-xs font-bold rounded-lg transition-all" :class="dashboardRange === '30days' ? 'bg-[#007AFF] shadow-lg text-white' : 'text-text-sec hover:text-text-main hover:bg-bg-card'">近 30 天</button>
|
||||
<button @click="dashboardRange = 'custom'" class="px-5 py-2 text-xs font-bold rounded-lg transition-all" :class="dashboardRange === 'custom' ? 'bg-[#007AFF] shadow-lg text-white' : 'text-text-sec hover:text-text-main hover:bg-bg-card'">自定义</button>
|
||||
</div>
|
||||
<div v-if="dashboardRange !== 'today'" class="flex bg-bg-input rounded-xl p-1 gap-1 border border-border-main/50 shadow-inner">
|
||||
<button @click="dailyAverageMode = 'natural'" class="px-3 py-1.5 text-[10px] font-bold rounded-lg transition-all" :class="dailyAverageMode === 'natural' ? 'bg-bg-card shadow-sm text-text-main' : 'text-text-sec hover:text-text-main'">自然天数 ({{dashboardStats.naturalDays}}天)</button>
|
||||
<button @click="dailyAverageMode = 'recorded'" class="px-3 py-1.5 text-[10px] font-bold rounded-lg transition-all" :class="dailyAverageMode === 'recorded' ? 'bg-bg-card shadow-sm text-text-main' : 'text-text-sec hover:text-text-main'">记录天数 ({{dashboardStats.recordedDays}}天)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Date Pickers -->
|
||||
<div v-if="dashboardRange === 'custom'" class="flex items-center gap-4 animate-in slide-in-from-top-2 duration-300">
|
||||
<div class="relative flex-1" ref="startCalendarRef">
|
||||
<button @click="isStartCalendarOpen = !isStartCalendarOpen; isEndCalendarOpen = false" class="w-full bg-bg-card border border-border-main rounded-xl pl-10 pr-4 py-2.5 text-xs font-bold text-left flex items-center hover:bg-bg-input transition-all">
|
||||
<Calendar :size="16" class="absolute left-3.5 text-[#007AFF]" />
|
||||
<span class="text-text-sec mr-2">从</span> {{ customStartDate }}
|
||||
</button>
|
||||
<div v-if="isStartCalendarOpen" class="absolute top-full left-0 mt-2 bg-bg-card rounded-3xl shadow-2xl border border-border-main z-120 p-5 w-72 animate-in fade-in zoom-in-95">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="startCalendarMonth = new Date(startCalendarMonth.getFullYear(), startCalendarMonth.getMonth()-1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronLeft :size="16"/></button>
|
||||
<span class="text-sm font-black">{{ startCalendarMonth.getFullYear() }}年 {{ startCalendarMonth.getMonth()+1 }}月</span>
|
||||
<button @click="startCalendarMonth = new Date(startCalendarMonth.getFullYear(), startCalendarMonth.getMonth()+1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronRight :size="16"/></button>
|
||||
</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 startCalendarDays" :key="i" class="aspect-square flex items-center justify-center">
|
||||
<button v-if="date" @click="customStartDate = toISODate(date); isStartCalendarOpen = false" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="toISODate(date) === customStartDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ date.getDate() }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-text-sec font-black text-xs">至</div>
|
||||
<div class="relative flex-1" ref="endCalendarRef">
|
||||
<button @click="isEndCalendarOpen = !isEndCalendarOpen; isStartCalendarOpen = false" class="w-full bg-bg-card border border-border-main rounded-xl pl-10 pr-4 py-2.5 text-xs font-bold text-left flex items-center hover:bg-bg-input transition-all">
|
||||
<Calendar :size="16" class="absolute left-3.5 text-[#007AFF]" />
|
||||
<span class="text-text-sec mr-2">至</span> {{ customEndDate }}
|
||||
</button>
|
||||
<div v-if="isEndCalendarOpen" class="absolute top-full right-0 mt-2 bg-bg-card rounded-3xl shadow-2xl border border-border-main z-120 p-5 w-72 animate-in fade-in zoom-in-95">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="endCalendarMonth = new Date(endCalendarMonth.getFullYear(), endCalendarMonth.getMonth()-1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronLeft :size="16"/></button>
|
||||
<span class="text-sm font-black">{{ endCalendarMonth.getFullYear() }}年 {{ endCalendarMonth.getMonth()+1 }}月</span>
|
||||
<button @click="endCalendarMonth = new Date(endCalendarMonth.getFullYear(), endCalendarMonth.getMonth()+1, 1)" class="p-2 hover:bg-bg-input rounded-xl"><ChevronRight :size="16"/></button>
|
||||
</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 endCalendarDays" :key="i" class="aspect-square flex items-center justify-center">
|
||||
<button v-if="date" @click="customEndDate = toISODate(date); isEndCalendarOpen = false" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="toISODate(date) === customEndDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ date.getDate() }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-2 gap-6 mb-10">
|
||||
<div class="bg-bg-card border border-border-main rounded-3xl p-6 shadow-sm flex flex-col justify-center">
|
||||
<div class="text-xs font-bold text-text-sec mb-2">总记录时长</div>
|
||||
<div class="text-4xl font-black text-text-main">{{ formatMinutes(dashboardStats.totalMinutes) }}</div>
|
||||
</div>
|
||||
<div class="bg-bg-card border border-border-main rounded-3xl p-6 shadow-sm flex flex-col justify-center">
|
||||
<div class="text-xs font-bold text-text-sec mb-2">日均时长</div>
|
||||
<div class="text-4xl font-black text-[#007AFF]">{{ formatMinutes(dashboardStats.dailyAverage) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual Bar -->
|
||||
<div class="mb-12 bg-bg-card border border-border-main p-6 rounded-3xl shadow-sm">
|
||||
<h3 class="text-sm font-bold text-text-sec mb-4 uppercase tracking-wider">占比总览</h3>
|
||||
<div class="h-6 w-full bg-bg-input rounded-full overflow-hidden flex shadow-inner mb-4 border border-border-main/30">
|
||||
<div v-for="tag in dashboardStats.mainTags" :key="tag.id" :style="{ width: tag.percentage + '%', backgroundColor: getTagColor(tag.id) }" class="h-full first:rounded-l-full last:rounded-r-full border-r border-bg-main/20 last:border-0 transition-all duration-500 hover:brightness-110 cursor-pointer" :title="getTagName(tag.id) + ' ' + tag.percentage.toFixed(1) + '%'"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 px-2">
|
||||
<div v-for="tag in dashboardStats.mainTags" :key="tag.id" class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full shadow-inner" :style="{ backgroundColor: getTagColor(tag.id) }"></div>
|
||||
<span class="text-xs font-bold text-text-sec">{{ getTagName(tag.id) }} <span class="text-text-main ml-1">{{ tag.percentage.toFixed(0) }}%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed List -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-bold text-text-sec mb-4 uppercase tracking-wider">时间分布明细</h3>
|
||||
<div v-for="tag in dashboardStats.mainTags" :key="tag.id" class="bg-bg-card border border-border-main rounded-3xl p-5 shadow-sm transition-all hover:border-border-main/80 hover:shadow-md">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-4 h-4 rounded-full shadow-inner" :style="{ backgroundColor: getTagColor(tag.id) }"></div>
|
||||
<span class="font-bold text-lg">{{ getTagName(tag.id) }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-black text-xl">{{ formatMinutes(tag.total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4 pl-7 text-[11px] font-bold text-text-sec">
|
||||
<span class="bg-bg-input/50 px-2 py-1 rounded-md border border-border-main/30">占比 {{ tag.percentage.toFixed(1) }}%</span>
|
||||
<span class="bg-bg-input/50 px-2 py-1 rounded-md border border-border-main/30 text-[#007AFF]">日均 {{ formatMinutes(tag.dailyAverage) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Sub-tags -->
|
||||
<div v-if="tag.subTags.length > 0" class="pl-7 space-y-3 pt-4 border-t border-border-main/50">
|
||||
<div v-for="sub in tag.subTags" :key="sub.id" class="flex justify-between items-center group gap-4">
|
||||
<span class="text-xs text-text-main font-bold w-16 truncate">{{ getTagName(sub.id) }}</span>
|
||||
<div class="flex items-center gap-3 flex-1 justify-end">
|
||||
<div class="w-full max-w-50 h-1.5 bg-bg-input rounded-full overflow-hidden shadow-inner">
|
||||
<div class="h-full rounded-full opacity-80" :style="{ width: sub.percentage + '%', backgroundColor: getTagColor(tag.id) }"></div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold text-text-sec whitespace-nowrap min-w-10 text-right">{{ formatMinutes(sub.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardStats.mainTags.length === 0" class="text-center py-20">
|
||||
<BarChart2 :size="48" class="mx-auto mb-4 text-text-sec opacity-20" />
|
||||
<div class="text-text-sec font-bold">该时间范围内没有记录数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
14
src/components/views/Preview.vue
Normal file
14
src/components/views/Preview.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Maximize2 } from "lucide-vue-next";
|
||||
import { previewSrc } from "../../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-12 bg-bg-input/30">
|
||||
<img v-if="previewSrc" :src="previewSrc" class="max-w-full max-h-full object-contain rounded-3xl shadow-2xl border border-border-main/50" />
|
||||
<div v-else class="text-text-sec text-center">
|
||||
<Maximize2 :size="48" class="mx-auto mb-4 opacity-20" />
|
||||
<p>在时间轴上滑动或拖拽以记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
117
src/store/dashboardStore.ts
Normal file
117
src/store/dashboardStore.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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, dbPath } from "./index";
|
||||
|
||||
// Re-export for easier access in Dashboard component
|
||||
export { getTagColor, getTagName, formatMinutes };
|
||||
|
||||
export const dashboardRange = ref<'today' | '7days' | '30days' | 'custom'>('today');
|
||||
export const dashboardStartDate = ref(currentDate.value);
|
||||
export const dashboardEndDate = ref(currentDate.value);
|
||||
|
||||
// Dedicated memory for custom dates so they don't get overwritten by auto modes
|
||||
export const customStartDate = ref(currentDate.value);
|
||||
export const customEndDate = ref(currentDate.value);
|
||||
|
||||
export const isStartCalendarOpen = ref(false);
|
||||
export const isEndCalendarOpen = ref(false);
|
||||
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 });
|
||||
};
|
||||
|
||||
watch([dashboardRange, currentDate], () => {
|
||||
if (dashboardRange.value === 'custom') {
|
||||
dashboardStartDate.value = customStartDate.value;
|
||||
dashboardEndDate.value = customEndDate.value;
|
||||
loadDashboardEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
const end = parseISODate(currentDate.value);
|
||||
let start = parseISODate(currentDate.value);
|
||||
|
||||
if (dashboardRange.value === '7days') {
|
||||
start.setDate(end.getDate() - 6);
|
||||
} else if (dashboardRange.value === '30days') {
|
||||
start.setDate(end.getDate() - 29);
|
||||
}
|
||||
|
||||
dashboardStartDate.value = toISODate(start);
|
||||
dashboardEndDate.value = toISODate(end);
|
||||
loadDashboardEvents();
|
||||
}, { immediate: true });
|
||||
|
||||
// Specific trigger for custom range changes
|
||||
watch([customStartDate, customEndDate], () => {
|
||||
if (dashboardRange.value === 'custom') {
|
||||
dashboardStartDate.value = customStartDate.value;
|
||||
dashboardEndDate.value = customEndDate.value;
|
||||
loadDashboardEvents();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-refresh dashboard when a refresh signal is received (event added/deleted)
|
||||
watch(refreshSignal, () => {
|
||||
if (viewMode.value === 'dashboard') {
|
||||
loadDashboardEvents();
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh when switching to dashboard mode to catch updates that happened in preview mode
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'dashboard') {
|
||||
loadDashboardEvents();
|
||||
}
|
||||
});
|
||||
|
||||
export const dashboardStats = computed(() => {
|
||||
let totalMinutes = 0;
|
||||
const mainTagMap = new Map<number, { total: number, subTags: Map<number, number> }>();
|
||||
const uniqueDays = new Set<string>();
|
||||
|
||||
dashboardEvents.value.forEach(ev => {
|
||||
const diff = Math.max(0, ev.end_minute - ev.start_minute);
|
||||
totalMinutes += diff;
|
||||
uniqueDays.add(ev.date);
|
||||
|
||||
if (!mainTagMap.has(ev.main_tag_id)) {
|
||||
mainTagMap.set(ev.main_tag_id, { total: 0, subTags: new Map() });
|
||||
}
|
||||
const mainStat = mainTagMap.get(ev.main_tag_id)!;
|
||||
mainStat.total += diff;
|
||||
|
||||
if (ev.sub_tag_id) {
|
||||
const subTotal = mainStat.subTags.get(ev.sub_tag_id) || 0;
|
||||
mainStat.subTags.set(ev.sub_tag_id, subTotal + diff);
|
||||
}
|
||||
});
|
||||
|
||||
const start = new Date(dashboardStartDate.value);
|
||||
const end = new Date(dashboardEndDate.value);
|
||||
const naturalDays = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 86400000) + 1);
|
||||
const recordedDays = Math.max(1, uniqueDays.size);
|
||||
const daysCount = dailyAverageMode.value === 'natural' ? naturalDays : recordedDays;
|
||||
|
||||
const mainTagsList = Array.from(mainTagMap.entries()).map(([id, stat]) => {
|
||||
const subTagsList = Array.from(stat.subTags.entries()).map(([subId, subTotal]) => ({
|
||||
id: subId,
|
||||
total: subTotal,
|
||||
percentage: stat.total > 0 ? (subTotal / stat.total) * 100 : 0
|
||||
})).sort((a, b) => b.total - a.total);
|
||||
|
||||
return {
|
||||
id,
|
||||
total: stat.total,
|
||||
dailyAverage: stat.total / daysCount,
|
||||
percentage: totalMinutes > 0 ? (stat.total / totalMinutes) * 100 : 0,
|
||||
subTags: subTagsList
|
||||
};
|
||||
}).sort((a, b) => b.total - a.total);
|
||||
|
||||
return { totalMinutes, dailyAverage: totalMinutes / daysCount, mainTags: mainTagsList, naturalDays, recordedDays, daysCount };
|
||||
});
|
||||
213
src/store/index.ts
Normal file
213
src/store/index.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
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;
|
||||
|
||||
// --- Date Utils (Logical Day Safe) ---
|
||||
export const getLogicDateStr = (date: Date = new Date()) => {
|
||||
// Subtract offset to get logic day
|
||||
const d = new Date(date.getTime() - TIME_OFFSET_MINUTES * 60000);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
export const parseISODate = (dateStr: string) => {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
return new Date(y, m - 1, d, 12, 0, 0);
|
||||
};
|
||||
|
||||
export const toISODate = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
// --- Config State ---
|
||||
export const isSetupComplete = ref(false);
|
||||
export const savePath = ref("");
|
||||
export const dbPath = ref("");
|
||||
export const retainDays = ref(30);
|
||||
export const captureInterval = ref(60);
|
||||
export const timelineZoom = ref(1.5);
|
||||
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("");
|
||||
export const selectedImage = ref<TimelineItem | null>(null);
|
||||
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
|
||||
|
||||
// --- Global Toast ---
|
||||
export const toast = ref<Toast>({ message: "", type: "success", visible: false });
|
||||
export const showToast = (message: string, type: "success" | "error" = "success") => {
|
||||
toast.value = { message, type, visible: true };
|
||||
setTimeout(() => { toast.value.visible = false; }, 3000);
|
||||
};
|
||||
|
||||
// --- Computed Helpers ---
|
||||
export const mainTags = computed(() => tags.value.filter(t => t.parent_id === null));
|
||||
export const getSubTags = (parentId: number) => tags.value.filter(t => t.parent_id === parentId);
|
||||
export const getTagColor = (tagId: number | null | undefined) => {
|
||||
if (tagId == null) return "#007AFF";
|
||||
const tag = tags.value.find(t => t.id === tagId);
|
||||
return tag?.color || "#007AFF";
|
||||
};
|
||||
export const getTagName = (tagId: number | null | undefined) => {
|
||||
if (tagId == null) return "";
|
||||
const tag = tags.value.find(t => t.id === tagId);
|
||||
return tag?.name || "";
|
||||
};
|
||||
|
||||
// --- Time Helper Functions ---
|
||||
export const logicalMinutesToTime = (min: number) => {
|
||||
let t = (min + TIME_OFFSET_MINUTES) % 1440;
|
||||
const h = Math.floor(t / 60); const m = Math.floor(t % 60);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const logicalMinutesFromTime = (timeStr: string) => {
|
||||
const [h, m] = timeStr.split(":").map(Number);
|
||||
let total = h * 60 + m;
|
||||
if (h < 3) total += 1440;
|
||||
return total - TIME_OFFSET_MINUTES;
|
||||
};
|
||||
|
||||
export const formatDuration = (start: number, end: number) => {
|
||||
let diff = end - start;
|
||||
if (diff < 0) diff += 1440;
|
||||
const h = Math.floor(diff / 60);
|
||||
const m = diff % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const formatMinutes = (mins: number) => {
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = Math.floor(mins % 60);
|
||||
if (h === 0) return `${m}m`;
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
// --- Shared Actions ---
|
||||
export const loadTags = async () => { tags.value = await invoke("get_tags"); };
|
||||
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);
|
||||
}
|
||||
};
|
||||
37
src/types/index.ts
Normal file
37
src/types/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface DBEvent {
|
||||
id: number;
|
||||
date: string;
|
||||
start_minute: number;
|
||||
end_minute: number;
|
||||
main_tag_id: number;
|
||||
sub_tag_id: number | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Reminder {
|
||||
id: number;
|
||||
date: string;
|
||||
minute: number;
|
||||
content: string;
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineItem {
|
||||
time: string;
|
||||
path: string;
|
||||
isNextDay?: boolean;
|
||||
logical_minute?: number;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
visible: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user