support export

This commit is contained in:
Julian Freeman
2026-03-22 19:05:57 -04:00
parent 43787517c4
commit b32d5ddbd3
4 changed files with 121 additions and 5 deletions

View File

@@ -103,12 +103,32 @@ pub fn get_events(path: &str, date: &str) -> anyhow::Result<Vec<Event>> {
content: row.get(6)?,
})
})?;
let mut results = Vec::new();
let mut events = Vec::new();
for event in event_iter {
results.push(event?);
events.push(event?);
}
Ok(results)
Ok(events)
}
pub fn get_events_range(path: &str, start_date: &str, end_date: &str) -> anyhow::Result<Vec<Event>> {
let conn = Connection::open(path)?;
let mut stmt = conn.prepare("SELECT id, date, start_minute, end_minute, main_tag_id, sub_tag_id, content FROM events WHERE date >= ?1 AND date <= ?2 ORDER BY date, start_minute")?;
let event_iter = stmt.query_map(params![start_date, end_date], |row| {
Ok(Event {
id: row.get(0)?,
date: row.get(1)?,
start_minute: row.get(2)?,
end_minute: row.get(3)?,
main_tag_id: row.get(4)?,
sub_tag_id: row.get(5)?,
content: row.get(6)?,
})
})?;
let mut events = Vec::new();
for event in event_iter {
events.push(event?);
}
Ok(events)
}
pub fn save_event(path: &str, event: Event) -> anyhow::Result<i64> {

View File

@@ -51,6 +51,13 @@ pub fn get_events(state: tauri::State<'_, AppState>, date: String) -> Result<Vec
crate::db::get_events(path, &date).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_events_range(state: tauri::State<'_, AppState>, start_date: String, end_date: String) -> Result<Vec<crate::db::Event>, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_events_range(path, &start_date, &end_date).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_event(state: tauri::State<'_, AppState>, event: crate::db::Event) -> Result<i64, String> {
let path = state.db_path.lock().unwrap();

View File

@@ -28,6 +28,7 @@ pub fn run() {
engine::add_tag,
engine::delete_tag,
engine::get_events,
engine::get_events_range,
engine::save_event,
engine::delete_event
])

View File

@@ -2,9 +2,10 @@
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 } from "lucide-vue-next";
import { Tag as TagIcon, FolderOpen, Settings, Play, Pause, Maximize2, X, RefreshCw, Plus, Trash2, ChevronDown, ChevronLeft, ChevronRight, Calendar, Download } from "lucide-vue-next";
// --- Types ---
interface Tag { id: number; name: string; parent_id: number | null; color: string; }
@@ -344,6 +345,64 @@ const resetTagForm = () => { newTagName.value = ""; newTagParent.value = null; n
// --- Custom Select State ---
const isTagSelectOpen = ref(false);
// --- Export State ---
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 toggleExportTag = (tagId: number) => {
if (exportSelectedTags.value.includes(tagId)) {
exportSelectedTags.value = exportSelectedTags.value.filter(id => id !== tagId);
} else {
exportSelectedTags.value.push(tagId);
}
};
const selectAllTags = () => {
exportSelectedTags.value = tags.value.map(t => t.id);
};
const openExportModal = () => {
exportEndDate.value = currentDate.value;
selectAllTags();
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");
return;
}
const savePath = await save({
filters: [{ name: "CSV 逗号分隔值文件", extensions: ["csv"] }],
defaultPath: `ChronoSnap_Export_${exportStartDate.value}_${exportEndDate.value}.csv`
});
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);
isExportModalOpen.value = false;
showToast("导出成功");
}
} catch (e) {
showToast("导出失败: " + e, "error");
}
};
</script>
<template>
@@ -416,6 +475,7 @@ const isTagSelectOpen = ref(false);
<div class="p-4 border-t border-[#E5E5E7] bg-white/50 flex justify-around">
<button @click="togglePauseState" class="p-3 rounded-2xl" :class="isPaused ? 'text-[#FF3B30]' : 'text-[#86868B]'"><Play v-if="isPaused" :size="22" /><Pause v-else :size="22" /></button>
<button @click="isTagManagerOpen = true" class="p-3 rounded-2xl text-[#86868B]"><TagIcon :size="22" /></button>
<button @click="openExportModal" class="p-3 rounded-2xl text-[#86868B]"><Download :size="22" /></button>
<button @click="isSettingsOpen = true" class="p-3 rounded-2xl text-[#86868B]"><Settings :size="22" /></button>
</div>
</div>
@@ -555,6 +615,34 @@ const isTagSelectOpen = ref(false);
</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 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>
</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>
</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 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>