refactor
This commit is contained in:
1005
src/App.vue
1005
src/App.vue
File diff suppressed because it is too large
Load Diff
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>
|
||||||
126
src/components/modals/ExportModal.vue
Normal file
126
src/components/modals/ExportModal.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { X, Calendar, ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||||
|
import { currentDate, showToast, getTagName, logicalMinutesToTime } from "../../store";
|
||||||
|
import { DBEvent } from "../../types";
|
||||||
|
|
||||||
|
defineEmits(['close']);
|
||||||
|
|
||||||
|
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 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 handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const events: DBEvent[] = await invoke("get_events_range", { startDate: exportStartDate.value, endDate: exportEndDate.value });
|
||||||
|
if (events.length === 0) {
|
||||||
|
showToast("所选范围内没有找到记录", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const savePath = await save({
|
||||||
|
filters: [{ name: "JSON 文件", extensions: ["json"] }],
|
||||||
|
defaultPath: `ChronoSnap_Export_${exportStartDate.value}_${exportEndDate.value}.json`
|
||||||
|
});
|
||||||
|
if (savePath) {
|
||||||
|
const exportData = events.map(e => ({
|
||||||
|
date: e.date,
|
||||||
|
start_time: logicalMinutesToTime(e.start_minute),
|
||||||
|
end_time: logicalMinutesToTime(e.end_minute),
|
||||||
|
main_tag: getTagName(e.main_tag_id),
|
||||||
|
sub_tag: getTagName(e.sub_tag_id),
|
||||||
|
content: e.content
|
||||||
|
}));
|
||||||
|
await invoke("write_file", { path: savePath, content: JSON.stringify(exportData, null, 2) });
|
||||||
|
showToast("导出成功");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
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 overflow-visible flex flex-col animate-in fade-in zoom-in duration-200">
|
||||||
|
<div class="p-8 border-b flex justify-between items-center"><h2 class="text-2xl font-bold">导出记录</h2><button @click="$emit('close')"><X :size="24" /></button></div>
|
||||||
|
<div class="p-10 space-y-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] font-bold text-text-sec">日期范围</label>
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<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 = date.toLocaleDateString('sv'); isExportStartCalendarOpen = false" 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-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">
|
||||||
|
<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 = date.toLocaleDateString('sv'); isExportEndCalendarOpen = false" 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-bg-input text-main'">{{ date.getDate() }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="handleExport" :disabled="!exportStartDate || !exportEndDate" class="w-full bg-[#007AFF] text-white py-4 rounded-2xl font-bold shadow-lg shadow-[#007AFF]/20 active:scale-95 transition-all disabled:opacity-50 disabled:active:scale-100">导出为 JSON</button>
|
||||||
|
</div>
|
||||||
|
</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-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>
|
||||||
85
src/components/views/Dashboard.vue
Normal file
85
src/components/views/Dashboard.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { BarChart2 } from "lucide-vue-next";
|
||||||
|
import {
|
||||||
|
dashboardStats, dashboardRange, dailyAverageMode,
|
||||||
|
formatMinutes, getTagColor, getTagName
|
||||||
|
} from "../../store/dashboardStore";
|
||||||
|
</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">
|
||||||
|
<div class="flex justify-between items-center mb-10">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
75
src/store/dashboardStore.ts
Normal file
75
src/store/dashboardStore.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { DBEvent } from "../types";
|
||||||
|
import { currentDate, getTagColor, getTagName, formatMinutes } from "./index";
|
||||||
|
|
||||||
|
// Re-export for easier access in Dashboard component
|
||||||
|
export { getTagColor, getTagName, formatMinutes };
|
||||||
|
|
||||||
|
export const dashboardRange = ref<'today' | '7days' | '30days'>('today');
|
||||||
|
export const dashboardStartDate = ref(currentDate.value);
|
||||||
|
export const dashboardEndDate = ref(currentDate.value);
|
||||||
|
export const dashboardEvents = ref<DBEvent[]>([]);
|
||||||
|
export const dailyAverageMode = ref<'natural' | 'recorded'>('natural');
|
||||||
|
|
||||||
|
export const loadDashboardEvents = async () => {
|
||||||
|
dashboardEvents.value = await invoke("get_events_range", { startDate: dashboardStartDate.value, endDate: dashboardEndDate.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
watch([dashboardRange, currentDate], () => {
|
||||||
|
const end = new Date(currentDate.value);
|
||||||
|
let start = new Date(currentDate.value);
|
||||||
|
if (dashboardRange.value === '7days') start.setDate(end.getDate() - 6);
|
||||||
|
else if (dashboardRange.value === '30days') start.setDate(end.getDate() - 29);
|
||||||
|
dashboardStartDate.value = start.toLocaleDateString('sv');
|
||||||
|
dashboardEndDate.value = end.toLocaleDateString('sv');
|
||||||
|
loadDashboardEvents();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
let diff = ev.end_minute - ev.start_minute;
|
||||||
|
if (diff < 0) diff += 1440;
|
||||||
|
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 };
|
||||||
|
});
|
||||||
75
src/store/index.ts
Normal file
75
src/store/index.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Tag, DBEvent, TimelineItem, Toast } from "../types";
|
||||||
|
|
||||||
|
export const TIME_OFFSET_MINUTES = 180;
|
||||||
|
export const TOTAL_MINUTES = 1440;
|
||||||
|
|
||||||
|
// --- 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(new Date(Date.now() - TIME_OFFSET_MINUTES * 60000).toLocaleDateString('sv'));
|
||||||
|
export const viewMode = ref<'preview' | 'dashboard'>('preview');
|
||||||
|
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 timelineImages = ref<TimelineItem[]>([]);
|
||||||
|
|
||||||
|
// --- 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) => tags.value.find(t => t.id === tagId)?.color || "#007AFF";
|
||||||
|
export const getTagName = (tagId: number | null) => tags.value.find(t => t.id === tagId)?.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 }); };
|
||||||
28
src/types/index.ts
Normal file
28
src/types/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 TimelineItem {
|
||||||
|
time: string;
|
||||||
|
path: string;
|
||||||
|
isNextDay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error";
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user