feat(calendar): U10 frontend calendar views with 3 view modes and drawer

This commit is contained in:
chiguyong 2026-06-23 23:50:28 +08:00
parent 8d4145ddf9
commit 8350b02d75
10 changed files with 982 additions and 0 deletions

View File

@ -9,6 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.0", "@ant-design/icons-vue": "^7.0.0",
"@fullcalendar/daygrid": "^6.1.0",
"@fullcalendar/interaction": "^6.1.0",
"@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/vue3": "^6.1.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@vue-flow/background": "^1.3.0", "@vue-flow/background": "^1.3.0",
@ -533,6 +537,56 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fullcalendar/core": {
"version": "6.1.21",
"resolved": "https://registry.npmmirror.com/@fullcalendar/core/-/core-6.1.21.tgz",
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.21",
"resolved": "https://registry.npmmirror.com/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz",
"integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.21",
"resolved": "https://registry.npmmirror.com/@fullcalendar/interaction/-/interaction-6.1.21.tgz",
"integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.21",
"resolved": "https://registry.npmmirror.com/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz",
"integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.21"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.21",
"resolved": "https://registry.npmmirror.com/@fullcalendar/vue3/-/vue3-6.1.21.tgz",
"integrity": "sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21",
"vue": "^3.0.11"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -2585,6 +2639,17 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/punycode.js": { "node_modules/punycode.js": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",

View File

@ -18,6 +18,10 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.0", "@ant-design/icons-vue": "^7.0.0",
"@fullcalendar/daygrid": "^6.1.0",
"@fullcalendar/interaction": "^6.1.0",
"@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/vue3": "^6.1.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@vue-flow/background": "^1.3.0", "@vue-flow/background": "^1.3.0",

View File

@ -0,0 +1,94 @@
<template>
<a-drawer
:open="open"
@close="emit('update:open', false)"
placement="right"
width="80%"
title="日历管理"
:bodyStyle="{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
>
<div class="calendar-drawer__toolbar">
<a-radio-group :value="store.viewMode" @change="onViewChange">
<a-radio-button value="calendar">日历</a-radio-button>
<a-radio-button value="card">卡片</a-radio-button>
<a-radio-button value="list">列表</a-radio-button>
</a-radio-group>
<a-space>
<a-badge :count="store.pendingInvitations.length" :offset="[6, 0]">
<a-button @click="emit('manage-invitations')">邀请管理</a-button>
</a-badge>
<a-button type="primary" @click="emit('create')">
<template #icon><PlusOutlined /></template>
新建事件
</a-button>
</a-space>
</div>
<div class="calendar-drawer__content">
<CalendarGrid
v-if="store.viewMode === 'calendar'"
@create="onCreate"
@edit="onEdit"
/>
<CardView
v-else-if="store.viewMode === 'card'"
@edit="onEdit"
/>
<ListView
v-else
@edit="onEdit"
/>
</div>
</a-drawer>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import CalendarGrid from './CalendarGrid.vue'
import CardView from './CardView.vue'
import ListView from './ListView.vue'
const store = useCalendarStore()
defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'create', start?: Date, end?: Date): void
(e: 'edit', event: ICalendarEvent): void
(e: 'manage-invitations'): void
}>()
function onViewChange(e: { target: { value: string } }): void {
store.setViewMode(e.target.value as 'calendar' | 'card' | 'list')
}
function onCreate(start?: Date, end?: Date): void {
emit('create', start, end)
}
function onEdit(event: ICalendarEvent): void {
emit('edit', event)
}
</script>
<style scoped>
.calendar-drawer__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) 0;
flex-shrink: 0;
}
.calendar-drawer__content {
flex: 1;
overflow: hidden;
border-top: 1px solid var(--border-color-split);
padding-top: var(--space-3);
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="calendar-grid">
<FullCalendar :options="calendarOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import type {
CalendarOptions,
EventInput,
DateSelectArg,
EventDropArg,
EventClickArg,
} from '@fullcalendar/core'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
const store = useCalendarStore()
const emit = defineEmits<{
(e: 'create', start?: Date, end?: Date): void
(e: 'edit', event: ICalendarEvent): void
}>()
function toFcEvent(ev: ICalendarEvent): EventInput {
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
const color = eventType?.color || '#1677ff'
return {
id: ev.id,
title: ev.title,
start: ev.start_time,
end: ev.end_time,
allDay: ev.is_all_day,
backgroundColor: color,
borderColor: color,
classNames: ev.is_invited ? ['fc-event-invited'] : [],
extendedProps: { event: ev },
}
}
function handleSelect(arg: DateSelectArg): void {
emit('create', arg.start, arg.end)
}
async function handleEventDrop(arg: EventDropArg): Promise<void> {
const ev = arg.event.extendedProps.event as ICalendarEvent
await store.updateEvent(ev.id, {
start_time: arg.event.start?.toISOString() ?? ev.start_time,
end_time: arg.event.end?.toISOString() ?? ev.end_time,
})
}
function handleEventClick(arg: EventClickArg): void {
const ev = arg.event.extendedProps.event as ICalendarEvent
emit('edit', ev)
}
const calendarOptions = computed<CalendarOptions>(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
selectable: true,
editable: true,
height: '100%',
events: store.events.map(toFcEvent),
select: handleSelect,
eventDrop: handleEventDrop,
eventClick: handleEventClick,
}))
</script>
<style scoped>
.calendar-grid {
height: 100%;
overflow: hidden;
}
.calendar-grid :deep(.fc) {
height: 100%;
}
.calendar-grid :deep(.fc-event-invited) {
border-style: dashed !important;
border-width: 2px !important;
opacity: 0.75;
}
</style>

