support export
This commit is contained in:
@@ -103,12 +103,32 @@ pub fn get_events(path: &str, date: &str) -> anyhow::Result<Vec<Event>> {
|
|||||||
content: row.get(6)?,
|
content: row.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
let mut events = Vec::new();
|
||||||
let mut results = Vec::new();
|
|
||||||
for event in event_iter {
|
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> {
|
pub fn save_event(path: &str, event: Event) -> anyhow::Result<i64> {
|
||||||
|
|||||||
@@ -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())
|
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]
|
#[tauri::command]
|
||||||
pub fn save_event(state: tauri::State<'_, AppState>, event: crate::db::Event) -> Result<i64, String> {
|
pub fn save_event(state: tauri::State<'_, AppState>, event: crate::db::Event) -> Result<i64, String> {
|
||||||
let path = state.db_path.lock().unwrap();
|
let path = state.db_path.lock().unwrap();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub fn run() {
|
|||||||
engine::add_tag,
|
engine::add_tag,
|
||||||
engine::delete_tag,
|
engine::delete_tag,
|
||||||
engine::get_events,
|
engine::get_events,
|
||||||
|
engine::get_events_range,
|
||||||
engine::save_event,
|
engine::save_event,
|
||||||
engine::delete_event
|
engine::delete_event
|
||||||
])
|
])
|
||||||
|
|||||||
90
src/App.vue
90
src/App.vue
@@ -2,9 +2,10 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
||||||
import { load } from "@tauri-apps/plugin-store";
|
import { load } from "@tauri-apps/plugin-store";
|
||||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
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 ---
|
// --- Types ---
|
||||||
interface Tag { id: number; name: string; parent_id: number | null; color: string; }
|
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 ---
|
// --- Custom Select State ---
|
||||||
const isTagSelectOpen = ref(false);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -416,6 +475,7 @@ const isTagSelectOpen = ref(false);
|
|||||||
<div class="p-4 border-t border-[#E5E5E7] bg-white/50 flex justify-around">
|
<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="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="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>
|
<button @click="isSettingsOpen = true" class="p-3 rounded-2xl text-[#86868B]"><Settings :size="22" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -555,6 +615,34 @@ const isTagSelectOpen = ref(false);
|
|||||||
</div>
|
</div>
|
||||||
</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 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="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-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">设置</h2><button @click="isSettingsOpen = false"><X :size="24" /></button></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user