feat: use AI to migrate calendar and event views

This commit is contained in:
2026-07-09 16:59:00 +08:00
parent e6fc06c0f9
commit 0f674d0483
12 changed files with 1276 additions and 24 deletions
+339 -3
View File
@@ -1,8 +1,344 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useTokenStore } from '@/stores/token';
import MessageBox from '@/components/MessageBox.vue';
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
import CalendarGrid from '@/components/calendar/CalendarGrid.vue';
import ScheduleList from '@/components/calendar/ScheduleList.vue';
import CollectionToggleItem from '@/components/calendar/CollectionToggleItem.vue';
import type { DisplayDay, DisplayEvent } from '@/components/calendar/types';
import {
getFull as apiCalendarGetFull,
deserializeDescription,
type CalendarRow,
} from '@/api/calendar';
import {
getFullOwn as apiCollectionGetFullOwn,
getShared as apiCollectionGetShared,
type CollectionRow,
type SharedCollectionRow,
} from '@/api/collection';
import {
DAY1_SPAN,
dayOfWeek,
resolveLoopRules2Event,
resolveLoopRules4Text,
} from '@/utils/datetime';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
const token = useTokenStore();
const router = useRouter();
const messagebox = ref<InstanceType<typeof MessageBox>>();
const picker = ref<InstanceType<typeof DateTimePicker>>();
const activeTab = ref<number>(1);
// The viewed month cursor. Only year + month are meaningful; the time-of-day
// participates in the grid start timestamp exactly like the legacy code.
const currentMonth = ref<Date>(new Date());
// Raw event rows covering the current grid window.
const eventsCache = ref<CalendarRow[]>([]);
// Collection lists for tab 2.
const ownedCollections = ref<CollectionRow[]>([]);
const sharedCollections = ref<SharedCollectionRow[]>([]);
// Per-collection visibility flags, keyed by collection uuid. Default (absent)
// means hidden, matching the legacy behaviour where missing cache entries are
// treated as false.
const ownedVisible = ref<Map<string, boolean>>(new Map());
const sharedVisible = ref<Map<string, boolean>>(new Map());
// region: Computed grid window and analyzed display data
const gridYear = computed(() => currentMonth.value.getFullYear());
const gridMonth = computed(() => currentMonth.value.getMonth() + 1);
const firstDow = computed(() => dayOfWeek(gridYear.value, gridMonth.value, 1));
const gridStartTimestamp = computed(() =>
Math.floor(currentMonth.value.getTime() / 60000) - firstDow.value * DAY1_SPAN
);
const gridEndTimestamp = computed(() => gridStartTimestamp.value + DAY1_SPAN * 6 * 7 - 1);
const textMonth = computed(() => `${gridYear.value} - ${MONTH_NAMES[gridMonth.value - 1]}`);
const myTimezoneOffset = computed(() => -(new Date().getTimezoneOffset()));
// Port of ccn_calendar_calendar_Analyse: build the 42-cell display cache,
// expand loop events and split occurrences that cross day boundaries.
const displayDays = computed<DisplayDay[]>(() => {
const startTimestamp = gridStartTimestamp.value;
const endTimestamp = gridEndTimestamp.value;
const gottenMonth = gridMonth.value;
const myTz = myTimezoneOffset.value;
// Build the 42 day cells.
const days: DisplayDay[] = [];
const cursor = new Date(startTimestamp * 60000);
for (let i = 0; i < 6 * 7; i++) {
days.push({
month: cursor.getMonth() + 1,
day: cursor.getDate(),
dayOfWeek: (cursor.getDay() === 0 ? 6 : cursor.getDay() - 1) + 1,
isCurrentMonth: (cursor.getMonth() + 1) === gottenMonth,
subcalendar: '',
events: [],
});
cursor.setTime(cursor.getTime() + DAY1_SPAN * 60000);
}
// Expand and place each event.
for (const item of eventsCache.value) {
const deserialized = deserializeDescription(item[3]);
const minStartTimestamp = startTimestamp - (item[6] - item[5]);
const result = resolveLoopRules2Event(
item[8],
item[9] < minStartTimestamp ? minStartTimestamp : item[9],
Math.min(item[10], endTimestamp),
item[5],
item[6],
item[7],
startTimestamp
);
if (typeof result === 'undefined') continue;
const loopText = resolveLoopRules4Text(item[8], item[5], item[7]);
const timezoneWarning = myTz !== item[7];
for (const it of result) {
const eventDateTime = new Date(it[0] * 60000);
let count = Math.floor((it[0] - startTimestamp) / DAY1_SPAN);
let exitFlag = false;
while (count < 6 * 7) {
// NOTE: legacy indexes the owned cache by the *event* uuid (item[0]),
// which is collection-keyed, so this is always false. Kept faithful.
const isLocked = ownedVisible.value.has(item[0]);
const belongVisible = ownedVisible.value.get(item[1]) || sharedVisible.value.get(item[1]) || false;
const eventItem: DisplayEvent = {
uuid: item[0],
belongTo: item[1],
title: item[2],
description: deserialized.description,
color: deserialized.color,
isVisible: belongVisible,
isLocked,
loopText,
timezoneWarning,
start: eventDateTime.toLocaleTimeString(),
end: '',
};
eventDateTime.setHours(23, 59, 0, 0);
if (it[1] <= Math.floor(eventDateTime.getTime() / 60000)) {
exitFlag = true;
eventDateTime.setTime(it[1] * 60000);
}
eventItem.end = eventDateTime.toLocaleTimeString();
days[count]!.events.push(eventItem);
if (exitFlag) break;
eventDateTime.setMinutes(eventDateTime.getMinutes() + 1, 0, 0);
count++;
}
}
}
return days;
});
// endregion
// region: Data fetching
const refreshEvents = async () => {
const rv = await apiCalendarGetFull(token.currentToken, gridStartTimestamp.value, gridEndTimestamp.value);
eventsCache.value = typeof rv === 'undefined' ? [] : rv;
};
const refreshCollections = async () => {
const newOwned = new Map<string, boolean>();
const newShared = new Map<string, boolean>();
const sharedRv = await apiCollectionGetShared(token.currentToken);
sharedCollections.value = typeof sharedRv === 'undefined' ? [] : sharedRv;
if (sharedCollections.value.length > 0) {
for (const item of sharedCollections.value) {
newShared.set(item[0], true);
}
}
const ownedRv = await apiCollectionGetFullOwn(token.currentToken);
ownedCollections.value = typeof ownedRv === 'undefined' ? [] : ownedRv;
if (ownedCollections.value.length > 0) {
for (const item of ownedCollections.value) {
newOwned.set(item[0], true);
}
}
ownedVisible.value = newOwned;
sharedVisible.value = newShared;
};
// endregion
// region: Navigation
const prevMonth = () => {
const d = new Date(currentMonth.value);
d.setMonth(d.getMonth() - 1);
currentMonth.value = d;
};
const nextMonth = () => {
const d = new Date(currentMonth.value);
d.setMonth(d.getMonth() + 1);
currentMonth.value = d;
};
const today = () => {
currentMonth.value = new Date();
};
const jump = () => {
picker.value?.modal(currentMonth.value, TabType.Month);
};
const onPickerConfirm = (date: Date) => {
currentMonth.value = date;
};
const addEvent = () => {
router.push({ name: 'CalendarEventAdd' });
};
const editEvent = (uuid: string) => {
router.push({ name: 'CalendarEventUpdate', params: { uuid } });
};
// endregion
// region: Collection visibility toggles
const toggleOwned = (uuid: string) => {
const m = new Map(ownedVisible.value);
m.set(uuid, !m.get(uuid));
ownedVisible.value = m;
};
const toggleShared = (uuid: string) => {
const m = new Map(sharedVisible.value);
m.set(uuid, !m.get(uuid));
sharedVisible.value = m;
};
// endregion
watch(currentMonth, () => { refreshEvents(); });
onMounted(async () => {
await refreshCollections();
await refreshEvents();
});
</script>
<template>
<h1>Congratulations</h1>
<p>This is calendar.</p>
<div class="container" style="margin-top: 20px;">
<div class="tabs">
<ul>
<li :class="{ 'is-active': activeTab === 1 }" @click="activeTab = 1"><a>Calendar</a></li>
<li :class="{ 'is-active': activeTab === 2 }" @click="activeTab = 2"><a>Collection</a></li>
<li :class="{ 'is-active': activeTab === 3 }" @click="activeTab = 3"><a>Display</a></li>
</ul>
</div>
</div>
<!-- Tab 1: Calendar -->
<div v-show="activeTab === 1" class="container" style="margin-top: 20px;">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevMonth">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
<div class="level-item control">
<a class="button" @click="jump">
<span>{{ textMonth }}</span>
</a>
</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="nextMonth">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
</nav>
<nav class="level is-mobile">
<div class="level-item control">
<a class="button is-info" @click="today">Today</a>
</div>
<div class="level-item control">
<a class="button is-primary" @click="addEvent">Add...</a>
</div>
</nav>
<div class="card" style="padding: 1.25rem;">
<CalendarGrid :cells="displayDays" :week-names="WEEK_NAMES" @event-click="editEvent" />
</div>
<div class="container" style="padding: 1.25rem; display: flex; flex-flow: column; margin-top: 1.25rem;">
<h1 class="title">Schedule</h1>
<ScheduleList :days="displayDays" @event-click="editEvent" />
</div>
</div>
<!-- Tab 2: Collection -->
<div v-show="activeTab === 2" class="container" style="margin-top: 20px;">
<div class="control" style="margin: 0.75rem;">
<a class="button is-primary" @click="refreshCollections">
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
</a>
</div>
<h1 class="title">My collections</h1>
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
<CollectionToggleItem v-for="item in ownedCollections" :key="item[0]" :name="item[1]"
:is-show="ownedVisible.get(item[0]) || false" @toggle="toggleOwned(item[0])" />
</div>
<h1 class="title">Shared collections</h1>
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
<CollectionToggleItem v-for="item in sharedCollections" :key="item[0]" :name="item[1]" :subtitle="item[2]"
:is-show="sharedVisible.get(item[0]) || false" @toggle="toggleShared(item[0])" />
</div>
</div>
<!-- Tab 3: Display (legacy dead UI, reproduced as-is) -->
<div v-show="activeTab === 3" class="container" style="margin-top: 20px;">
<div class="field">
<label class="label">The first day of week</label>
<div class="control">
<div class="select">
<select>
<option v-for="(name, i) in WEEK_NAMES" :key="i">{{ name }}</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Sub-Calendar</label>
<div class="control">
<div class="select">
<select>
<option>Chinese Lunisolar Calendar</option>
</select>
</div>
</div>
</div>
</div>
<MessageBox ref="messagebox" />
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
</template>
<style scoped></style>