fiee-official-website/src/components/DateWheelPicker.vue
2025-10-13 17:17:21 +08:00

398 lines
12 KiB
Vue

<template>
<div class="picker-mask" @click.self="emit('close')">
<div class="picker-panel">
<div class="picker-title">
Select Time
<svg
@click="emit('close')"
style="cursor: pointer"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.666016 9.74935C0.666016 4.59469 4.84469 0.416016 9.99935 0.416016C15.154 0.416016 19.3327 4.59469 19.3327 9.74935C19.3327 14.904 15.154 19.0827 9.99935 19.0827C4.84469 19.0827 0.666016 14.904 0.666016 9.74935Z"
fill="#CCCCCC"
/>
<path
d="M12.833 5.84961C13.1273 5.55596 13.6042 5.55565 13.8965 5.84863C14.1907 6.14223 14.1893 6.61848 13.8965 6.91211L11.0615 9.74609L13.9043 12.5898C14.1973 12.8848 14.1986 13.3607 13.9043 13.6543C13.6114 13.947 13.1344 13.9471 12.8408 13.6543L9.99707 10.8105L7.1582 13.6504C6.86386 13.9444 6.38729 13.9446 6.09375 13.6504C5.8002 13.3574 5.80045 12.8809 6.09473 12.5859L8.93359 9.74707L6.10254 6.91602C5.80956 6.62236 5.80889 6.1452 6.10254 5.85156C6.39486 5.55817 6.87209 5.55802 7.16699 5.85156L9.99805 8.68262L12.833 5.84961Z"
fill="white"
/>
</svg>
</div>
<div
class="picker-columns"
:style="{ height: wheelViewportHeight + 'px' }"
>
<div class="center-lines" :style="{ height: wheelItemHeight + 'px' }">
<div class="line"></div>
<div class="line"></div>
</div>
<div
class="picker-col year-col"
ref="yearColRef"
@scroll.passive="(e) => handleWheelScroll('year', e)"
@wheel.prevent="(e) => handleWheelStep('year', e)"
:style="{
scrollPaddingTop: wheelCenterPad + 'px',
scrollPaddingBottom: wheelCenterPad + 'px',
}"
>
<div :style="{ height: wheelCenterPad + 'px' }"></div>
<div
v-for="y in years"
:key="'y' + y"
class="picker-item"
:class="{ active: y === localYear }"
:style="{
height: wheelItemHeight + 'px',
}"
@click="localYear = y"
>
{{ y }}
</div>
<div :style="{ height: wheelCenterPad + 'px' }"></div>
</div>
<div
class="picker-col month-col"
ref="monthColRef"
@scroll.passive="(e) => handleWheelScroll('month', e)"
@wheel.prevent="(e) => handleWheelStep('month', e)"
:style="{
scrollPaddingTop: wheelCenterPad + 'px',
scrollPaddingBottom: wheelCenterPad + 'px',
}"
>
<div :style="{ height: wheelCenterPad + 'px' }"></div>
<div
v-for="m in months"
:key="'m' + m"
class="picker-item"
:class="{ active: m === localMonth }"
:style="{
height: wheelItemHeight + 'px',
}"
@click="localMonth = m"
>
{{ m }}
</div>
<div :style="{ height: wheelCenterPad + 'px' }"></div>
</div>
<div
class="picker-col day-col"
ref="dayColRef"
@scroll.passive="(e) => handleWheelScroll('day', e)"
@wheel.prevent="(e) => handleWheelStep('day', e)"
:style="{
scrollPaddingTop: wheelCenterPad + 'px',
scrollPaddingBottom: wheelCenterPad + 'px',
}"
>
<div :style="{ height: wheelCenterPad + 'px' }"></div>
<div
v-for="d in daysInMonth(localYear, localMonth)"
:key="'d' + d"
class="picker-item"
:class="{ active: d === localDay }"
:style="{
height: wheelItemHeight + 'px',
}"
@click="localDay = d"
>
{{ d }}
</div>
<div :style="{ height: wheelCenterPad + 'px' }"></div>
</div>
</div>
<div class="picker-actions">
<button class="picker-confirm" @click="confirm">
Confirm Selection
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from "vue";
const props = defineProps({
modelValue: { type: Object, required: true }, // { year, month, day }
minYear: { type: Number, default: 2009 },
maxYear: { type: Number, default: new Date().getFullYear() },
});
const emit = defineEmits(["update:modelValue", "close", "confirm"]);
const wheelItemBase = 8 * 5.12;
const wheelItemHeight = Math.round(wheelItemBase); // use integer px to avoid fractional rounding
const wheelViewportHeight = wheelItemHeight * 5;
const wheelCenterPad = (wheelViewportHeight - wheelItemHeight) / 2;
const isUserScrolling = ref(false);
const years = computed(() =>
Array.from(
{ length: props.maxYear - props.minYear + 1 },
(_, i) => props.minYear + i
)
);
const months = Array.from({ length: 12 }, (_, i) => i + 1);
const localYear = ref(props.modelValue.year);
const localMonth = ref(props.modelValue.month);
const localDay = ref(props.modelValue.day);
watch(
() => props.modelValue,
(v) => {
localYear.value = v.year;
localMonth.value = v.month;
localDay.value = v.day;
nextTick(syncWheelPositions);
}
);
const yearColRef = ref(null);
const monthColRef = ref(null);
const dayColRef = ref(null);
function daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
watch([localYear, localMonth], () => {
const dim = daysInMonth(localYear.value, localMonth.value);
if (localDay.value > dim) localDay.value = dim;
if (!isUserScrolling.value) {
nextTick(syncWheelPositions);
}
});
function syncWheelPositions() {
const yearIdx = years.value.indexOf(localYear.value);
const monthIdx = localMonth.value - 1;
const dayIdx = localDay.value - 1;
if (yearColRef.value) yearColRef.value.scrollTop = yearIdx * wheelItemHeight;
if (monthColRef.value)
monthColRef.value.scrollTop = monthIdx * wheelItemHeight;
if (dayColRef.value) dayColRef.value.scrollTop = dayIdx * wheelItemHeight;
}
const scrollTimers = { year: null, month: null, day: null };
const isAnimating = { year: false, month: false, day: false };
function getCenteredIndex(el) {
if (!el) return 0;
const raw = (el.scrollTop - wheelCenterPad) / wheelItemHeight;
return Math.round(raw);
}
function clamp(n, min, max) {
return Math.max(min, Math.min(n, max));
}
function getCenteredIndexByRect(el) {
if (!el) return 0;
const items = el.querySelectorAll(".picker-item");
if (!items || items.length === 0) return 0;
const containerRect = el.getBoundingClientRect();
const centerY = containerRect.top + containerRect.height / 2;
let nearestIdx = 0;
let nearestDist = Number.POSITIVE_INFINITY;
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect();
const mid = r.top + r.height / 2;
const dist = Math.abs(mid - centerY);
if (dist < nearestDist) {
nearestDist = dist;
nearestIdx = i;
}
}
return nearestIdx;
}
function handleWheelStep(type, e) {
const refMap = { year: yearColRef, month: monthColRef, day: dayColRef };
const el = refMap[type].value;
if (!el || isAnimating[type]) return;
const direction = e.deltaY > 0 ? 1 : -1;
let idx = getCenteredIndexByRect(el);
if (type === "year") {
idx = clamp(idx + direction, 0, years.value.length - 1);
localYear.value = years.value[idx];
} else if (type === "month") {
idx = clamp(idx + direction, 0, 11);
localMonth.value = idx + 1;
} else {
const dim = daysInMonth(localYear.value, localMonth.value);
idx = clamp(idx + direction, 0, dim - 1);
localDay.value = idx + 1;
}
isAnimating[type] = true;
isUserScrolling.value = true;
el.scrollTo({ top: idx * wheelItemHeight, behavior: "smooth" });
setTimeout(() => {
isAnimating[type] = false;
isUserScrolling.value = false;
}, 180);
}
function handleWheelScroll(type, e) {
clearTimeout(scrollTimers[type]);
isUserScrolling.value = true;
scrollTimers[type] = setTimeout(() => {
const el = e.target;
const idx = getCenteredIndexByRect(el);
if (type === "year") {
localYear.value = years.value[clamp(idx, 0, years.value.length - 1)];
} else if (type === "month") {
localMonth.value = clamp(idx + 1, 1, 12);
} else if (type === "day") {
const dim = daysInMonth(localYear.value, localMonth.value);
localDay.value = clamp(idx + 1, 1, dim);
}
isUserScrolling.value = false;
}, 120);
}
function confirm() {
// 以滚轮当前“水平线”居中项为最终值(基于 DOM 位置,避免像素取整误差)
if (yearColRef.value) {
const idx = clamp(
getCenteredIndexByRect(yearColRef.value),
0,
years.value.length - 1
);
localYear.value = years.value[idx];
}
if (monthColRef.value) {
const idx = clamp(getCenteredIndexByRect(monthColRef.value) + 1, 1, 12);
localMonth.value = idx;
}
if (dayColRef.value) {
const dim = daysInMonth(localYear.value, localMonth.value);
const idx = clamp(getCenteredIndexByRect(dayColRef.value) + 1, 1, dim);
localDay.value = idx;
}
// 输出固定为本地日期,不跨时区
const payload = {
year: localYear.value,
month: localMonth.value,
day: localDay.value,
};
emit("update:modelValue", payload);
nextTick(() => emit("confirm", payload));
}
nextTick(syncWheelPositions);
</script>
<style scoped lang="scss">
.picker-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.picker-panel {
width: 311 * 5.12px;
background: #fff;
border-radius: 8 * 5.12px;
box-shadow: 0 3 * 5.12px 14 * 5.12px rgba(0, 0, 0, 0.16);
padding: 16 * 5.12px;
}
.picker-title {
font-family: "PingFang SC", sans-serif;
font-weight: 500;
font-size: 14 * 5.12px;
color: #455363;
margin-bottom: 16 * 5.12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.picker-columns {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
column-gap: 32 * 5.12px;
align-items: stretch;
justify-items: stretch;
padding: 0;
}
.center-lines {
pointer-events: none;
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
z-index: 0;
}
.center-lines .line {
position: absolute;
left: 16 * 5.12px; /* align with design padding */
right: 16 * 5.12px;
height: 1px; /* crisp hairline */
background: #ededed;
}
.center-lines .line:first-child {
top: 0;
}
.center-lines .line:last-child {
bottom: 0;
}
.picker-col {
position: relative;
overflow-y: auto;
z-index: 1; /* paint above guide lines */
scroll-snap-type: y mandatory;
overscroll-behavior: contain;
}
.picker-col::-webkit-scrollbar {
width: 0;
height: 0;
}
.picker-item {
font-family: "PingFang SC", sans-serif;
font-weight: 400;
font-size: 14 * 5.12px;
color: #9da3ad;
display: flex;
justify-content: center;
align-items: center;
scroll-snap-align: center;
scroll-snap-stop: always;
}
.year-col .picker-item {
justify-content: flex-start;
padding-left: 16 * 5.12px;
}
.month-col .picker-item {
justify-content: center;
}
.day-col .picker-item {
justify-content: flex-end;
padding-right: 16 * 5.12px;
}
.picker-item.active {
color: #000;
font-weight: 500;
}
.picker-actions {
margin-top: 16 * 5.12px;
}
.picker-confirm {
width: 100%;
height: 44 * 5.12px;
background: #ff7bac;
color: #fff;
border: none;
border-radius: 8 * 5.12px;
font-family: "PingFang SC", sans-serif;
font-weight: 500;
font-size: 14 * 5.12px;
}
</style>