10 Commits
v0.2.1 ... main

Author SHA1 Message Date
Julian Freeman
0e39ad7ed8 upgrade cal 2026-03-27 12:48:14 -04:00
Julian Freeman
07faa81d21 opt 2026-03-27 12:25:46 -04:00
Julian Freeman
2d51467bb9 support badge 2026-03-27 12:09:13 -04:00
Julian Freeman
c4366e62e1 upgrade 2026-03-26 21:07:50 -04:00
Julian Freeman
42be9062b4 add export to json 2026-03-26 21:02:59 -04:00
Julian Freeman
b1b63a9949 custon scrollbar 2026-03-26 20:48:08 -04:00
Julian Freeman
3fe7f013dc support resize 2026-03-26 20:45:46 -04:00
Julian Freeman
5f14a0dd10 support copy col 2026-03-26 20:35:46 -04:00
Julian Freeman
b838ce7aa8 fix bug 2026-03-26 20:24:55 -04:00
Julian Freeman
97ba35c4c4 support export table 2026-03-26 20:15:24 -04:00
12 changed files with 545 additions and 34 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "chrono-snap",
"private": true,
"version": "0.2.1",
"version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@@ -667,7 +667,7 @@ dependencies = [
[[package]]
name = "chrono-snap"
version = "0.2.1"
version = "0.2.3"
dependencies = [
"anyhow",
"base64 0.22.1",

View File

@@ -1,6 +1,6 @@
[package]
name = "chrono-snap"
version = "0.2.1"
version = "0.2.3"
description = "An app to record screens and events"
authors = ["you"]
edition = "2021"

View File

@@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-set-overlay-icon",
"opener:default",
"notification:default"
]

View File

