feat: add EventList and HeaderBar with organize/undo/download
This commit is contained in:
21
src/App.vue
21
src/App.vue
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useCalendar } from './composables/useCalendar.js'
|
import { useCalendar } from './composables/useCalendar.js'
|
||||||
import DropZone from './components/DropZone.vue'
|
import DropZone from './components/DropZone.vue'
|
||||||
|
import HeaderBar from './components/HeaderBar.vue'
|
||||||
|
import EventList from './components/EventList.vue'
|
||||||
|
|
||||||
const { isLoaded } = useCalendar()
|
const { isLoaded } = useCalendar()
|
||||||
</script>
|
</script>
|
||||||
@@ -9,12 +11,16 @@ const { isLoaded } = useCalendar()
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>📅 ICS 日程整理器</h1>
|
<h1>📅 ICS 日程整理器</h1>
|
||||||
|
<HeaderBar />
|
||||||
</header>
|
</header>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<DropZone v-if="!isLoaded" />
|
<DropZone v-if="!isLoaded" />
|
||||||
<div v-else class="placeholder">
|
<template v-else>
|
||||||
<p>文件已加载(列表与编辑器待实现)</p>
|
<EventList />
|
||||||
</div>
|
<div class="detail-area">
|
||||||
|
<div class="empty">从左侧选择一个日程进行编辑</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -38,11 +44,10 @@ body {
|
|||||||
}
|
}
|
||||||
.app-header h1 { font-size: 18px; font-weight: 600; }
|
.app-header h1 { font-size: 18px; font-weight: 600; }
|
||||||
.app-main { flex: 1; display: flex; overflow: hidden; }
|
.app-main { flex: 1; display: flex; overflow: hidden; }
|
||||||
.placeholder {
|
.detail-area { flex: 1; overflow-y: auto; }
|
||||||
flex: 1;
|
.empty {
|
||||||
display: flex;
|
text-align: center;
|
||||||
align-items: center;
|
padding: 60px 20px;
|
||||||
justify-content: center;
|
|
||||||
color: #95a5a6;
|
color: #95a5a6;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
90
src/components/EventList.vue
Normal file
90
src/components/EventList.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useCalendar } from '../composables/useCalendar.js'
|
||||||
|
import { DOW } from '../lib/weekday.js'
|
||||||
|
|
||||||
|
const { state, stats, addEvent } = useCalendar()
|
||||||
|
|
||||||
|
function selectEvent(uid) {
|
||||||
|
state.selectedUid = uid
|
||||||
|
}
|
||||||
|
|
||||||
|
function dowFor(ev) {
|
||||||
|
return DOW[new Date(ev.dtstartDate + 'T00:00:00').getDay()]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-list">
|
||||||
|
<div class="stat">
|
||||||
|
共 {{ stats.total }} 个日程({{ stats.recurring }} 重复 / {{ stats.single }} 单次)
|
||||||
|
</div>
|
||||||
|
<button class="add-btn" @click="addEvent">➕ 新增日程</button>
|
||||||
|
<div class="list">
|
||||||
|
<div
|
||||||
|
v-for="ev in state.events"
|
||||||
|
:key="ev.uid"
|
||||||
|
class="event-item"
|
||||||
|
:class="{ active: ev.uid === state.selectedUid }"
|
||||||
|
@click="selectEvent(ev.uid)"
|
||||||
|
>
|
||||||
|
<div class="title">
|
||||||
|
{{ ev.summary || '(无标题)' }}
|
||||||
|
<span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'">
|
||||||
|
{{ ev.rrule ? '重复' : '单次' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
{{ dowFor(ev) }} {{ ev.dtstartTime }}–{{ ev.dtendTime }}
|
||||||
|
<span v-if="ev.location"> · {{ ev.location }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-list {
|
||||||
|
width: 340px;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e0e3e6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat { font-size: 12px; color: #95a5a6; padding: 12px 16px 8px; }
|
||||||
|
.add-btn {
|
||||||
|
margin: 0 16px 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px dashed #3498db;
|
||||||
|
background: #eaf4fc;
|
||||||
|
color: #2980b9;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.add-btn:hover { background: #d4eaf9; }
|
||||||
|
.list { flex: 1; overflow-y: auto; }
|
||||||
|
.event-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #eef0f2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.event-item:hover { background: #f8f9fa; }
|
||||||
|
.event-item.active {
|
||||||
|
background: #eaf4fc;
|
||||||
|
border-left: 3px solid #3498db;
|
||||||
|
}
|
||||||
|
.title { font-weight: 600; font-size: 14px; color: #2c3e50; margin-bottom: 3px; }
|
||||||
|
.meta { font-size: 12px; color: #7f8c8d; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.badge-recur { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.badge-single { background: #fff3e0; color: #e65100; }
|
||||||
|
</style>
|
||||||
77
src/components/HeaderBar.vue
Normal file
77
src/components/HeaderBar.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useCalendar } from '../composables/useCalendar.js'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const { state, canUndo, canRedo, organize, undo, redo, serialize, markSaved } = useCalendar()
|
||||||
|
const fileInput = ref(null)
|
||||||
|
|
||||||
|
function onOrganize() {
|
||||||
|
const stats = organize()
|
||||||
|
if (stats && stats.series === 0) {
|
||||||
|
alert('未发现可合并的重复模式')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImport() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilePicked(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
const { loadFile } = useCalendar()
|
||||||
|
loadFile(file).catch((err) => alert('解析失败:' + (err.message || err)))
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDownload() {
|
||||||
|
if (state.dirty && !confirm('有未保存的更改,仍要下载?')) return
|
||||||
|
const text = serialize()
|
||||||
|
const blob = new Blob([text], { type: 'text/calendar' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
const base = state.fileName ? state.fileName.replace(/\.ics$/i, '') : 'calendar'
|
||||||
|
a.href = url
|
||||||
|
a.download = base + '.edited.ics'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
markSaved()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
||||||
|
🔁 整理去重
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="undo" :disabled="!canUndo">↶ 撤销</button>
|
||||||
|
<button class="btn btn-secondary" @click="redo" :disabled="!canRedo">↷ 重做</button>
|
||||||
|
<button class="btn btn-secondary" @click="onImport">📥 导入文件</button>
|
||||||
|
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
||||||
|
⬇ 下载 ICS
|
||||||
|
</button>
|
||||||
|
<span v-if="state.dirty" class="dirty-dot" title="有未保存更改">●</span>
|
||||||
|
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-actions { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.btn {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: #3498db; color: #fff; }
|
||||||
|
.btn-primary:hover:not(:disabled) { background: #2980b9; }
|
||||||
|
.btn-success { background: #27ae60; color: #fff; }
|
||||||
|
.btn-success:hover:not(:disabled) { background: #229954; }
|
||||||
|
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
|
||||||
|
.btn-secondary:hover:not(:disabled) { background: #d5dbdb; }
|
||||||
|
.dirty-dot { color: #e74c3c; font-size: 16px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user