feat(calendar): U11 event editor, invitation manager and batch operations
This commit is contained in:
parent
8350b02d75
commit
3131769aed
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue