时间选择器
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 { 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(
|
||||
|
||||
@ -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}
|
||||
<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));
|
||||
}}
|
||||
/>
|
||||
<span>—</span>
|
||||
<input
|
||||
aria-label="结束日期"
|
||||
onChange={(event) => updateFilter("customEnd", event.target.value)}
|
||||
type="date"
|
||||
value={filters.customEnd}
|
||||
/>
|
||||
</span>
|
||||
) : 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="趋势粒度">
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,6 +142,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
onClose={closePopover}
|
||||
>
|
||||
<form className={styles.panel} onSubmit={submitDraft}>
|
||||
{withTime ? (
|
||||
<div className={styles.quickRanges}>
|
||||
{quickRanges.map(([quickLabel, durationMs]) => (
|
||||
<Button
|
||||
@ -147,16 +154,19 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
</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,6 +207,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{withTime ? (
|
||||
<div className={styles.timeGrid}>
|
||||
<TextField
|
||||
label="时"
|
||||
@ -225,6 +236,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
))}
|
||||
</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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user