View File

@ -0,0 +1,299 @@
<template>
<div class="calendar-panel" :class="{ 'calendar-panel--highlight': highlight }">
<div class="calendar-panel__header">
<div class="calendar-panel__title">
<CalendarOutlined />
<span>日历</span>
<span class="calendar-panel__date">{{ todayLabel }}</span>
</div>
<a-button size="small" type="link" @click="openDrawer">
<template #icon><ExpandOutlined /></template>
展开
</a-button>
</div>
<div v-if="store.isLoading && store.events.length === 0" class="calendar-panel__loading">
<a-spin size="small" />
<span>加载日程...</span>
</div>
<div v-else-if="store.error" class="calendar-panel__error">
<WarningOutlined />
<span>{{ store.error }}</span>
</div>
<div v-else-if="todayEvents.length === 0 && upcomingEvents.length === 0" class="calendar-panel__empty">
<CalendarOutlined />
<span>暂无日程</span>
</div>
<div v-else class="calendar-panel__body">
<div v-if="todayEvents.length > 0" class="calendar-panel__section">
<div class="calendar-panel__section-title">今日</div>
<div
v-for="ev in todayEvents"
:key="ev.id"
class="calendar-panel__item"
:class="{ 'calendar-panel__item--invited': ev.is_invited }"
@click="onEventClick(ev)"
>
<span class="calendar-panel__item-time">{{ formatTime(ev.start_time) }}</span>
<span class="calendar-panel__item-title">{{ ev.title }}</span>
<EventBadge :event="ev" />
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
</div>
</div>
<div v-if="upcomingEvents.length > 0" class="calendar-panel__section">
<div class="calendar-panel__section-title">即将到来</div>
<div
v-for="ev in upcomingEvents"
:key="ev.id"
class="calendar-panel__item"
:class="{ 'calendar-panel__item--invited': ev.is_invited }"
@click="onEventClick(ev)"
>
<span class="calendar-panel__item-time">{{ formatUpcomingTime(ev.start_time) }}</span>
<span class="calendar-panel__item-title">{{ ev.title }}</span>
<EventBadge :event="ev" />
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
</div>
</div>
</div>
<CalendarDrawer
:open="drawerOpen"
@update:open="drawerOpen = $event"
@create="onCreate"
@edit="onEdit"
@manage-invitations="onManageInvitations"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { CalendarOutlined, ExpandOutlined, WarningOutlined } from '@ant-design/icons-vue'
import { notification } from 'ant-design-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import EventBadge from './EventBadge.vue'
import CalendarDrawer from './CalendarDrawer.vue'
const store = useCalendarStore()
const drawerOpen = ref(false)
const highlight = ref(false)
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const todayLabel = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(new Date())
const todayEvents = computed(() => {
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString()
return store.events
.filter((e) => e.start_time >= startOfDay && e.start_time <= endOfDay)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
})
const upcomingEvents = computed(() => {
const now = new Date()
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString()
return store.events
.filter((e) => e.start_time > endOfDay)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.slice(0, 3)
})
// Highlight animation when new event arrives via WS
watch(
() => store.events.length,
(newLen, oldLen) => {
if (newLen > (oldLen ?? 0)) {
highlight.value = true
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
highlight.value = false
}, 2000)
}
},
)
function openDrawer(): void {
drawerOpen.value = true
}
function onEventClick(ev: ICalendarEvent): void {
onEdit(ev)
}
function onCreate(start?: Date, end?: Date): void {
const timeHint = start
? `预填时间:${formatTime(start.toISOString())}${end ? ' - ' + formatTime(end.toISOString()) : ''}`
: ''
notification.info({
message: '新建事件',
description: timeHint
? `事件创建器将在 U11 中实现(${timeHint}`
: '事件创建器将在 U11 中实现',
})
}
function onEdit(ev: ICalendarEvent): void {
if (ev.is_invited) {
notification.info({
message: '受邀事件',
description: `${ev.title}」的邀请回复功能将在 U11 中实现`,
})
} else {
notification.info({
message: '事件详情',
description: `${ev.title}」的编辑器将在 U11 中实现`,
})
}
}
function onManageInvitations(): void {
notification.info({
message: '邀请管理',
description: `你有 ${store.pendingInvitations.length} 个待处理邀请,完整管理界面将在 U11 中实现`,
})
}
function formatTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
function formatUpcomingTime(iso: string): string {
const date = new Date(iso)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === tomorrow.toDateString()) {
return `明天 ${formatTime(iso)}`
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
onMounted(() => {
store.loadEvents()
store.loadEventTypes()
store.loadTags()
store.loadInvitations()
})
</script>
<style scoped>
.calendar-panel {
height: 100%;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
transition: background var(--transition-normal);
}
.calendar-panel--highlight {
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
}
.calendar-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.calendar-panel__title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.calendar-panel__date {
font-size: var(--font-xs);
color: var(--text-tertiary);
font-weight: normal;
}
.calendar-panel__loading,
.calendar-panel__error,
.calendar-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-8) 0;
color: var(--text-tertiary);
font-size: var(--font-sm);
}
.calendar-panel__error {
color: var(--color-error);
}
.calendar-panel__section {
margin-bottom: var(--space-4);
}
.calendar-panel__section-title {
font-size: var(--font-xs);
color: var(--text-tertiary);
margin-bottom: var(--space-2);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calendar-panel__item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
margin-bottom: 2px;
}
.calendar-panel__item:hover {
background: var(--bg-tertiary);
}
.calendar-panel__item--invited {
border: 1px dashed var(--border-color-hover);
background: var(--color-primary-light, rgba(22, 119, 255, 0.04));
}
.calendar-panel__item-time {
font-size: var(--font-xs);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
min-width: 48px;
}
.calendar-panel__item-title {
flex: 1;
font-size: var(--font-sm);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,213 @@
<template>
<div class="card-view">
<div class="card-view__toolbar">
<a-radio-group :value="groupBy" @change="onGroupChange">
<a-radio-button value="date">按日期</a-radio-button>
<a-radio-button value="type">按类型</a-radio-button>
</a-radio-group>
</div>
<a-empty v-if="sortedEvents.length === 0" description="暂无日程" class="card-view__empty" />
<div v-else class="card-view__groups">
<div v-for="group in groups" :key="group.key" class="card-view__group">
<div class="card-view__group-header">
<span class="card-view__group-label">{{ group.label }}</span>
<a-tag size="small">{{ group.events.length }}</a-tag>
</div>
<div class="card-view__cards">
<div
v-for="ev in group.events"
:key="ev.id"
class="card-view__card"
:class="{ 'card-view__card--invited': ev.is_invited }"
:style="cardStyle(ev)"
@click="emit('edit', ev)"
>
<div class="card-view__card-header">
<span class="card-view__card-time">{{ formatTime(ev.start_time) }}</span>
<EventBadge :event="ev" />
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
</div>
<div class="card-view__card-title">{{ ev.title }}</div>
<div v-if="ev.location" class="card-view__card-location">
<EnvironmentOutlined />
<span>{{ ev.location }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { EnvironmentOutlined } from '@ant-design/icons-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import EventBadge from './EventBadge.vue'
const store = useCalendarStore()
const emit = defineEmits<{
(e: 'edit', event: ICalendarEvent): void
}>()
const groupBy = ref<'date' | 'type'>('date')
function onGroupChange(e: { target: { value: string } }): void {
groupBy.value = e.target.value as 'date' | 'type'
}
const sortedEvents = computed(() =>
[...store.events].sort((a, b) => a.start_time.localeCompare(b.start_time)),
)
interface CardGroup {
key: string
label: string
events: ICalendarEvent[]
}
const groups = computed<CardGroup[]>(() => {
const map = new Map<string, ICalendarEvent[]>()
if (groupBy.value === 'date') {
for (const ev of sortedEvents.value) {
const key = ev.start_time.slice(0, 10)
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
return Array.from(map.entries()).map(([key, events]) => ({
key,
label: formatDateLabel(key),
events,
}))
}
for (const ev of sortedEvents.value) {
const key = ev.event_type_id ?? 'none'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
return Array.from(map.entries()).map(([key, events]) => ({
key,
label:
key === 'none'
? '未分类'
: store.eventTypes.find((t) => t.id === key)?.name ?? '未分类',
events,
}))
})
function cardStyle(ev: ICalendarEvent): { borderLeftColor: string } {
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
return { borderLeftColor: eventType?.color || '#1677ff' }
}
function formatTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
function formatDateLabel(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00')
const today = new Date()
if (date.toDateString() === today.toDateString()) return '今天'
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === tomorrow.toDateString()) return '明天'
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(date)
}
</script>
<style scoped>
.card-view {
height: 100%;
overflow-y: auto;
padding: var(--space-3) 0;
}
.card-view__toolbar {
padding: 0 var(--space-3) var(--space-3);
}
.card-view__empty {
padding: var(--space-8) 0;
}
.card-view__groups {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: 0 var(--space-3);
}
.card-view__group-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
}
.card-view__cards {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card-view__card {
padding: var(--space-3);
border: 1px solid var(--border-color);
border-left: 3px solid #1677ff;
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.card-view__card:hover {
background: var(--bg-tertiary);
border-color: var(--border-color-hover);
}
.card-view__card--invited {
border-style: dashed;
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
}
.card-view__card-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.card-view__card-time {
font-size: var(--font-xs);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.card-view__card-title {
font-weight: var(--font-weight-medium);
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.card-view__card-location {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-xs);
color: var(--text-tertiary);
}
</style>

View File

@ -0,0 +1,54 @@
<template>
<span v-if="icon" class="event-badge" :class="`event-badge--${event.source}`" :title="title">
<component :is="icon" />
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RobotOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import type { ICalendarEvent } from '@/api/calendar'
const props = defineProps<{
event: ICalendarEvent
}>()
const icon = computed(() => {
switch (props.event.source) {
case 'agent':
return RobotOutlined
case 'post_extract':
return ThunderboltOutlined
default:
return null
}
})
const title = computed(() => {
switch (props.event.source) {
case 'agent':
return 'Agent 创建'
case 'post_extract':
return '对话提取'
default:
return ''
}
})
</script>
<style scoped>
.event-badge {
display: inline-flex;
align-items: center;
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.event-badge--agent {
color: var(--accent-team, #722ed1);
}
.event-badge--post_extract {
color: var(--accent-board, #fa8c16);
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<div class="list-view">
<a-empty v-if="sortedEvents.length === 0" description="暂无日程" class="list-view__empty" />
<a-table
v-else
:dataSource="sortedEvents"
:columns="columns"
rowKey="id"
size="small"
:pagination="{ pageSize: 50, showSizeChanger: false }"
:customRow="customRow"
:scroll="{ y: 'calc(100% - 64px)' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'start_time'">
<span class="list-view__time">
{{ formatDateTime(record.start_time) }}
<span class="list-view__time-sep"></span>
{{ formatTime(record.end_time) }}
</span>
</template>
<template v-else-if="column.key === 'title'">
<span class="list-view__title" :class="{ 'list-view__title--invited': record.is_invited }">
{{ record.title }}
<a-tag v-if="record.is_invited" color="purple" size="small">受邀</a-tag>
</span>
</template>
<template v-else-if="column.key === 'source'">
<EventBadge :event="record" />
</template>
<template v-else-if="column.key === 'event_type_id'">
<a-tag
v-if="record.event_type_id"
:color="getTypeColor(record.event_type_id)"
size="small"
>
{{ getTypeName(record.event_type_id) }}
</a-tag>
<span v-else class="list-view__muted"></span>
</template>
<template v-else-if="column.key === 'location'">
<span v-if="record.location">{{ record.location }}</span>
<span v-else class="list-view__muted"></span>
</template>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import EventBadge from './EventBadge.vue'
const store = useCalendarStore()
const emit = defineEmits<{
(e: 'edit', event: ICalendarEvent): void
}>()
const columns = [
{ title: '时间', dataIndex: 'start_time', key: 'start_time', width: 200 },
{ title: '标题', dataIndex: 'title', key: 'title' },
{ title: '来源', dataIndex: 'source', key: 'source', width: 60 },
{ title: '类型', dataIndex: 'event_type_id', key: 'event_type_id', width: 100 },
{ title: '地点', dataIndex: 'location', key: 'location', width: 140 },
]
const sortedEvents = computed(() =>
[...store.events].sort((a, b) => a.start_time.localeCompare(b.start_time)),
)
function customRow(record: ICalendarEvent): Record<string, () => void> {
return {
onClick: () => emit('edit', record),
}
}
function getTypeName(id: string): string {
return store.eventTypes.find((t) => t.id === id)?.name ?? '未知'
}
function getTypeColor(id: string): string {
return store.eventTypes.find((t) => t.id === id)?.color ?? 'blue'
}
function formatTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
function formatDateTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
</script>
<style scoped>
.list-view {
height: 100%;
overflow: hidden;
padding: var(--space-3) 0;
}
.list-view__empty {
padding: var(--space-8) 0;
}
.list-view__time {
font-size: var(--font-xs);
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.list-view__time-sep {
margin: 0 var(--space-1);
color: var(--text-placeholder);
}
.list-view__title {
font-weight: var(--font-weight-medium);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-1);
}
.list-view__title--invited {
font-style: italic;
}
.list-view__muted {
color: var(--text-placeholder);
}
</style>

View File

@ -74,6 +74,9 @@
<template #skills> <template #skills>
<SkillsView /> <SkillsView />
</template> </template>
<template #calendar>
<CalendarTab />
</template>
<template #settings> <template #settings>
<SettingsView /> <SettingsView />
</template> </template>
@ -104,6 +107,7 @@ import {
AppstoreOutlined, AppstoreOutlined,
SettingOutlined, SettingOutlined,
DesktopOutlined, DesktopOutlined,
CalendarOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import TopNav from './TopNav.vue' import TopNav from './TopNav.vue'
@ -121,6 +125,7 @@ const KnowledgeBaseView = defineAsyncComponent(() => import('@/views/KnowledgeBa
const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue')) const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue'))
const SkillsView = defineAsyncComponent(() => import('@/views/SkillsView.vue')) const SkillsView = defineAsyncComponent(() => import('@/views/SkillsView.vue'))
const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue')) const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue'))
const CalendarTab = defineAsyncComponent(() => import('./tabs/CalendarTab.vue'))
const route = useRoute() const route = useRoute()
const chatStore = useChatStore() const chatStore = useChatStore()
@ -152,6 +157,7 @@ const topRightTabs: QuadrantTab[] = [
const bottomRightTabs: QuadrantTab[] = [ const bottomRightTabs: QuadrantTab[] = [
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component }, { key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component }, { key: 'skills', label: '技能', icon: AppstoreOutlined as Component },
{ key: 'calendar', label: '日历', icon: CalendarOutlined as Component },
{ key: 'settings', label: '设置', icon: SettingOutlined as Component }, { key: 'settings', label: '设置', icon: SettingOutlined as Component },
] ]

View File

@ -0,0 +1,7 @@
<template>
<CalendarPanel />
</template>
<script setup lang="ts">
import CalendarPanel from '@/components/calendar/CalendarPanel.vue'
</script>