diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..90164a1 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "admin-platform", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 7001 + } + ] +} diff --git a/databi/src/main.jsx b/databi/src/main.jsx index 3d0a5fd..d0a418f 100644 --- a/databi/src/main.jsx +++ b/databi/src/main.jsx @@ -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( diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 88ee116..303acb0 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -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 }) { ))} {filters.preset === "custom" ? ( - - updateFilter("customStart", event.target.value)} - type="date" - value={filters.customStart} - /> - - updateFilter("customEnd", event.target.value)} - type="date" - value={filters.customEnd} - /> - + { + updateFilter("customStart", msToDayText(range.startMs)); + updateFilter("customEnd", msToDayText(range.endMs)); + }} + /> ) : null} ); } +// 自定义区间在筛选状态里以天字符串(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 (
diff --git a/databi/src/styles/social-v2.css b/databi/src/styles/social-v2.css index c816211..d6b13f5 100644 --- a/databi/src/styles/social-v2.css +++ b/databi/src/styles/social-v2.css @@ -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 { diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index f03744c..76cdfd9 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -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} >
-
- {quickRanges.map(([quickLabel, durationMs]) => ( - - ))} -
+ {withTime ? ( +
+ {quickRanges.map(([quickLabel, durationMs]) => ( + + ))} +
+ ) : null}
activateField("start")} /> activateField("end")} /> @@ -197,34 +207,36 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时 ); })}
-
- changeTime("hour", event.target.value)} - > - {Array.from({ length: 24 }, (_, hour) => ( - - {pad2(hour)} - - ))} - - changeTime("minute", event.target.value)} - > - {Array.from({ length: 60 }, (_, minute) => ( - - {pad2(minute)} - - ))} - -
+ {withTime ? ( +
+ changeTime("hour", event.target.value)} + > + {Array.from({ length: 24 }, (_, hour) => ( + + {pad2(hour)} + + ))} + + changeTime("minute", event.target.value)} + > + {Array.from({ length: 60 }, (_, minute) => ( + + {pad2(minute)} + + ))} + +
+ ) : null} {error ?
{error}
: null}
@@ -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 ( ); } @@ -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) { diff --git a/src/shared/ui/TimeRangeFilter.test.jsx b/src/shared/ui/TimeRangeFilter.test.jsx index 9d30370..27eab40 100644 --- a/src/shared/ui/TimeRangeFilter.test.jsx +++ b/src/shared/ui/TimeRangeFilter.test.jsx @@ -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(); + + 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();