576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
import {
|
|
CalendarClock,
|
|
Check,
|
|
Clock3,
|
|
OctagonAlert,
|
|
PanelBottomClose,
|
|
PanelBottomOpen,
|
|
RotateCcw,
|
|
TriangleAlert
|
|
} from "lucide-react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import { useState } from "react";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
|
import {
|
|
clusterOperationalEvents,
|
|
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
|
filterOperationalEvents,
|
|
getOperationalEventSummary,
|
|
OPERATIONAL_TIMELINE_LAYOUT,
|
|
type OperationalEvent,
|
|
type OperationalEventCategory,
|
|
type OperationalEventCluster,
|
|
type OperationalEventLane,
|
|
type OperationalEventVisibility,
|
|
type OperationalTimelineMode
|
|
} from "./operational-timeline-model";
|
|
|
|
type OperationalTimelineProps = {
|
|
date: string;
|
|
cursorTime: string;
|
|
events: OperationalEvent[];
|
|
mode: OperationalTimelineMode;
|
|
selectedEventId: string | null;
|
|
mobile?: boolean;
|
|
onModeChange?: (mode: OperationalTimelineMode) => void;
|
|
onSelectEvent: (event: OperationalEvent) => void;
|
|
onClearSelection: () => void;
|
|
onReturnNow: () => void;
|
|
};
|
|
|
|
const LANES: Array<{ id: OperationalEventLane; label: string }> = [
|
|
{ id: "plan", label: "计划" },
|
|
{ id: "actual", label: "实际" },
|
|
{ id: "exception", label: "异常" }
|
|
];
|
|
|
|
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
|
|
{ id: "plan", label: "计划" },
|
|
{ id: "actual", label: "实际" },
|
|
{ id: "attention", label: "关注" },
|
|
{ id: "exception", label: "异常" }
|
|
];
|
|
|
|
export function OperationalTimeline({
|
|
date,
|
|
cursorTime,
|
|
events,
|
|
mode,
|
|
selectedEventId,
|
|
mobile = false,
|
|
onModeChange,
|
|
onSelectEvent,
|
|
onClearSelection,
|
|
onReturnNow
|
|
}: OperationalTimelineProps) {
|
|
const prefersReducedMotion = useReducedMotion();
|
|
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
|
|
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
|
|
}));
|
|
const summary = getOperationalEventSummary(events);
|
|
const visibleEvents = filterOperationalEvents(events, eventVisibility);
|
|
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
|
|
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
|
|
const cursorPosition = getDayPosition(cursorTime);
|
|
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
|
|
const sizeTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };
|
|
|
|
if (mode === "summary" && !mobile) {
|
|
return (
|
|
<motion.section
|
|
aria-label="运行时间轴摘要"
|
|
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
|
|
initial={false}
|
|
animate={{ height }}
|
|
transition={sizeTransition}
|
|
>
|
|
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
|
<span className="text-xs font-semibold">{formatDate(date)}</span>
|
|
<span className="h-4 w-px bg-slate-300" />
|
|
<TimelineSummary summary={summary} />
|
|
{selectedEvent ? (
|
|
<button
|
|
type="button"
|
|
onClick={onClearSelection}
|
|
className="min-w-0 truncate text-left text-xs text-slate-600 hover:text-blue-700"
|
|
>
|
|
已选 {formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
|
</button>
|
|
) : (
|
|
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
|
)}
|
|
<TimelineLegend
|
|
visibility={eventVisibility}
|
|
onVisibilityChange={setEventVisibility}
|
|
/>
|
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
|
</motion.section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={180}>
|
|
<motion.section
|
|
aria-label="运行时间轴"
|
|
className={cn(
|
|
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
|
mobile
|
|
? "surface-reading h-full rounded-t-xl border-t"
|
|
: "acrylic-panel rounded-2xl border"
|
|
)}
|
|
initial={false}
|
|
animate={{ height }}
|
|
transition={sizeTransition}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
|
mode === "compact" && !mobile ? "h-10" : "h-12"
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
|
<div className="flex items-center gap-2">
|
|
<p
|
|
className={cn(
|
|
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
|
|
((mode === "compact" && !mobile) || mobile) && "hidden"
|
|
)}
|
|
>
|
|
运行时间轴
|
|
</p>
|
|
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
|
|
</div>
|
|
</div>
|
|
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
|
|
{!mobile ? <TimelineSummary summary={summary} /> : null}
|
|
{selectedEvent && !mobile ? (
|
|
<button
|
|
type="button"
|
|
onClick={onClearSelection}
|
|
title="清除当前事件选择"
|
|
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
|
|
>
|
|
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
|
</button>
|
|
) : null}
|
|
<TimelineLegend
|
|
visibility={eventVisibility}
|
|
onVisibilityChange={setEventVisibility}
|
|
/>
|
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
|
</div>
|
|
|
|
<AnimatePresence initial={false} mode="wait">
|
|
{mode === "expanded" || mobile ? (
|
|
<motion.div
|
|
key="expanded-timeline"
|
|
className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
|
|
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
|
>
|
|
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
|
|
<span />
|
|
{LANES.map((lane) => (
|
|
<span key={lane.id} className="flex items-center justify-center font-semibold">
|
|
{lane.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
|
|
<TimeTicks />
|
|
{LANES.map((lane) => (
|
|
<TimelineLane
|
|
key={lane.id}
|
|
lane={lane.id}
|
|
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
|
|
selectedEventId={selectedEventId}
|
|
onSelectEvent={onSelectEvent}
|
|
/>
|
|
))}
|
|
<div
|
|
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
|
|
style={{ left: `${cursorPosition}%` }}
|
|
>
|
|
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="compact-timeline"
|
|
className="relative min-h-0 flex-1 px-3"
|
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
|
|
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
|
>
|
|
<CompactTimeTicks />
|
|
<div className="absolute bottom-2 left-3 right-3 top-4">
|
|
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
|
|
{clusters.map((cluster, index) => (
|
|
<TimelineMarker
|
|
key={cluster.id}
|
|
cluster={cluster}
|
|
compact
|
|
markerIndex={index}
|
|
selectedEventId={selectedEventId}
|
|
onSelectEvent={onSelectEvent}
|
|
/>
|
|
))}
|
|
<div
|
|
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
|
|
style={{ left: `${cursorPosition}%` }}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.section>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
function TimelineLane({
|
|
lane,
|
|
clusters,
|
|
selectedEventId,
|
|
onSelectEvent
|
|
}: {
|
|
lane: OperationalEventLane;
|
|
clusters: OperationalEventCluster[];
|
|
selectedEventId: string | null;
|
|
onSelectEvent: (event: OperationalEvent) => void;
|
|
}) {
|
|
return (
|
|
<div className="relative border-t border-slate-200" data-lane={lane}>
|
|
{clusters.map((cluster, index) => (
|
|
<TimelineMarker
|
|
key={cluster.id}
|
|
cluster={cluster}
|
|
markerIndex={index}
|
|
selectedEventId={selectedEventId}
|
|
onSelectEvent={onSelectEvent}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineMarker({
|
|
cluster,
|
|
compact = false,
|
|
markerIndex,
|
|
selectedEventId,
|
|
onSelectEvent
|
|
}: {
|
|
cluster: OperationalEventCluster;
|
|
compact?: boolean;
|
|
markerIndex: number;
|
|
selectedEventId: string | null;
|
|
onSelectEvent: (event: OperationalEvent) => void;
|
|
}) {
|
|
const event = pickClusterEvent(cluster.events);
|
|
const selected = cluster.events.some((item) => item.id === selectedEventId);
|
|
const position = getDayPosition(event.cursorTime);
|
|
const exception = event.lane === "exception";
|
|
const verticalPosition = compact ? 56 : [24, 50, 76][markerIndex % 3];
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
|
|
onClick={() => onSelectEvent(event)}
|
|
className={cn(
|
|
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600",
|
|
compact ? "h-2 w-2" : "h-5 w-5",
|
|
event.lane === "plan" &&
|
|
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
|
|
event.lane === "actual" &&
|
|
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
|
|
exception &&
|
|
event.status !== "failed" &&
|
|
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
|
|
event.status === "failed" &&
|
|
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
|
|
event.importance === "critical" && !compact && "h-6 w-6",
|
|
selected && "ring-2 ring-blue-600 ring-offset-2 ring-offset-white"
|
|
)}
|
|
style={{ left: `${position}%`, top: `${verticalPosition}%` }}
|
|
>
|
|
{!compact ? <OperationalEventGlyph event={event} /> : null}
|
|
{cluster.events.length > 1 && !compact ? (
|
|
<span className="absolute -right-2 -top-2 min-w-3.5 rounded-full bg-white px-1 text-[8px] font-bold text-slate-950">
|
|
{cluster.events.length}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="top"
|
|
sideOffset={10}
|
|
className={cn(
|
|
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
|
|
)}
|
|
>
|
|
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
|
|
<div className="flex items-start gap-2.5">
|
|
<span
|
|
className={cn(
|
|
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
|
|
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
|
|
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
event.lane === "exception" &&
|
|
event.status !== "failed" &&
|
|
"border-amber-200 bg-amber-50 text-amber-800",
|
|
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
|
|
)}
|
|
>
|
|
<OperationalEventGlyph event={event} />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span
|
|
className={cn(
|
|
"text-[10px] font-bold tracking-[0.08em]",
|
|
event.lane === "plan" && "text-blue-700",
|
|
event.lane === "actual" && "text-emerald-700",
|
|
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
|
|
event.status === "failed" && "text-rose-700"
|
|
)}
|
|
>
|
|
{operationalEventNatureLabel(event)}
|
|
</span>
|
|
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
|
|
{formatClock(event.cursorTime)}
|
|
</time>
|
|
</div>
|
|
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
|
|
</div>
|
|
</div>
|
|
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
|
|
{event.title}
|
|
</p>
|
|
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
|
|
{event.description}
|
|
</p>
|
|
{cluster.events.length > 1 ? (
|
|
<p className="mt-2 text-[10px] text-slate-500">同一时间窗 {cluster.events.length} 项</p>
|
|
) : null}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
|
|
if (event.status === "failed") {
|
|
return <OctagonAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
|
}
|
|
if (event.lane === "exception") {
|
|
return <TriangleAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
|
}
|
|
if (event.lane === "actual") {
|
|
return <Check size={12} strokeWidth={2.8} aria-hidden="true" />;
|
|
}
|
|
return <CalendarClock size={11} strokeWidth={2.2} aria-hidden="true" />;
|
|
}
|
|
|
|
function operationalEventNatureLabel(event: OperationalEvent) {
|
|
if (event.status === "failed") return "异常";
|
|
if (event.lane === "exception") return "关注";
|
|
if (event.lane === "actual") return "实际执行";
|
|
return "计划任务";
|
|
}
|
|
|
|
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
|
|
<span>
|
|
计划 <strong className="text-blue-700">{summary.planned}</strong>
|
|
</span>
|
|
<span>
|
|
执行 <strong className="text-emerald-700">{summary.active}</strong>
|
|
</span>
|
|
<span>
|
|
关注 <strong className="text-amber-700">{summary.attention}</strong>
|
|
</span>
|
|
<span>
|
|
异常 <strong className="text-rose-700">{summary.exceptions}</strong>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineActions({
|
|
mode,
|
|
onModeChange,
|
|
onReturnNow
|
|
}: {
|
|
mode: OperationalTimelineMode;
|
|
onModeChange?: (mode: OperationalTimelineMode) => void;
|
|
onReturnNow: () => void;
|
|
}) {
|
|
return (
|
|
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
|
|
<button
|
|
type="button"
|
|
title="返回现在"
|
|
aria-label="返回现在"
|
|
onClick={onReturnNow}
|
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
|
>
|
|
<RotateCcw size={13} aria-hidden="true" />
|
|
<span className="hidden sm:inline">现在</span>
|
|
</button>
|
|
{onModeChange ? (
|
|
<>
|
|
<span className="mx-0.5 h-4 w-px bg-slate-300/80" aria-hidden="true" />
|
|
<button
|
|
type="button"
|
|
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
|
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
|
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
|
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
|
>
|
|
{mode === "expanded" ? (
|
|
<PanelBottomClose size={14} aria-hidden="true" />
|
|
) : (
|
|
<PanelBottomOpen size={14} aria-hidden="true" />
|
|
)}
|
|
<span>{mode === "expanded" ? "收起" : "展开"}</span>
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineLegend({
|
|
visibility,
|
|
onVisibilityChange
|
|
}: {
|
|
visibility: OperationalEventVisibility;
|
|
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
aria-label="时间轴图例"
|
|
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
|
|
>
|
|
{LEGEND_ITEMS.map((item) => {
|
|
const visible = visibility[item.id];
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
|
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
|
aria-pressed={visible}
|
|
onClick={() =>
|
|
onVisibilityChange({
|
|
...visibility,
|
|
[item.id]: !visible
|
|
})
|
|
}
|
|
className={cn(
|
|
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
|
|
visible ? "opacity-100" : "grayscale opacity-25"
|
|
)}
|
|
>
|
|
<LegendSwatch category={item.id} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"block h-3.5 w-3.5 shrink-0 border",
|
|
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
|
|
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
|
|
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
|
|
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function TimeTicks() {
|
|
return (
|
|
<div className="relative text-[9px] text-slate-600">
|
|
{[0, 6, 12, 18, 24].map((hour) => (
|
|
<span
|
|
key={hour}
|
|
className={cn(
|
|
"absolute",
|
|
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
|
)}
|
|
style={{ left: `${(hour / 24) * 100}%` }}
|
|
>
|
|
{String(hour).padStart(2, "0")}:00
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompactTimeTicks() {
|
|
return (
|
|
<div className="absolute inset-x-3 top-0 text-[8px] text-slate-500">
|
|
{[0, 6, 12, 18, 24].map((hour) => (
|
|
<span
|
|
key={hour}
|
|
className={cn(
|
|
"absolute",
|
|
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
|
)}
|
|
style={{ left: `${(hour / 24) * 100}%` }}
|
|
>
|
|
{String(hour).padStart(2, "0")}:00
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function pickClusterEvent(events: OperationalEvent[]) {
|
|
return [...events].sort(
|
|
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
|
|
)[0];
|
|
}
|
|
|
|
function importanceRank(value: OperationalEvent["importance"]) {
|
|
return value === "critical" ? 2 : value === "important" ? 1 : 0;
|
|
}
|
|
|
|
function getDayPosition(value: string) {
|
|
const date = new Date(value);
|
|
const minutes = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
|
|
return Math.min(100, Math.max(0, (minutes / (24 * 60)) * 100));
|
|
}
|
|
|
|
function formatClock(value: string) {
|
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function formatDate(value: string) {
|
|
const [year, month, day] = value.split("-");
|
|
return `${year}.${month}.${day}`;
|
|
}
|