feat(calendar): U9 frontend store, API client and types

CalendarApiClient with 20 methods covering all backend endpoints
(listEvents, createEvent, importIcs, searchUsers, syncNow, etc.).
useCalendarStore Pinia store with events/eventTypes/tags state,
view mode switching, and handleWsEvent dispatch for 4 calendar WS
message types. WsServerMessage union extended with calendar variants.

- src/agentkit/server/frontend/src/api/calendar.ts — API client + types
- src/agentkit/server/frontend/src/stores/calendar.ts — Pinia store
- src/agentkit/server/frontend/src/api/types.ts — WS message types
This commit is contained in:
chiguyong 2026-06-23 22:52:40 +08:00
parent 40d326cd3f
commit 40bc27822f
3 changed files with 628 additions and 0 deletions

View File

@ -0,0 +1,324 @@
/** Calendar API client — thin wrapper over /api/v1/calendar endpoints. */
import { BaseApiClient, getDynamicBaseURL } from './base'
// ── Domain types (co-located with API client) ──────────────────────────
export interface ICalendarEvent {
id: string
user_id: string
title: string
description: string
start_time: string // ISO 8601 UTC (KTD-11)
end_time: string // ISO 8601 UTC
is_all_day: boolean
location: string
event_type_id: string | null
rrule: string | null // RFC 5545 RRULE string
source: 'manual' | 'agent' | 'post_extract'
is_invited: boolean
conversation_id: string | null
external_id: string | null
external_provider: string | null
last_modified: string
created_at: string
}
export interface IEventType {
id: string
user_id: string
name: string
color: string
is_default: boolean
}
export interface ITag {
id: string
user_id: string
name: string
}
export interface IInvitation {
id: string
event_id: string
inviter_user_id: string
invitee_email: string
status: 'pending' | 'accepted' | 'declined' | 'tentative'
responded_at: string | null
}
export interface IExternalCalendarConfig {
id: string
user_id: string
provider: 'caldav' | 'outlook'
credentials: string // '***' on read-back; never real credentials
sync_frequency: number
sync_scope: string[]
last_sync: string | null
sync_token: string | null
}
export interface IUserSearchResult {
username: string
email: string
}
// ── Request types ──────────────────────────────────────────────────────
export interface ICreateEventRequest {
title: string
start_time: string
end_time: string
description?: string
location?: string
is_all_day?: boolean
event_type_id?: string | null
rrule?: string | null
tag_ids?: string[]
}
export interface IUpdateEventRequest {
title?: string
start_time?: string
end_time?: string
description?: string
location?: string
is_all_day?: boolean
event_type_id?: string | null
rrule?: string | null
}
export interface ICreateEventTypeRequest {
name: string
color?: string
}
export interface ICreateTagRequest {
name: string
}
export interface ICreateInvitationRequest {
event_id: string
invitee_email: string
}
export interface ICreateExternalConfigRequest {
provider: 'caldav' | 'outlook'
credentials: string // JSON string with provider-specific auth
sync_frequency?: number
sync_scope?: string[]
}
// ── Runtime type guard ─────────────────────────────────────────────────
/**
* Runtime guard for ICalendarEvent validates the minimum fields required
* for the calendar store to function safely.
* ponytail: checks only the keys the store actually reads; full schema
* validation belongs at the API boundary, not in the WS event handler.
*/
export function isCalendarEvent(value: unknown): value is ICalendarEvent {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
return (
typeof v.id === 'string' &&
typeof v.title === 'string' &&
typeof v.start_time === 'string' &&
typeof v.end_time === 'string' &&
typeof v.is_all_day === 'boolean'
)
}
// ── API client ─────────────────────────────────────────────────────────
const API_BASE = '/api/v1/calendar'
class CalendarApiClient extends BaseApiClient {
constructor(baseUrl: string = API_BASE) {
super(baseUrl)
}
/** List events with optional date/type/tag filters */
async listEvents(
start?: string,
end?: string,
eventTypeId?: string,
tagId?: string,
): Promise<{ success: boolean; events: ICalendarEvent[]; count: number }> {
const params = new URLSearchParams()
if (start) params.set('start', start)
if (end) params.set('end', end)
if (eventTypeId) params.set('type_id', eventTypeId)
if (tagId) params.set('tag_id', tagId)
const qs = params.toString()
const path = qs ? `/events?${qs}` : '/events'
return this.request(path, { method: 'GET' })
}
/** Create a new event */
async createEvent(
data: ICreateEventRequest,
): Promise<{ success: boolean; event: ICalendarEvent }> {
return this.request('/events', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** Get a single event by id */
async getEvent(id: string): Promise<{ success: boolean; event: ICalendarEvent }> {
return this.request(`/events/${id}`, { method: 'GET' })
}
/** Update specific fields of an event (PATCH — partial update) */
async updateEvent(
id: string,
data: IUpdateEventRequest,
): Promise<{ success: boolean; event: ICalendarEvent; updated: boolean }> {
return this.request(`/events/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
/** Delete an event */
async deleteEvent(id: string): Promise<{ success: boolean; deleted: boolean }> {
return this.request(`/events/${id}`, { method: 'DELETE' })
}
/** List event types for the current user */
async listEventTypes(): Promise<{
success: boolean
event_types: IEventType[]
count: number
}> {
return this.request('/event-types', { method: 'GET' })
}
/** Create an event type */
async createEventType(
data: ICreateEventTypeRequest,
): Promise<{ success: boolean; event_type: IEventType }> {
return this.request('/event-types', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** List tags for the current user */
async listTags(): Promise<{ success: boolean; tags: ITag[]; count: number }> {
return this.request('/tags', { method: 'GET' })
}
/** Create a tag */
async createTag(data: ICreateTagRequest): Promise<{ success: boolean; tag: ITag }> {
return this.request('/tags', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** Import events from an uploaded .ics file (multipart) */
async importIcs(file: File): Promise<{
success: boolean
imported: number
skipped: number
[key: string]: unknown
}> {
const formData = new FormData()
formData.append('file', file)
return this.request('/import-ics', {
method: 'POST',
body: formData,
headers: {}, // Let browser set Content-Type for FormData
})
}
/**
* Build the export-ics download URL for a date range.
* Returns an absolute or relative URL the caller can open/download.
* ponytail: the endpoint returns binary text/calendar, not JSON, so we
* hand back a URL rather than going through request<T> (which JSON-parses).
*/
exportIcs(start?: string, end?: string): string {
const base = getDynamicBaseURL()
const params = new URLSearchParams()
if (start) params.set('start', start)
if (end) params.set('end', end)
const qs = params.toString()
const path = qs ? `/api/v1/calendar/export-ics?${qs}` : '/api/v1/calendar/export-ics'
return base ? `${base}${path}` : path
}
/** Create an invitation (invite a user to an event by email) */
async createInvitation(
data: ICreateInvitationRequest,
): Promise<{ success: boolean; invitation: IInvitation }> {
return this.request(`/events/${data.event_id}/invitations`, {
method: 'POST',
body: JSON.stringify({ invitee_email: data.invitee_email }),
})
}
/** List invitations for the current user */
async listInvitations(): Promise<{
success: boolean
invitations: IInvitation[]
count: number
}> {
return this.request('/invitations', { method: 'GET' })
}
/** Respond to an invitation (accept/decline/tentative) */
async respondToInvitation(
id: string,
status: 'accepted' | 'declined' | 'tentative',
): Promise<{ success: boolean; status: string }> {
return this.request(`/invitations/${id}/respond`, {
method: 'POST',
body: JSON.stringify({ status }),
})
}
/** Search users by username or email — returns top 10 matches (G5/A3) */
async searchUsers(q: string): Promise<{
success: boolean
users: IUserSearchResult[]
count: number
}> {
return this.request(`/users/search?q=${encodeURIComponent(q)}`, { method: 'GET' })
}
/** List external calendar configs for the current user */
async listExternalConfigs(): Promise<{
success: boolean
configs: IExternalCalendarConfig[]
count: number
}> {
return this.request('/external-configs', { method: 'GET' })
}
/** Create an external calendar config */
async createExternalConfig(
data: ICreateExternalConfigRequest,
): Promise<{ success: boolean; config: IExternalCalendarConfig }> {
return this.request('/external-configs', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** Test connection to an external calendar */
async testExternalConnection(
id: string,
): Promise<{ success: boolean; connected: boolean; error?: string }> {
return this.request(`/external-configs/${id}/test`, { method: 'POST' })
}
/** Trigger an immediate sync for an external calendar config */
async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> {
return this.request(`/external-configs/${id}/sync`, { method: 'POST' })
}
}
export const calendarApi = new CalendarApiClient()

View File

@ -1,3 +1,5 @@
import type { ICalendarEvent, IInvitation } from './calendar'
/** Chat request payload */
export interface IChatRequest {
message: string
@ -130,6 +132,11 @@ export type WsServerMessage =
| { type: 'round_summary'; data: IRoundSummaryData }
| { type: 'user_intervention'; data: IUserInterventionData }
| { type: 'board_concluded'; data: IBoardConcludedData }
// Calendar 事件 (KTD-10 — piggyback on chat WS)
| { type: 'calendar_event_created'; data: ICalendarEventCreatedData }
| { type: 'calendar_reminder'; data: ICalendarReminderData }
| { type: 'calendar_invitation'; data: ICalendarInvitationData }
| { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData }
/** Expert info within a team */
export interface IExpertInfo {
@ -233,6 +240,39 @@ export interface IBoardMessage {
timestamp: number
}
// ── Calendar WS 事件 payload 类型 ───────────────────────────────────
/** calendar_event_created payload */
export interface ICalendarEventCreatedData {
event: ICalendarEvent
}
/** calendar_reminder payload */
export interface ICalendarReminderData {
event_id: string
title: string
start_time: string
offset_minutes: number
channels: string[]
}
/** calendar_invitation payload (G6) */
export interface ICalendarInvitationData {
invitation: IInvitation
event_title: string
inviter_name: string
}
/** calendar_sync_conflict payload (G4) */
export interface ICalendarSyncConflictData {
event_id: string
event_title: string
provider: string
local_modified: string
remote_modified: string
resolution: string
}
/** Expert template (matches backend GET /api/v1/experts response item) */
export interface IExpertTemplate {
name: string

View File

@ -0,0 +1,264 @@
/**
* Pinia store for calendar feature events, event types, tags,
* invitations, and WebSocket event dispatch.
*
* ponytail: stores/chat.ts handleWsMessage dispatch is deferred the
* orchestrator will wire the 4 calendar_* WS cases to call handleWsEvent().
* This store owns the calendar-specific state mutations + notifications.
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { notification } from 'ant-design-vue'
import { calendarApi, isCalendarEvent } from '@/api/calendar'
import type {
ICalendarEvent,
IEventType,
ITag,
IInvitation,
ICreateEventRequest,
IUpdateEventRequest,
IUserSearchResult,
} from '@/api/calendar'
import type { WsServerMessage, ICalendarSyncConflictData } from '@/api/types'
export type CalendarViewMode = 'calendar' | 'card' | 'list'
export const useCalendarStore = defineStore('calendar', () => {
// --- State ---
const events = ref<ICalendarEvent[]>([])
const eventTypes = ref<IEventType[]>([])
const tags = ref<ITag[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedEvent = ref<ICalendarEvent | null>(null)
const viewMode = ref<CalendarViewMode>('calendar')
const dateRange = ref<{ start: string | null; end: string | null }>({
start: null,
end: null,
})
const pendingInvitations = ref<IInvitation[]>([])
const syncConflicts = ref<ICalendarSyncConflictData[]>([])
// --- Getters ---
const upcomingEvents = computed(() => {
const now = new Date().toISOString()
return events.value
.filter((e) => e.start_time >= now)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
})
// --- Actions ---
/** Load events with current dateRange filter */
async function loadEvents(): Promise<void> {
isLoading.value = true
error.value = null
try {
const resp = await calendarApi.listEvents(
dateRange.value.start ?? undefined,
dateRange.value.end ?? undefined,
)
events.value = resp.events || []
} catch (err) {
error.value = err instanceof Error ? err.message : '加载日程失败'
console.warn('Failed to load calendar events:', err)
} finally {
isLoading.value = false
}
}
/** Create a new event and add it to state */
async function createEvent(data: ICreateEventRequest): Promise<ICalendarEvent | null> {
isLoading.value = true
error.value = null
try {
const resp = await calendarApi.createEvent(data)
if (resp.event) {
events.value.push(resp.event)
}
return resp.event ?? null
} catch (err) {
error.value = err instanceof Error ? err.message : '创建日程失败'
console.error('Failed to create event:', err)
throw err
} finally {
isLoading.value = false
}
}
/** Update an existing event in state */
async function updateEvent(id: string, data: IUpdateEventRequest): Promise<void> {
error.value = null
try {
const resp = await calendarApi.updateEvent(id, data)
if (resp.event) {
const idx = events.value.findIndex((e) => e.id === id)
if (idx !== -1) {
events.value[idx] = resp.event
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : '更新日程失败'
console.error('Failed to update event:', err)
throw err
}
}
/** Delete an event from server and state */
async function deleteEvent(id: string): Promise<void> {
error.value = null
try {
await calendarApi.deleteEvent(id)
events.value = events.value.filter((e) => e.id !== id)
} catch (err) {
error.value = err instanceof Error ? err.message : '删除日程失败'
console.error('Failed to delete event:', err)
throw err
}
}
/** Load event types for the current user */
async function loadEventTypes(): Promise<void> {
try {
const resp = await calendarApi.listEventTypes()
eventTypes.value = resp.event_types || []
} catch (err) {
console.warn('Failed to load event types:', err)
}
}
/** Load tags for the current user */
async function loadTags(): Promise<void> {
try {
const resp = await calendarApi.listTags()
tags.value = resp.tags || []
} catch (err) {
console.warn('Failed to load tags:', err)
}
}
/** Set the calendar view mode */
function setViewMode(mode: CalendarViewMode): void {
viewMode.value = mode
}
/** Load pending invitations for the current user */
async function loadInvitations(): Promise<void> {
try {
const resp = await calendarApi.listInvitations()
pendingInvitations.value = (resp.invitations || []).filter(
(i) => i.status === 'pending',
)
} catch (err) {
console.warn('Failed to load invitations:', err)
}
}
/** Respond to an invitation and remove it from pending list */
async function respondToInvitation(
id: string,
status: 'accepted' | 'declined' | 'tentative',
): Promise<void> {
try {
await calendarApi.respondToInvitation(id, status)
pendingInvitations.value = pendingInvitations.value.filter((i) => i.id !== id)
} catch (err) {
error.value = err instanceof Error ? err.message : '回复邀请失败'
console.error('Failed to respond to invitation:', err)
throw err
}
}
/** Search users by username or email (G5/A3) */
async function searchUsers(q: string): Promise<IUserSearchResult[]> {
if (!q.trim()) return []
try {
const resp = await calendarApi.searchUsers(q)
return resp.users || []
} catch (err) {
console.warn('Failed to search users:', err)
return []
}
}
/**
* Dispatch calendar WS messages to the appropriate handler.
* Handles 4 message types: calendar_event_created, calendar_reminder,
* calendar_invitation (G6), calendar_sync_conflict (G4).
* Non-calendar message types are silently ignored.
*/
function handleWsEvent(msg: WsServerMessage): void {
switch (msg.type) {
case 'calendar_event_created': {
const event = msg.data.event
if (isCalendarEvent(event)) {
// Avoid duplicates if the event was created locally
if (!events.value.some((e) => e.id === event.id)) {
events.value.push(event)
}
}
break
}
case 'calendar_reminder': {
const d = msg.data
notification.warning({
message: '日程提醒',
description: `${d.title} · ${d.start_time}`,
duration: 0,
})
break
}
case 'calendar_invitation': {
const d = msg.data
if (d.invitation) {
pendingInvitations.value.push(d.invitation)
}
notification.info({
message: '收到日程邀请',
description: `${d.inviter_name} 邀请你参加「${d.event_title}`,
duration: 0,
})
break
}
case 'calendar_sync_conflict': {
const d = msg.data
syncConflicts.value.push(d)
notification.warning({
message: '日历同步冲突',
description: `${d.event_title}」与 ${d.provider} 同步冲突,已按 ${d.resolution} 策略处理`,
duration: 0,
})
break
}
}
}
return {
// State
events,
eventTypes,
tags,
isLoading,
error,
selectedEvent,
viewMode,
dateRange,
pendingInvitations,
syncConflicts,
// Getters
upcomingEvents,
// Actions
loadEvents,
createEvent,
updateEvent,
deleteEvent,
loadEventTypes,
loadTags,
setViewMode,
loadInvitations,
respondToInvitation,
searchUsers,
handleWsEvent,
}
})