Files
chrono-snap/src/components/modals/ExportModal.vue
Julian Freeman 5f14a0dd10 support copy col
2026-03-26 20:35:46 -04:00

292 lines
15 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { X, Calendar, ChevronLeft, ChevronRight, Copy, ArrowDown, ArrowUp } from "lucide-vue-next";
import { currentDate, showToast, getTagName, logicalMinutesToTime, toISODate, mainTags } from "../../store";
import { DBEvent } from "../../types";
defineEmits(['close']);
const startCalendarRef = ref<HTMLElement | null>(null);
const endCalendarRef = ref<HTMLElement | null>(null);
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 selectedTags = ref<number[]>(mainTags.value.map(t => t.id));
// 表格数据
const dateList = ref<string[]>([]);
const previewData = ref<Record<string, Record<number, string>>>({});
const isDesc = ref(false);
const sortedDateList = computed(() => isDesc.value ? [...dateList.value].reverse() : dateList.value);
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)) {
isExportStartCalendarOpen.value = false;
}
if (isExportEndCalendarOpen.value && endCalendarRef.value && !endCalendarRef.value.contains(target)) {
isExportEndCalendarOpen.value = false;
}
};
onMounted(() => {
window.addEventListener('mousedown', handleClickOutside);
});
onUnmounted(() => {
window.removeEventListener('mousedown', handleClickOutside);
});
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 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 });
// 生成日期范围列表
const dates: string[] = [];
let tempDate = new Date(curDate);
while (tempDate <= endDate) {
dates.push(toISODate(tempDate));
tempDate.setDate(tempDate.getDate() + 1);
}
dateList.value = dates;
// 过滤选中的主标签并构建矩阵
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 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] || "";
// Excel/Sheets 处理带换行符的单元格,需要用双引号包围
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");
}
};
</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 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-[500px]'">
<div class="p-8 border-b flex justify-between items-center flex-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 flex-shrink-0">
<!-- 日期选择 -->
<div class="space-y-2">
<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">
<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 = toISODate(date); isExportStartCalendarOpen = false" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="toISODate(date) === 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" ref="endCalendarRef">
<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 = toISODate(date); isExportEndCalendarOpen = false" class="w-8 h-8 rounded-full text-xs font-medium transition-all" :class="toISODate(date) === exportEndDate ? 'bg-[#007AFF] text-white font-bold' : 'hover:bg-bg-input text-main'">{{ date.getDate() }}</button>
</div>
</div>
</div>
</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>
<button @click="handlePreview" :disabled="!exportStartDate || !exportEndDate" class="w-full bg-bg-main border border-border-main text-text-main hover:bg-bg-input py-3.5 rounded-2xl font-bold shadow-sm transition-all disabled:opacity-50">
生成预览表格
</button>
</div>
<!-- 表格预览区域 -->
<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 flex-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>
</div>
<div class="flex-1 overflow-auto border border-border-main rounded-2xl bg-bg-input/30 relative select-text">
<table class="w-full text-left border-collapse text-xs">
<thead class="bg-bg-card sticky top-0 z-10 shadow-sm">
<tr>
<th class="p-4 border-b border-border-main font-bold whitespace-nowrap text-text-sec w-32 group">
<div class="flex items-center gap-2">
日期
<button @click="copyColumn('date')" title="复制此列" class="opacity-0 group-hover:opacity-100 p-1 hover:bg-bg-input rounded transition-all text-text-sec"><Copy :size="12"/></button>
</div>
</th>
<th v-for="tagId in selectedTags" :key="tagId" class="p-4 border-b border-l border-border-main/50 font-bold whitespace-nowrap group" :style="{ color: mainTags.find(t => t.id === tagId)?.color || 'inherit' }">
<div class="flex items-center gap-2">
{{ 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"><Copy :size="12"/></button>
</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="p-4 font-bold whitespace-nowrap text-text-main align-top">
{{ date }}
</td>
<td v-for="tagId in selectedTags" :key="tagId" class="p-4 border-l border-border-main/30 align-top whitespace-pre-wrap leading-relaxed text-text-sec min-w-[150px]">
{{ previewData[date]?.[tagId] || '' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>