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 }> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -7,48 +7,63 @@
|
|||
title="日历管理"
|
||||
:bodyStyle="{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
|
||||
>
|
||||
<div class="calendar-drawer__toolbar">
|
||||
<a-radio-group :value="store.viewMode" @change="onViewChange">
|
||||
<a-radio-button value="calendar">日历</a-radio-button>
|
||||
<a-radio-button value="card">卡片</a-radio-button>
|
||||
<a-radio-button value="list">列表</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-space>
|
||||
<a-badge :count="store.pendingInvitations.length" :offset="[6, 0]">
|
||||
<a-button @click="emit('manage-invitations')">邀请管理</a-button>
|
||||
</a-badge>
|
||||
<a-button type="primary" @click="emit('create')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建事件
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-tabs v-model:activeKey="activeTab" class="calendar-drawer__tabs">
|
||||
<a-tab-pane key="calendar" tab="日历">
|
||||
<div class="calendar-drawer__toolbar">
|
||||
<a-radio-group :value="store.viewMode" @change="onViewChange">
|
||||
<a-radio-button value="calendar">日历</a-radio-button>
|
||||
<a-radio-button value="card">卡片</a-radio-button>
|
||||
<a-radio-button value="list">列表</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-space>
|
||||
<a-badge :count="store.pendingInvitations.length" :offset="[6, 0]">
|
||||
<a-button @click="emit('manage-invitations')">邀请管理</a-button>
|
||||
</a-badge>
|
||||
<a-button type="primary" @click="emit('create')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建事件
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="calendar-drawer__content">
|
||||
<CalendarGrid
|
||||
v-if="store.viewMode === 'calendar'"
|
||||
@create="onCreate"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
<CardView
|
||||
v-else-if="store.viewMode === 'card'"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
<ListView
|
||||
v-else
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-drawer__content">
|
||||
<CalendarGrid
|
||||
v-if="store.viewMode === 'calendar'"
|
||||
@create="onCreate"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
<CardView
|
||||
v-else-if="store.viewMode === 'card'"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
<ListView
|
||||
v-else
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
</a-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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { useCalendarStore } from '@/stores/calendar'
|
||||
import type { ICalendarEvent } from '@/api/calendar'
|
||||
import CalendarGrid from './CalendarGrid.vue'
|
||||
import CardView from './CardView.vue'
|
||||
import ListView from './ListView.vue'
|
||||
import ReminderConfig from './ReminderConfig.vue'
|
||||
import SyncSettings from './SyncSettings.vue'
|
||||
|
||||
const store = useCalendarStore()
|
||||
|
||||
|
|
@ -63,6 +78,8 @@ const emit = defineEmits<{
|
|||
(e: 'manage-invitations'): void
|
||||
}>()
|
||||
|
||||
const activeTab = ref<string>('calendar')
|
||||
|
||||
function onViewChange(e: { target: { value: string } }): void {
|
||||
store.setViewMode(e.target.value as 'calendar' | 'card' | 'list')
|
||||
}
|
||||
|
|
@ -77,6 +94,18 @@ function onEdit(event: ICalendarEvent): void {
|
|||
</script>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
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