时间选择器
This commit is contained in:
parent
fb168822cc
commit
cf60f9ce2c
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "admin-platform",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 7001
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { DatabiApp } from "./DatabiApp.jsx";
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
|
// 全局设计 token(@/styles/tokens.css)供共享组件(如 TimeRangeFilter)取色,先于本地样式引入。
|
||||||
|
import "@/styles/tokens.css";
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("databi-root")).render(
|
createRoot(document.getElementById("databi-root")).render(
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
toggleMultiValue,
|
toggleMultiValue,
|
||||||
writeStateToURL
|
writeStateToURL
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { appColor } from "./metrics.js";
|
import { appColor } from "./metrics.js";
|
||||||
import { useSocialBiData } from "./useSocialBiData.js";
|
import { useSocialBiData } from "./useSocialBiData.js";
|
||||||
import { OverviewView } from "./views/OverviewView.jsx";
|
import { OverviewView } from "./views/OverviewView.jsx";
|
||||||
@ -168,26 +169,46 @@ function DatePresetControl({ filters, updateFilter }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filters.preset === "custom" ? (
|
{filters.preset === "custom" ? (
|
||||||
<span className="sbi-custom-range">
|
<TimeRangeFilter
|
||||||
<input
|
className="sbi-custom-range"
|
||||||
aria-label="开始日期"
|
label="自定义区间"
|
||||||
onChange={(event) => updateFilter("customStart", event.target.value)}
|
value={{ endMs: dayTextToEndMs(filters.customEnd), startMs: dayTextToStartMs(filters.customStart) }}
|
||||||
type="date"
|
withTime={false}
|
||||||
value={filters.customStart}
|
onChange={(range) => {
|
||||||
/>
|
updateFilter("customStart", msToDayText(range.startMs));
|
||||||
<span>—</span>
|
updateFilter("customEnd", msToDayText(range.endMs));
|
||||||
<input
|
}}
|
||||||
aria-label="结束日期"
|
/>
|
||||||
onChange={(event) => updateFilter("customEnd", event.target.value)}
|
|
||||||
type="date"
|
|
||||||
value={filters.customEnd}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 }) {
|
function GranularityControl({ filters, updateFilter }) {
|
||||||
return (
|
return (
|
||||||
<div className="sbi-granularity" role="radiogroup" aria-label="趋势粒度">
|
<div className="sbi-granularity" role="radiogroup" aria-label="趋势粒度">
|
||||||
|
|||||||
@ -184,22 +184,9 @@
|
|||||||
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 自定义区间触发按钮:复用全局 TimeRangeFilter(@/shared/ui),这里只做与预设条的对齐微调。 */
|
||||||
.sbi-custom-range {
|
.sbi-custom-range {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-left: 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 {
|
.sbi-granularity {
|
||||||
|
|||||||
@ -18,7 +18,9 @@ const quickRanges = [
|
|||||||
];
|
];
|
||||||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
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 range = useMemo(() => normalizeRange(value), [value]);
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const [draft, setDraft] = useState(() => range);
|
const [draft, setDraft] = useState(() => range);
|
||||||
@ -39,7 +41,14 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
}
|
}
|
||||||
}, [open, range]);
|
}, [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) => {
|
const openPopover = (event) => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
@ -71,15 +80,12 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
const changeDay = (day) => {
|
const changeDay = (day) => {
|
||||||
setDraft((current) => {
|
setDraft((current) => {
|
||||||
const base = dateForField(current, activeField, viewDate);
|
const base = dateForField(current, activeField, viewDate);
|
||||||
const nextDate = new Date(
|
const timeParts = withTime
|
||||||
day.getFullYear(),
|
? [base.getHours(), base.getMinutes(), 0, 0]
|
||||||
day.getMonth(),
|
: activeField === "start"
|
||||||
day.getDate(),
|
? [0, 0, 0, 0]
|
||||||
base.getHours(),
|
: [23, 59, 59, 999];
|
||||||
base.getMinutes(),
|
const nextDate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), ...timeParts);
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return setRangeField(current, activeField, nextDate.getTime());
|
return setRangeField(current, activeField, nextDate.getTime());
|
||||||
});
|
});
|
||||||
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
|
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||||
@ -105,7 +111,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const nextRange = normalizeRange(draft);
|
const nextRange = normalizeRange(draft);
|
||||||
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
|
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
|
||||||
setError("结束时间必须晚于开始时间");
|
setError(withTime ? "结束时间必须晚于开始时间" : "结束日期不能早于开始日期");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyRange(nextRange);
|
applyRange(nextRange);
|
||||||
@ -136,27 +142,31 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
onClose={closePopover}
|
onClose={closePopover}
|
||||||
>
|
>
|
||||||
<form className={styles.panel} onSubmit={submitDraft}>
|
<form className={styles.panel} onSubmit={submitDraft}>
|
||||||
<div className={styles.quickRanges}>
|
{withTime ? (
|
||||||
{quickRanges.map(([quickLabel, durationMs]) => (
|
<div className={styles.quickRanges}>
|
||||||
<Button
|
{quickRanges.map(([quickLabel, durationMs]) => (
|
||||||
key={quickLabel}
|
<Button
|
||||||
className={styles.quickButton}
|
key={quickLabel}
|
||||||
onClick={() => applyQuickRange(durationMs)}
|
className={styles.quickButton}
|
||||||
>
|
onClick={() => applyQuickRange(durationMs)}
|
||||||
{quickLabel}
|
>
|
||||||
</Button>
|
{quickLabel}
|
||||||
))}
|
</Button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className={styles.rangeFields}>
|
<div className={styles.rangeFields}>
|
||||||
<RangeFieldButton
|
<RangeFieldButton
|
||||||
active={activeField === "start"}
|
active={activeField === "start"}
|
||||||
label="开始时间"
|
formatPart={formatPart}
|
||||||
|
label={withTime ? "开始时间" : "开始日期"}
|
||||||
value={draft.startMs}
|
value={draft.startMs}
|
||||||
onClick={() => activateField("start")}
|
onClick={() => activateField("start")}
|
||||||
/>
|
/>
|
||||||
<RangeFieldButton
|
<RangeFieldButton
|
||||||
active={activeField === "end"}
|
active={activeField === "end"}
|
||||||
label="结束时间"
|
formatPart={formatPart}
|
||||||
|
label={withTime ? "结束时间" : "结束日期"}
|
||||||
value={draft.endMs}
|
value={draft.endMs}
|
||||||
onClick={() => activateField("end")}
|
onClick={() => activateField("end")}
|
||||||
/>
|
/>
|
||||||
@ -197,34 +207,36 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.timeGrid}>
|
{withTime ? (
|
||||||
<TextField
|
<div className={styles.timeGrid}>
|
||||||
label="时"
|
<TextField
|
||||||
select
|
label="时"
|
||||||
size="small"
|
select
|
||||||
value={String(selectedDate.getHours())}
|
size="small"
|
||||||
onChange={(event) => changeTime("hour", event.target.value)}
|
value={String(selectedDate.getHours())}
|
||||||
>
|
onChange={(event) => changeTime("hour", event.target.value)}
|
||||||
{Array.from({ length: 24 }, (_, hour) => (
|
>
|
||||||
<MenuItem key={hour} value={String(hour)}>
|
{Array.from({ length: 24 }, (_, hour) => (
|
||||||
{pad2(hour)}
|
<MenuItem key={hour} value={String(hour)}>
|
||||||
</MenuItem>
|
{pad2(hour)}
|
||||||
))}
|
</MenuItem>
|
||||||
</TextField>
|
))}
|
||||||
<TextField
|
</TextField>
|
||||||
label="分"
|
<TextField
|
||||||
select
|
label="分"
|
||||||
size="small"
|
select
|
||||||
value={String(selectedDate.getMinutes())}
|
size="small"
|
||||||
onChange={(event) => changeTime("minute", event.target.value)}
|
value={String(selectedDate.getMinutes())}
|
||||||
>
|
onChange={(event) => changeTime("minute", event.target.value)}
|
||||||
{Array.from({ length: 60 }, (_, minute) => (
|
>
|
||||||
<MenuItem key={minute} value={String(minute)}>
|
{Array.from({ length: 60 }, (_, minute) => (
|
||||||
{pad2(minute)}
|
<MenuItem key={minute} value={String(minute)}>
|
||||||
</MenuItem>
|
{pad2(minute)}
|
||||||
))}
|
</MenuItem>
|
||||||
</TextField>
|
))}
|
||||||
</div>
|
</TextField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{error ? <div className={styles.error}>{error}</div> : null}
|
{error ? <div className={styles.error}>{error}</div> : null}
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button onClick={clearRange}>清空</Button>
|
<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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className={[styles.rangeField, active ? styles.rangeFieldActive : ""].filter(Boolean).join(" ")}
|
className={[styles.rangeField, active ? styles.rangeFieldActive : ""].filter(Boolean).join(" ")}
|
||||||
@ -246,7 +258,7 @@ function RangeFieldButton({ active, label, onClick, value }) {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<span className={styles.rangeFieldLabel}>{label}</span>
|
<span className={styles.rangeFieldLabel}>{label}</span>
|
||||||
<span className={styles.rangeFieldValue}>{formatRangePart(value) || "不限"}</span>
|
<span className={styles.rangeFieldValue}>{formatPart(value) || "不限"}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -337,6 +349,15 @@ function formatRangePart(value) {
|
|||||||
return text ? text.replace("-", "/").replace("-", "/") : "";
|
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) {
|
function msToDatetimeText(value) {
|
||||||
const ms = normalizeMs(value);
|
const ms = normalizeMs(value);
|
||||||
if (!ms) {
|
if (!ms) {
|
||||||
|
|||||||
@ -26,6 +26,33 @@ describe("TimeRangeFilter helpers", () => {
|
|||||||
expect(timeRangeLabel("时间", {})).toBe("时间");
|
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 () => {
|
test("confirms picker changes without submitting an outer form", 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