@@ -220,3 +220,44 @@ pub fn toggle_reminder(path: &str, id: i64, is_completed: bool) -> anyhow::Resul
conn.execute("UPDATE reminders SET is_completed=?1 WHERE id=?2", params![is_completed, id])?;
Ok(())
}
pub fn get_overdue_reminders_count(path: &str, date: &str, minute: i32) -> anyhow::Result<i32> {
let conn = Connection::open(path)?;
let mut stmt = conn.prepare("SELECT COUNT(*) FROM reminders WHERE is_completed = 0 AND (date < ?1 OR (date = ?1 AND minute < ?2))")?;
let count: i32 = stmt.query_row(params![date, minute], |row| row.get(0))?;
Ok(count)
}
#[derive(Serialize, Deserialize)]
pub struct DayStatus {
pub date: String,
pub has_overdue: bool,
pub has_upcoming: bool,
}
pub fn get_reminders_by_month(path: &str, year_month: &str, today: &str, now_minute: i32) -> anyhow::Result<Vec<DayStatus>> {
let conn = Connection::open(path)?;
// 查找该月份内有提醒的所有日期
let mut stmt = conn.prepare("
SELECT date,
MAX(CASE WHEN is_completed = 0 AND (date < ?2 OR (date = ?2 AND minute < ?3)) THEN 1 ELSE 0 END) as has_overdue,
MAX(CASE WHEN is_completed = 0 AND (date > ?2 OR (date = ?2 AND minute >= ?3)) THEN 1 ELSE 0 END) as has_upcoming
FROM reminders
WHERE date LIKE ?1
GROUP BY date
")?;
let rows = stmt.query_map(params![format!("{}%", year_month), today, now_minute], |row| {
Ok(DayStatus {
date: row.get(0)?,
has_overdue: row.get::<_, i32>(1)? == 1,
has_upcoming: row.get::<_, i32>(2)? == 1,
})
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}

View File

@@ -100,6 +100,20 @@ pub fn toggle_reminder(state: tauri::State<'_, AppState>, id: i64, is_completed:
crate::db::toggle_reminder(path, id, is_completed).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_overdue_reminders_count(state: tauri::State<'_, AppState>, date: String, minute: i32) -> Result<i32, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_overdue_reminders_count(path, &date, minute).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_reminders_by_month(state: tauri::State<'_, AppState>, year_month: String, today: String, now_minute: i32) -> Result<Vec<crate::db::DayStatus>, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_reminders_by_month(path, &year_month, &today, now_minute).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_interval(state: tauri::State<'_, AppState>, seconds: u64) {
state.capture_interval_secs.store(seconds, Ordering::SeqCst);

View File

@@ -36,6 +36,8 @@ pub fn run() {
engine::save_reminder,
engine::delete_reminder,
engine::toggle_reminder,
engine::get_overdue_reminders_count,
engine::get_reminders_by_month,
engine::write_file
])
.setup(|app| {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChronoSnap",
"version": "0.2.1",
"version": "0.2.3",
"identifier": "top.volan.chrono-snap",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "瞬影 - 时间记录 v0.2.1",
"title": "瞬影 - 时间记录 v0.2.3",
"width": 1760,
"height": 1100
}

View File

@@ -14,7 +14,8 @@ import {
retainDays, captureInterval,
TIME_OFFSET_MINUTES, TOTAL_MINUTES, getTagColor, getTagName, mainTags, getSubTags,
logicalMinutesToTime, logicalMinutesFromTime, formatDuration,
loadTags, loadEvents, loadReminders, toISODate
loadTags, loadEvents, loadReminders, toISODate, refreshBadgeCount,
calendarStatus, loadCalendarStatus, currentCalendarMonthStr
} from "./store";
import { DBEvent, TimelineItem } from "./types";
@@ -89,7 +90,6 @@ const checkReminders = async (currentMin: number) => {
}
for (const r of dueReminders) {
showToast(`⏰ 提醒: ${r.content}`);
if (hasPermission) {
sendNotification({ title: '瞬影 提醒', body: r.content });
}
@@ -109,6 +109,8 @@ const updateCurrentMinute = () => {
} else {
currentLogicalMinute.value = -1;
}
// 无论是否看今天,每分钟都尝试刷新全局徽章和日历状态点
refreshBadgeCount();
};
let currentMinuteTimeout: number | null = null;
@@ -125,6 +127,13 @@ const startMinuteTimer = () => {
}, msUntilNextMinute);
};
watch(calendarMonth, (newMonth) => {
const y = newMonth.getFullYear();
const m = String(newMonth.getMonth() + 1).padStart(2, '0');
currentCalendarMonthStr.value = `${y}-${m}`;
loadCalendarStatus(currentCalendarMonthStr.value);
}, { immediate: true });
watch(currentDate, () => {
updateCurrentMinute();
});
@@ -391,8 +400,23 @@ const togglePause = async () => {
</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 calendarDays" :key="i" class="aspect-square flex items-center justify-center">
<button v-if="date" @click="selectCalendarDate(date)" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="date.toLocaleDateString('sv') === currentDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ date.getDate() }}</button>
<div v-for="(date, i) in calendarDays" :key="i" class="aspect-square flex flex-col items-center justify-center relative">
<button v-if="date" @click="selectCalendarDate(date)"
class="w-8 h-8 rounded-full text-xs font-medium transition-all flex items-center justify-center relative"
:class="[
date.toLocaleDateString('sv') === getLogicDateStr()
? 'bg-[#007AFF] text-white font-black shadow-[0_4px_12px_rgba(0,122,255,0.4)] ' + (date.toLocaleDateString('sv') === currentDate ? 'ring-2 ring-offset-2 ring-[#007AFF]' : '')
: (date.toLocaleDateString('sv') === currentDate
? 'bg-bg-input text-[#007AFF] font-black ring-1 ring-border-main shadow-sm'
: 'hover:bg-bg-input text-main')
]">
{{ date.getDate() }}
</button>
<!-- Status Dots -->
<div v-if="date && calendarStatus[date.toLocaleDateString('sv')]" class="absolute -bottom-1.5 flex gap-0.5">
<div v-if="calendarStatus[date.toLocaleDateString('sv')].has_overdue" class="w-1.5 h-1.5 rounded-full bg-[#FF3B30] border-[1.5px] border-bg-card shadow-sm"></div>
<div v-if="calendarStatus[date.toLocaleDateString('sv')].has_upcoming" class="w-1.5 h-1.5 rounded-full bg-[#007AFF] border-[1.5px] border-bg-card shadow-sm"></div>
</div>
</div>
</div>
</div>

View File

@@ -55,3 +55,43 @@ body {
.text-main { color: var(--text-main); }
.text-sec { color: var(--text-sec); }
.border-main { border-color: var(--border-color); }
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border: 2px solid transparent;
background-clip: padding-box;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
border: 2px solid transparent;
background-clip: padding-box;
}

View File

@@ -2,8 +2,8 @@
import { ref, computed, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog";
import { X, Calendar, ChevronLeft, ChevronRight } from "lucide-vue-next";
import { currentDate, showToast, getTagName, logicalMinutesToTime, toISODate } from "../../store";
import { X, Calendar, ChevronLeft, ChevronRight, Copy, ArrowDown, ArrowUp, Download } from "lucide-vue-next";
import { currentDate, showToast, getTagName, logicalMinutesToTime, toISODate, mainTags } from "../../store";
import { DBEvent } from "../../types";
defineEmits(['close']);
@@ -18,6 +18,75 @@ const exportEndMonth = ref(new Date(exportEndDate.value));
const isExportStartCalendarOpen = ref(false);
const isExportEndCalendarOpen = ref(false);
// 选择的主标签,默认全选
const selectedTags = ref<number[]>(mainTags.value.map(t => t.id));
// 表格数据
const dateList = ref<string[]>([]);
const previewData = ref<Record<string, Record<number, string>>>({});
const isDesc = ref(true);
const sortedDateList = computed(() => isDesc.value ? [...dateList.value].reverse() : dateList.value);
// 拖拽调整宽高状态
const colWidths = ref<Record<string | number, number>>({});
const rowHeights = ref<Record<string, number>>({});
const isDragging = ref(false);
let dragType: 'col' | 'row' | null = null;
let dragKey: string | number | null = null;
let startPos = 0;
let startSize = 0;
const MIN_COL_WIDTH = 120;
const MIN_ROW_HEIGHT = 48;
const startResizeCol = (e: MouseEvent, key: string | number) => {
isDragging.value = true;
dragType = 'col';
dragKey = key;
startPos = e.clientX;
startSize = colWidths.value[key] || MIN_COL_WIDTH;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const startResizeRow = (e: MouseEvent, key: string) => {
isDragging.value = true;
dragType = 'row';
dragKey = key;
startPos = e.clientY;
startSize = rowHeights.value[key] || (e.target as HTMLElement).closest('td')?.offsetHeight || MIN_ROW_HEIGHT;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value || dragKey === null) return;
if (dragType === 'col') {
const delta = e.clientX - startPos;
colWidths.value[dragKey] = Math.max(MIN_COL_WIDTH, startSize + delta);
} else if (dragType === 'row') {
const delta = e.clientY - startPos;
rowHeights.value[dragKey as string] = Math.max(MIN_ROW_HEIGHT, startSize + delta);
}
};
const onMouseUp = () => {
isDragging.value = false;
dragType = null;
dragKey = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
const toggleTag = (id: number) => {
if (selectedTags.value.includes(id)) {
selectedTags.value = selectedTags.value.filter(tId => tId !== id);
} else {
selectedTags.value.push(id);
}
};
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (isExportStartCalendarOpen.value && startCalendarRef.value && !startCalendarRef.value.contains(target)) {
@@ -34,6 +103,8 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
const exportStartCalendarDays = computed(() => {
@@ -60,43 +131,183 @@ const exportEndCalendarDays = computed(() => {
return days;
});
const handleExport = async () => {
const parseDateStr = (str: string) => {
const [y, m, d] = str.split('-').map(Number);
return new Date(y, m - 1, d);
};
const handlePreview = async () => {
if (selectedTags.value.length === 0) {
showToast("请至少选择一个主标签", "error");
return;
}
const curDate = parseDateStr(exportStartDate.value);
const endDate = parseDateStr(exportEndDate.value);
if (curDate > endDate) {
showToast("开始日期不能晚于结束日期", "error");
return;
}
try {
const events: DBEvent[] = await invoke("get_events_range", { startDate: exportStartDate.value, endDate: exportEndDate.value });
if (events.length === 0) {
showToast("所选范围内没有找到记录", "error");
return;
// 生成日期范围列表
const dates: string[] = [];
let tempDate = new Date(curDate);
while (tempDate <= endDate) {
dates.push(toISODate(tempDate));
tempDate.setDate(tempDate.getDate() + 1);
}
const savePath = await save({
filters: [{ name: "JSON 文件", extensions: ["json"] }],
defaultPath: `ChronoSnap_Export_${exportStartDate.value}_${exportEndDate.value}.json`
dateList.value = dates;
// 初始化列宽
colWidths.value = { date: 150 };
selectedTags.value.forEach(tagId => {
colWidths.value[tagId] = 200;
});
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) });
// 初始化统一行高
rowHeights.value = {};
dates.forEach(d => {
rowHeights.value[d] = 100;
});
// 过滤选中的主标签并构建矩阵
const matrix: Record<string, Record<number, string>> = {};
dates.forEach(d => matrix[d] = {});
const filteredEvents = events.filter(e => selectedTags.value.includes(e.main_tag_id));
filteredEvents.forEach(e => {
if (!matrix[e.date]) matrix[e.date] = {}; // 容错
const cellArr = matrix[e.date][e.main_tag_id] ? matrix[e.date][e.main_tag_id].split('\n') : [];
const startTime = logicalMinutesToTime(e.start_minute);
const endTime = logicalMinutesToTime(e.end_minute);
const subTag = getTagName(e.sub_tag_id);
// 替换换行符为空格
const content = e.content ? e.content.replace(/\n/g, ' ') : '';
let line = `${startTime}-${endTime}`;
if (subTag) line += ` ${subTag}`;
if (content) line += ` ${content}`;
cellArr.push(line);
matrix[e.date][e.main_tag_id] = cellArr.join('\n');
});
previewData.value = matrix;
} catch (e) {
showToast("获取数据失败: " + e, "error");
}
};
const exportToJson = async () => {
if (selectedTags.value.length === 0) {
showToast("请至少选择一个主标签", "error");
return;
}
const curDate = parseDateStr(exportStartDate.value);
const endDate = parseDateStr(exportEndDate.value);
if (curDate > endDate) {
showToast("开始日期不能晚于结束日期", "error");
return;
}
try {
const events: DBEvent[] = await invoke("get_events_range", { startDate: exportStartDate.value, endDate: exportEndDate.value });
const filteredEvents = events.filter(e => selectedTags.value.includes(e.main_tag_id));
// Add tag names to the exported data for better readability
const exportData = filteredEvents.map(e => ({
...e,
main_tag_name: getTagName(e.main_tag_id),
sub_tag_name: getTagName(e.sub_tag_id),
start_time_str: logicalMinutesToTime(e.start_minute),
end_time_str: logicalMinutesToTime(e.end_minute),
}));
const jsonStr = JSON.stringify(exportData, null, 2);
const filePath = await save({
filters: [{ name: 'JSON', extensions: ['json'] }],
defaultPath: `chrono-snap-export-${exportStartDate.value}-to-${exportEndDate.value}.json`
});
if (filePath) {
await invoke("write_file", { path: filePath, content: jsonStr });
showToast("导出成功");
}
} catch (e) {
showToast("导出失败: " + e, "error");
}
};
const copyColumn = async (tagId: number | 'date') => {
let lines: string[] = [];
for (const date of sortedDateList.value) {
if (tagId === 'date') {
lines.push(date);
} else {
let cellStr = previewData.value[date][tagId] || "";
if (cellStr.includes('\n') || cellStr.includes('\t') || cellStr.includes('"')) {
cellStr = `"${cellStr.replace(/"/g, '""')}"`;
}
lines.push(cellStr);
}
}
try {
await navigator.clipboard.writeText(lines.join("\n"));
showToast(`列数据已复制`);
} catch(err) {
showToast("复制失败", "error");
}
};
const copyToClipboard = async () => {
const header = ["日期", ...selectedTags.value.map(id => getTagName(id))];
let tsv = header.join("\t") + "\n";
for (const date of sortedDateList.value) {
let row = [date];
for (const tagId of selectedTags.value) {
let cellStr = previewData.value[date][tagId] || "";
if (cellStr.includes('\n') || cellStr.includes('\t') || cellStr.includes('"')) {
cellStr = `"${cellStr.replace(/"/g, '""')}"`;
}
row.push(cellStr);
}
tsv += row.join("\t") + "\n";
}
try {
await navigator.clipboard.writeText(tsv);
showToast("表格已复制,可直接粘贴到 Excel");
} catch(err) {
showToast("复制失败", "error");
}
};
</script>
<template>
<div class="fixed inset-0 z-100 bg-black/40 backdrop-blur-sm flex items-center justify-center p-6" @click.self="$emit('close')">
<div class="bg-bg-card rounded-[40px] shadow-2xl w-full 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="bg-bg-card rounded-[40px] shadow-2xl w-full flex flex-col animate-in fade-in zoom-in duration-300 transition-all"
:class="dateList.length > 0 ? 'max-w-[90vw] md:max-w-6xl h-[90vh] overflow-hidden' : 'max-w-4xl min-h-125'">
<div class="p-8 border-b flex justify-between items-center shrink-0">
<h2 class="text-2xl font-bold">导出记录</h2>
<button @click="$emit('close')" class="hover:bg-bg-input p-2 rounded-full transition-colors"><X :size="24" /></button>
</div>
<div class="p-8 flex-1 flex flex-col gap-8 no-scrollbar" :class="dateList.length > 0 ? 'overflow-y-auto' : 'overflow-visible'">
<!-- 配置区域 -->
<div class="space-y-6 shrink-0">
<!-- 日期选择 -->
<div class="space-y-2">
<label class="text-[10px] font-bold text-text-sec">日期范围</label>
<label class="text-[11px] font-bold text-text-sec">日期范围</label>
<div class="flex gap-4 items-center">
<div class="relative flex-1" ref="startCalendarRef">
<button @click="isExportStartCalendarOpen = !isExportStartCalendarOpen; isExportEndCalendarOpen = false" class="w-full bg-bg-input rounded-xl pl-10 pr-4 py-3 text-sm outline-none border border-transparent focus:border-[#007AFF] transition-all text-left flex items-center">
@@ -139,8 +350,87 @@ const handleExport = async () => {
</div>
</div>
</div>
<!-- 主标签选择 -->
<div class="space-y-2">
<label class="text-[11px] font-bold text-text-sec">选择要导出的主标签</label>
<div class="flex flex-wrap gap-2">
<button v-for="tag in mainTags" :key="tag.id"
@click="toggleTag(tag.id)"
class="px-3 py-1.5 rounded-xl text-[12px] font-bold border-2 transition-all active:scale-95"
:style="selectedTags.includes(tag.id) ? { backgroundColor: tag.color, borderColor: tag.color, color: 'white' } : { borderColor: tag.color, color: tag.color }"
>
{{ tag.name }}
</button>
</div>
</div>
<div class="flex gap-4">
<button @click="exportToJson" :disabled="!exportStartDate || !exportEndDate" class="flex-1 bg-bg-input border border-border-main text-text-sec hover:bg-border-main hover:text-text-main py-3.5 rounded-2xl font-bold shadow-sm transition-all disabled:opacity-50 flex items-center justify-center gap-2">
<Download :size="16" /> 导出为 JSON
</button>
<button @click="handlePreview" :disabled="!exportStartDate || !exportEndDate" class="flex-1 bg-[#007AFF] text-white py-3.5 rounded-2xl font-bold shadow-lg shadow-[#007AFF]/20 active:scale-95 transition-all disabled:opacity-50 disabled:active:scale-100">
生成预览表格
</button>
</div>
</div>
<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 v-if="dateList.length > 0" class="flex-1 flex flex-col border-t border-border-main pt-6">
<div class="flex justify-between items-center mb-4 shrink-0">
<div class="flex items-center gap-4">
<h3 class="text-sm font-bold text-text-sec flex items-center gap-2">数据预览</h3>
<button @click="isDesc = !isDesc" class="flex items-center gap-1.5 px-3 py-1.5 bg-bg-input hover:bg-border-main text-text-sec hover:text-text-main rounded-lg text-xs font-bold transition-all active:scale-95">
<ArrowDown v-if="!isDesc" :size="14" />
<ArrowUp v-else :size="14" />
{{ isDesc ? '倒序 (由近及远)' : '正序 (由远及近)' }}
</button>
</div>
<button @click="copyToClipboard" class="flex items-center gap-1.5 px-4 py-2 bg-[#007AFF] hover:bg-[#007AFF]/90 text-white rounded-xl text-xs font-bold transition-all shadow-lg shadow-[#007AFF]/20 active:scale-95">
<Copy :size="14" /> 一键复制完整表格
</button>
</div>
<div class="flex-1 overflow-auto border border-border-main rounded-2xl bg-bg-input/30 relative" :class="isDragging ? 'select-none' : 'select-text'">
<table class="w-max min-w-full text-left border-collapse text-xs table-fixed">
<thead class="bg-bg-card sticky top-0 z-30 shadow-sm">
<tr>
<th class="relative p-4 border-b border-border-main font-bold whitespace-nowrap text-text-sec bg-bg-card group" :style="{ width: colWidths['date'] + 'px' }">
<div class="flex items-center gap-2 overflow-hidden w-full">
日期
<button @click="copyColumn('date')" title="复制此列" class="opacity-0 group-hover:opacity-100 p-1 hover:bg-bg-input rounded transition-all text-text-sec shrink-0"><Copy :size="12"/></button>
</div>
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-[#007AFF] z-40 group-hover:bg-border-main" @mousedown.prevent="startResizeCol($event, 'date')"></div>
</th>
<th v-for="tagId in selectedTags" :key="tagId" class="relative p-4 border-b border-l border-border-main/50 font-bold whitespace-nowrap bg-bg-card group" :style="{ color: mainTags.find(t => t.id === tagId)?.color || 'inherit', width: colWidths[tagId] + 'px' }">
<div class="flex items-center gap-2 overflow-hidden w-full">
{{ getTagName(tagId) }}
<button @click="copyColumn(tagId)" title="复制此列" class="opacity-0 group-hover:opacity-100 p-1 hover:bg-bg-input rounded transition-all text-text-sec shrink-0"><Copy :size="12"/></button>
</div>
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-[#007AFF] z-40 group-hover:bg-border-main" @mousedown.prevent="startResizeCol($event, tagId)"></div>
</th>
</tr>
</thead>
<tbody class="bg-bg-card">
<tr v-for="date in sortedDateList" :key="date" class="border-b border-border-main/30 hover:bg-bg-input/50 transition-colors">
<td class="relative p-4 font-bold whitespace-nowrap text-text-main align-top group">
<div class="flex items-start overflow-hidden w-full" :style="{ height: rowHeights[date] ? Math.max(0, rowHeights[date] - 32) + 'px' : 'auto', minHeight: '16px' }">
{{ date }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-1.5 cursor-row-resize hover:bg-[#007AFF] z-20 group-hover:bg-border-main" @mousedown.prevent="startResizeRow($event, date)"></div>
</td>
<td v-for="tagId in selectedTags" :key="tagId" class="relative p-4 border-l border-border-main/30 align-top whitespace-pre-wrap leading-relaxed text-text-sec group">
<div class="w-full overflow-y-auto no-scrollbar wrap-break-words" :style="{ height: rowHeights[date] ? Math.max(0, rowHeights[date] - 32) + 'px' : 'auto', minHeight: '16px' }">
{{ previewData[date]?.[tagId] || '' }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-1.5 cursor-row-resize hover:bg-[#007AFF] z-20 group-hover:bg-border-main" @mousedown.prevent="startResizeRow($event, date)"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,7 @@
import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Image } from "@tauri-apps/api/image";
import { Tag, DBEvent, TimelineItem, Toast, Reminder } from "../types";
export const TIME_OFFSET_MINUTES = 180;
@@ -50,6 +52,7 @@ export const lockedImage = ref<TimelineItem | null>(null);
export const tags = ref<Tag[]>([]);
export const dayEvents = ref<DBEvent[]>([]);
export const reminders = ref<Reminder[]>([]);
export const calendarStatus = ref<Record<string, { has_overdue: boolean, has_upcoming: boolean }>>({});
export const timelineImages = ref<TimelineItem[]>([]);
export const refreshSignal = ref(0); // Counter to trigger dashboard refreshes
@@ -111,4 +114,100 @@ export const loadEvents = async () => {
};
export const loadReminders = async () => {
reminders.value = await invoke("get_reminders", { date: currentDate.value });
refreshBadgeCount();
refreshCalendarStatus(); // 提醒更新时也刷新日历状态
};
export const loadCalendarStatus = async (yearMonth: string) => {
if (!dbPath.value) return;
try {
const { date, minute } = getRealNowLogicalTime();
const statuses: any[] = await invoke("get_reminders_by_month", {
yearMonth,
today: date,
nowMinute: minute
});
const map: Record<string, any> = {};
statuses.forEach(s => { map[s.date] = s; });
calendarStatus.value = map;
} catch (e) {
console.error("Failed to load calendar status:", e);
}
};
// 当前显示的日历月份,用于自动刷新状态
export const currentCalendarMonthStr = ref("");
export const refreshCalendarStatus = () => {
if (currentCalendarMonthStr.value) {
loadCalendarStatus(currentCalendarMonthStr.value);
}
};
export const overdueCount = ref(0);
// 获取当前真实的逻辑时间和日期(不依赖 UI 状态)
export const getRealNowLogicalTime = () => {
const now = new Date();
const d = new Date(now.getTime() - TIME_OFFSET_MINUTES * 60000);
const date = toISODate(d);
const m = now.getHours() * 60 + now.getMinutes();
const minute = (m < TIME_OFFSET_MINUTES ? m + 1440 : m) - TIME_OFFSET_MINUTES;
return { date, minute };
};
export const refreshBadgeCount = async () => {
if (!dbPath.value) return;
try {
const { date, minute } = getRealNowLogicalTime();
const count: number = await invoke("get_overdue_reminders_count", {
date: date,
minute: minute
});
overdueCount.value = count;
await updateTaskbarBadge(count);
} catch (e) {
console.error("Failed to refresh badge count:", e);
}
};
const updateTaskbarBadge = async (count: number) => {
try {
const win = getCurrentWindow();
if (count <= 0) {
await win.setOverlayIcon(undefined);
return;
}
const canvas = document.createElement('canvas');
const size = 64;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
// Draw red circle
ctx.fillStyle = '#FF3B30';
ctx.beginPath();
ctx.arc(size/2, size/2, size/2 - 2, 0, Math.PI * 2);
ctx.fill();
// Draw white text
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 38px Arial';
const displayCount = count > 99 ? '99+' : count.toString();
if (displayCount.length > 2) ctx.font = 'bold 28px Arial';
ctx.fillText(displayCount, size/2, size/2 + 2);
// Get raw RGBA pixels and wrap in Tauri Image object
const imageData = ctx.getImageData(0, 0, size, size);
const img = await Image.new(new Uint8Array(imageData.data), size, size);
await win.setOverlayIcon(img);
} catch (e) {
console.error("Failed to set overlay icon:", e);
}
};