时间选择器
This commit is contained in:
parent
2a5238563b
commit
6d237766f9
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user