时间选择器

This commit is contained in:
zhx 2026-07-04 00:26:36 +08:00
parent fb168822cc
commit cf60f9ce2c
6 changed files with 153 additions and 84 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "admin-platform",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 7001
}
]
}

View File

@ -1,6 +1,8 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { DatabiApp } from "./DatabiApp.jsx";
// token@/styles/tokens.css TimeRangeFilter
import "@/styles/tokens.css";
import "./styles/index.css";
createRoot(document.getElementById("databi-root")).render(

View File

@ -18,6 +18,7 @@ import {
toggleMultiValue,
writeStateToURL
} from "./state.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { appColor } from "./metrics.js";
import { useSocialBiData } from "./useSocialBiData.js";
import { OverviewView } from "./views/OverviewView.jsx";
@ -168,26 +169,46 @@ function DatePresetControl({ filters, updateFilter }) {
</button>
))}
{filters.preset === "custom" ? (
<span className="sbi-custom-range">
<input
aria-label="开始日期"
onChange={(event) => updateFilter("customStart", event.target.value)}
type="date"
value={filters.customStart}
/>
<span></span>
<input
aria-label="结束日期"
onChange={(event) => updateFilter("customEnd", event.target.value)}
type="date"
value={filters.customEnd}
/>
</span>
<TimeRangeFilter
className="sbi-custom-range"
label="自定义区间"
value={{ endMs: dayTextToEndMs(filters.customEnd), startMs: dayTextToStartMs(filters.customStart) }}
withTime={false}
onChange={(range) => {
updateFilter("customStart", msToDayText(range.startMs));
updateFilter("customEnd", msToDayText(range.endMs));
}}
/>
) : null}
</div>
);
}
// YYYY-MM-DD
function parseDayText(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ""));
return match ? { day: Number(match[3]), month: Number(match[2]), year: Number(match[1]) } : null;
}
function dayTextToStartMs(dayText) {
const parts = parseDayText(dayText);
return parts ? new Date(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0).getTime() : "";
}
function dayTextToEndMs(dayText) {
const parts = parseDayText(dayText);
return parts ? new Date(parts.year, parts.month - 1, parts.day, 23, 59, 59, 999).getTime() : "";
}
function msToDayText(ms) {
if (!ms) {
return "";
}
const date = new Date(Number(ms));
const pad = (value) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function GranularityControl({ filters, updateFilter }) {
return (
<div className="sbi-granularity" role="radiogroup" aria-label="趋势粒度">

View File

@ -184,22 +184,9 @@
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
}
/* 自定义区间触发按钮:复用全局 TimeRangeFilter@/shared/ui这里只做与预设条的对齐微调。 */
.sbi-custom-range {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 6px;
color: var(--sbi-text-3);
}
.sbi-custom-range input {
height: 30px;
padding: 0 8px;
border: 1px solid var(--sbi-border);
border-radius: 7px;
background: #ffffff;
color: var(--sbi-text);
font-size: 12.5px;
}
.sbi-granularity {

View File

@ -18,7 +18,9 @@ const quickRanges = [
];
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value }) {
// withTime=false /
// / 00:00:00.000 23:59:59.999
export function TimeRangeFilter({ className = "", disabled = false, label = "时间", onChange, value, withTime = true }) {
const range = useMemo(() => normalizeRange(value), [value]);
const [anchorEl, setAnchorEl] = useState(null);
const [draft, setDraft] = useState(() => range);
@ -39,7 +41,14 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
}
}, [open, range]);
const displayLabel = useMemo(() => timeRangeLabel(label, range), [label, range]);
const formatPart = withTime ? formatRangePart : formatDayPart;
const displayLabel = useMemo(
() =>
!range.startMs && !range.endMs
? label
: `${formatPart(range.startMs) || "不限"} - ${formatPart(range.endMs) || "不限"}`,
[formatPart, label, range],
);
const openPopover = (event) => {
if (disabled) {
@ -71,15 +80,12 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
const changeDay = (day) => {
setDraft((current) => {
const base = dateForField(current, activeField, viewDate);
const nextDate = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
base.getHours(),
base.getMinutes(),
0,
0,
);
const timeParts = withTime
? [base.getHours(), base.getMinutes(), 0, 0]
: activeField === "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());
});
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
@ -105,7 +111,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
event.stopPropagation();
const nextRange = normalizeRange(draft);
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
setError("结束时间必须晚于开始时间");
setError(withTime ? "结束时间必须晚于开始时间" : "结束日期不能早于开始日期");
return;
}
applyRange(nextRange);
@ -136,27 +142,31 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
onClose={closePopover}
>
<form className={styles.panel} onSubmit={submitDraft}>
<div className={styles.quickRanges}>
{quickRanges.map(([quickLabel, durationMs]) => (
<Button
key={quickLabel}
className={styles.quickButton}
onClick={() => applyQuickRange(durationMs)}
>
{quickLabel}
</Button>
))}
</div>
{withTime ? (
<div className={styles.quickRanges}>
{quickRanges.map(([quickLabel, durationMs]) => (
<Button
key={quickLabel}
className={styles.quickButton}
onClick={() => applyQuickRange(durationMs)}
>
{quickLabel}
</Button>
))}
</div>
) : null}
<div className={styles.rangeFields}>
<RangeFieldButton
active={activeField === "start"}
label="开始时间"
formatPart={formatPart}
label={withTime ? "开始时间" : "开始日期"}
value={draft.startMs}
onClick={() => activateField("start")}
/>
<RangeFieldButton
active={activeField === "end"}
label="结束时间"
formatPart={formatPart}
label={withTime ? "结束时间" : "结束日期"}
value={draft.endMs}
onClick={() => activateField("end")}
/>
@ -197,34 +207,36 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
);
})}
</div>
<div className={styles.timeGrid}>
<TextField
label="时"
select
size="small"
value={String(selectedDate.getHours())}
onChange={(event) => changeTime("hour", event.target.value)}
>
{Array.from({ length: 24 }, (_, hour) => (
<MenuItem key={hour} value={String(hour)}>
{pad2(hour)}
</MenuItem>
))}
</TextField>
<TextField
label="分"
select
size="small"
value={String(selectedDate.getMinutes())}
onChange={(event) => changeTime("minute", event.target.value)}
>
{Array.from({ length: 60 }, (_, minute) => (
<MenuItem key={minute} value={String(minute)}>
{pad2(minute)}
</MenuItem>
))}
</TextField>
</div>
{withTime ? (
<div className={styles.timeGrid}>
<TextField
label="时"
select
size="small"
value={String(selectedDate.getHours())}
onChange={(event) => changeTime("hour", event.target.value)}
>
{Array.from({ length: 24 }, (_, hour) => (
<MenuItem key={hour} value={String(hour)}>
{pad2(hour)}
</MenuItem>
))}
</TextField>
<TextField
label="分"
select
size="small"
value={String(selectedDate.getMinutes())}
onChange={(event) => changeTime("minute", event.target.value)}
>
{Array.from({ length: 60 }, (_, minute) => (
<MenuItem key={minute} value={String(minute)}>
{pad2(minute)}
</MenuItem>
))}
</TextField>
</div>
) : null}
{error ? <div className={styles.error}>{error}</div> : null}
<div className={styles.actions}>
<Button onClick={clearRange}>清空</Button>
@ -238,7 +250,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
);
}
function RangeFieldButton({ active, label, onClick, value }) {
function RangeFieldButton({ active, formatPart = formatRangePart, label, onClick, value }) {
return (
<button
className={[styles.rangeField, active ? styles.rangeFieldActive : ""].filter(Boolean).join(" ")}
@ -246,7 +258,7 @@ function RangeFieldButton({ active, label, onClick, value }) {
onClick={onClick}
>
<span className={styles.rangeFieldLabel}>{label}</span>
<span className={styles.rangeFieldValue}>{formatRangePart(value) || "不限"}</span>
<span className={styles.rangeFieldValue}>{formatPart(value) || "不限"}</span>
</button>
);
}
@ -337,6 +349,15 @@ function formatRangePart(value) {
return text ? text.replace("-", "/").replace("-", "/") : "";
}
function formatDayPart(value) {
const ms = normalizeMs(value);
if (!ms) {
return "";
}
const date = new Date(ms);
return `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())}`;
}
function msToDatetimeText(value) {
const ms = normalizeMs(value);
if (!ms) {

View File

@ -26,6 +26,33 @@ describe("TimeRangeFilter helpers", () => {
expect(timeRangeLabel("时间", {})).toBe("时间");
});
test("date-only mode hides time controls and returns day boundaries", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const startMs = datetimeTextToMs("2026-05-13 00:00");
const endMs = datetimeTextToMs("2026-05-20 23:59");
render(<TimeRangeFilter label="自定义区间" value={{ endMs, startMs }} withTime={false} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /自定义区间/ }));
expect(screen.queryByLabelText("时")).toBeNull();
expect(screen.queryByRole("button", { name: "近 24 小时" })).toBeNull();
await user.click(screen.getByRole("button", { name: /结束日期/ }));
await user.click(screen.getByRole("button", { name: "21" }));
await user.click(screen.getByRole("button", { name: "确定" }));
const next = onChange.mock.calls[0][0];
const end = new Date(next.endMs);
expect(next.startMs).toBe(startMs);
expect(end.getFullYear()).toBe(2026);
expect(end.getMonth()).toBe(4);
expect(end.getDate()).toBe(21);
expect(end.getHours()).toBe(23);
expect(end.getMinutes()).toBe(59);
});
test("confirms picker changes without submitting an outer form", async () => {
const user = userEvent.setup();
const onChange = vi.fn();