feat: refresh workbench visual layout
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
|
||||
export type MobileWorkbenchSheetSnap = "peek" | "half" | "full";
|
||||
|
||||
export function getMobileWorkbenchSheetHeight(
|
||||
snap: MobileWorkbenchSheetSnap,
|
||||
viewportHeight: number
|
||||
) {
|
||||
if (snap === "peek") {
|
||||
return WORKBENCH_LAYOUT.mobileSheet.peekHeight;
|
||||
}
|
||||
|
||||
if (snap === "half") {
|
||||
return Math.round(viewportHeight * WORKBENCH_LAYOUT.mobileSheet.halfViewportRatio);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
WORKBENCH_LAYOUT.mobileSheet.peekHeight,
|
||||
viewportHeight - WORKBENCH_LAYOUT.mobileSheet.fullTopGap
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMobileWorkbenchSheetHeight } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
describe("mobile workbench sheet heights", () => {
|
||||
it("keeps the three deterministic snap heights", () => {
|
||||
expect(getMobileWorkbenchSheetHeight("peek", 844)).toBe(96);
|
||||
expect(getMobileWorkbenchSheetHeight("half", 844)).toBe(439);
|
||||
expect(getMobileWorkbenchSheetHeight("full", 844)).toBe(776);
|
||||
});
|
||||
|
||||
it("always leaves the configured map strip visible at full height", () => {
|
||||
const viewportHeight = 667;
|
||||
const sheetHeight = getMobileWorkbenchSheetHeight("full", viewportHeight);
|
||||
|
||||
expect(viewportHeight - sheetHeight).toBe(68);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
getMobileWorkbenchSheetHeight,
|
||||
type MobileWorkbenchSheetSnap
|
||||
} from "./mobile-workbench-sheet-layout";
|
||||
|
||||
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
|
||||
|
||||
type MobileWorkbenchSheetProps = {
|
||||
label: string;
|
||||
snap: MobileWorkbenchSheetSnap;
|
||||
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
closing?: boolean;
|
||||
onCloseComplete?: () => void;
|
||||
};
|
||||
|
||||
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["peek", "half", "full"];
|
||||
|
||||
export function MobileWorkbenchSheet({
|
||||
label,
|
||||
snap,
|
||||
onSnapChange,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
closing = false,
|
||||
onCloseComplete
|
||||
}: MobileWorkbenchSheetProps) {
|
||||
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(
|
||||
typeof window === "undefined" ? 844 : window.innerHeight
|
||||
);
|
||||
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||
pointerStartRef.current = {
|
||||
y: event.clientY,
|
||||
height: restingHeight
|
||||
};
|
||||
draggedRef.current = false;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
|
||||
const start = pointerStartRef.current;
|
||||
if (!start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = start.y - event.clientY;
|
||||
if (Math.abs(delta) > 4) {
|
||||
draggedRef.current = true;
|
||||
}
|
||||
|
||||
const minHeight = getMobileWorkbenchSheetHeight("peek", window.innerHeight);
|
||||
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
|
||||
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
|
||||
if (!pointerStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalHeight = dragHeight ?? pointerStartRef.current.height;
|
||||
pointerStartRef.current = null;
|
||||
setDragHeight(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (!draggedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (draggedRef.current) {
|
||||
draggedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = SNAP_ORDER.indexOf(snap);
|
||||
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onSnapChange(getAdjacentSnap(snap, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
onSnapChange("peek");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
onSnapChange("full");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
data-snap={snap}
|
||||
className={cn(
|
||||
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
|
||||
closing && "pointer-events-none",
|
||||
className
|
||||
)}
|
||||
style={{ height: closing ? 0 : (dragHeight ?? restingHeight) }}
|
||||
onTransitionEnd={(event) => {
|
||||
if (closing && event.propertyName === "height" && event.target === event.currentTarget) {
|
||||
onCloseComplete?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-7 shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
||||
className="group flex h-7 flex-1 touch-none items-center justify-center"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${label}`}
|
||||
className="absolute right-2 grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getAdjacentSnap(
|
||||
current: MobileWorkbenchSheetSnap,
|
||||
direction: -1 | 1
|
||||
): MobileWorkbenchSheetSnap {
|
||||
const currentIndex = SNAP_ORDER.indexOf(current);
|
||||
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
|
||||
return SNAP_ORDER[nextIndex] ?? current;
|
||||
}
|
||||
|
||||
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
|
||||
return SNAP_ORDER.reduce((closest, candidate) => {
|
||||
const candidateDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
|
||||
);
|
||||
const closestDistance = Math.abs(
|
||||
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
|
||||
);
|
||||
return candidateDistance < closestDistance ? candidate : closest;
|
||||
}, "peek");
|
||||
}
|
||||
|
||||
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
|
||||
if (snap === "peek") {
|
||||
return "预览档";
|
||||
}
|
||||
|
||||
if (snap === "half") {
|
||||
return "半屏档";
|
||||
}
|
||||
|
||||
return "全屏档";
|
||||
}
|
||||
@@ -36,10 +36,18 @@ import {
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME =
|
||||
"surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||
"surface-control rounded-xl border border-slate-200/70";
|
||||
|
||||
export type ScheduledConditionFeedPresentation =
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
presentation?: ScheduledConditionFeedPresentation;
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
@@ -60,6 +68,7 @@ type ScheduledConditionFeedFocusRequest = {
|
||||
};
|
||||
|
||||
export function ScheduledConditionFeed({
|
||||
presentation = "desktop-dock",
|
||||
conditions = [],
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
@@ -74,9 +83,14 @@ export function ScheduledConditionFeed({
|
||||
onSubmitPrompt
|
||||
}: ScheduledConditionFeedProps) {
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const mobileSheet = presentation === "mobile-sheet";
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
||||
@@ -89,10 +103,16 @@ export function ScheduledConditionFeed({
|
||||
);
|
||||
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
||||
const taskFilteredConditions = useMemo(
|
||||
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
||||
() =>
|
||||
conditions.filter(
|
||||
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
|
||||
),
|
||||
[conditions, taskFilter]
|
||||
);
|
||||
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
||||
const statusFilterOptions = useMemo(
|
||||
() => createStatusFilterOptions(taskFilteredConditions),
|
||||
[taskFilteredConditions]
|
||||
);
|
||||
const taskFilteredConditionCount = taskFilteredConditions.length;
|
||||
const filteredConditions = useMemo(
|
||||
() =>
|
||||
@@ -101,7 +121,10 @@ export function ScheduledConditionFeed({
|
||||
),
|
||||
[statusFilter, taskFilteredConditions]
|
||||
);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
||||
const { recentConditions, futureWorkOrders } = useMemo(
|
||||
() => groupScheduledConditions(filteredConditions),
|
||||
[filteredConditions]
|
||||
);
|
||||
const timelineConditions = useMemo(
|
||||
() => [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions]
|
||||
@@ -170,7 +193,9 @@ export function ScheduledConditionFeed({
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
||||
const targetCondition = allTimelineConditions.find(
|
||||
(condition) => condition.id === focusConditionId
|
||||
);
|
||||
if (!targetCondition) {
|
||||
return;
|
||||
}
|
||||
@@ -180,11 +205,20 @@ export function ScheduledConditionFeed({
|
||||
updateSelectedConditionId(targetCondition.id);
|
||||
updateExpanded(true);
|
||||
onFocusRequestHandled?.(focusRequestId);
|
||||
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
||||
}, [
|
||||
allTimelineConditions,
|
||||
focusConditionId,
|
||||
focusRequestId,
|
||||
onFocusRequestHandled,
|
||||
updateExpanded,
|
||||
updateSelectedConditionId
|
||||
]);
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
||||
updateSelectedConditionId(
|
||||
selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function handleSelectTimelineCondition(conditionId: string) {
|
||||
@@ -226,7 +260,8 @@ export function ScheduledConditionFeed({
|
||||
id: "scheduled-condition-generated-session",
|
||||
tone: "error",
|
||||
title: "工况对话发起失败",
|
||||
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
message:
|
||||
submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -240,15 +275,34 @@ export function ScheduledConditionFeed({
|
||||
aria-label="工况任务"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl"
|
||||
mobileSheet
|
||||
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||
: cn(
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? "max-h-[calc(100dvh-8rem)] rounded-2xl"
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
: "w-[var(--workbench-condition-width)]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2.5 py-2",
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700",
|
||||
"rounded-lg"
|
||||
)}
|
||||
>
|
||||
<CalendarClock size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -276,13 +330,23 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
|
||||
mobileSheet
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
|
||||
mobileSheet
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter"
|
||||
: "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
@@ -295,9 +359,15 @@ export function ScheduledConditionFeed({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
expanded={expanded}
|
||||
presentation={presentation}
|
||||
loading={loading}
|
||||
recentConditions={recentConditions}
|
||||
futureWorkOrders={futureWorkOrders}
|
||||
@@ -305,7 +375,7 @@ export function ScheduledConditionFeed({
|
||||
statusFilter={statusFilter}
|
||||
taskFilterOptions={taskFilterOptions}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
||||
selectedConditionId={expanded ? (selectedCondition?.id ?? null) : null}
|
||||
totalConditionCount={totalConditionCount}
|
||||
taskFilteredConditionCount={taskFilteredConditionCount}
|
||||
filteredConditionCount={filteredConditionCount}
|
||||
@@ -325,6 +395,7 @@ export function ScheduledConditionFeed({
|
||||
|
||||
type ConditionTimelinePanelProps = {
|
||||
expanded: boolean;
|
||||
presentation: ScheduledConditionFeedPresentation;
|
||||
loading: boolean;
|
||||
recentConditions: ScheduledConditionItem[];
|
||||
futureWorkOrders: ScheduledConditionItem[];
|
||||
@@ -344,6 +415,7 @@ type ConditionTimelinePanelProps = {
|
||||
|
||||
function ConditionTimelinePanel({
|
||||
expanded,
|
||||
presentation,
|
||||
loading,
|
||||
recentConditions,
|
||||
futureWorkOrders,
|
||||
@@ -365,9 +437,14 @@ function ConditionTimelinePanel({
|
||||
return (
|
||||
<section
|
||||
aria-busy={loading}
|
||||
data-testid="scheduled-condition-timeline"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-[calc(100dvh-8.75rem)] max-h-[calc(100dvh-8.75rem)]"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
@@ -387,7 +464,12 @@ function ConditionTimelinePanel({
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<ConditionGroupHeader
|
||||
icon={ClipboardList}
|
||||
title="预约任务"
|
||||
count={futureWorkOrders.length}
|
||||
compact
|
||||
/>
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionRows
|
||||
conditions={futureWorkOrders}
|
||||
@@ -397,7 +479,11 @@ function ConditionTimelinePanel({
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
|
||||
<ConditionGroupHeader
|
||||
icon={History}
|
||||
title="最近工况"
|
||||
count={loading ? 0 : recentConditions.length}
|
||||
/>
|
||||
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
|
||||
{loading ? (
|
||||
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
|
||||
@@ -410,7 +496,11 @@ function ConditionTimelinePanel({
|
||||
) : (
|
||||
<ConditionEmptyState
|
||||
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
||||
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
||||
description={
|
||||
filterActive
|
||||
? "调整任务或状态筛选后查看其他工况记录。"
|
||||
: "新的工况任务完成后会出现在这里。"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -446,7 +536,7 @@ function ConditionFilterBar({
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||
@@ -519,10 +609,15 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
|
||||
<span
|
||||
className="block truncate font-semibold text-slate-800"
|
||||
title={selectedOption?.label}
|
||||
>
|
||||
{selectedOption?.label ?? "全部"}
|
||||
</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} 条</span>
|
||||
<span className="block text-xs leading-3 text-slate-400">
|
||||
{selectedOption?.count ?? 0} 条
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
@@ -536,7 +631,10 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
sideOffset={6}
|
||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => onValueChange(nextValue as TValue)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
@@ -582,7 +680,10 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{index < rows - 1 ? (
|
||||
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||
</span>
|
||||
@@ -626,7 +727,10 @@ function ConditionDetailSkeleton() {
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
|
||||
<div
|
||||
key={index}
|
||||
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3"
|
||||
>
|
||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||
@@ -653,9 +757,19 @@ type ConditionGroupHeaderProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
|
||||
function ConditionGroupHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
count,
|
||||
compact = false
|
||||
}: ConditionGroupHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500",
|
||||
compact ? "mb-1" : "mb-2"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
<span>{title}</span>
|
||||
@@ -694,7 +808,12 @@ type ConditionTimelineRowProps = {
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
||||
function ConditionTimelineRow({
|
||||
condition,
|
||||
selected,
|
||||
isLast,
|
||||
onSelect
|
||||
}: ConditionTimelineRowProps) {
|
||||
const StatusIcon = getStatusIcon(condition.status);
|
||||
const isWorkOrder = condition.kind === "work_order";
|
||||
|
||||
@@ -705,13 +824,15 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
||||
selected
|
||||
? "surface-reading border-blue-100 text-blue-900"
|
||||
: "border-transparent hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
<span
|
||||
className="relative flex min-h-12 justify-center pt-1.5"
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">
|
||||
{formatTime(condition.scheduledAt)}
|
||||
</span>
|
||||
<span className="relative flex min-h-12 justify-center pt-1.5">
|
||||
{!isLast ? (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
|
||||
@@ -728,21 +849,27 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
: getStatusIconClassName(condition.status)
|
||||
)}
|
||||
>
|
||||
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
||||
<StatusIcon
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className={condition.status === "running" ? "animate-spin" : undefined}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 px-2 py-1.5 transition-colors",
|
||||
selected
|
||||
? "text-blue-900"
|
||||
: "text-slate-700"
|
||||
selected ? "text-blue-900" : "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
|
||||
<span className="block truncate text-xs font-semibold leading-4">
|
||||
{condition.title}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">
|
||||
{condition.summary}
|
||||
</span>
|
||||
</span>
|
||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import {
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { ComponentProps } from "react";
|
||||
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||
import { useEffect, type ComponentProps } from "react";
|
||||
import {
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchViewportLayout
|
||||
} from "../layout/workbench-layout";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
@@ -14,105 +13,73 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
mobileOpen: boolean;
|
||||
mobilePanelCollapsing: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
viewportWidth: number;
|
||||
onCollapsePanel: () => void;
|
||||
onCloseMobilePanel: () => void;
|
||||
onExpandPanel: () => void;
|
||||
onOpenMobilePanel: () => void;
|
||||
onWidthCommit?: (width: number) => void;
|
||||
};
|
||||
|
||||
export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
mobileOpen,
|
||||
mobilePanelCollapsing,
|
||||
personaState,
|
||||
statusLabel,
|
||||
viewportWidth,
|
||||
onCollapsePanel,
|
||||
onCloseMobilePanel,
|
||||
onExpandPanel,
|
||||
onOpenMobilePanel
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const resize = useAgentPanelResize(viewportWidth);
|
||||
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
const committedWidth = resize.committedWidth ?? defaultWidth;
|
||||
|
||||
useEffect(() => {
|
||||
onWidthCommit?.(committedWidth);
|
||||
}, [committedWidth, onWidthCommit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={resize.width === null ? undefined : { width: resize.width }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
||||
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||
)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="拖拽调整 Agent 面板宽度"
|
||||
onKeyDown={resize.handleKeyDown}
|
||||
onPointerDown={resize.handlePointerDown}
|
||||
>
|
||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[620px] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={resize.width === null ? undefined : { width: resize.width }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-floating"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileOpen ? (
|
||||
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
collapsing={mobilePanelCollapsing}
|
||||
onCollapse={onCloseMobilePanel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={onOpenMobilePanel}
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={getAgentPanelMaxWidth(viewportWidth)}
|
||||
aria-valuemin={defaultWidth}
|
||||
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
|
||||
className={cn(
|
||||
"grid h-12 w-12 place-items-center text-violet-700",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
||||
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||
)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="拖拽调整 Agent 面板宽度"
|
||||
onKeyDown={resize.handleKeyDown}
|
||||
onPointerDown={resize.handlePointerDown}
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-10 w-10" state={personaState} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
FlaskConical,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
@@ -77,7 +75,8 @@ export function WorkbenchTopBar({
|
||||
onExportConfig
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const activeScenario =
|
||||
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||
setOpenMenu(open ? menuId : null);
|
||||
};
|
||||
@@ -85,11 +84,18 @@ export function WorkbenchTopBar({
|
||||
return (
|
||||
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<Droplets size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">供水管网智能调度系统</h1>
|
||||
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950">
|
||||
供水管网智能调度系统
|
||||
</h1>
|
||||
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,18 +139,31 @@ export function WorkbenchTopBar({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{devPanelEnabled ? <div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
onClick={onToggleDevPanel}
|
||||
className={cn("group px-2 active:scale-95", headerControlButtonClassName, devPanelOpen && "bg-blue-50/80 text-blue-700")}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, devPanelOpen && "bg-blue-600/10 text-blue-700")}><FlaskConical size={14} aria-hidden="true" /></span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div> : null}
|
||||
{devPanelEnabled ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换地图 Dev Panel"
|
||||
aria-pressed={devPanelOpen}
|
||||
onClick={onToggleDevPanel}
|
||||
className={cn(
|
||||
"group px-2 active:scale-95",
|
||||
headerControlButtonClassName,
|
||||
devPanelOpen && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
devPanelOpen && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<FlaskConical size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>Dev</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lg:hidden">
|
||||
<AlertMenu
|
||||
compact
|
||||
@@ -199,12 +218,7 @@ function TaskTickerToggle({
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>任务浮条</span>
|
||||
@@ -212,17 +226,13 @@ function TaskTickerToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionFeedToggle({
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="工况任务"
|
||||
aria-pressed={visible}
|
||||
title="工况任务"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group px-2",
|
||||
@@ -230,12 +240,7 @@ function ConditionFeedToggle({
|
||||
visible && "bg-blue-50/80 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
visible && "bg-blue-600/10 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<span className={cn(headerControlIconClassName, visible && "bg-blue-600/10 text-blue-700")}>
|
||||
<CalendarClock size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>工况任务</span>
|
||||
@@ -243,12 +248,22 @@ function ConditionFeedToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
||||
function HeaderReadout({
|
||||
icon: Icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
||||
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
||||
<span className="hidden text-xs font-medium text-slate-500 2xl:inline">{label}</span>
|
||||
<span className="max-w-16 truncate text-sm font-semibold text-slate-800 2xl:max-w-28">{value}</span>
|
||||
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user