Compare commits
10 Commits
b32d5ddbd3
...
7afea3912c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7afea3912c | ||
|
|
be40442fa8 | ||
|
|
a504fb36e0 | ||
|
|
0af5f1c6a9 | ||
|
|
019087be7a | ||
|
|
da041cf9c8 | ||
|
|
47cd20c72c | ||
|
|
98aeda3a46 | ||
|
|
f0112ffcd4 | ||
|
|
facce53aba |
@@ -1,7 +1,3 @@
|
||||
# Tauri + Vue + TypeScript
|
||||
# 瞬影 - 时间记录
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
由 Gemini CLI 生成。
|
||||
BIN
app-icon.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
58
icon.svg
Normal file
@@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<!-- Background Gradient -->
|
||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#007AFF" />
|
||||
<stop offset="100%" stop-color="#0056B3" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Shutter Gradient -->
|
||||
<linearGradient id="shutterGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#F2F2F7" stop-opacity="0.8" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Drop Shadow for depth -->
|
||||
<filter id="dropShadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.25" />
|
||||
</filter>
|
||||
|
||||
<!-- Inner Shadow for the lens -->
|
||||
<filter id="innerShadow">
|
||||
<feOffset dx="0" dy="4"/>
|
||||
<feGaussianBlur stdDeviation="6" result="offset-blur"/>
|
||||
<feComposite operator="out" in="SourceGraphic" in2="offset-blur" result="inverse"/>
|
||||
<feFlood flood-color="black" flood-opacity="0.15" result="color"/>
|
||||
<feComposite operator="in" in="color" in2="inverse" result="shadow"/>
|
||||
<feComposite operator="over" in="shadow" in2="SourceGraphic"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- App Icon Base (Squircle) -->
|
||||
<rect x="32" y="32" width="448" height="448" rx="100" ry="100" fill="url(#bgGradient)" filter="url(#dropShadow)" />
|
||||
|
||||
<!-- Outer Camera/Clock Ring -->
|
||||
<circle cx="256" cy="256" r="160" fill="none" stroke="#FFFFFF" stroke-width="24" stroke-opacity="0.3" />
|
||||
|
||||
<!-- Time Track / Timeline segments (representing intervals) -->
|
||||
<path d="M 256 96 A 160 160 0 0 1 416 256" fill="none" stroke="#FFFFFF" stroke-width="24" stroke-linecap="round" />
|
||||
|
||||
<!-- Central Shutter/Lens Background -->
|
||||
<circle cx="256" cy="256" r="112" fill="url(#shutterGradient)" filter="url(#innerShadow)" />
|
||||
|
||||
<!-- Inner Dark Lens -->
|
||||
<circle cx="256" cy="256" r="64" fill="#1D1D1F" />
|
||||
|
||||
<!-- Camera Flash / Sparkle -->
|
||||
<circle cx="340" cy="172" r="16" fill="#FFFFFF" />
|
||||
|
||||
<!-- Clock Hands (Time element) inside the lens -->
|
||||
<!-- Minute Hand -->
|
||||
<line x1="256" y1="256" x2="256" y2="200" stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" />
|
||||
<!-- Hour Hand -->
|
||||
<line x1="256" y1="256" x2="296" y2="256" stroke="#007AFF" stroke-width="8" stroke-linecap="round" />
|
||||
|
||||
<!-- Center Pin -->
|
||||
<circle cx="256" cy="256" r="12" fill="#FFFFFF" />
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "chrono-snap"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
description = "An app to record screens and events"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 53 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 66 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -1,6 +1,5 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Tag {
|
||||
|
||||
@@ -168,6 +168,11 @@ pub fn get_timeline(date: String, base_dir: String) -> Vec<serde_json::Value> {
|
||||
results
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_file(path: String, content: String) -> Result<(), String> {
|
||||
std::fs::write(&path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn start_engine(app: AppHandle) {
|
||||
// Start Cleanup routine
|
||||
let app_cleanup = app.clone();
|
||||
@@ -278,10 +283,7 @@ async fn capture_screens(app: &AppHandle) -> anyhow::Result<()> {
|
||||
fs::create_dir_all(&base_dir)?;
|
||||
}
|
||||
|
||||
let merge_screens = store
|
||||
.get("mergeScreens")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let merge_screens = true;
|
||||
|
||||
let monitors = Monitor::all()?;
|
||||
let now = Local::now();
|
||||
|
||||
@@ -30,7 +30,8 @@ pub fn run() {
|
||||
engine::get_events,
|
||||
engine::get_events_range,
|
||||
engine::save_event,
|
||||
engine::delete_event
|
||||
engine::delete_event,
|
||||
engine::write_file
|
||||
])
|
||||
.setup(|app| {
|
||||
app.manage(engine::AppState {
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
*state.toggle_menu_item.lock().unwrap() = Some(toggle_i.clone());
|
||||
|
||||
TrayIconBuilder::with_id("main_tray")
|
||||
.tooltip("Chrono Snap")
|
||||
.tooltip("瞬影")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "chrono-snap",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"title": "瞬影 - 时间记录 v0.1.0",
|
||||
"width": 1760,
|
||||
"height": 1100
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
231
src/App.vue
@@ -2,7 +2,6 @@
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
||||
import { load } from "@tauri-apps/plugin-store";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Tag as TagIcon, FolderOpen, Settings, Play, Pause, Maximize2, X, RefreshCw, Plus, Trash2, ChevronDown, ChevronLeft, ChevronRight, Calendar, Download } from "lucide-vue-next";
|
||||
@@ -20,7 +19,7 @@ const isSetupComplete = ref(false);
|
||||
const savePath = ref("");
|
||||
const dbPath = ref("");
|
||||
const isPaused = ref(false);
|
||||
const currentDate = ref(new Date().toISOString().split("T")[0]);
|
||||
const currentDate = ref(new Date().toLocaleDateString('sv'));
|
||||
const timelineImages = ref<TimelineItem[]>([]);
|
||||
const selectedImage = ref<TimelineItem | null>(null);
|
||||
const lockedImage = ref<TimelineItem | null>(null);
|
||||
@@ -36,9 +35,8 @@ const showToast = (message: string, type = "success") => {
|
||||
setTimeout(() => { toast.value.visible = false; }, 3000);
|
||||
};
|
||||
|
||||
const mergeScreens = ref(false);
|
||||
const retainDays = ref(30);
|
||||
const captureInterval = ref(30);
|
||||
const captureInterval = ref(60);
|
||||
const timelineZoom = ref(1.5);
|
||||
|
||||
const tags = ref<Tag[]>([]);
|
||||
@@ -65,11 +63,15 @@ const calendarRef = ref<HTMLElement | null>(null);
|
||||
const startTimePickerRef = ref<HTMLElement | null>(null);
|
||||
const endTimePickerRef = ref<HTMLElement | null>(null);
|
||||
const tagSelectRef = ref<HTMLElement | null>(null);
|
||||
const exportStartCalendarRef = ref<HTMLElement | null>(null);
|
||||
const exportEndCalendarRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const isCalendarOpen = ref(false);
|
||||
const calendarMonth = ref(new Date());
|
||||
const isStartTimeOpen = ref(false);
|
||||
const isEndTimeOpen = ref(false);
|
||||
const isExportStartCalendarOpen = ref(false);
|
||||
const isExportEndCalendarOpen = ref(false);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
@@ -85,6 +87,12 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isTagSelectOpen.value && tagSelectRef.value && !tagSelectRef.value.contains(target)) {
|
||||
isTagSelectOpen.value = false;
|
||||
}
|
||||
if (isExportStartCalendarOpen.value && exportStartCalendarRef.value && !exportStartCalendarRef.value.contains(target)) {
|
||||
isExportStartCalendarOpen.value = false;
|
||||
}
|
||||
if (isExportEndCalendarOpen.value && exportEndCalendarRef.value && !exportEndCalendarRef.value.contains(target)) {
|
||||
isExportEndCalendarOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openStartTimePicker = async () => {
|
||||
@@ -92,7 +100,7 @@ const openStartTimePicker = async () => {
|
||||
isEndTimeOpen.value = false;
|
||||
if (isStartTimeOpen.value) {
|
||||
await nextTick();
|
||||
const activeItems = document.querySelectorAll('.z-\\[120\\] .bg-\\[\\#007AFF\\].text-white');
|
||||
const activeItems = document.querySelectorAll('.z-120 .bg-\\[\\#007AFF\\].text-white');
|
||||
activeItems.forEach(el => el.scrollIntoView({ block: 'center' }));
|
||||
}
|
||||
};
|
||||
@@ -102,7 +110,7 @@ const openEndTimePicker = async () => {
|
||||
isStartTimeOpen.value = false;
|
||||
if (isEndTimeOpen.value) {
|
||||
await nextTick();
|
||||
const activeItems = document.querySelectorAll('.z-\\[120\\] .bg-\\[\\#007AFF\\].text-white');
|
||||
const activeItems = document.querySelectorAll('.z-120 .bg-\\[\\#007AFF\\].text-white');
|
||||
activeItems.forEach(el => el.scrollIntoView({ block: 'center' }));
|
||||
}
|
||||
};
|
||||
@@ -136,9 +144,8 @@ onMounted(async () => {
|
||||
dbPath.value = dPath as string;
|
||||
isSetupComplete.value = true;
|
||||
await invoke("update_db_path", { path: dbPath.value });
|
||||
mergeScreens.value = (await store.get("mergeScreens")) as boolean || false;
|
||||
retainDays.value = (await store.get("retainDays")) as number || 30;
|
||||
captureInterval.value = (await store.get("captureInterval")) as number || 30;
|
||||
captureInterval.value = Math.max((await store.get("captureInterval")) as number || 60, 60);
|
||||
timelineZoom.value = (await store.get("timelineZoom")) as number || 1.5;
|
||||
await invoke("update_interval", { seconds: captureInterval.value });
|
||||
isPaused.value = await invoke("get_pause_state");
|
||||
@@ -217,8 +224,8 @@ const deleteEvent = async (id: number) => {
|
||||
};
|
||||
|
||||
const timeToLogicalMinutes = (timeStr: string, isNextDay = false) => {
|
||||
const [h, m] = timeStr.split(":").map(Number);
|
||||
let t = h * 60 + m; if (isNextDay) t += 1440; return t - TIME_OFFSET_MINUTES;
|
||||
const [h, m, s = 0] = timeStr.split(":").map(Number);
|
||||
let t = h * 60 + m + (s / 60); if (isNextDay) t += 1440; return t - TIME_OFFSET_MINUTES;
|
||||
};
|
||||
|
||||
const logicalMinutesToTime = (min: number) => {
|
||||
@@ -265,14 +272,12 @@ const handleTimelineMouseMove = (e: MouseEvent) => {
|
||||
if (min >= 0 && min < 1440) {
|
||||
hoveredTime.value = logicalMinutesToTime(min);
|
||||
if (isDragging.value) dragEndMin.value = min;
|
||||
else {
|
||||
const closest = timelineImages.value.reduce((p, c) => {
|
||||
const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - min);
|
||||
const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - min);
|
||||
return cd < pd ? c : p;
|
||||
}, timelineImages.value[0]);
|
||||
if (closest) updatePreview(closest);
|
||||
}
|
||||
const closest = timelineImages.value.reduce((p, c) => {
|
||||
const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - min);
|
||||
const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - min);
|
||||
return cd < pd ? c : p;
|
||||
}, timelineImages.value[0]);
|
||||
if (closest) updatePreview(closest);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,7 +320,6 @@ const handleTimelineWheel = (e: WheelEvent) => {
|
||||
const updateSettings = async () => {
|
||||
await store.set("savePath", savePath.value);
|
||||
await store.set("dbPath", dbPath.value);
|
||||
await store.set("mergeScreens", mergeScreens.value);
|
||||
await store.set("retainDays", retainDays.value);
|
||||
await store.set("captureInterval", captureInterval.value);
|
||||
await store.set("timelineZoom", timelineZoom.value);
|
||||
@@ -350,52 +354,75 @@ const isTagSelectOpen = ref(false);
|
||||
const isExportModalOpen = ref(false);
|
||||
const exportStartDate = ref(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toLocaleDateString('sv'));
|
||||
const exportEndDate = ref(currentDate.value);
|
||||
const exportSelectedTags = ref<number[]>([]);
|
||||
const exportStartMonth = ref(new Date(exportStartDate.value));
|
||||
const exportEndMonth = ref(new Date(exportEndDate.value));
|
||||
|
||||
const toggleExportTag = (tagId: number) => {
|
||||
if (exportSelectedTags.value.includes(tagId)) {
|
||||
exportSelectedTags.value = exportSelectedTags.value.filter(id => id !== tagId);
|
||||
} else {
|
||||
exportSelectedTags.value.push(tagId);
|
||||
}
|
||||
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 selectExportStartDate = (date: Date) => {
|
||||
exportStartDate.value = date.toLocaleDateString('sv');
|
||||
isExportStartCalendarOpen.value = false;
|
||||
};
|
||||
|
||||
const selectAllTags = () => {
|
||||
exportSelectedTags.value = tags.value.map(t => t.id);
|
||||
const selectExportEndDate = (date: Date) => {
|
||||
exportEndDate.value = date.toLocaleDateString('sv');
|
||||
isExportEndCalendarOpen.value = false;
|
||||
};
|
||||
|
||||
const openExportModal = () => {
|
||||
exportEndDate.value = currentDate.value;
|
||||
selectAllTags();
|
||||
exportStartMonth.value = new Date(exportStartDate.value);
|
||||
exportEndMonth.value = new Date(exportEndDate.value);
|
||||
isExportModalOpen.value = true;
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const events: DBEvent[] = await invoke("get_events_range", { startDate: exportStartDate.value, endDate: exportEndDate.value });
|
||||
const filteredEvents = events.filter(e => exportSelectedTags.value.includes(e.main_tag_id) || (e.sub_tag_id && exportSelectedTags.value.includes(e.sub_tag_id)));
|
||||
|
||||
if (filteredEvents.length === 0) {
|
||||
showToast("所选范围内没有找到匹配的记录", "error");
|
||||
if (events.length === 0) {
|
||||
showToast("所选范围内没有找到记录", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const savePath = await save({
|
||||
filters: [{ name: "CSV 逗号分隔值文件", extensions: ["csv"] }],
|
||||
defaultPath: `ChronoSnap_Export_${exportStartDate.value}_${exportEndDate.value}.csv`
|
||||
filters: [{ name: "JSON 文件", extensions: ["json"] }],
|
||||
defaultPath: `ChronoSnap_Export_${exportStartDate.value}_${exportEndDate.value}.json`
|
||||
});
|
||||
|
||||
if (savePath) {
|
||||
let csvContent = "日期,开始时间,结束时间,主标签,副标签,事件内容\n";
|
||||
for (const e of filteredEvents) {
|
||||
const start = logicalMinutesToTime(e.start_minute);
|
||||
const end = logicalMinutesToTime(e.end_minute);
|
||||
const mainTag = getTagName(e.main_tag_id);
|
||||
const subTag = getTagName(e.sub_tag_id);
|
||||
const content = `"${e.content.replace(/"/g, '""')}"`;
|
||||
csvContent += `${e.date},${start},${end},${mainTag},${subTag},${content}\n`;
|
||||
}
|
||||
await writeTextFile(savePath, "\uFEFF" + csvContent);
|
||||
const exportData = events.map(e => ({
|
||||
date: e.date,
|
||||
start_time: logicalMinutesToTime(e.start_minute),
|
||||
end_time: logicalMinutesToTime(e.end_minute),
|
||||
main_tag: getTagName(e.main_tag_id),
|
||||
sub_tag: getTagName(e.sub_tag_id),
|
||||
content: e.content
|
||||
}));
|
||||
|
||||
await invoke("write_file", { path: savePath, content: JSON.stringify(exportData, null, 2) });
|
||||
isExportModalOpen.value = false;
|
||||
showToast("导出成功");
|
||||
}
|
||||
@@ -409,9 +436,9 @@ const handleExport = async () => {
|
||||
<div class="h-screen w-screen flex flex-col overflow-hidden text-[#1D1D1F] bg-[#FBFBFD]">
|
||||
|
||||
<div v-if="!isSetupComplete" class="flex-1 flex items-center justify-center p-10">
|
||||
<div class="bg-white p-12 rounded-[32px] shadow-2xl max-w-lg text-center border border-[#E5E5E7]">
|
||||
<div class="bg-white p-12 rounded-4xl shadow-2xl max-w-lg text-center border border-[#E5E5E7]">
|
||||
<Settings :size="40" class="text-[#007AFF] mx-auto mb-8" />
|
||||
<h1 class="text-3xl font-bold mb-4">初始化 Chrono Snap</h1>
|
||||
<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-[#86868B]">截图保存目录</label>
|
||||
@@ -421,9 +448,9 @@ const handleExport = async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-bold text-[#86868B]">SQLite 数据库文件</label>
|
||||
<label class="text-xs font-bold text-[#86868B]">数据库文件</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" readonly :value="dbPath" placeholder="请选择或创建 .db 文件..." class="flex-1 bg-[#F2F2F7] rounded-xl px-4 py-2.5 text-sm outline-none" />
|
||||
<input type="text" readonly :value="dbPath" placeholder="请选择或创建数据库文件..." class="flex-1 bg-[#F2F2F7] rounded-xl px-4 py-2.5 text-sm outline-none" />
|
||||
<div class="flex gap-1">
|
||||
<button @click="selectDBFile" class="bg-white border border-[#E5E5E7] p-2.5 rounded-xl hover:border-[#007AFF]"><FolderOpen :size="18" /></button>
|
||||
<button @click="createDBFile" class="bg-white border border-[#E5E5E7] p-2.5 rounded-xl hover:border-[#007AFF]"><Plus :size="18" /></button>
|
||||
@@ -438,13 +465,13 @@ const handleExport = async () => {
|
||||
<div v-else class="flex flex-1 overflow-hidden">
|
||||
<div class="w-80 bg-[#F8FAFD] border-r border-[#E5E5E7] flex flex-col select-none relative">
|
||||
<div class="p-6 bg-[#F8FAFD]/80 backdrop-blur-md sticky top-0 z-50 border-b border-[#E5E5E7]/50">
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-lg font-bold">历史活动</h2><button @click="loadTimeline(true); loadEvents()" class="p-2 hover:bg-white rounded-xl text-[#86868B]"><RefreshCw :size="18" /></button></div>
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-lg font-bold">瞬影 - 时间记录</h2><button @click="loadTimeline(true); loadEvents()" class="p-2 hover:bg-white rounded-xl text-[#86868B]"><RefreshCw :size="18" /></button></div>
|
||||
<div ref="calendarRef" class="relative group">
|
||||
<button @click="isCalendarOpen = !isCalendarOpen" class="w-full bg-white border border-[#E5E5E7] rounded-xl pl-11 pr-4 py-2.5 text-sm font-bold text-left flex items-center hover:bg-[#F2F2F7] transition-all">
|
||||
<Calendar :size="16" class="absolute left-4 text-[#86868B]" />
|
||||
{{ currentDate }}
|
||||
</button>
|
||||
<div v-if="isCalendarOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-[24px] shadow-2xl border border-[#E5E5E7] z-[100] p-5 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div v-if="isCalendarOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-3xl shadow-2xl border border-[#E5E5E7] z-100 p-5 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button @click="calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth()-1, 1)" class="p-2 hover:bg-[#F2F2F7] rounded-xl"><ChevronLeft :size="18"/></button>
|
||||
<span class="text-sm font-black">{{ calendarMonth.getFullYear() }}年 {{ calendarMonth.getMonth()+1 }}月</span>
|
||||
@@ -463,10 +490,10 @@ const handleExport = async () => {
|
||||
<div ref="timelineRef" class="flex-1 overflow-y-auto no-scrollbar hover:cursor-crosshair relative" @mousedown="handleTimelineMouseDown" @mousemove="handleTimelineMouseMove" @mouseup="handleTimelineMouseUp" @mouseleave="handleTimelineMouseLeave" @wheel="handleTimelineWheel">
|
||||
<div :style="{ height: rulerHeight + 'px' }" class="relative ml-14 mr-4">
|
||||
<div v-for="h in 24" :key="h" class="absolute left-0 w-full border-t border-[#E5E5E7]/60" :style="{ top: (h-1) * 60 * timelineZoom + 'px' }">
|
||||
<span class="absolute -left-11 -top-2.5 text-[10px] font-bold text-[#86868B]">{{ String((h - 1 + 3) % 24).padStart(2, '0') }}:00</span>
|
||||
<span v-if="h > 1" class="absolute -left-11 -top-2.5 text-[10px] font-bold text-[#86868B]">{{ String((h - 1 + 3) % 24).padStart(2, '0') }}:00</span>
|
||||
</div>
|
||||
<div v-for="ev in dayEvents" :key="ev.id" class="absolute left-0 w-[45%] opacity-80 border-l-4" :style="{ top: ev.start_minute * timelineZoom + 'px', height: (ev.end_minute - ev.start_minute) * timelineZoom + 'px', backgroundColor: getTagColor(ev.main_tag_id) + '22', borderColor: getTagColor(ev.main_tag_id) }" @click.stop="editingEvent = { ...ev }; isEventModalOpen = true"></div>
|
||||
<div v-for="img in timelineImages" :key="img.path" class="absolute left-[50%] right-2 h-1 bg-[#007AFF]/20 rounded-full" :class="[selectedImage?.path === img.path ? 'bg-[#007AFF]/60 h-2 z-10' : '', lockedImage?.path === img.path ? 'bg-[#007AFF] h-2.5 ring-2 ring-[#007AFF]/20 z-20' : '']" :style="{ top: timeToLogicalMinutes(img.time, img.isNextDay) * timelineZoom + 'px' }"></div>
|
||||
<div v-for="img in timelineImages" :key="img.path" class="absolute left-[50%] right-2 h-0.5 bg-[#007AFF]/20 rounded-full" :class="[selectedImage?.path === img.path ? 'bg-[#007AFF]/60 h-1 z-10' : '', lockedImage?.path === img.path ? 'bg-[#007AFF] h-1.5 ring-2 ring-[#007AFF]/20 z-20' : '']" :style="{ top: timeToLogicalMinutes(img.time, img.isNextDay) * timelineZoom + 'px' }"></div>
|
||||
<div v-if="dragStartMin !== null && dragEndMin !== null" class="absolute left-0 w-full bg-[#007AFF]/10 border-y-2 border-[#007AFF] pointer-events-none z-30" :style="{ top: Math.min(dragStartMin, dragEndMin) * timelineZoom + 'px', height: Math.abs(dragEndMin - dragStartMin) * timelineZoom + 'px' }"></div>
|
||||
<div v-if="hoveredTime" class="absolute left-0 right-0 border-t-2 border-[#007AFF] z-40 pointer-events-none" :style="{ top: timeToLogicalMinutes(hoveredTime, hoveredTime < '03:00') * timelineZoom + 'px' }"><div class="absolute -left-12 -top-3 bg-[#007AFF] text-white text-[9px] px-1 py-0.5 rounded font-bold">{{ hoveredTime }}</div></div>
|
||||
</div>
|
||||
@@ -482,7 +509,7 @@ const handleExport = async () => {
|
||||
|
||||
<div class="flex-1 bg-[#FBFBFD] flex flex-col relative overflow-hidden">
|
||||
<div class="p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur-md z-10">
|
||||
<div v-if="selectedImage" class="flex items-center gap-3"><span class="text-lg font-bold">{{ selectedImage.time }}</span><span class="text-xs font-bold px-2 py-0.5" :class="lockedImage?.path === selectedImage.path ? 'bg-[#007AFF] text-white' : 'bg-[#F2F2F7] text-[#86868B]'">{{ lockedImage?.path === selectedImage.path ? '已定格' : '预览中' }}</span></div>
|
||||
<div v-if="selectedImage" class="flex items-center gap-3"><span class="text-lg font-bold">{{ selectedImage.time }}</span><span class="text-xs font-bold px-2 py-0.5 rounded-md" :class="lockedImage?.path === selectedImage.path ? 'bg-[#007AFF] text-white' : 'bg-[#F2F2F7] text-[#86868B]'">{{ lockedImage?.path === selectedImage.path ? '已定格' : '预览中' }}</span></div>
|
||||
<div v-else class="text-[#86868B] font-medium">未选中活动</div>
|
||||
<button v-if="selectedImage" @click="isFullscreen = true" class="p-2.5 text-[#86868B]"><Maximize2 :size="20" /></button>
|
||||
</div>
|
||||
@@ -494,9 +521,9 @@ const handleExport = async () => {
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div v-if="isEventModalOpen" class="fixed inset-0 z-[110] bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isEventModalOpen = false">
|
||||
<div v-if="isEventModalOpen" class="fixed inset-0 z-110 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isEventModalOpen = false">
|
||||
<div class="bg-white rounded-[40px] shadow-2xl w-full max-w-lg overflow-hidden flex flex-col">
|
||||
<div class="p-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">{{ editingEvent.id ? '编辑事件' : '新增记录' }}</h2><button @click="isEventModalOpen = false; isStartTimeOpen = false; isEndTimeOpen = false"><X :size="24" /></button></div>
|
||||
<div class="p-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">{{ editingEvent.id ? '编辑记录' : '新增记录' }}</h2><button @click="isEventModalOpen = false; isStartTimeOpen = false; isEndTimeOpen = false"><X :size="24" /></button></div>
|
||||
<div class="p-10 space-y-8">
|
||||
<div class="flex gap-4">
|
||||
<!-- Start Time Custom Picker -->
|
||||
@@ -507,9 +534,9 @@ const handleExport = async () => {
|
||||
<!-- <Clock :size="14" class="text-[#86868B] block" /> -->
|
||||
<span class="text-sm font-bold leading-none">{{ startTimeInput }}</span>
|
||||
</button>
|
||||
<div v-if="isStartTimeOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-3xl shadow-2xl border border-[#E5E5E7] z-[120] p-4 animate-in fade-in slide-in-from-top-2 flex gap-4 h-64">
|
||||
<div v-if="isStartTimeOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-3xl shadow-2xl border border-[#E5E5E7] z-120 p-4 animate-in fade-in slide-in-from-top-2 flex gap-4 h-64">
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1">
|
||||
<div class="text-[9px] font-bold text-sec mb-2 sticky top-0 bg-white py-1 text-center border-b border-[#E5E5E7]/30">时</div>
|
||||
<div class="text-[12px] font-bold text-sec mb-2 sticky top-0 bg-white py-1 text-center border-b border-[#E5E5E7]/30">时</div>
|
||||
<button v-for="h in 24" :key="h"
|
||||
@click="editingEvent.start_minute = logicalMinutesFromTime(`${String((h-1+3)%24).padStart(2,'0')}:${String(editingEvent.start_minute % 60).padStart(2,'0')}`)"
|
||||
class="py-2 text-[11px] rounded-lg transition-colors w-full text-center flex items-center justify-center"
|
||||
@@ -518,7 +545,7 @@ const handleExport = async () => {
|
||||
</div>
|
||||
<div class="w-px bg-[#E5E5E7] my-2"></div>
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1">
|
||||
<div class="text-[9px] font-bold text-sec mb-2 sticky top-0 bg-white py-1 text-center border-b border-[#E5E5E7]/30">分</div>
|
||||
<div class="text-[12px] font-bold text-sec mb-2 sticky top-0 bg-white py-1 text-center border-b border-[#E5E5E7]/30">分</div>
|
||||
<button v-for="m in 60" :key="m"
|
||||
@click="editingEvent.start_minute = Math.floor(editingEvent.start_minute / 60) * 60 + (m-1)"
|
||||
class="py-2 text-[11px] rounded-lg transition-colors w-full text-center flex items-center justify-center"
|
||||
@@ -535,9 +562,9 @@ const handleExport = async () => {
|
||||
<!-- <Clock :size="14" class="text-[#86868B] block" /> -->
|
||||
<span class="text-sm font-bold leading-none">{{ endTimeInput }}</span>
|
||||
</button>
|
||||
<div v-if="isEndTimeOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-3xl shadow-2xl border border-[#E5E5E7] z-[120] p-4 animate-in fade-in slide-in-from-top-2 flex gap-4 h-64">
|
||||
<div v-if="isEndTimeOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-3xl shadow-2xl border border-[#E5E5E7] z-120 p-4 animate-in fade-in slide-in-from-top-2 flex gap-4 h-64">
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1 text-center">
|
||||
<div class="text-[9px] font-bold text-sec mb-2 sticky top-0 bg-white py-1">时</div>
|
||||
<div class="text-[12px] font-bold text-sec mb-2 sticky top-0 bg-white py-1">时</div>
|
||||
<button v-for="h in 24" :key="h"
|
||||
@click="editingEvent.end_minute = logicalMinutesFromTime(`${String((h-1+3)%24).padStart(2,'0')}:${String(editingEvent.end_minute % 60).padStart(2,'0')}`)"
|
||||
class="py-2 text-xs rounded-lg transition-colors w-full text-center"
|
||||
@@ -546,7 +573,7 @@ const handleExport = async () => {
|
||||
</div>
|
||||
<div class="w-px bg-[#E5E5E7] my-2"></div>
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar flex flex-col gap-1 text-center">
|
||||
<div class="text-[9px] font-bold text-sec mb-2 sticky top-0 bg-white py-1">分</div>
|
||||
<div class="text-[12px] font-bold text-sec mb-2 sticky top-0 bg-white py-1">分</div>
|
||||
<button v-for="m in 60" :key="m"
|
||||
@click="editingEvent.end_minute = Math.floor(editingEvent.end_minute / 60) * 60 + (m-1)"
|
||||
class="py-2 text-xs rounded-lg transition-colors w-full text-center"
|
||||
@@ -558,16 +585,16 @@ const handleExport = async () => {
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2"><label class="text-[10px] font-bold text-[#86868B]">主标签</label>
|
||||
<div class="grid grid-cols-4 gap-2"><button v-for="tag in mainTags" :key="tag.id" @click="editingEvent.main_tag_id = tag.id; editingEvent.sub_tag_id = null" class="px-2 py-2 rounded-xl text-[10px] font-bold border-2" :style="{ backgroundColor: editingEvent.main_tag_id === tag.id ? tag.color : 'transparent', borderColor: tag.color, color: editingEvent.main_tag_id === tag.id ? 'white' : tag.color }">{{ tag.name }}</button></div>
|
||||
<div class="grid grid-cols-4 gap-2"><button v-for="tag in mainTags" :key="tag.id" @click="editingEvent.main_tag_id = tag.id; editingEvent.sub_tag_id = null" class="px-2 py-2 rounded-xl text-[12px] font-bold border-2" :style="{ backgroundColor: editingEvent.main_tag_id === tag.id ? tag.color : 'transparent', borderColor: tag.color, color: editingEvent.main_tag_id === tag.id ? 'white' : tag.color }">{{ tag.name }}</button></div>
|
||||
</div>
|
||||
<div v-if="editingEvent.main_tag_id && getSubTags(editingEvent.main_tag_id).length" class="space-y-2"><label class="text-[10px] font-bold text-[#86868B]">副标签</label>
|
||||
<div class="flex flex-wrap gap-2"><button v-for="sub in getSubTags(editingEvent.main_tag_id)" :key="sub.id" @click="editingEvent.sub_tag_id = sub.id" class="px-3 py-1.5 rounded-lg text-[10px] font-medium" :class="editingEvent.sub_tag_id === sub.id ? 'bg-[#1D1D1F] text-white' : 'bg-[#F2F2F7] text-[#86868B]'">{{ sub.name }}</button></div>
|
||||
<div class="flex flex-wrap gap-2"><button v-for="sub in getSubTags(editingEvent.main_tag_id)" :key="sub.id" @click="editingEvent.sub_tag_id = sub.id" class="px-3 py-1.5 rounded-lg text-[12px] font-medium" :class="editingEvent.sub_tag_id === sub.id ? 'bg-[#1D1D1F] text-white' : 'bg-[#F2F2F7] text-[#86868B]'">{{ sub.name }}</button></div>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="editingEvent.content"
|
||||
placeholder="记录具体内容..."
|
||||
@keydown.ctrl.enter="saveEvent"
|
||||
class="w-full bg-[#F2F2F7] rounded-2xl p-4 text-sm min-h-[100px] outline-none border border-transparent focus:bg-white focus:border-[#E5E5E7] transition-all"
|
||||
class="w-full bg-[#F2F2F7] rounded-2xl p-4 text-sm min-h-25 outline-none border border-transparent focus:bg-white focus:border-[#E5E5E7] transition-all"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex gap-4 pt-4">
|
||||
@@ -578,7 +605,7 @@ const handleExport = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isTagManagerOpen" class="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isTagManagerOpen = false">
|
||||
<div v-if="isTagManagerOpen" class="fixed inset-0 z-100 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isTagManagerOpen = false">
|
||||
<div class="bg-white 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="isTagManagerOpen = false"><X :size="24" /></button></div>
|
||||
<div class="flex-1 overflow-hidden p-10 flex gap-10">
|
||||
@@ -600,7 +627,7 @@ const handleExport = async () => {
|
||||
<span>{{ getTagName(newTagParent) }}</span>
|
||||
<ChevronDown :size="14" class="text-[#86868B] transition-transform" :class="{ 'rotate-180': isTagSelectOpen }" />
|
||||
</button>
|
||||
<div v-if="isTagSelectOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl shadow-xl border border-[#E5E5E7] z-[60] overflow-hidden py-2 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div v-if="isTagSelectOpen" class="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl shadow-xl border border-[#E5E5E7] 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-[#F2F2F7] 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-[#F2F2F7] cursor-pointer" :class="{ 'text-[#007AFF] font-bold': newTagParent === t.id }">{{ t.name }}</div>
|
||||
</div>
|
||||
@@ -615,50 +642,76 @@ const handleExport = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExportModalOpen" class="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isExportModalOpen = false">
|
||||
<div class="bg-white rounded-[40px] shadow-2xl w-full max-w-md 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="isExportModalOpen = false"><X :size="24" /></button></div>
|
||||
<div v-if="isExportModalOpen" class="fixed inset-0 z-100 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isExportModalOpen = false">
|
||||
<div class="bg-white rounded-[40px] shadow-2xl w-full max-w-2xl overflow-visible flex flex-col animate-in fade-in zoom-in duration-200">
|
||||
<div class="p-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">导出记录</h2><button @click="isExportModalOpen = false"><X :size="24" /></button></div>
|
||||
<div class="p-10 space-y-8">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-bold text-[#86868B]">日期范围</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input type="date" v-model="exportStartDate" class="flex-1 bg-[#F2F2F7] rounded-xl px-4 py-2.5 text-sm outline-none border border-transparent focus:border-[#007AFF] transition-all" />
|
||||
<span class="text-[#86868B] font-bold">至</span>
|
||||
<input type="date" v-model="exportEndDate" class="flex-1 bg-[#F2F2F7] rounded-xl px-4 py-2.5 text-sm outline-none border border-transparent focus:border-[#007AFF] transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-end">
|
||||
<label class="text-[10px] font-bold text-[#86868B]">包含的标签</label>
|
||||
<button @click="selectAllTags" class="text-[10px] font-bold text-[#007AFF] hover:underline">全选</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-1 no-scrollbar">
|
||||
<button v-for="tag in tags" :key="tag.id" @click="toggleExportTag(tag.id)" class="px-3 py-1.5 rounded-lg text-[10px] font-medium border-2 transition-all" :style="{ backgroundColor: exportSelectedTags.includes(tag.id) ? tag.color : 'transparent', borderColor: tag.color, color: exportSelectedTags.includes(tag.id) ? 'white' : tag.color }">{{ tag.name }}</button>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div ref="exportStartCalendarRef" class="relative flex-1">
|
||||
<button @click="isExportStartCalendarOpen = !isExportStartCalendarOpen; isExportEndCalendarOpen = false" class="w-full bg-[#F2F2F7] 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-[#86868B]" />
|
||||
{{ exportStartDate }}
|
||||
</button>
|
||||
<div v-if="isExportStartCalendarOpen" class="absolute top-full left-0 mt-2 bg-white rounded-3xl shadow-2xl border border-[#E5E5E7] 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-[#F2F2F7] 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-[#F2F2F7] 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-[#86868B]">{{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="selectExportStartDate(date)" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="date.toLocaleDateString('sv') === exportStartDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-[#F2F2F7] text-main'">{{ date.getDate() }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[#86868B] font-black text-xs">至</span>
|
||||
<div ref="exportEndCalendarRef" class="relative flex-1">
|
||||
<button @click="isExportEndCalendarOpen = !isExportEndCalendarOpen; isExportStartCalendarOpen = false" class="w-full bg-[#F2F2F7] 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-[#86868B]" />
|
||||
{{ exportEndDate }}
|
||||
</button>
|
||||
<div v-if="isExportEndCalendarOpen" class="absolute top-full right-0 mt-2 bg-white rounded-3xl shadow-2xl border border-[#E5E5E7] 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-[#F2F2F7] 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-[#F2F2F7] 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-[#86868B]">{{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="selectExportEndDate(date)" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="date.toLocaleDateString('sv') === exportEndDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-[#F2F2F7] text-main'">{{ date.getDate() }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="handleExport" :disabled="!exportStartDate || !exportEndDate || exportSelectedTags.length === 0" class="w-full bg-[#007AFF] text-white py-4 rounded-2xl font-bold shadow-lg shadow-[#007AFF]/20 active:scale-95 transition-all disabled:opacity-50 disabled:active:scale-100">导出为 CSV</button>
|
||||
<button @click="handleExport" :disabled="!exportStartDate || !exportEndDate" class="w-full bg-[#007AFF] text-white py-4 rounded-2xl font-bold shadow-lg shadow-[#007AFF]/20 active:scale-95 transition-all disabled:opacity-50 disabled:active:scale-100">导出为 JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isSettingsOpen" class="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isSettingsOpen = false">
|
||||
<div v-if="isSettingsOpen" class="fixed inset-0 z-100 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="isSettingsOpen = false">
|
||||
<div class="bg-white 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="isSettingsOpen = false"><X :size="24" /></button></div>
|
||||
<div class="p-10 space-y-8">
|
||||
<div class="space-y-3"><label class="text-xs font-bold text-[#86868B]">截图保存位置</label><div class="flex gap-2"><input type="text" readonly :value="savePath" class="flex-1 bg-[#F2F2F7] rounded-xl px-4 py-2 text-sm outline-none" /><button @click="selectFolder(); updateSettings()" class="bg-white 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-[#86868B]">SQLite 数据库文件</label><div class="flex gap-2"><input type="text" readonly :value="dbPath" class="flex-1 bg-[#F2F2F7] rounded-xl px-4 py-2 text-sm outline-none" /><div class="flex gap-1"><button @click="selectDBFile().then(updateSettings)" title="打开现有" class="bg-white border p-2 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button><button @click="createDBFile().then(updateSettings)" title="新建" class="bg-white border p-2 rounded-xl hover:border-[#007AFF] transition-all"><Plus :size="18" /></button></div></div></div>
|
||||
<div class="flex items-center justify-between p-4 bg-[#F2F2F7] rounded-3xl"><div class="space-y-0.5"><div class="font-bold">多屏合并</div><div class="text-[10px] text-[#86868B]">拼接所有屏幕</div></div><button @click="mergeScreens = !mergeScreens; updateSettings()" class="w-12 h-6 rounded-full relative transition-all" :class="mergeScreens ? 'bg-[#34C759]' : 'bg-[#E5E5E7]'"><div class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-all" :style="{ transform: mergeScreens ? 'translateX(24px)' : 'translateX(0)' }"></div></button></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="10" max="600" step="10" @change="updateSettings" class="w-full h-1 bg-[#E5E5E7] accent-[#007AFF]" /></div>
|
||||
<div class="space-y-3"><label class="text-xs font-bold text-[#86868B]">数据库文件</label><div class="flex gap-2"><input type="text" readonly :value="dbPath" class="flex-1 bg-[#F2F2F7] rounded-xl px-4 py-2 text-sm outline-none" /><div class="flex gap-1"><button @click="selectDBFile().then(updateSettings)" title="打开现有" class="bg-white border p-2 rounded-xl hover:border-[#007AFF] transition-all"><FolderOpen :size="18" /></button><button @click="createDBFile().then(updateSettings)" title="新建" class="bg-white 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><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-[#E5E5E7] 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-[#E5E5E7] accent-[#007AFF]" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isFullscreen && previewSrc" class="fixed inset-0 z-[200] bg-black/95 flex items-center justify-center p-6 backdrop-blur-xl"><button @click="isFullscreen = false" class="absolute top-10 right-10 w-12 h-12 bg-white/10 rounded-full flex items-center justify-center"><X :size="32" class="text-white" /></button><img :src="previewSrc" class="max-w-full max-h-full object-contain shadow-2xl" /></div>
|
||||
<div v-if="isFullscreen && previewSrc" class="fixed inset-0 z-200 bg-black/95 flex items-center justify-center p-6 backdrop-blur-xl"><button @click="isFullscreen = false" class="absolute top-10 right-10 w-12 h-12 bg-white/10 rounded-full flex items-center justify-center"><X :size="32" class="text-white" /></button><img :src="previewSrc" class="max-w-full max-h-full object-contain shadow-2xl" /></div>
|
||||
|
||||
<div v-if="toast.visible" class="fixed bottom-10 left-1/2 -translate-x-1/2 z-[300] animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div v-if="toast.visible" class="fixed bottom-10 left-1/2 -translate-x-1/2 z-300 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div class="px-6 py-3 rounded-2xl shadow-2xl backdrop-blur-md flex items-center gap-3 border border-white/20" :class="toast.type === 'error' ? 'bg-[#FF3B30] text-white' : 'bg-white/90 text-[#1D1D1F]'">
|
||||
<div v-if="toast.type === 'error'" class="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[12px] font-black">!</div>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-[#34C759] flex items-center justify-center text-white text-[10px]">✓</div>
|
||||
|
||||