feat(calendar): U11 event editor, invitation manager and batch operations

This commit is contained in:
chiguyong 2026-06-24 05:02:12 +08:00
parent 8350b02d75
commit 3131769aed
5 changed files with 766 additions and 26 deletions

View File

@ -68,17 +68,31 @@
@edit="onEdit"
@manage-invitations="onManageInvitations"
/>
<EventEditor
v-model:open="editorOpen"
:event="editorEvent"
:prefill-start="editorPrefillStart"
:prefill-end="editorPrefillEnd"
@saved="onSaved"
/>
<InvitationManager
v-model:open="invitationOpen"
:event="editorEvent"
/>
</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'
import EventEditor from './EventEditor.vue'
import InvitationManager from './InvitationManager.vue'
const store = useCalendarStore()
@ -86,6 +100,15 @@ const drawerOpen = ref(false)
const highlight = ref(false)
let highlightTimer: ReturnType<typeof setTimeout> | null = null
// EventEditor state
const editorOpen = ref(false)
const editorEvent = ref<ICalendarEvent | null>(null)
const editorPrefillStart = ref<Date | null>(null)
const editorPrefillEnd = ref<Date | null>(null)
// InvitationManager state
const invitationOpen = ref(false)
const todayLabel = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
@ -133,36 +156,25 @@ function onEventClick(ev: ICalendarEvent): void {
}
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 中实现',
})
editorEvent.value = null
editorPrefillStart.value = start ?? null
editorPrefillEnd.value = end ?? null
editorOpen.value = true
}
function onEdit(ev: ICalendarEvent): void {
if (ev.is_invited) {
notification.info({
message: '受邀事件',
description: `${ev.title}」的邀请回复功能将在 U11 中实现`,
})
} else {
notification.info({
message: '事件详情',
description: `${ev.title}」的编辑器将在 U11 中实现`,
})
}
editorEvent.value = ev
editorPrefillStart.value = null
editorPrefillEnd.value = null
editorOpen.value = true
}
function onManageInvitations(): void {
notification.info({
message: '邀请管理',
description: `你有 ${store.pendingInvitations.length} 个待处理邀请,完整管理界面将在 U11 中实现`,
})
invitationOpen.value = true
}
function onSaved(_event: ICalendarEvent): void {
// Event already added/updated in store by EventEditor; nothing else needed.
}
function formatTime(iso: string): string {

View File

@ -0,0 +1,332 @@
<template>
<a-drawer
:open="open"
@close="close"
placement="right"
:width="560"
:title="isEdit ? '编辑事件' : '新建事件'"
:destroyOnClose="true"
>
<a-form layout="vertical">
<a-form-item label="标题" required>
<a-input v-model:value="form.title" placeholder="事件标题" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="form.description" :rows="3" placeholder="事件描述" />
</a-form-item>
<a-form-item label="全天">
<a-switch v-model:checked="form.is_all_day" />
</a-form-item>
<a-form-item label="日期范围" required>
<a-range-picker v-model:value="dateRange" style="width: 100%" />
</a-form-item>
<a-form-item v-if="!form.is_all_day" label="时间">
<a-space>
<a-time-picker v-model:value="startTime" format="HH:mm" :allowClear="false" />
<span></span>
<a-time-picker v-model:value="endTime" format="HH:mm" :allowClear="false" />
</a-space>
</a-form-item>
<a-form-item label="地点">
<a-input v-model:value="form.location" placeholder="事件地点" />
</a-form-item>
<a-form-item label="事件类型">
<a-select v-model:value="form.event_type_id" allowClear placeholder="选择类型">
<a-select-option v-for="t in store.eventTypes" :key="t.id" :value="t.id">
<a-tag :color="t.color" size="small">{{ t.name }}</a-tag>
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="标签">
<a-select
v-model:value="selectedTagIds"
mode="tags"
placeholder="选择或输入标签"
:token-separators="[',']"
>
<a-select-option v-for="t in store.tags" :key="t.id" :value="t.id">
{{ t.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="重复">
<a-space>
<a-select v-model:value="rruleFreq" style="width: 120px">
<a-select-option value="none">不重复</a-select-option>
<a-select-option value="DAILY">每天</a-select-option>
<a-select-option value="WEEKLY">每周</a-select-option>
<a-select-option value="MONTHLY">每月</a-select-option>
</a-select>
<template v-if="rruleFreq !== 'none'">
<span></span>
<a-input-number v-model:value="rruleInterval" :min="1" :max="99" />
<span>{{ freqUnitLabel }}</span>
</template>
</a-space>
</a-form-item>
<a-form-item label="提醒">
<!-- ponytail: reminders UI is local-only; ICreateEventRequest has no reminders field yet.
Upgrade path: add reminders array to ICreateEventRequest/IUpdateEventRequest when backend supports it. -->
<div v-for="(r, i) in reminders" :key="i" class="event-editor__reminder">
<a-input-number v-model:value="r.offset" :min="0" addon-before="提前" addon-after="分钟" />
<a-select v-model:value="r.channel" style="width: 100px">
<a-select-option value="in_app">应用内</a-select-option>
<a-select-option value="email">邮件</a-select-option>
</a-select>
<a-button danger size="small" @click="reminders.splice(i, 1)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<a-button size="small" @click="reminders.push({ offset: 15, channel: 'in_app' })">
<template #icon><PlusOutlined /></template>
添加提醒
</a-button>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="close">取消</a-button>
<a-button type="primary" :loading="store.isLoading" @click="onSave">保存</a-button>
</a-space>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import dayjs, { type Dayjs } from 'dayjs'
import { message } from 'ant-design-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent, ICreateEventRequest, IUpdateEventRequest } from '@/api/calendar'
const props = defineProps<{
open: boolean
event: ICalendarEvent | null
prefillStart?: Date | null
prefillEnd?: Date | null
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'saved', event: ICalendarEvent): void
}>()
const store = useCalendarStore()
const isEdit = computed(() => !!props.event)
interface ReminderRule {
offset: number
channel: 'in_app' | 'email'
}
const form = reactive({
title: '',
description: '',
is_all_day: false,
location: '',
event_type_id: null as string | null,
})
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const startTime = ref<Dayjs>(dayjs().hour(9).minute(0).second(0))
const endTime = ref<Dayjs>(dayjs().hour(10).minute(0).second(0))
const selectedTagIds = ref<string[]>([])
const rruleFreq = ref<'none' | 'DAILY' | 'WEEKLY' | 'MONTHLY'>('none')
const rruleInterval = ref(1)
const reminders = ref<ReminderRule[]>([])
const freqUnitLabel = computed(() => {
switch (rruleFreq.value) {
case 'DAILY':
return '天'
case 'WEEKLY':
return '周'
case 'MONTHLY':
return '月'
default:
return ''
}
})
// Initialize form when drawer opens or event changes
watch(
() => [props.open, props.event],
() => {
if (!props.open) return
if (props.event) {
// Edit mode pre-fill from event
form.title = props.event.title
form.description = props.event.description
form.is_all_day = props.event.is_all_day
form.location = props.event.location
form.event_type_id = props.event.event_type_id
const start = dayjs(props.event.start_time)
const end = dayjs(props.event.end_time)
dateRange.value = [start.startOf('day'), end.startOf('day')]
startTime.value = start
endTime.value = end
parseRrule(props.event.rrule)
// ponytail: ICalendarEvent has no tags field; can't pre-fill tag selection.
selectedTagIds.value = []
reminders.value = []
} else {
// Create mode
form.title = ''
form.description = ''
form.is_all_day = false
form.location = ''
form.event_type_id = null
const start = props.prefillStart ? dayjs(props.prefillStart) : dayjs().hour(9).minute(0)
const end = props.prefillEnd ? dayjs(props.prefillEnd) : start.add(1, 'hour')
dateRange.value = [start.startOf('day'), end.startOf('day')]
startTime.value = start
endTime.value = end
rruleFreq.value = 'none'
rruleInterval.value = 1
selectedTagIds.value = []
reminders.value = []
}
},
{ immediate: true },
)
function parseRrule(rrule: string | null): void {
if (!rrule) {
rruleFreq.value = 'none'
rruleInterval.value = 1
return
}
const freqMatch = rrule.match(/FREQ=(\w+)/)
const intervalMatch = rrule.match(/INTERVAL=(\d+)/)
if (freqMatch && ['DAILY', 'WEEKLY', 'MONTHLY'].includes(freqMatch[1])) {
rruleFreq.value = freqMatch[1] as 'DAILY' | 'WEEKLY' | 'MONTHLY'
rruleInterval.value = intervalMatch ? parseInt(intervalMatch[1], 10) : 1
} else {
rruleFreq.value = 'none'
rruleInterval.value = 1
}
}
function buildRrule(): string | null {
if (rruleFreq.value === 'none') return null
return `FREQ=${rruleFreq.value};INTERVAL=${rruleInterval.value}`
}
function close(): void {
emit('update:open', false)
}
/** Resolve tag selections to IDs, creating new tags as needed */
async function resolveTagIds(): Promise<string[]> {
const ids: string[] = []
for (const val of selectedTagIds.value) {
const existing = store.tags.find((t) => t.id === val)
if (existing) {
ids.push(existing.id)
} else {
// New tag name typed by user create it
const tag = await store.createTag({ name: val })
if (tag) ids.push(tag.id)
}
}
return ids
}
async function onSave(): Promise<void> {
if (!form.title.trim()) {
message.warning('请输入标题')
return
}
if (!dateRange.value) {
message.warning('请选择日期范围')
return
}
const [startDate, endDate] = dateRange.value
let startIso: string
let endIso: string
if (form.is_all_day) {
startIso = startDate.startOf('day').toISOString()
endIso = endDate.startOf('day').toISOString()
} else {
// KTD-11: combine local date + time, convert to UTC ISO 8601
startIso = startDate
.hour(startTime.value.hour())
.minute(startTime.value.minute())
.second(0)
.millisecond(0)
.toISOString()
endIso = endDate
.hour(endTime.value.hour())
.minute(endTime.value.minute())
.second(0)
.millisecond(0)
.toISOString()
}
const rrule = buildRrule()
try {
if (props.event) {
const updateData: IUpdateEventRequest = {
title: form.title,
description: form.description,
start_time: startIso,
end_time: endIso,
is_all_day: form.is_all_day,
location: form.location,
event_type_id: form.event_type_id,
rrule,
}
await store.updateEvent(props.event.id, updateData)
message.success('事件已更新')
emit('saved', props.event)
} else {
const tagIds = await resolveTagIds()
const createData: ICreateEventRequest = {
title: form.title,
description: form.description,
start_time: startIso,
end_time: endIso,
is_all_day: form.is_all_day,
location: form.location,
event_type_id: form.event_type_id,
rrule,
tag_ids: tagIds,
}
const created = await store.createEvent(createData)
message.success('事件已创建')
if (created) emit('saved', created)
}
close()
} catch {
message.error(props.event ? '更新失败' : '创建失败')
}
}
</script>
<style scoped>
.event-editor__reminder {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
</style>

View File

@ -0,0 +1,275 @@
<template>
<a-drawer
:open="open"
@close="close"
placement="right"
:width="480"
title="邀请管理"
:destroyOnClose="true"
>
<!-- Event selector + invitees for selected event -->
<div class="invitation-manager__section">
<div class="invitation-manager__section-header">
<span class="invitation-manager__section-title">事件邀请</span>
<a-button
v-if="selectedEventId"
size="small"
type="primary"
@click="inviteModalOpen = true"
>
<template #icon><PlusOutlined /></template>
邀请
</a-button>
</div>
<a-select
v-model:value="selectedEventId"
allowClear
placeholder="选择事件以管理邀请"
style="width: 100%; margin-bottom: 12px"
@change="loadEventInvitations"
>
<a-select-option v-for="ev in store.events" :key="ev.id" :value="ev.id">
{{ ev.title }}
</a-select-option>
</a-select>
<template v-if="selectedEventId">
<a-empty v-if="eventInvitations.length === 0" description="暂无邀请" />
<a-list v-else :dataSource="eventInvitations" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.invitee_email }}</template>
<template #description>
<a-tag :color="statusColor(item.status)">{{ statusLabel(item.status) }}</a-tag>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</div>
<a-divider />
<!-- Incoming invitations (G6) -->
<div class="invitation-manager__section">
<div class="invitation-manager__section-header">
<span class="invitation-manager__section-title">收到的邀请</span>
<a-badge :count="store.pendingInvitations.length" />
</div>
<a-empty v-if="store.pendingInvitations.length === 0" description="暂无待处理邀请" />
<a-list v-else :dataSource="store.pendingInvitations" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ invitationEventTitle(item.event_id) }}</template>
<template #description>{{ item.invitee_email }}</template>
</a-list-item-meta>
<template #actions>
<a-space>
<a-button size="small" type="primary" @click="respond(item.id, 'accepted')">
接受
</a-button>
<a-button size="small" @click="respond(item.id, 'tentative')">待定</a-button>
<a-button size="small" danger @click="respond(item.id, 'declined')">
拒绝
</a-button>
</a-space>
</template>
</a-list-item>
</template>
</a-list>
</div>
<!-- User search modal for inviting (G5/A3) -->
<a-modal v-model:open="inviteModalOpen" title="邀请用户" :footer="null" :width="420">
<a-input-search
v-model:value="searchQuery"
placeholder="搜索用户名或邮箱"
@search="doSearch"
:loading="searching"
/>
<a-list
v-if="searchResults.length > 0"
:dataSource="searchResults"
size="small"
class="invitation-manager__search-results"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.username }}</template>
<template #description>{{ item.email }}</template>
</a-list-item-meta>
<template #actions>
<a-button
size="small"
type="primary"
:loading="invitingEmail === item.email"
@click="inviteUser(item.email)"
>
邀请
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-modal>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useCalendarStore } from '@/stores/calendar'
import { calendarApi } from '@/api/calendar'
import type { ICalendarEvent, IInvitation, IUserSearchResult } from '@/api/calendar'
const props = defineProps<{
open: boolean
event: ICalendarEvent | null
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const store = useCalendarStore()
const selectedEventId = ref<string | null>(null)
const eventInvitations = ref<IInvitation[]>([])
const inviteModalOpen = ref(false)
const searchQuery = ref('')
const searchResults = ref<IUserSearchResult[]>([])
const searching = ref(false)
const invitingEmail = ref<string | null>(null)
// Sync event prop to internal selection
watch(
() => [props.open, props.event],
() => {
if (!props.open) return
selectedEventId.value = props.event?.id ?? null
if (selectedEventId.value) {
loadEventInvitations()
} else {
eventInvitations.value = []
}
},
{ immediate: true },
)
function close(): void {
emit('update:open', false)
}
async function loadEventInvitations(): Promise<void> {
if (!selectedEventId.value) {
eventInvitations.value = []
return
}
try {
const resp = await calendarApi.listInvitations()
eventInvitations.value = (resp.invitations || []).filter(
(i) => i.event_id === selectedEventId.value,
)
} catch (err) {
console.warn('Failed to load event invitations:', err)
}
}
async function doSearch(): Promise<void> {
if (!searchQuery.value.trim()) return
searching.value = true
try {
searchResults.value = await store.searchUsers(searchQuery.value)
} finally {
searching.value = false
}
}
async function inviteUser(email: string): Promise<void> {
if (!selectedEventId.value) return
invitingEmail.value = email
try {
await calendarApi.createInvitation({
event_id: selectedEventId.value,
invitee_email: email,
})
message.success(`已邀请 ${email}`)
await loadEventInvitations()
} catch {
message.error('邀请失败')
} finally {
invitingEmail.value = null
}
}
async function respond(
id: string,
status: 'accepted' | 'declined' | 'tentative',
): Promise<void> {
try {
await store.respondToInvitation(id, status)
message.success(status === 'accepted' ? '已接受' : status === 'declined' ? '已拒绝' : '已标记待定')
} catch {
message.error('操作失败')
}
}
function invitationEventTitle(eventId: string): string {
return store.events.find((e) => e.id === eventId)?.title ?? '未知事件'
}
function statusColor(status: IInvitation['status']): string {
switch (status) {
case 'accepted':
return 'green'
case 'declined':
return 'red'
case 'tentative':
return 'orange'
default:
return 'default'
}
}
function statusLabel(status: IInvitation['status']): string {
switch (status) {
case 'accepted':
return '已接受'
case 'declined':
return '已拒绝'
case 'tentative':
return '待定'
default:
return '待回复'
}
}
</script>
<style scoped>
.invitation-manager__section {
margin-bottom: var(--space-3);
}
.invitation-manager__section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.invitation-manager__section-title {
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.invitation-manager__search-results {
margin-top: var(--space-3);
}
</style>

View File

@ -1,5 +1,39 @@
<template>
<div class="list-view">
<!-- Batch operations toolbar -->
<div v-if="selectedRowKeys.length > 0" class="list-view__toolbar">
<span class="list-view__toolbar-count">已选 {{ selectedRowKeys.length }} </span>
<a-space>
<a-popconfirm title="确认删除选中事件?" @confirm="batchDelete">
<a-button size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
<a-select
v-model:value="batchTypeId"
size="small"
placeholder="更改类型"
allowClear
style="width: 140px"
@change="batchChangeType"
>
<a-select-option v-for="t in store.eventTypes" :key="t.id" :value="t.id">
{{ t.name }}
</a-select-option>
</a-select>
<!-- ponytail: IUpdateEventRequest has no tag_ids field; batch add-tag is UI-only until backend supports it. -->
<a-input-search
v-model:value="batchTagName"
size="small"
placeholder="添加标签"
style="width: 140px"
@search="batchAddTag"
/>
<a-button size="small" @click="clearSelection">取消选择</a-button>
</a-space>
</div>
<a-empty v-if="sortedEvents.length === 0" description="暂无日程" class="list-view__empty" />
<a-table
v-else
@ -9,6 +43,7 @@
size="small"
:pagination="{ pageSize: 50, showSizeChanger: false }"
:customRow="customRow"
:rowSelection="rowSelection"
:scroll="{ y: 'calc(100% - 64px)' }"
>
<template #bodyCell="{ column, record }">
@ -48,7 +83,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { DeleteOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import EventBadge from './EventBadge.vue'
@ -71,6 +108,58 @@ const sortedEvents = computed(() =>
[...store.events].sort((a, b) => a.start_time.localeCompare(b.start_time)),
)
// --- Batch selection ---
const selectedRowKeys = ref<(string | number)[]>([])
const batchTypeId = ref<string | null>(null)
const batchTagName = ref('')
const rowSelection = {
selectedRowKeys,
onChange: (keys: (string | number)[]): void => {
selectedRowKeys.value = keys
},
}
function clearSelection(): void {
selectedRowKeys.value = []
batchTypeId.value = null
batchTagName.value = ''
}
async function batchDelete(): Promise<void> {
const count = selectedRowKeys.value.length
try {
for (const id of selectedRowKeys.value) {
await store.deleteEvent(id as string)
}
message.success(`已删除 ${count} 个事件`)
clearSelection()
} catch {
message.error('批量删除失败')
}
}
async function batchChangeType(typeId: string | null): Promise<void> {
if (!typeId) return
const count = selectedRowKeys.value.length
try {
for (const id of selectedRowKeys.value) {
await store.updateEvent(id as string, { event_type_id: typeId })
}
message.success(`已更新 ${count} 个事件的类型`)
batchTypeId.value = null
clearSelection()
} catch {
message.error('批量更新类型失败')
}
}
function batchAddTag(): void {
// ponytail: IUpdateEventRequest has no tag_ids; API doesn't support batch tag assignment yet.
message.info('批量添加标签功能暂未支持API 待扩展)')
batchTagName.value = ''
}
function customRow(record: ICalendarEvent): Record<string, () => void> {
return {
onClick: () => emit('edit', record),
@ -111,6 +200,21 @@ function formatDateTime(iso: string): string {
padding: var(--space-3) 0;
}
.list-view__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
border-radius: var(--radius-md);
}
.list-view__toolbar-count {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.list-view__empty {
padding: var(--space-8) 0;
}

View File

@ -18,6 +18,7 @@ import type {
IInvitation,
ICreateEventRequest,
IUpdateEventRequest,
ICreateTagRequest,
IUserSearchResult,
} from '@/api/calendar'
import type { WsServerMessage, ICalendarSyncConflictData } from '@/api/types'
@ -138,6 +139,21 @@ export const useCalendarStore = defineStore('calendar', () => {
}
}
/** Create a new tag and add it to state */
async function createTag(data: ICreateTagRequest): Promise<ITag | null> {
try {
const resp = await calendarApi.createTag(data)
if (resp.tag) {
tags.value.push(resp.tag)
}
return resp.tag ?? null
} catch (err) {
console.error('Failed to create tag:', err)
throw err
}
}
/** Set the calendar view mode */
function setViewMode(mode: CalendarViewMode): void {
viewMode.value = mode
@ -255,6 +271,7 @@ export const useCalendarStore = defineStore('calendar', () => {
deleteEvent,
loadEventTypes,
loadTags,
createTag,
setViewMode,
loadInvitations,
respondToInvitation,