时间选择器

This commit is contained in:
zhx 2026-07-14 11:05:48 +08:00
parent 2a5238563b
commit 6d237766f9
3 changed files with 245 additions and 9 deletions

View File

@ -29,7 +29,14 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
const [error, setError] = useState("");
const open = Boolean(anchorEl);
const days = useMemo(() => calendarDays(viewDate), [viewDate]);
const selectedDate = dateForField(draft, activeField, viewDate);
// 沿
// 1 00:00
const selectedDate = dateForField(
draft,
activeField,
activeField === "end" && draft.startMs ? new Date(draft.startMs) : viewDate,
);
const rangeDistance = formatRangeDistance(draft.startMs, draft.endMs, withTime);
const hasRange = Boolean(range.startMs || range.endMs);
useEffect(() => {
@ -71,24 +78,33 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
};
const activateField = (field) => {
setActiveField(field);
setViewDate(dateForField(draft, field, viewDate));
setViewDate(
dateForField(draft, field, field === "end" && draft.startMs ? new Date(draft.startMs) : viewDate),
);
setError("");
};
const changeMonth = (offset) => {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
};
const changeDay = (day) => {
const editingField = activeField;
setDraft((current) => {
const base = dateForField(current, activeField, viewDate);
const base = dateForField(current, editingField, viewDate);
const timeParts = withTime
? [base.getHours(), base.getMinutes(), 0, 0]
: activeField === "start"
: editingField === "start"
? [0, 0, 0, 0]
: [23, 59, 59, 999];
const nextDate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), ...timeParts);
return setRangeField(current, activeField, nextDate.getTime());
return setRangeField(current, editingField, nextDate.getTime());
});
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
setError("");
if (editingField === "start") {
//
//
setActiveField("end");
}
};
const changeTime = (unit, nextValue) => {
setDraft((current) => {
@ -171,6 +187,17 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
onClick={() => activateField("end")}
/>
</div>
<div className={styles.rangeOverview} aria-live="polite">
<span className={styles.rangeEdge}>
<span className={`${styles.rangeEdgeMarker} ${styles.rangeEdgeMarkerStart}`}></span>
{formatDayPosition(draft.startMs) || "待选择"}
</span>
<span className={styles.rangeDistance}>{rangeDistance}</span>
<span className={`${styles.rangeEdge} ${styles.rangeEdgeEnd}`}>
{formatDayPosition(draft.endMs) || "待选择"}
<span className={`${styles.rangeEdgeMarker} ${styles.rangeEdgeMarkerEnd}`}></span>
</span>
</div>
<div className={styles.calendarHeader}>
<IconButton aria-label="上个月" size="small" onClick={() => changeMonth(-1)}>
<ChevronLeftOutlined fontSize="small" />
@ -180,7 +207,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
<ChevronRightOutlined fontSize="small" />
</IconButton>
</div>
<div className={styles.calendarGrid}>
<div className={`${styles.calendarGrid} ${styles.rangeCalendarGrid}`}>
{weekLabels.map((weekday) => (
<span className={styles.weekday} key={weekday}>
{weekday}
@ -188,16 +215,24 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
))}
{days.map((day) => {
const isOutside = day.getMonth() !== viewDate.getMonth();
const isSelected = sameDay(day, selectedDate) && Boolean(draft[`${activeField}Ms`]);
const isRangeStart = sameDayMs(day, draft.startMs);
const isRangeEnd = sameDayMs(day, draft.endMs);
const isInRange = isDayInRange(day, draft.startMs, draft.endMs);
const rangeEdge =
isRangeStart && isRangeEnd ? "始末" : isRangeStart ? "始" : isRangeEnd ? "末" : "";
return (
<button
className={[
styles.dayButton,
isOutside ? styles.dayOutside : "",
isSelected ? styles.daySelected : "",
isInRange ? styles.dayInRange : "",
isRangeStart ? styles.dayRangeStart : "",
isRangeEnd ? styles.dayRangeEnd : "",
isRangeStart || isRangeEnd ? styles.daySelected : "",
]
.filter(Boolean)
.join(" ")}
data-range-edge={rangeEdge || undefined}
key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`}
type="button"
onClick={() => changeDay(day)}
@ -358,6 +393,69 @@ function formatDayPart(value) {
return `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())}`;
}
function formatDayPosition(value) {
const ms = normalizeMs(value);
if (!ms) {
return "";
}
const date = new Date(ms);
return `${pad2(date.getMonth() + 1)}/${pad2(date.getDate())}`;
}
export function formatRangeDistance(startValue, endValue, withTime = true) {
const startMs = normalizeMs(startValue);
const endMs = normalizeMs(endValue);
if (!startMs || !endMs) {
return "选择完整区间";
}
if (endMs <= startMs) {
return withTime ? "结束需晚于开始" : "结束不能早于开始";
}
if (!withTime) {
const start = new Date(startMs);
const end = new Date(endMs);
// 使 UTC 23/25
const startDay = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
const endDay = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate());
const days = Math.round((endDay - startDay) / (24 * 60 * 60 * 1000));
return days === 0 ? "同一天" : `间隔 ${days}`;
}
const totalMinutes = Math.floor((endMs - startMs) / (60 * 1000));
if (totalMinutes < 1) {
return "间隔少于 1 分钟";
}
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
const parts = [
days ? `${days}` : "",
hours ? `${hours} 小时` : "",
minutes ? `${minutes} 分钟` : "",
].filter(Boolean);
return `间隔 ${parts.join(" ")}`;
}
function sameDayMs(day, value) {
const ms = normalizeMs(value);
return Boolean(ms) && sameDay(day, new Date(ms));
}
function isDayInRange(day, startValue, endValue) {
const startMs = normalizeMs(startValue);
const endMs = normalizeMs(endValue);
if (!startMs || !endMs || endMs <= startMs) {
return false;
}
const dayValue = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const start = new Date(startMs);
const end = new Date(endMs);
const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime();
const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime();
return dayValue >= startDay && dayValue <= endDay;
}
function msToDatetimeText(value) {
const ms = normalizeMs(value);
if (!ms) {

View File

@ -146,6 +146,73 @@
white-space: nowrap;
}
.rangeOverview {
display: grid;
grid-template-columns: minmax(72px, auto) minmax(0, 1fr) minmax(72px, auto);
align-items: center;
gap: var(--space-2);
min-height: 24px;
color: var(--text-tertiary);
font-size: 12px;
line-height: 18px;
}
.rangeEdge {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
color: var(--text-secondary);
font-weight: 650;
white-space: nowrap;
}
.rangeEdgeEnd {
justify-content: flex-end;
}
.rangeEdgeMarker {
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 10px;
font-weight: 760;
}
.rangeEdgeMarkerStart {
background: var(--primary);
color: var(--active-contrast);
}
.rangeEdgeMarkerEnd {
border: 1px solid var(--primary);
background: var(--bg-card);
color: var(--primary);
}
.rangeDistance {
position: relative;
overflow: hidden;
color: var(--primary);
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.rangeDistance::before,
.rangeDistance::after {
display: inline-block;
width: 18px;
height: 1px;
margin: 0 6px 3px;
background: var(--primary-border);
content: "";
}
.calendarHeader {
display: grid;
grid-template-columns: var(--control-height) 1fr var(--control-height);
@ -166,6 +233,10 @@
gap: var(--space-1);
}
.rangeCalendarGrid {
column-gap: 0;
}
.weekday {
display: inline-flex;
height: 24px;
@ -174,6 +245,7 @@
}
.dayButton {
position: relative;
display: inline-flex;
min-width: 0;
height: 34px;
@ -192,6 +264,28 @@
color var(--motion-fast) var(--ease-standard);
}
.dayInRange {
border-radius: 0;
background: var(--primary-surface);
color: var(--primary-strong);
}
.dayButton.dayInRange:hover {
background: var(--primary-surface-strong);
}
.dayRangeStart {
border-radius: var(--radius-control) 0 0 var(--radius-control);
}
.dayRangeEnd {
border-radius: 0 var(--radius-control) var(--radius-control) 0;
}
.dayRangeStart.dayRangeEnd {
border-radius: var(--radius-control);
}
.dayButton:hover {
border-color: var(--primary-border);
background: var(--primary-surface);
@ -208,6 +302,18 @@
font-weight: 760;
}
.daySelected::after {
position: absolute;
top: 1px;
right: 3px;
color: currentcolor;
content: attr(data-range-edge);
font-size: 8px;
font-weight: 760;
line-height: 10px;
opacity: 0.82;
}
.daySelected:hover {
border-color: var(--primary-strong);
background: var(--primary-strong);

View File

@ -1,7 +1,13 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest";
import { TimeRangeFilter, datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx";
import {
TimeRangeFilter,
datetimeTextToMs,
formatRangeDistance,
normalizeRange,
timeRangeLabel,
} from "@/shared/ui/TimeRangeFilter.jsx";
describe("TimeRangeFilter helpers", () => {
test("parses strict local date time text", () => {
@ -26,6 +32,32 @@ describe("TimeRangeFilter helpers", () => {
expect(timeRangeLabel("时间", {})).toBe("时间");
});
test("formats the selected interval for timed and date-only ranges", () => {
const startMs = datetimeTextToMs("2026-05-13 10:00");
const endMs = datetimeTextToMs("2026-05-14 12:30");
expect(formatRangeDistance(startMs, endMs)).toBe("间隔 1 天 2 小时 30 分钟");
expect(formatRangeDistance(startMs, endMs, false)).toBe("间隔 1 天");
expect(formatRangeDistance(startMs, "")).toBe("选择完整区间");
});
test("switches to the end field after choosing a start date and marks the whole range", async () => {
const user = userEvent.setup();
const startMs = datetimeTextToMs("2026-05-13 10:00");
const endMs = datetimeTextToMs("2026-05-16 12:00");
render(<TimeRangeFilter label="活动时间" value={{ endMs, startMs }} onChange={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /活动时间/ }));
await user.click(screen.getByRole("button", { name: "14" }));
expect(screen.getByRole("button", { name: /结束时间/ }).className).toContain("rangeFieldActive");
expect(screen.getByText("间隔 2 天 2 小时")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "14" })).toHaveAttribute("data-range-edge", "始");
expect(screen.getByRole("button", { name: "15" }).className).toContain("dayInRange");
expect(screen.getByRole("button", { name: "16" })).toHaveAttribute("data-range-edge", "末");
});
test("date-only mode hides time controls and returns day boundaries", async () => {
const user = userEvent.setup();
const onChange = vi.fn();