complete frontend - refact from vue

This commit is contained in:
2025-06-28 16:57:26 +08:00
parent 1e65c2d872
commit 1d41ad6ef1
9 changed files with 1772 additions and 96 deletions
@@ -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">
&lt;
</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">
&gt;
</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>
);
}