Files
coconut-leaf/frontend/src/components/DateTimePicker.vue
T

704 lines
22 KiB
Vue
Raw Normal View History

2026-07-20 21:59:57 +08:00
<script lang="ts">
export enum TabType {
Year = 0,
Month = 1,
Day = 2,
Hour = 3,
Minute = 4,
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
MIN_YEAR,
MAX_YEAR,
MIN_DATETIME,
MAX_DATETIME,
MONTH_DAY_COUNT,
dayOfWeek,
isLeapYear,
} from '@/utils/datetime';
import { MONTH_NAMES, WEEK_NAMES, universalGetMonth } from '@/utils/i18n';
// region: Constants
const ALL_TABS: TabType[] = [TabType.Year, TabType.Month, TabType.Day, TabType.Hour, TabType.Minute];
const DIAL_PLATE_WIDTH = 200;
const DIAL_PLATE_RADIUS = DIAL_PLATE_WIDTH / 2;
const DIAL_PLATE_HOUR_INNER_PERCENT = 0.6;
const DIAL_PLATE_HOUR_OUTER_PERCENT = 0.8;
const DIAL_PLATE_HOUR_DISTINGUISH_PERCENT = 0.7;
const DIAL_PLATE_MINUTE_PERCENT = 0.8;
const DIAL_PLATE_HOUR_RESOLUTION = Math.PI * 2 / 12;
const DIAL_PLATE_MINUTE_RESOLUTION = Math.PI * 2 / 60;
// endregion
// region: State
const isVisible = ref<boolean>(false);
const mode = ref<TabType>(TabType.Day);
const currentTab = ref<TabType>(TabType.Day);
const internalDateTime = ref<Date>(new Date());
const displayCacheDateTime = ref<Date>(new Date());
const enableHourDrag = ref<boolean>(false);
const enableMinuteDrag = ref<boolean>(false);
const hourSvgEl = ref<SVGSVGElement | null>(null);
const minuteSvgEl = ref<SVGSVGElement | null>(null);
const pickerBodyEl = ref<HTMLElement | null>(null);
const svgFontEm = ref<number>(1);
// endregion
// region: Exposed API
const emit = defineEmits<{
(e: 'confirm', date: Date): void
(e: 'cancel'): void
}>();
/**
* Open the picker seeded with `initialDate`, with the deepest selectable tab
* set to `modeType`.
*
* NOTE: The DateTimePicker ALWAYS edits the passed Date via LOCAL accessors
* (getFullYear / getMonth / getDate / getHours / getMinutes). It performs NO
* timezone conversion. If the value represents a wall-clock in a non-browser
* timezone, the CALLER must pre-construct a Date whose LOCAL components equal
* that wall-clock, and recompute any timestamp from the components afterwards.
*/
const modal = (initialDate: Date, modeType: TabType) => {
internalDateTime.value = new Date(initialDate.getTime());
displayCacheDateTime.value = new Date(initialDate.getTime());
mode.value = modeType;
currentTab.value = modeType;
isVisible.value = true;
}
const confirm = () => {
isVisible.value = false;
emit('confirm', new Date(internalDateTime.value.getTime()));
}
const cancel = () => {
isVisible.value = false;
emit('cancel');
}
defineExpose({ modal })
// endregion
// region: Helpers
const cloneDate = (d: Date): Date => new Date(d.getTime());
const clampDateTime = (d: Date): void => {
if (d < MIN_DATETIME) d.setTime(MIN_DATETIME.getTime());
if (d >= MAX_DATETIME) d.setTime(MAX_DATETIME.getTime());
}
const switchTab = (newTab: TabType): void => {
displayCacheDateTime.value = new Date(internalDateTime.value.getTime());
currentTab.value = newTab;
}
// endregion
// region: Header
const visibleTabs = computed<TabType[]>(() => ALL_TABS.filter(t => t <= mode.value));
const isYearTab = computed<boolean>(() => currentTab.value === TabType.Year);
const isMonthTab = computed<boolean>(() => currentTab.value === TabType.Month);
const isDayTab = computed<boolean>(() => currentTab.value === TabType.Day);
const isHourTab = computed<boolean>(() => currentTab.value === TabType.Hour);
const isMinuteTab = computed<boolean>(() => currentTab.value === TabType.Minute);
const tabLabel = (t: TabType): string => {
if (t === TabType.Year) return 'Year';
if (t === TabType.Month) return 'Month';
if (t === TabType.Day) return 'Day';
if (t === TabType.Hour) return 'Hour';
return 'Minute';
}
const headerValue = (t: TabType): number => {
if (t === TabType.Year) return internalDateTime.value.getFullYear();
if (t === TabType.Month) return internalDateTime.value.getMonth() + 1;
if (t === TabType.Day) return internalDateTime.value.getDate();
if (t === TabType.Hour) return internalDateTime.value.getHours();
return internalDateTime.value.getMinutes();
}
// endregion
// region: Year Panel
const yearPanelStartYear = computed<number>(() => {
return Math.floor((displayCacheDateTime.value.getFullYear() - MIN_YEAR) / 12) * 12 + MIN_YEAR;
})
interface Cell {
value: number | null;
picked: boolean;
}
const yearCells = computed<Cell[]>(() => {
const start = yearPanelStartYear.value;
const pickedYear = internalDateTime.value.getFullYear();
const cells: Cell[] = [];
for (let i = 0; i < 12; i++) {
const v = start + i;
cells.push({ value: v < MAX_YEAR ? v : null, picked: v === pickedYear && v < MAX_YEAR });
}
return cells;
})
const yearRows = computed<Cell[][]>(() => {
const cells = yearCells.value;
return [cells.slice(0, 4), cells.slice(4, 8), cells.slice(8, 12)];
})
const yearPanelTitle = computed<string>(() => {
const start = yearPanelStartYear.value;
const end = start + 12 < MAX_YEAR ? start + 12 : MAX_YEAR;
return `${start} - ${end}`;
})
const prevNextYear = (isPrev: boolean): void => {
const d = cloneDate(displayCacheDateTime.value);
d.setFullYear(d.getFullYear() + (isPrev ? -12 : 12));
clampDateTime(d);
displayCacheDateTime.value = d;
}
const clickYear = (cell: Cell): void => {
if (cell.value === null) return;
const d = cloneDate(internalDateTime.value);
d.setFullYear(cell.value);
clampDateTime(d);
internalDateTime.value = d;
if (mode.value !== TabType.Year) switchTab(TabType.Month);
}
// endregion
// region: Month Panel
interface MonthCell {
idx: number;
name: string;
picked: boolean;
}
const monthCells = computed<MonthCell[]>(() => {
const sameYear = displayCacheDateTime.value.getFullYear() === internalDateTime.value.getFullYear();
const pickedMonth = internalDateTime.value.getMonth();
return MONTH_NAMES.map((name, idx) => ({
idx,
name,
picked: sameYear && idx === pickedMonth,
}));
})
const monthRows = computed<MonthCell[][]>(() => {
const cells = monthCells.value;
return [cells.slice(0, 4), cells.slice(4, 8), cells.slice(8, 12)];
})
const monthPanelTitle = computed<string>(() => `${displayCacheDateTime.value.getFullYear()}`);
const prevNextMonth = (isPrev: boolean): void => {
const d = cloneDate(displayCacheDateTime.value);
d.setFullYear(d.getFullYear() + (isPrev ? -1 : 1));
clampDateTime(d);
displayCacheDateTime.value = d;
}
const clickMonth = (cell: MonthCell): void => {
const d = cloneDate(internalDateTime.value);
d.setFullYear(displayCacheDateTime.value.getFullYear(), cell.idx);
clampDateTime(d);
internalDateTime.value = d;
if (mode.value !== TabType.Month) switchTab(TabType.Day);
}
// endregion
// region: Day Panel
const dayCells = computed<Cell[]>(() => {
const year = displayCacheDateTime.value.getFullYear();
const month1 = displayCacheDateTime.value.getMonth() + 1;
const offset = dayOfWeek(year, month1, 1);
const days = MONTH_DAY_COUNT[month1 - 1]! + ((month1 === 2 && isLeapYear(year)) ? 1 : 0);
const sameYearMonth = displayCacheDateTime.value.getFullYear() === internalDateTime.value.getFullYear()
&& displayCacheDateTime.value.getMonth() === internalDateTime.value.getMonth();
const pickedDay = sameYearMonth ? internalDateTime.value.getDate() : -1;
const cells: Cell[] = [];
for (let i = 0; i < 42; i++) {
const counter = i - offset;
if (counter < 0 || counter >= days) {
cells.push({ value: null, picked: false });
} else {
const day = counter + 1;
cells.push({ value: day, picked: day === pickedDay });
}
}
return cells;
})
const dayRows = computed<Cell[][]>(() => {
const cells = dayCells.value;
const rows: Cell[][] = [];
for (let i = 0; i < 6; i++) rows.push(cells.slice(i * 7, i * 7 + 7));
return rows;
})
const dayPanelTitle = computed<string>(() => {
return `${displayCacheDateTime.value.getFullYear()} - ${universalGetMonth(displayCacheDateTime.value.getMonth())}`;
})
const prevNextDay = (isPrev: boolean): void => {
const d = cloneDate(displayCacheDateTime.value);
d.setMonth(d.getMonth() + (isPrev ? -1 : 1));
clampDateTime(d);
displayCacheDateTime.value = d;
}
const clickDay = (cell: Cell): void => {
if (cell.value === null) return;
const d = cloneDate(internalDateTime.value);
d.setFullYear(
displayCacheDateTime.value.getFullYear(),
displayCacheDateTime.value.getMonth(),
cell.value);
clampDateTime(d);
internalDateTime.value = d;
if (mode.value !== TabType.Day) switchTab(TabType.Hour);
}
// endregion
// region: Hour / Minute Dial
const hourHand = computed<{ x: number; y: number }>(() => {
const h = displayCacheDateTime.value.getHours();
const angle = (3 - h) * DIAL_PLATE_HOUR_RESOLUTION;
const radius = DIAL_PLATE_RADIUS * (h < 12 ? DIAL_PLATE_HOUR_OUTER_PERCENT : DIAL_PLATE_HOUR_INNER_PERCENT);
return {
x: Math.cos(angle) * radius + DIAL_PLATE_RADIUS,
y: -Math.sin(angle) * radius + DIAL_PLATE_RADIUS,
};
})
const minuteHand = computed<{ x: number; y: number }>(() => {
const m = displayCacheDateTime.value.getMinutes();
const angle = (15 - m) * DIAL_PLATE_MINUTE_RESOLUTION;
const radius = DIAL_PLATE_RADIUS * DIAL_PLATE_MINUTE_PERCENT;
return {
x: Math.cos(angle) * radius + DIAL_PLATE_RADIUS,
y: -Math.sin(angle) * radius + DIAL_PLATE_RADIUS,
};
})
// static numeric labels around each dial (positions match the legacy template)
const hourOuterLabels = computed(() => Array.from({ length: 12 }, (_, h) => {
const a = h * DIAL_PLATE_HOUR_RESOLUTION;
return { label: String(h), x: 100 + 80 * Math.sin(a), y: 100 - 80 * Math.cos(a) };
}))
const hourInnerLabels = computed(() => Array.from({ length: 12 }, (_, h) => {
const a = h * DIAL_PLATE_HOUR_RESOLUTION;
return { label: String(h + 12), x: 100 + 60 * Math.sin(a), y: 100 - 60 * Math.cos(a) };
}))
const minuteLabels = computed(() => Array.from({ length: 12 }, (_, i) => {
const m = i * 5;
const a = m * DIAL_PLATE_MINUTE_RESOLUTION;
return { label: String(m), x: 100 + 80 * Math.sin(a), y: 100 - 80 * Math.cos(a) };
}))
const getUniformedXY = (e: MouseEvent | TouchEvent, el: SVGSVGElement): { x: number; y: number } => {
const rect = el.getBoundingClientRect();
const halfWidth = rect.width / 2;
const halfHeight = rect.height / 2;
const halfSquare = Math.min(rect.width, rect.height) / 2;
let realX = 0, realY = 0;
if ('targetTouches' in e) {
const te = e as TouchEvent;
if (te.targetTouches.length >= 1) {
realX = te.targetTouches[0]!.clientX;
realY = te.targetTouches[0]!.clientY;
}
} else {
const me = e as MouseEvent;
realX = me.clientX;
realY = me.clientY;
}
const x = (realX - rect.left - halfWidth) / halfSquare * DIAL_PLATE_RADIUS;
const y = -((realY - rect.top - halfHeight) / halfSquare * DIAL_PLATE_RADIUS);
return { x, y };
}
const startDragHour = (): void => { enableHourDrag.value = true; }
const draggingHour = (e: MouseEvent | TouchEvent): void => {
if (!enableHourDrag.value || !hourSvgEl.value) return;
const { x, y } = getUniformedXY(e, hourSvgEl.value);
const distance = Math.sqrt(x * x + y * y);
if (distance < 0.001) return;
let angle = Math.acos(x / distance);
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
angle += DIAL_PLATE_HOUR_RESOLUTION / 2;
if (angle > Math.PI * 2) angle -= Math.PI * 2;
let number = Math.floor(angle / DIAL_PLATE_HOUR_RESOLUTION);
if (number >= 12) number = 11; // prevent unexpected result at the edge.
number = (15 - number) % 12;
if (distance < DIAL_PLATE_RADIUS * DIAL_PLATE_HOUR_DISTINGUISH_PERCENT) number += 12;
// judge
if (displayCacheDateTime.value.getHours() !== number) {
const d = cloneDate(displayCacheDateTime.value);
d.setHours(number);
displayCacheDateTime.value = d;
}
e.preventDefault();
}
const stopDragHour = (): void => {
if (!enableHourDrag.value) return;
enableHourDrag.value = false;
const d = cloneDate(internalDateTime.value);
d.setHours(displayCacheDateTime.value.getHours());
clampDateTime(d);
internalDateTime.value = d;
if (mode.value !== TabType.Hour) switchTab(TabType.Minute);
}
const startDragMinute = (): void => { enableMinuteDrag.value = true; }
const draggingMinute = (e: MouseEvent | TouchEvent): void => {
if (!enableMinuteDrag.value || !minuteSvgEl.value) return;
const { x, y } = getUniformedXY(e, minuteSvgEl.value);
const distance = Math.sqrt(x * x + y * y);
if (distance < 0.001) return;
let angle = Math.acos(x / distance);
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
angle += DIAL_PLATE_MINUTE_RESOLUTION / 2; // correct offset
if (angle > Math.PI * 2) angle -= Math.PI * 2;
let number = Math.floor(angle / DIAL_PLATE_MINUTE_RESOLUTION);
if (number >= 60) number = 59; // prevent unexpected result at the edge.
number = (75 - number) % 60;
// judge
if (displayCacheDateTime.value.getMinutes() !== number) {
const d = cloneDate(displayCacheDateTime.value);
d.setMinutes(number);
displayCacheDateTime.value = d;
}
e.preventDefault();
}
const stopDragMinute = (): void => {
if (!enableMinuteDrag.value) return;
enableMinuteDrag.value = false;
const d = cloneDate(internalDateTime.value);
d.setMinutes(displayCacheDateTime.value.getMinutes());
clampDateTime(d);
internalDateTime.value = d;
// Minute is the deepest tab; no further advance.
}
// endregion
// region: SVG font scaling
const recomputeSvgFont = (): void => {
const el = currentTab.value === TabType.Hour ? hourSvgEl.value
: currentTab.value === TabType.Minute ? minuteSvgEl.value
: null;
if (!el) return;
const rect = el.getBoundingClientRect();
const minSide = Math.min(rect.width, rect.height);
if (minSide > 0) svgFontEm.value = DIAL_PLATE_WIDTH / minSide;
}
let resizeObserver: ResizeObserver | null = null;
const onWindowResize = (): void => recomputeSvgFont();
onMounted(() => {
if (pickerBodyEl.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => recomputeSvgFont());
resizeObserver.observe(pickerBodyEl.value);
}
window.addEventListener('resize', onWindowResize);
})
onBeforeUnmount(() => {
resizeObserver?.disconnect();
resizeObserver = null;
window.removeEventListener('resize', onWindowResize);
})
watch(currentTab, (t) => {
if (t === TabType.Hour || t === TabType.Minute) {
// immediately trigger once svg resize
nextTick(() => recomputeSvgFont());
}
})
// endregion
</script>
<template>
<div class="modal" :class="{ 'is-active': isVisible }"
style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
<div class="modal-background"></div>
<div class="modal-card" style="height: 70%;">
<header class="modal-card-head pickerHeader">
<div v-for="t in visibleTabs" :key="t" @click="switchTab(t)">
<small>{{ tabLabel(t) }}</small>
<span>{{ headerValue(t) }}</span>
</div>
</header>
<div ref="pickerBodyEl" class="modal-card-body pickerContainer">
<div v-show="isYearTab">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextYear(true)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left" /></span>
</a>
</div>
</div>
<div class="level-item">{{ yearPanelTitle }}</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextYear(false)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right" /></span>
</a>
</div>
</div>
</nav>
<div class="perfectTable">
<div v-for="(row, ri) in yearRows" :key="ri">
<div v-for="(cell, ci) in row" :key="ci" :class="{ picked: cell.picked }" @click="clickYear(cell)">
<template v-if="cell.value !== null">{{ cell.value }}</template>
<template v-else>&nbsp;</template>
</div>
</div>
</div>
</div>
<div v-show="isMonthTab">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextMonth(true)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left" /></span>
</a>
</div>
</div>
<div class="level-item">{{ monthPanelTitle }}</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextMonth(false)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right" /></span>
</a>
</div>
</div>
</nav>
<div class="perfectTable">
<div v-for="(row, ri) in monthRows" :key="ri">
<div v-for="cell in row" :key="cell.idx" :class="{ picked: cell.picked }" @click="clickMonth(cell)">
{{ cell.name }}
</div>
</div>
</div>
</div>
<div v-show="isDayTab">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextDay(true)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left" /></span>
</a>
</div>
</div>
<div class="level-item">{{ dayPanelTitle }}</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextDay(false)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right" /></span>
</a>
</div>
</div>
</nav>
<div class="perfectTable">
<div>
<div v-for="(w, i) in WEEK_NAMES" :key="i">{{ w }}</div>
</div>
<div v-for="(row, ri) in dayRows" :key="ri">
<div v-for="(cell, ci) in row" :key="ci" :class="{ picked: cell.picked }" @click="clickDay(cell)">
<template v-if="cell.value !== null">{{ cell.value }}</template>
<template v-else>&nbsp;</template>
</div>
</div>
</div>
</div>
<svg v-show="isHourTab" ref="hourSvgEl" xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" :style="{ fontSize: svgFontEm + 'em' }"
@mousedown="startDragHour" @mousemove="draggingHour" @mouseup="stopDragHour"
@mouseleave="stopDragHour" @touchstart="startDragHour" @touchmove="draggingHour"
@touchend="stopDragHour">
<circle cx="100" cy="100" r="100" type="background"></circle>
<line x1="100" y1="100" :x2="hourHand.x" :y2="hourHand.y"></line>
<circle :cx="hourHand.x" :cy="hourHand.y" r="1em" type="symbol"></circle>
<text v-for="(lb, i) in hourOuterLabels" :key="'o' + i" :x="lb.x" :y="lb.y">{{ lb.label }}</text>
<text v-for="(lb, i) in hourInnerLabels" :key="'i' + i" :x="lb.x" :y="lb.y">{{ lb.label }}</text>
</svg>
<svg v-show="isMinuteTab" ref="minuteSvgEl" xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" :style="{ fontSize: svgFontEm + 'em' }"
@mousedown="startDragMinute" @mousemove="draggingMinute" @mouseup="stopDragMinute"
@mouseleave="stopDragMinute" @touchstart="startDragMinute" @touchmove="draggingMinute"
@touchend="stopDragMinute">
<circle cx="100" cy="100" r="100" type="background"></circle>
<line x1="100" y1="100" :x2="minuteHand.x" :y2="minuteHand.y"></line>
<circle :cx="minuteHand.x" :cy="minuteHand.y" r="1em" type="symbol"></circle>
<text v-for="(lb, i) in minuteLabels" :key="i" :x="lb.x" :y="lb.y">{{ lb.label }}</text>
</svg>
</div>
<footer class="modal-card-foot">
<a class="button is-success" @click="confirm"><span>OK</span></a>
<a class="button" @click="cancel"><span>Cancel</span></a>
</footer>
</div>
</div>
</template>
<style scoped>
div.perfectTable {
display: flex;
flex-flow: column;
}
/* div.perfectTable > div > div:nth-child(1) {
border-left: 1px solid black;
}
div.perfectTable > div:nth-child(1) > div {
border-top: 1px solid black;
} */
div.perfectTable > div > div {
/* border-top: 0 solid black;
border-left: 0 solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
padding: 0.75em; */
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
overflow: hidden;
display: flex;
justify-content: center;
padding-top: 1rem;
padding-bottom: 1rem;
}
div.perfectTable > div {
display: flex;
flex-flow: row;
}
div.perfectTable > div > div.picked {
background: hsl(171, 100%, 41%);
color: #fff;
}
div.pickerContainer {
display: flex;
align-items: center;
flex-flow: row;
justify-content: center;
}
div.pickerContainer > div {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
}
div.pickerContainer > svg {
width: 100%;
height: 100%;
}
div.pickerContainer > svg > text {
dominant-baseline: middle;
text-anchor: middle;
user-select: none;
cursor: default;
}
div.pickerContainer > svg > circle[type=background] {
stroke-width: 0;
fill: #d0d0d0;
}
div.pickerContainer > svg > circle[type=symbol] {
stroke-width: 0;
fill: hsl(171, 100%, 41%); /* $primary */
}
div.pickerContainer > svg > line {
stroke-width: 0.125em;
stroke: hsl(171, 100%, 41%); /* $primary */
}
header.pickerHeader {
display: flex;
flex-flow: row;
flex-grow: 0;
flex-basis: 0;
flex-shrink: 0;
justify-content: space-between;
}
header.pickerHeader > div {
display: flex;
flex-flow: column;
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
}
</style>