feat(calendar): U12 reminder config and external sync settings UI
This commit is contained in:
parent
3131769aed
commit
394d734d42
|
|
@ -319,6 +319,11 @@ class CalendarApiClient extends BaseApiClient {
|
||||||
async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> {
|
async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> {
|
||||||
return this.request(`/external-configs/${id}/sync`, { method: 'POST' })
|
return this.request(`/external-configs/${id}/sync`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete an external calendar config */
|
||||||
|
async deleteExternalConfig(id: string): Promise<{ success: boolean; deleted: boolean }> {
|
||||||
|
return this.request(`/external-configs/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calendarApi = new CalendarApiClient()
|
export const calendarApi = new CalendarApiClient()
|
||||||
|
|
|
||||||
|
|
@ -7,48 +7,63 @@
|
||||||
title="日历管理"
|
title="日历管理"
|
||||||
:bodyStyle="{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
|
:bodyStyle="{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
|
||||||
>
|
>
|
||||||
<div class="calendar-drawer__toolbar">
|
<a-tabs v-model:activeKey="activeTab" class="calendar-drawer__tabs">
|
||||||
<a-radio-group :value="store.viewMode" @change="onViewChange">
|
<a-tab-pane key="calendar" tab="日历">
|
||||||
<a-radio-button value="calendar">日历</a-radio-button>
|
<div class="calendar-drawer__toolbar">
|
||||||
<a-radio-button value="card">卡片</a-radio-button>
|
<a-radio-group :value="store.viewMode" @change="onViewChange">
|
||||||
<a-radio-button value="list">列表</a-radio-button>
|
<a-radio-button value="calendar">日历</a-radio-button>
|
||||||
</a-radio-group>
|
<a-radio-button value="card">卡片</a-radio-button>
|
||||||
<a-space>
|
<a-radio-button value="list">列表</a-radio-button>
|
||||||
<a-badge :count="store.pendingInvitations.length" :offset="[6, 0]">
|
</a-radio-group>
|
||||||
<a-button @click="emit('manage-invitations')">邀请管理</a-button>
|
<a-space>
|
||||||
</a-badge>
|
<a-badge :count="store.pendingInvitations.length" :offset="[6, 0]">
|
||||||
<a-button type="primary" @click="emit('create')">
|
<a-button @click="emit('manage-invitations')">邀请管理</a-button>
|
||||||
<template #icon><PlusOutlined /></template>
|
</a-badge>
|
||||||
新建事件
|
<a-button type="primary" @click="emit('create')">
|
||||||
</a-button>
|
<template #icon><PlusOutlined /></template>
|
||||||
</a-space>
|
新建事件
|
||||||
</div>
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="calendar-drawer__content">
|
<div class="calendar-drawer__content">
|
||||||
<CalendarGrid
|
<CalendarGrid
|
||||||
v-if="store.viewMode === 'calendar'"
|
v-if="store.viewMode === 'calendar'"
|
||||||
@create="onCreate"
|
@create="onCreate"
|
||||||
@edit="onEdit"
|
@edit="onEdit"
|
||||||
/>
|
/>
|
||||||
<CardView
|
<CardView
|
||||||
v-else-if="store.viewMode === 'card'"
|
v-else-if="store.viewMode === 'card'"
|
||||||
@edit="onEdit"
|
@edit="onEdit"
|
||||||
/>
|
/>
|
||||||
<ListView
|
<ListView
|
||||||
v-else
|
v-else
|
||||||
@edit="onEdit"
|
@edit="onEdit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="reminder" tab="提醒设置" force-render>
|
||||||
|
<ReminderConfig />
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="sync" tab="同步设置" force-render>
|
||||||
|
<SyncSettings />
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||||
import { useCalendarStore } from '@/stores/calendar'
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
import type { ICalendarEvent } from '@/api/calendar'
|
import type { ICalendarEvent } from '@/api/calendar'
|
||||||
import CalendarGrid from './CalendarGrid.vue'
|
import CalendarGrid from './CalendarGrid.vue'
|
||||||
import CardView from './CardView.vue'
|
import CardView from './CardView.vue'
|
||||||
import ListView from './ListView.vue'
|
import ListView from './ListView.vue'
|
||||||
|
import ReminderConfig from './ReminderConfig.vue'
|
||||||
|
import SyncSettings from './SyncSettings.vue'
|
||||||
|
|
||||||
const store = useCalendarStore()
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
|
@ -63,6 +78,8 @@ const emit = defineEmits<{
|
||||||
(e: 'manage-invitations'): void
|
(e: 'manage-invitations'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const activeTab = ref<string>('calendar')
|
||||||
|
|
||||||
function onViewChange(e: { target: { value: string } }): void {
|
function onViewChange(e: { target: { value: string } }): void {
|
||||||
store.setViewMode(e.target.value as 'calendar' | 'card' | 'list')
|
store.setViewMode(e.target.value as 'calendar' | 'card' | 'list')
|
||||||
}
|
}
|
||||||
|
|
@ -77,6 +94,18 @@ function onEdit(event: ICalendarEvent): void {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.calendar-drawer__tabs {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-drawer__toolbar {
|
.calendar-drawer__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
<template>
|
||||||
|
<div class="reminder-config">
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="为每种事件类型配置默认提醒规则。新建事件时将由后端(U5)自动继承对应规则。"
|
||||||
|
style="margin-bottom: var(--space-3)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-form layout="inline" style="margin-bottom: var(--space-3)">
|
||||||
|
<a-form-item label="事件类型">
|
||||||
|
<a-select
|
||||||
|
:value="selectedTypeId"
|
||||||
|
style="width: 220px"
|
||||||
|
placeholder="选择事件类型"
|
||||||
|
:options="typeOptions"
|
||||||
|
allow-clear
|
||||||
|
@change="onTypeChange"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<div v-if="!selectedTypeId" class="reminder-config__empty">
|
||||||
|
<BellOutlined />
|
||||||
|
<span>请选择事件类型以配置提醒规则</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="reminder-config__rules">
|
||||||
|
<div v-if="draftRules.length === 0" class="reminder-config__empty">
|
||||||
|
<BellOutlined />
|
||||||
|
<span>暂无提醒规则</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="reminder-config__rule-list">
|
||||||
|
<div
|
||||||
|
v-for="(rule, index) in draftRules"
|
||||||
|
:key="index"
|
||||||
|
class="reminder-config__rule"
|
||||||
|
>
|
||||||
|
<span class="reminder-config__rule-offset">
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{{ rule.offset_minutes }} 分钟前
|
||||||
|
</span>
|
||||||
|
<div class="reminder-config__rule-channels">
|
||||||
|
<a-tag
|
||||||
|
v-for="ch in rule.channels"
|
||||||
|
:key="ch"
|
||||||
|
:color="channelColor(ch)"
|
||||||
|
>
|
||||||
|
{{ channelLabel(ch) }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<a-button type="link" danger size="small" @click="removeRule(index)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
移除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider style="margin: var(--space-3) 0" />
|
||||||
|
|
||||||
|
<div class="reminder-config__add">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="newOffset"
|
||||||
|
:min="0"
|
||||||
|
:step="5"
|
||||||
|
addon-before="提前"
|
||||||
|
addon-after="分钟"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<a-checkbox-group v-model:value="newChannels" :options="channelOptions" />
|
||||||
|
<a-button type="primary" :disabled="!canAdd" @click="addRule">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
添加规则
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: var(--space-4)">
|
||||||
|
<a-button type="primary" @click="save">
|
||||||
|
<template #icon><CheckCircleOutlined /></template>
|
||||||
|
保存规则
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { notification } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
|
||||||
|
type ReminderChannel = 'client' | 'email' | 'webhook'
|
||||||
|
|
||||||
|
interface IReminderRule {
|
||||||
|
offset_minutes: number
|
||||||
|
channels: ReminderChannel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'calendar:reminder-rules'
|
||||||
|
|
||||||
|
const channelOptions: Array<{ label: string; value: ReminderChannel }> = [
|
||||||
|
{ label: '客户端', value: 'client' },
|
||||||
|
{ label: '邮件', value: 'email' },
|
||||||
|
{ label: 'Webhook', value: 'webhook' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ponytail: backend endpoint for per-event-type reminder rule CRUD is
|
||||||
|
// deferred — U5 inherits rules at event creation time. Until the CRUD
|
||||||
|
// route lands, rules are persisted locally in localStorage (native
|
||||||
|
// platform feature, no new deps). Functional but per-device; the upgrade
|
||||||
|
// path is a GET/PUT /event-types/{id}/reminder-rules endpoint.
|
||||||
|
function loadRules(): Record<string, IReminderRule[]> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as Record<string, IReminderRule[]>) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRules(rules: Record<string, IReminderRule[]>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(rules))
|
||||||
|
} catch {
|
||||||
|
/* quota / private mode — non-fatal, rules stay in-memory for the session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const allRules = ref<Record<string, IReminderRule[]>>(loadRules())
|
||||||
|
const selectedTypeId = ref<string | null>(null)
|
||||||
|
const draftRules = ref<IReminderRule[]>([])
|
||||||
|
const newOffset = ref(15)
|
||||||
|
const newChannels = ref<ReminderChannel[]>(['client'])
|
||||||
|
|
||||||
|
const typeOptions = computed(() =>
|
||||||
|
store.eventTypes.map((t) => ({ label: t.name, value: t.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const canAdd = computed(() => newChannels.value.length > 0 && newOffset.value >= 0)
|
||||||
|
|
||||||
|
// When the selected type changes, load its rules into the working draft.
|
||||||
|
watch(
|
||||||
|
selectedTypeId,
|
||||||
|
(id) => {
|
||||||
|
if (!id) {
|
||||||
|
draftRules.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftRules.value = (allRules.value[id] ?? []).map((r) => ({
|
||||||
|
offset_minutes: r.offset_minutes,
|
||||||
|
channels: [...r.channels],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function onTypeChange(id: string | undefined): void {
|
||||||
|
selectedTypeId.value = id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRule(): void {
|
||||||
|
if (!canAdd.value) return
|
||||||
|
draftRules.value.push({
|
||||||
|
offset_minutes: newOffset.value,
|
||||||
|
channels: [...newChannels.value],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRule(index: number): void {
|
||||||
|
draftRules.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
if (!selectedTypeId.value) return
|
||||||
|
allRules.value[selectedTypeId.value] = draftRules.value.map((r) => ({
|
||||||
|
offset_minutes: r.offset_minutes,
|
||||||
|
channels: [...r.channels],
|
||||||
|
}))
|
||||||
|
persistRules(allRules.value)
|
||||||
|
notification.success({ message: '提醒规则已保存' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(ch: ReminderChannel): string {
|
||||||
|
return channelOptions.find((o) => o.value === ch)?.label ?? ch
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelColor(ch: ReminderChannel): string {
|
||||||
|
return ch === 'client' ? 'blue' : ch === 'email' ? 'green' : 'orange'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// ponytail: Tauri system-notification integration (showing native OS
|
||||||
|
// notifications for calendar_reminder WS events) is deferred — the
|
||||||
|
// store currently surfaces reminders via ant-design notification only.
|
||||||
|
if (store.eventTypes.length === 0) {
|
||||||
|
store.loadEventTypes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reminder-config {
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-6) 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border-color-split);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule-offset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__rule-channels {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-config__add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
<template>
|
||||||
|
<div class="sync-settings">
|
||||||
|
<!-- G4: persistent conflict alerts sourced from store.syncConflicts
|
||||||
|
(populated by the store's handleWsEvent on calendar_sync_conflict). -->
|
||||||
|
<a-alert
|
||||||
|
v-for="(c, i) in store.syncConflicts"
|
||||||
|
:key="`${c.event_id}-${i}`"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:message="`同步冲突:${c.event_title}`"
|
||||||
|
style="margin-bottom: var(--space-3)"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<div class="sync-settings__conflict">
|
||||||
|
<div><span class="sync-settings__label">提供商:</span>{{ c.provider }}</div>
|
||||||
|
<div><span class="sync-settings__label">本地修改:</span>{{ formatTime(c.local_modified) }}</div>
|
||||||
|
<div><span class="sync-settings__label">远端修改:</span>{{ formatTime(c.remote_modified) }}</div>
|
||||||
|
<div><span class="sync-settings__label">处理策略:</span>{{ c.resolution }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<div class="sync-settings__actions">
|
||||||
|
<a-button @click="showAppleForm = true">
|
||||||
|
<template #icon><AppleOutlined /></template>
|
||||||
|
添加 Apple 日历
|
||||||
|
</a-button>
|
||||||
|
<!-- ponytail: OAuth callback route (/api/v1/calendar/auth/outlook/callback)
|
||||||
|
is deferred — this button only starts the redirect flow; the backend
|
||||||
|
will redirect to Microsoft's consent page and back. -->
|
||||||
|
<a-button @click="gotoOutlookAuth">
|
||||||
|
<template #icon><WindowsOutlined /></template>
|
||||||
|
添加 Outlook
|
||||||
|
</a-button>
|
||||||
|
<a-button :loading="loading" @click="loadConfigs">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-empty
|
||||||
|
v-if="configs.length === 0 && !loading"
|
||||||
|
description="尚未配置外部日历"
|
||||||
|
style="padding: var(--space-6) 0"
|
||||||
|
/>
|
||||||
|
<div v-else class="sync-settings__list">
|
||||||
|
<a-card
|
||||||
|
v-for="cfg in configs"
|
||||||
|
:key="cfg.id"
|
||||||
|
size="small"
|
||||||
|
class="sync-settings__card"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="sync-settings__card-title">
|
||||||
|
<AppleOutlined v-if="cfg.provider === 'caldav'" />
|
||||||
|
<WindowsOutlined v-else />
|
||||||
|
<span>{{ providerLabel(cfg.provider) }}</span>
|
||||||
|
<a-badge status="success" text="已连接" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" :loading="syncingId === cfg.id" @click="onSync(cfg.id)">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
立即同步
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" :loading="testingId === cfg.id" @click="onTest(cfg.id)">
|
||||||
|
<template #icon><ApiOutlined /></template>
|
||||||
|
测试连接
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm title="确认移除该外部日历?" @confirm="onRemove(cfg.id)">
|
||||||
|
<a-button size="small" danger>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
移除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="sync-settings__card-body">
|
||||||
|
<div class="sync-settings__row">
|
||||||
|
<span class="sync-settings__label">上次同步</span>
|
||||||
|
<span>{{ cfg.last_sync ? formatTime(cfg.last_sync) : '从未同步' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sync-settings__row">
|
||||||
|
<span class="sync-settings__label">同步范围</span>
|
||||||
|
<!-- ponytail: no PATCH endpoint for sync_scope yet — edits are
|
||||||
|
local-only until the backend adds an update route. -->
|
||||||
|
<a-select
|
||||||
|
v-model:value="cfg.sync_scope"
|
||||||
|
mode="multiple"
|
||||||
|
style="flex: 1; min-width: 200px"
|
||||||
|
placeholder="选择要同步的事件类型(留空同步全部)"
|
||||||
|
:options="scopeOptions"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
|
||||||
|
<!-- Apple Calendar (CalDAV) add form -->
|
||||||
|
<a-modal
|
||||||
|
:open="showAppleForm"
|
||||||
|
title="添加 Apple 日历 (CalDAV)"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
ok-text="添加"
|
||||||
|
cancel-text="取消"
|
||||||
|
@ok="submitApple"
|
||||||
|
@cancel="showAppleForm = false"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="CalDAV URL" required>
|
||||||
|
<a-input v-model:value="appleForm.url" placeholder="https://caldav.icloud.com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Apple ID" required>
|
||||||
|
<a-input v-model:value="appleForm.username" placeholder="apple@example.com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="App 专用密码" required>
|
||||||
|
<a-input-password v-model:value="appleForm.password" placeholder="应用专用密码" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="同步范围">
|
||||||
|
<a-select
|
||||||
|
v-model:value="appleForm.scope"
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="留空则同步全部"
|
||||||
|
:options="scopeOptions"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { notification } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
AppleOutlined,
|
||||||
|
WindowsOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import { calendarApi } from '@/api/calendar'
|
||||||
|
import { useCalendarStore } from '@/stores/calendar'
|
||||||
|
import type { IExternalCalendarConfig } from '@/api/calendar'
|
||||||
|
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const configs = ref<IExternalCalendarConfig[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncingId = ref<string | null>(null)
|
||||||
|
const testingId = ref<string | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const showAppleForm = ref(false)
|
||||||
|
const appleForm = ref({
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
scope: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ponytail: OAuth callback route is deferred — this URL starts the redirect
|
||||||
|
// flow only; the backend redirects to Microsoft's consent page.
|
||||||
|
const OUTLOOK_AUTH_URL = '/api/v1/calendar/auth/outlook'
|
||||||
|
|
||||||
|
const scopeOptions = computed(() =>
|
||||||
|
store.eventTypes.map((t) => ({ label: t.name, value: t.name })),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadConfigs(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.listExternalConfigs()
|
||||||
|
configs.value = resp.configs || []
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '加载外部日历配置失败' })
|
||||||
|
console.warn('listExternalConfigs failed:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSync(id: string): Promise<void> {
|
||||||
|
syncingId.value = id
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.syncNow(id)
|
||||||
|
if (resp.synced) {
|
||||||
|
notification.success({ message: '同步已触发' })
|
||||||
|
await loadConfigs()
|
||||||
|
} else {
|
||||||
|
notification.warning({ message: '同步失败', description: resp.error })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '同步失败' })
|
||||||
|
console.warn('syncNow failed:', err)
|
||||||
|
} finally {
|
||||||
|
syncingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTest(id: string): Promise<void> {
|
||||||
|
testingId.value = id
|
||||||
|
try {
|
||||||
|
const resp = await calendarApi.testExternalConnection(id)
|
||||||
|
if (resp.connected) {
|
||||||
|
notification.success({ message: '连接正常' })
|
||||||
|
} else {
|
||||||
|
notification.warning({ message: '连接失败', description: resp.error })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '测试连接失败' })
|
||||||
|
console.warn('testExternalConnection failed:', err)
|
||||||
|
} finally {
|
||||||
|
testingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await calendarApi.deleteExternalConfig(id)
|
||||||
|
configs.value = configs.value.filter((c) => c.id !== id)
|
||||||
|
notification.success({ message: '已移除外部日历' })
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '移除失败' })
|
||||||
|
console.warn('deleteExternalConfig failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitApple(): Promise<void> {
|
||||||
|
const f = appleForm.value
|
||||||
|
if (!f.url || !f.username || !f.password) {
|
||||||
|
notification.warning({ message: '请填写完整的 CalDAV 信息' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const credentials = JSON.stringify({
|
||||||
|
url: f.url,
|
||||||
|
username: f.username,
|
||||||
|
password: f.password,
|
||||||
|
})
|
||||||
|
const resp = await calendarApi.createExternalConfig({
|
||||||
|
provider: 'caldav',
|
||||||
|
credentials,
|
||||||
|
sync_scope: f.scope,
|
||||||
|
})
|
||||||
|
configs.value.push(resp.config)
|
||||||
|
showAppleForm.value = false
|
||||||
|
appleForm.value = { url: '', username: '', password: '', scope: [] }
|
||||||
|
notification.success({ message: 'Apple 日历已添加' })
|
||||||
|
} catch (err) {
|
||||||
|
notification.error({ message: '添加失败' })
|
||||||
|
console.warn('createExternalConfig failed:', err)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoOutlookAuth(): void {
|
||||||
|
window.location.href = OUTLOOK_AUTH_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(p: IExternalCalendarConfig['provider']): string {
|
||||||
|
return p === 'caldav' ? 'Apple 日历 (CalDAV)' : 'Outlook'
|
||||||
|
}
|
||||||
|
|
||||||
|
// KTD-11: format ISO timestamps in the user's local timezone.
|
||||||
|
const dtf = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return dtf.format(new Date(iso))
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConfigs()
|
||||||
|
if (store.eventTypes.length === 0) {
|
||||||
|
store.loadEventTypes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sync-settings {
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__conflict {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings__label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue