时间选择器

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 [error, setError] = useState("");
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const days = useMemo(() => calendarDays(viewDate), [viewDate]); 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); const hasRange = Boolean(range.startMs || range.endMs);
useEffect(() => { useEffect(() => {
@ -71,24 +78,33 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
}; };
const activateField = (field) => { const activateField = (field) => {
setActiveField(field); setActiveField(field);
setViewDate(dateForField(draft, field, viewDate)); setViewDate(
dateForField(draft, field, field === "end" && draft.startMs ? new Date(draft.startMs) : viewDate),
);
setError(""); setError("");
}; };
const changeMonth = (offset) => { const changeMonth = (offset) => {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1)); setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
}; };
const changeDay = (day) => { const changeDay = (day) => {
const editingField = activeField;
setDraft((current) => { setDraft((current) => {
const base = dateForField(current, activeField, viewDate); const base = dateForField(current, editingField, viewDate);
const timeParts = withTime const timeParts = withTime
? [base.getHours(), base.getMinutes(), 0, 0] ? [base.getHours(), base.getMinutes(), 0, 0]
: activeField === "start" : editingField === "start"
? [0, 0, 0, 0] ? [0, 0, 0, 0]
: [23, 59, 59, 999]; : [23, 59, 59, 999];
const nextDate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), ...timeParts); 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)); setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
setError("");
if (editingField === "start") {
//
//
setActiveField("end");
}
}; };
const changeTime = (unit, nextValue) => { const changeTime = (unit, nextValue) => {
setDraft((current) => { setDraft((current) => {
@ -171,6 +187,17 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
onClick={() => activateField("end")} onClick={() => activateField("end")}
/> />
</div> </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}> <div className={styles.calendarHeader}>
<IconButton aria-label="上个月" size="small" onClick={() => changeMonth(-1)}> <IconButton aria-label="上个月" size="small" onClick={() => changeMonth(-1)}>
<ChevronLeftOutlined fontSize="small" /> <ChevronLeftOutlined fontSize="small" />
@ -180,7 +207,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
<ChevronRightOutlined fontSize="small" /> <ChevronRightOutlined fontSize="small" />
</IconButton> </IconButton>
</div> </div>
<div className={styles.calendarGrid}> <div className={`${styles.calendarGrid} ${styles.rangeCalendarGrid}`}>
{weekLabels.map((weekday) => ( {weekLabels.map((weekday) => (
<span className={styles.weekday} key={weekday}> <span className={styles.weekday} key={weekday}>
{weekday} {weekday}
@ -188,16 +215,24 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
))} ))}
{days.map((day) => { {days.map((day) => {
const isOutside = day.getMonth() !== viewDate.getMonth(); 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 ( return (
<button <button
className={[ className={[
styles.dayButton, styles.dayButton,
isOutside ? styles.dayOutside : "", isOutside ? styles.dayOutside : "",
isSelected ? styles.daySelected : "", isInRange ? styles.dayInRange : "",
isRangeStart ? styles.dayRangeStart : "",
isRangeEnd ? styles.dayRangeEnd : "",
isRangeStart || isRangeEnd ? styles.daySelected : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
data-range-edge={rangeEdge || undefined}
key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`} key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`}
type="button" type="button"
onClick={() => changeDay(day)} onClick={() => changeDay(day)}
@ -358,6 +393,69 @@ function formatDayPart(value) {
return `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())}`; 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) { function msToDatetimeText(value) {
const ms = normalizeMs(value); const ms = normalizeMs(value);
if (!ms) { if (!ms) {

View File

@ -146,6 +146,73 @@
white-space: nowrap; 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 { .calendarHeader {
display: grid; display: grid;
grid-template-columns: var(--control-height) 1fr var(--control-height); grid-template-columns: var(--control-height) 1fr var(--control-height);
@ -166,6 +233,10 @@
gap: var(--space-1); gap: var(--space-1);
} }
.rangeCalendarGrid {
column-gap: 0;
}
.weekday { .weekday {
display: inline-flex; display: inline-flex;
height: 24px; height: 24px;
@ -174,6 +245,7 @@
} }
.dayButton { .dayButton {
position: relative;
display: inline-flex; display: inline-flex;
min-width: 0; min-width: 0;
height: 34px; height: 34px;
@ -192,6 +264,28 @@
color var(--motion-fast) var(--ease-standard); 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 { .dayButton:hover {
border-color: var(--primary-border); border-color: var(--primary-border);
background: var(--primary-surface); background: var(--primary-surface);
@ -208,6 +302,18 @@
font-weight: 760; 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 { .daySelected:hover {
border-color: var(--primary-strong); border-color: var(--primary-strong);
background: var(--primary-strong); background: var(--primary-strong);

View File

@ -1,7 +1,13 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest"; 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", () => { describe("TimeRangeFilter helpers", () => {
test("parses strict local date time text", () => { test("parses strict local date time text", () => {
@ -26,6 +32,32 @@ describe("TimeRangeFilter helpers", () => {
expect(timeRangeLabel("时间", {})).toBe("时间"); 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 () => { test("date-only mode hides time controls and returns day boundaries", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn(); const onChange = vi.fn();