complete frontend - refact from vue
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface RecordDatePickerProps {
|
||||
value: string | null;
|
||||
dailyCounts: Record<string, number>;
|
||||
onChange: (date: string) => void;
|
||||
}
|
||||
|
||||
export default function RecordDatePicker({ value, dailyCounts, onChange }: RecordDatePickerProps) {
|
||||
const today = new Date();
|
||||
const initialDate = value ? new Date(value) : today;
|
||||
|
||||
const [currentYear, setCurrentYear] = useState(initialDate.getFullYear());
|
||||
const [currentMonth, setCurrentMonth] = useState(initialDate.getMonth());
|
||||
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentYear;
|
||||
const month = currentMonth;
|
||||
const firstDayOfMonth = new Date(year, month, 1);
|
||||
const lastDayOfMonth = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDayOfMonth.getDate();
|
||||
const startDayIndex = firstDayOfMonth.getDay();
|
||||
const totalCells = Math.ceil((startDayIndex + daysInMonth) / 7) * 7;
|
||||
|
||||
const days = [];
|
||||
const startDate = new Date(year, month, 1 - startDayIndex);
|
||||
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + i);
|
||||
const dateString = `${date.getFullYear()}-${(date.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
|
||||
|
||||
days.push({
|
||||
date,
|
||||
day: date.getDate(),
|
||||
isCurrentMonth: date.getMonth() === month,
|
||||
dateString,
|
||||
count: dailyCounts[dateString] || 0
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}, [currentYear, currentMonth, dailyCounts]);
|
||||
|
||||
const maxCount = useMemo(() => {
|
||||
let max = 0;
|
||||
calendarDays.forEach((day) => {
|
||||
if (day.count > max) {
|
||||
max = day.count;
|
||||
}
|
||||
});
|
||||
return max;
|
||||
}, [calendarDays]);
|
||||
|
||||
const selectDay = (day: { date: Date; dateString: string; isCurrentMonth: boolean }) => {
|
||||
if (!day.isCurrentMonth) {
|
||||
setCurrentYear(day.date.getFullYear());
|
||||
setCurrentMonth(day.date.getMonth());
|
||||
}
|
||||
onChange(day.dateString);
|
||||
};
|
||||
|
||||
const getDayStyle = (day: { dateString: string; count: number }) => {
|
||||
const styles: React.CSSProperties = {};
|
||||
|
||||
if (value === day.dateString) {
|
||||
styles.outline = '2px solid #2563EB';
|
||||
}
|
||||
|
||||
if (day.count > 0) {
|
||||
const ratio = maxCount > 0 ? day.count / maxCount : 0;
|
||||
const opacity = 0.3 + ratio * 0.7;
|
||||
styles.backgroundColor = `rgba(16, 185, 129, ${opacity})`;
|
||||
styles.color = opacity > 0.6 ? 'white' : 'black';
|
||||
} else {
|
||||
styles.backgroundColor = 'transparent';
|
||||
styles.color = 'inherit';
|
||||
}
|
||||
|
||||
return styles;
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentYear(currentYear - 1);
|
||||
setCurrentMonth(11);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentYear(currentYear + 1);
|
||||
setCurrentMonth(0);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded shadow">
|
||||
{/* 月份切换头部 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={prevMonth} className="p-2 rounded hover:bg-gray-200">
|
||||
<
|
||||
</button>
|
||||
<div className="font-semibold text-lg">
|
||||
{currentYear} - {(currentMonth + 1).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<button onClick={nextMonth} className="p-2 rounded hover:bg-gray-200">
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 星期标题 */}
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-sm font-medium text-gray-600 mb-1">
|
||||
{weekDays.map((day) => (
|
||||
<div key={day}>{day}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 日历网格 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendarDays.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-2 my-1 rounded cursor-pointer text-sm flex items-center justify-center ${
|
||||
!day.isCurrentMonth ? 'opacity-50' : ''
|
||||
}`}
|
||||
style={getDayStyle(day)}
|
||||
onClick={() => selectDay(day)}
|
||||
>
|
||||
<div className="relative">
|
||||
<span>{day.day}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Segment {
|
||||
start: number;
|
||||
end: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface Marker {
|
||||
time: number;
|
||||
label?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineSliderProps {
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
value: number;
|
||||
mode?: 'default' | 'segments' | 'ticks';
|
||||
segments?: Segment[];
|
||||
markers?: Marker[];
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export default function TimelineSlider({
|
||||
minTime,
|
||||
maxTime,
|
||||
value,
|
||||
mode = 'default',
|
||||
segments = [],
|
||||
markers = [],
|
||||
onChange
|
||||
}: TimelineSliderProps) {
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
// 同步外部value到内部状态(只在非拖拽状态下)
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
setInternalValue(value);
|
||||
}
|
||||
}, [value, dragging]);
|
||||
|
||||
const formattedValue = format(new Date(internalValue), 'HH:mm:ss');
|
||||
const percentage = ((internalValue - minTime) / (maxTime - minTime)) * 100;
|
||||
|
||||
const getSnapValue = useCallback((val: number): number => {
|
||||
if (mode === 'ticks' && markers.length > 0) {
|
||||
let closest = markers[0].time;
|
||||
let minDiff = Math.abs(val - markers[0].time);
|
||||
for (const marker of markers) {
|
||||
const diff = Math.abs(val - marker.time);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = marker.time;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
} else if (mode === 'segments' && segments.length > 0) {
|
||||
let closestMidpoint: number | null = null;
|
||||
let minDiff = Infinity;
|
||||
for (const segment of segments) {
|
||||
if (segment.active) {
|
||||
const midpoint = (segment.start + segment.end) / 2;
|
||||
const diff = Math.abs(val - midpoint);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closestMidpoint = midpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (closestMidpoint !== null) {
|
||||
return closestMidpoint;
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}, [mode, markers, segments]);
|
||||
|
||||
// 使用ref来跟踪上次通知的值,避免频繁调用onChange
|
||||
const lastNotifiedValue = useRef(value);
|
||||
|
||||
// 指针移动处理函数
|
||||
const pointerMoveHandler = useCallback((event: PointerEvent) => {
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
let x = event.clientX - rect.left;
|
||||
x = Math.max(0, Math.min(x, rect.width));
|
||||
const newValue = minTime + (x / rect.width) * (maxTime - minTime);
|
||||
setInternalValue(newValue);
|
||||
|
||||
// 节流调用onChange,避免过于频繁的更新
|
||||
const snapped = getSnapValue(newValue);
|
||||
if (Math.abs(snapped - lastNotifiedValue.current) > 100) { // 100ms的节流
|
||||
lastNotifiedValue.current = snapped;
|
||||
onChange(snapped);
|
||||
}
|
||||
}, [minTime, maxTime, getSnapValue, onChange]);
|
||||
|
||||
// 指针释放处理函数
|
||||
const pointerUpHandler = useCallback(() => {
|
||||
setDragging(false);
|
||||
setShowTooltip(false);
|
||||
window.removeEventListener('pointermove', pointerMoveHandler);
|
||||
window.removeEventListener('pointerup', pointerUpHandler);
|
||||
|
||||
// 最终确保调用onChange通知最新的值
|
||||
setInternalValue(currentValue => {
|
||||
const snapped = getSnapValue(currentValue);
|
||||
lastNotifiedValue.current = snapped;
|
||||
onChange(snapped);
|
||||
return snapped;
|
||||
});
|
||||
}, [getSnapValue, onChange, pointerMoveHandler]);
|
||||
|
||||
// 指针按下处理函数
|
||||
const onPointerDown = (event: React.PointerEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
let x = event.clientX - rect.left;
|
||||
x = Math.max(0, Math.min(x, rect.width));
|
||||
const newValue = minTime + (x / rect.width) * (maxTime - minTime);
|
||||
|
||||
setDragging(true);
|
||||
setShowTooltip(true);
|
||||
setInternalValue(newValue);
|
||||
|
||||
// 立即通知父组件值的变化
|
||||
const snapped = getSnapValue(newValue);
|
||||
lastNotifiedValue.current = snapped;
|
||||
onChange(snapped);
|
||||
|
||||
window.addEventListener('pointermove', pointerMoveHandler);
|
||||
window.addEventListener('pointerup', pointerUpHandler);
|
||||
};
|
||||
|
||||
const getSegmentStyle = (segment: Segment) => {
|
||||
const startPercent = ((segment.start - minTime) / (maxTime - minTime)) * 100;
|
||||
const endPercent = ((segment.end - minTime) / (maxTime - minTime)) * 100;
|
||||
const widthPercent = endPercent - startPercent;
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${startPercent}%`,
|
||||
width: `${widthPercent}%`,
|
||||
height: '100%',
|
||||
backgroundColor: segment.active ? '#4ADE80' : '#E5E7EB',
|
||||
borderLeft: '1px solid #E5E7EB',
|
||||
borderRight: '1px solid #E5E7EB',
|
||||
};
|
||||
};
|
||||
|
||||
const getMarkerStyle = (marker: Marker) => {
|
||||
const pos = ((marker.time - minTime) / (maxTime - minTime)) * 100;
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${pos}%`,
|
||||
top: '50%',
|
||||
width: '2px',
|
||||
height: '100%',
|
||||
backgroundColor: marker.active ? '#4ADE80' : '#9CA3AF',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="relative h-10 select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
{/* 底部轨道 */}
|
||||
<div
|
||||
className="absolute top-1/2 left-0 right-0 h-1.5 bg-gray-200 transform -translate-y-1/2 rounded-sm"
|
||||
>
|
||||
{/* Segments 模式 */}
|
||||
{mode === 'segments' && segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={getSegmentStyle(segment)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ticks 模式 */}
|
||||
{mode === 'ticks' && markers.map((marker, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={getMarkerStyle(marker)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 可拖动的滑块 */}
|
||||
<div
|
||||
className="absolute top-1/2 w-5 h-5 bg-green-400 border-2 border-white rounded-full shadow-lg cursor-pointer z-10"
|
||||
style={{
|
||||
left: `${percentage}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
|
||||
>
|
||||
{/* Tooltip */}
|
||||
{showTooltip && (
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black bg-opacity-70 text-white text-xs rounded whitespace-nowrap">
|
||||
{formattedValue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user