refactor: migrate frontend event page

This commit is contained in:
2026-07-21 14:34:23 +08:00
parent 02546c12f1
commit 36ce59c4ab
2 changed files with 677 additions and 10 deletions
+8 -6
View File
@@ -81,7 +81,7 @@ const windowRange = computed<WindowRange>(() => {
return { startTs, endTs, year, month1 } return { startTs, endTs, year, month1 }
}) })
// Analyse phase: build the 42 cells and expand every event (loop expansion + // Analyse phase: build the 6 * 7 cells and expand every event (loop expansion +
// multi-day splitting). Deliberately does NOT read the visibility maps so it // multi-day splitting). Deliberately does NOT read the visibility maps so it
// stays cached across visibility toggles. // stays cached across visibility toggles.
const expandedCells = computed<CalendarCell[]>(() => { const expandedCells = computed<CalendarCell[]>(() => {
@@ -89,10 +89,10 @@ const expandedCells = computed<CalendarCell[]>(() => {
const owned = ownedUuids.value const owned = ownedUuids.value
const myTz = myTimezone.value const myTz = myTimezone.value
// build the 42 day cells // build the 6 * 7 day cells
const cells: CalendarCell[] = [] const cells: CalendarCell[] = []
const iter = new Date(startTs * 60000) const iter = new Date(startTs * 60000)
for (let i = 0; i < 42; i++) { for (let i = 0; i < 6 * 7; i++) {
cells.push({ cells.push({
month: iter.getMonth() + 1, month: iter.getMonth() + 1,
day: iter.getDate(), day: iter.getDate(),
@@ -119,17 +119,19 @@ const expandedCells = computed<CalendarCell[]>(() => {
if (typeof result === 'undefined') continue if (typeof result === 'undefined') continue
const loopText = resolveLoopRules4Text(item[8], item[5], item[7]) const loopText = resolveLoopRules4Text(item[8], item[5], item[7])
const tzWarn = myTz !== item[7] const tzWarn = myTz !== item[7]
// lock icon = event is in one of MY collections (belongTo). (Legacy // lock icon = event is NOT in one of MY collections (belongTo). (Legacy
// mistakenly keyed this by the event uuid; fixed per migration decision.) // mistakenly keyed this by the event uuid; fixed per migration decision.)
const isLocked = owned.has(item[1]) const isLocked = !owned.has(item[1])
for (const range of result) { for (const range of result) {
const it0 = range[0] const it0 = range[0]
const it1 = range[1] const it1 = range[1]
// try get event belong to which cell
const eventDateTime = new Date(it0 * 60000) const eventDateTime = new Date(it0 * 60000)
let count = Math.floor((it0 - startTs) / DAY1_SPAN) let count = Math.floor((it0 - startTs) / DAY1_SPAN)
let exitFlag = false let exitFlag = false
while (count < 42) { // then split event
while (count < 6 * 7) {
const eventItem: ScheduleEventItem = { const eventItem: ScheduleEventItem = {
uuid: item[0], uuid: item[0],
belongTo: item[1], belongTo: item[1],
+669 -4
View File
@@ -1,8 +1,673 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useTokenStore } from '@/stores/token';
import MessageBox from '@/components/MessageBox.vue';
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
import { goToCalendar } from '@/router';
import {
getDetail as apiCalendarGetDetail,
add as apiCalendarAdd,
update as apiCalendarUpdate,
deserializeDescription,
serializeDescription,
type CalendarRow,
} from '@/api/calendar';
import { getFullOwn as apiCollectionGetFullOwn, type CollectionRow } from '@/api/collection';
import { resolveLoopRules4UI, getDayInMonth } from '@/utils/datetime';
import { WEEK_NAMES } from '@/utils/i18n';
import { format, getWeekday, DEFAULT_COLOR } from '@/utils/utils';
const token = useTokenStore();
const route = useRoute();
const messagebox = ref<InstanceType<typeof MessageBox>>();
const picker = ref<InstanceType<typeof DateTimePicker>>();
// the event being edited (undefined = add mode)
const editingEvent = ref<CalendarRow | undefined>(undefined);
// basic fields
const title = ref<string>('');
const description = ref<string>('');
const color = ref<string>(DEFAULT_COLOR);
const belongTo = ref<string>('');
const collections = ref<CollectionRow[]>([]);
// datetime (3 slots share one picker)
const startDt = ref<Date>(new Date());
const endDt = ref<Date>(new Date());
const loopStopDt = ref<Date>(new Date());
const editingSlot = ref<'start' | 'end' | 'loopstop'>('start');
// loop method
const loopMethod = ref<'never' | 'day' | 'week' | 'month' | 'year'>('never');
const loopDaySpan = ref<string>('1');
const loopWeekSpan = ref<string>('1');
const loopWeekChecks = ref<boolean[]>([false, false, false, false, false, false, false]);
const loopMonthSpan = ref<string>('1');
const loopMonthMode = ref<'A' | 'B' | 'C' | 'D'>('A');
const loopYearSpan = ref<string>('1');
// loop stop
const loopStop = ref<'forever' | 'datetime' | 'times'>('forever');
const loopStopTimes = ref<string>('1');
// strict mode + timezone
const strictMode = ref<'strict' | 'rough'>('strict');
const timezoneOption = ref<'keep' | 'replace'>('keep');
const showTimezoneBox = ref<boolean>(false);
// region: v-show conditions (legacy RefreshRadioDiaplay)
const showBoxLoopStop = computed<boolean>(() => loopMethod.value !== 'never');
const showBoxLoopDay = computed<boolean>(() => loopMethod.value === 'day');
const showBoxLoopWeek = computed<boolean>(() => loopMethod.value === 'week');
const showBoxLoopMonth = computed<boolean>(() => loopMethod.value === 'month');
const showBoxLoopYear = computed<boolean>(() => loopMethod.value === 'year');
const showBoxStrictMode = computed<boolean>(() => loopMethod.value === 'month' || loopMethod.value === 'year');
const showBoxLoopStopDateTime = computed<boolean>(() => loopStop.value === 'datetime');
const showBoxLoopStopTimes = computed<boolean>(() => loopStop.value === 'times');
// endregion
// region: month-loop option labels (legacy RefreshLoopMonthType)
const monthOptionLabels = computed(() => {
const d = getDayInMonth(startDt.value.getFullYear(), startDt.value.getMonth() + 1, startDt.value.getDate());
return {
a: format('Day {0} in month', d[0]),
b: format('Day {0} from the end of the month', d[1]),
c: format('Day {1} in week {0}', d[2], d[3] + 1),
d: format('Day {1} in week {0} from the end of the month', d[4], d[5] + 1),
};
})
// endregion
// region: Helpers
// Given a Date whose UTC components represent a wall-clock in the event's
// timezone, return a Date whose LOCAL components equal that wall-clock. The
// DateTimePicker edits via local accessors, so this lets us display/edit the
// event's wall-clock directly regardless of the browser timezone.
const wallClockToLocal = (utcWall: Date): Date => {
return new Date(
utcWall.getUTCFullYear(),
utcWall.getUTCMonth(),
utcWall.getUTCDate(),
utcWall.getUTCHours(),
utcWall.getUTCMinutes(),
);
}
// endregion
// region: Init
const init = async () => {
const uuid = route.params.uuid as string | undefined;
let isAdd = true;
if (typeof uuid === 'string' && uuid !== '') {
const ev = await apiCalendarGetDetail(token.currentToken, uuid);
if (typeof ev !== 'undefined') {
editingEvent.value = ev;
isAdd = false;
}
}
const ev = editingEvent.value;
const desc = isAdd ? undefined : deserializeDescription(ev![3]);
// title / description / color
title.value = isAdd ? '' : ev![2];
description.value = isAdd ? '' : desc!.description;
color.value = isAdd ? DEFAULT_COLOR : desc!.color;
// collection select
const collectionsRv = await apiCollectionGetFullOwn(token.currentToken);
collections.value = collectionsRv ?? [];
belongTo.value = isAdd ? '' : ev![1];
// start / end datetime
if (isAdd) {
const cur = new Date();
cur.setMilliseconds(0);
cur.setSeconds(0);
cur.setMinutes(0);
startDt.value = new Date(cur.getTime());
cur.setHours(cur.getHours() + 2);
endDt.value = new Date(cur.getTime());
} else {
// (eventDateTime + tzOffset) * 60000 yields a Date whose UTC fields are the
// event's wall-clock in its own timezone; relabel to local for the picker.
startDt.value = wallClockToLocal(new Date((ev![5] + ev![7]) * 60000));
endDt.value = wallClockToLocal(new Date((ev![6] + ev![7]) * 60000));
}
// timezone box (only when editing AND event tz != browser tz)
timezoneOption.value = 'keep';
const nowtime = new Date();
showTimezoneBox.value = (!isAdd) && (-nowtime.getTimezoneOffset()) !== ev![7];
// loop rules
let loopIsAdd = isAdd;
let data: ReturnType<typeof resolveLoopRules4UI> = undefined;
if (!isAdd) {
data = resolveLoopRules4UI(ev![8]);
if (typeof data === 'undefined') loopIsAdd = true;
}
// defaults (applied regardless of mode)
loopMonthMode.value = 'A';
loopWeekChecks.value = [false, false, false, false, false, false, false];
loopWeekChecks.value[getWeekday(nowtime)] = true;
strictMode.value = 'strict';
if (loopIsAdd) {
loopMethod.value = 'never';
} else {
const d = data!;
switch (d[0][0]) {
case 0: {
// year: [0, isStrict, yearSpan]
const r = d[0] as [number, boolean, number];
loopMethod.value = 'year';
loopYearSpan.value = String(r[2]);
strictMode.value = r[1] ? 'strict' : 'rough';
break;
}
case 1: {
// month: [1, isStrict, mode, monthSpan]
const r = d[0] as [number, boolean, 'A' | 'B' | 'C' | 'D', number];
loopMethod.value = 'month';
loopMonthSpan.value = String(r[3]);
loopMonthMode.value = r[2];
strictMode.value = r[1] ? 'strict' : 'rough';
break;
}
case 2: {
// week: [2, 7 booleans, weekSpan]
const r = d[0] as [number, boolean, boolean, boolean, boolean, boolean, boolean, boolean, number];
loopMethod.value = 'week';
loopWeekSpan.value = String(r[8]);
loopWeekChecks.value = [r[1], r[2], r[3], r[4], r[5], r[6], r[7]];
break;
}
case 3: {
// day: [3, daySpan]
const r = d[0] as [number, number];
loopMethod.value = 'day';
loopDaySpan.value = String(r[1]);
break;
}
}
}
// loopStopDt default now
loopStopDt.value = new Date();
if (loopIsAdd) {
loopStop.value = 'forever';
} else {
const d = data!;
switch (d[1][0]) {
case 0:
loopStop.value = 'forever';
break;
case 1: {
const r = d[1] as [number, number];
loopStop.value = 'datetime';
loopStopDt.value = wallClockToLocal(new Date((r[1] + ev![7]) * 60000));
break;
}
case 2: {
const r = d[1] as [number, number];
loopStop.value = 'times';
loopStopTimes.value = String(r[1]);
break;
}
}
}
}
// endregion
// region: Picker
const openStartPicker = () => {
editingSlot.value = 'start';
picker.value?.modal(startDt.value, TabType.Minute);
}
const openEndPicker = () => {
editingSlot.value = 'end';
picker.value?.modal(endDt.value, TabType.Minute);
}
const openLoopStopPicker = () => {
editingSlot.value = 'loopstop';
picker.value?.modal(loopStopDt.value, TabType.Day);
}
const onPickerConfirm = (d: Date) => {
if (editingSlot.value === 'start') startDt.value = d;
else if (editingSlot.value === 'end') endDt.value = d;
else loopStopDt.value = d;
}
// endregion
// region: Quick helpers
const spot = () => {
const d = new Date(startDt.value.getTime());
d.setMinutes(d.getMinutes() + 1);
endDt.value = d;
}
const fullDay = () => {
const s = new Date(startDt.value.getTime());
s.setMinutes(0);
s.setHours(0);
startDt.value = s;
const e = new Date(s.getTime());
e.setMinutes(59);
e.setHours(23);
endDt.value = e;
}
// endregion
// region: Form collection + submit
// returns [belongTo, title, description, start, end, tzOffset, loopRules]
// or undefined on validation failure.
const getForm = (): [string, string, string, number, number, number, string] | undefined => {
// basic
if (title.value === '') return undefined;
if (description.value === '') return undefined;
if (color.value === '') return undefined;
if (!belongTo.value) return undefined;
const isAdd = typeof editingEvent.value === 'undefined';
const keepTimezone = timezoneOption.value === 'keep';
const isStrict = strictMode.value === 'strict';
// time
let eventDateTimeStart: number;
let eventDateTimeEnd: number;
let timezoneOffset: number;
if (!isAdd && !keepTimezone) {
// keep the event's original timezone (event[7]); recompute the instant
// from the wall-clock components (NOT getTime, which uses browser tz).
timezoneOffset = editingEvent.value![7];
eventDateTimeStart = Math.floor(Date.UTC(
startDt.value.getFullYear(), startDt.value.getMonth(), startDt.value.getDate(),
startDt.value.getHours(), startDt.value.getMinutes(),
) / 60000) - timezoneOffset;
eventDateTimeEnd = Math.floor(Date.UTC(
endDt.value.getFullYear(), endDt.value.getMonth(), endDt.value.getDate(),
endDt.value.getHours(), endDt.value.getMinutes(),
) / 60000) - timezoneOffset;
} else {
// use the browser timezone; the wall-clock IS local so getTime is correct.
timezoneOffset = -startDt.value.getTimezoneOffset();
eventDateTimeStart = Math.floor(startDt.value.getTime() / 60000);
eventDateTimeEnd = Math.floor(endDt.value.getTime() / 60000);
}
// loopRules
let loopRules: string;
if (loopMethod.value === 'never') {
loopRules = '';
} else if (loopMethod.value === 'day') {
loopRules = `D${loopDaySpan.value}`;
} else if (loopMethod.value === 'week') {
let cache = '';
for (let i = 0; i < 7; i++) cache += loopWeekChecks.value[i] ? 'T' : 'F';
loopRules = `W${cache}${loopWeekSpan.value}`;
} else if (loopMethod.value === 'month') {
loopRules = `M${isStrict ? 'S' : 'R'}${loopMonthMode.value}${loopMonthSpan.value}`;
} else {
// year
loopRules = `Y${isStrict ? 'S' : 'R'}${loopYearSpan.value}`;
}
// loop stop (only when looping)
if (loopRules !== '') {
loopRules += '-';
if (loopStop.value === 'forever') {
loopRules += 'F';
} else if (loopStop.value === 'datetime') {
let ts: number;
if (!isAdd && !keepTimezone) {
ts = Math.floor(Date.UTC(
loopStopDt.value.getFullYear(), loopStopDt.value.getMonth(), loopStopDt.value.getDate(),
23, 59,
) / 60000) - timezoneOffset;
} else {
ts = Math.floor(loopStopDt.value.getTime() / 60000);
}
loopRules += `D${ts}`;
} else {
// times
loopRules += `T${loopStopTimes.value}`;
}
}
return [
belongTo.value,
title.value,
serializeDescription(description.value, color.value),
eventDateTimeStart,
eventDateTimeEnd,
timezoneOffset,
loopRules,
];
}
const submit = async () => {
const submitData = getForm();
if (typeof submitData === 'undefined') {
messagebox.value?.show("Your filled event form is not fufilled or have error. Please check it and try again.");
return;
}
const isAdd = typeof editingEvent.value === 'undefined';
if (isAdd) {
const result = await apiCalendarAdd(
token.currentToken,
submitData[0],
submitData[1],
submitData[2],
submitData[3],
submitData[4],
submitData[6],
submitData[5],
);
if (typeof result === 'undefined') {
messagebox.value?.show("An add operation failed. It may caused by wrong arguments. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
} else {
goToCalendar();
}
} else {
const ev = editingEvent.value!;
const result = await apiCalendarUpdate(
token.currentToken,
ev[0],
ev[4],
ev[1] === submitData[0] ? undefined : submitData[0],
ev[2] === submitData[1] ? undefined : submitData[1],
ev[3] === submitData[2] ? undefined : submitData[2],
ev[5] === submitData[3] ? undefined : submitData[3],
ev[6] === submitData[4] ? undefined : submitData[4],
ev[8] === submitData[6] ? undefined : submitData[6],
ev[7] === submitData[5] ? undefined : submitData[5],
);
if (typeof result === 'undefined') {
messagebox.value?.show("An update operation failed. It may caused by wrong arguments or lost target. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
} else {
goToCalendar();
}
}
}
const cancel = () => {
goToCalendar();
}
// endregion
onMounted(async () => {
await init();
})
</script>
<template> <template>
<h1>Congratulations</h1> <div class="eventFormBody container" style="margin-top: 20px;">
<p>This is calendar event.</p> <h1 class="title">Edit Event</h1>
<section class="section">
<div class="field">
<label class="label">Title</label>
<div class="control">
<input v-model="title" class="input" type="text">
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea v-model="description" class="textarea"></textarea>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<input v-model="color" class="input" type="color">
</div>
</div>
<div class="field">
<label class="label">Collection</label>
<div class="control">
<div class="select">
<select v-model="belongTo">
<option v-for="c in collections" :key="c[0]" :value="c[0]">{{ c[1] }}</option>
</select>
</div>
</div>
</div>
</section>
<section class="section">
<h2 class="subtitle">Start Date Time</h2>
<a class="button" @click="openStartPicker">
<span>{{ startDt.toLocaleString() }}</span>
</a>
<h2 class="subtitle">Stop Date Time</h2>
<div class="button-list">
<div class="control">
<a class="button is-link" @click="spot">Spot</a>
</div>
<div class="control">
<a class="button is-link" @click="fullDay">Full day</a>
</div>
</div>
<a class="button" @click="openEndPicker">
<span>{{ endDt.toLocaleString() }}</span>
</a>
</section>
<section class="section">
<h2 class="subtitle">Event Loop</h2>
<div class="button-list">
<label class="radio">
<input type="radio" value="never" v-model="loopMethod">
<span>Never</span>
</label>
<label class="radio">
<input type="radio" value="day" v-model="loopMethod">
<span>Day</span>
</label>
<label class="radio">
<input type="radio" value="week" v-model="loopMethod">
<span>Week</span>
</label>
<label class="radio">
<input type="radio" value="month" v-model="loopMethod">
<span>Month</span>
</label>
<label class="radio">
<input type="radio" value="year" v-model="loopMethod">
<span>Year</span>
</label>
</div>
<div v-show="showBoxLoopDay">
<div class="field">
<label class="label">Day span</label>
<div class="control">
<input v-model="loopDaySpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
<div v-show="showBoxLoopWeek">
<div class="field">
<label class="label">Week span</label>
<div class="control">
<input v-model="loopWeekSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
<div class="field">
<label class="label">Week options</label>
<div class="button-list">
<label class="checkbox">
<input type="checkbox" v-model="loopWeekChecks[0]">
<span>{{ WEEK_NAMES[0] }}</span>
</label>
<label class="checkbox">
<input type="checkbox" v-model="loopWeekChecks[1]">
<span>{{ WEEK_NAMES[1] }}</span>
</label>
<label class="checkbox">
<input type="checkbox" v-model="loopWeekChecks[2]">
<span>{{ WEEK_NAMES[2] }}</span>
</label>
<label class="checkbox">
<input type="checkbox" v-model="loopWeekChecks[3]">
<span>{{ WEEK_NAMES[3] }}</span>
</label>
<label class="checkbox">
<input type="checkbox" v-model="loopWeekChecks[4]">
<span>{{ WEEK_NAMES[4] }}</span>
</label>
<label class="checkbox">
<input type="checkbox" v-model="loopWeekChecks[5]">
<span>{{ WEEK_NAMES[5] }}</span>
</label>
<label class="checkbox">
<input type="checkbox" v-model="loopWeekChecks[6]">
<span>{{ WEEK_NAMES[6] }}</span>
</label>
</div>
</div>
</div>
<div v-show="showBoxLoopMonth">
<div class="field">
<label class="label">Month span</label>
<div class="control">
<input v-model="loopMonthSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
<div class="field">
<label class="label">Month mode</label>
<div class="button-list">
<label class="radio">
<input type="radio" value="A" v-model="loopMonthMode">
<span>{{ monthOptionLabels.a }}</span>
</label>
<label class="radio">
<input type="radio" value="B" v-model="loopMonthMode">
<span>{{ monthOptionLabels.b }}</span>
</label>
<label class="radio">
<input type="radio" value="C" v-model="loopMonthMode">
<span>{{ monthOptionLabels.c }}</span>
</label>
<label class="radio">
<input type="radio" value="D" v-model="loopMonthMode">
<span>{{ monthOptionLabels.d }}</span>
</label>
</div>
</div>
</div>
<div v-show="showBoxLoopYear">
<div class="field">
<label class="label">Year span</label>
<div class="control">
<input v-model="loopYearSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
</section>
<section v-show="showBoxLoopStop" class="section">
<h2 class="subtitle">Event Loop Stop</h2>
<div class="button-list">
<label class="radio">
<input type="radio" value="forever" v-model="loopStop">
<span>Forever</span>
</label>
<label class="radio">
<input type="radio" value="datetime" v-model="loopStop">
<span>Date Time</span>
</label>
<label class="radio">
<input type="radio" value="times" v-model="loopStop">
<span>Times</span>
</label>
</div>
<div v-show="showBoxLoopStopDateTime">
<a class="button" @click="openLoopStopPicker">
<span>{{ loopStopDt.toLocaleDateString() }}</span>
</a>
</div>
<div v-show="showBoxLoopStopTimes">
<div class="field">
<div class="control">
<input v-model="loopStopTimes" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
</section>
<section v-show="showBoxStrictMode" class="section">
<h2 class="subtitle">Strict Mode in Event Loop</h2>
<p>You can choose strict mode or rough mode in following content. This is only effect on looped event.</p>
<div class="button-list">
<label class="radio">
<input type="radio" value="strict" v-model="strictMode">
<span>Strict Mode. If ordered day is not existing, skip it.</span>
</label>
<label class="radio">
<input type="radio" value="rough" v-model="strictMode">
<span>Rough mode. If ordered day is not existing, choose the day closing with original day to arrange
event.</span>
</label>
</div>
</section>
<section v-show="showTimezoneBox" class="section">
<h2 class="subtitle">Timezone</h2>
<p>The timezone of this event is not corresponding with your current timezone. All of date and time in this page
are shown as the original timezone of this event. You can choose a timezone option in follwing content. If you
are not familar with this, please pick keep timezone.</p>
<div class="button-list">
<label class="radio">
<input type="radio" value="keep" v-model="timezoneOption">
<span>Keep timezone</span>
</label>
<label class="radio">
<input type="radio" value="replace" v-model="timezoneOption">
<span>Use my timezone</span>
</label>
</div>
</section>
<section class="section">
<div class="button-list">
<a class="button is-success" @click="submit">Submit</a>
<a class="button" @click="cancel">Cancel</a>
</div>
</section>
<MessageBox ref="messagebox" />
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
</div>
</template> </template>
<style scoped></style> <style scoped>
.eventFormBody > section {
border-top: 1px solid rgba(219, 219, 219, 0.5);
}
</style>