时间选择器
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 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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user