feat(calendar): U12 reminder config and external sync settings UI

This commit is contained in:
chiguyong 2026-06-24 05:02:29 +08:00
parent 3131769aed
commit 394d734d42
4 changed files with 680 additions and 31 deletions

View File

@ -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()

View File

@ -7,6 +7,8 @@
title="日历管理"
:bodyStyle="{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
>
<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>
@ -39,16 +41,29 @@
@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;

View File

@ -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>

View File

@ -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>