755 lines
29 KiB
TypeScript
755 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
CalendarClock,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
ClipboardList,
|
|
History,
|
|
XCircle
|
|
} from "lucide-react";
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
|
} from "@/features/map/core/components/map-control-styles";
|
|
import { showMapNotice } from "@/features/map/core/components/notice";
|
|
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
|
|
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
|
import type { ScheduledConditionItem } from "@/features/workbench/types";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuTrigger
|
|
} from "@/shared/ui/dropdown-menu";
|
|
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
|
import {
|
|
CONDITION_FILTER_ALL,
|
|
createStatusFilterOptions,
|
|
createTaskFilterOptions,
|
|
formatTime,
|
|
getConditionTime,
|
|
getStatusIcon,
|
|
getStatusIconClassName,
|
|
groupScheduledConditions,
|
|
type ConditionStatusFilterOption,
|
|
type ConditionStatusFilterValue,
|
|
type ConditionTaskFilterOption,
|
|
type ConditionTaskFilterValue
|
|
} from "./scheduled-condition-feed-utils";
|
|
|
|
type ScheduledConditionFeedProps = {
|
|
conditions?: ScheduledConditionItem[];
|
|
expanded?: boolean;
|
|
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
|
loading?: boolean;
|
|
selectedConditionId?: string | null;
|
|
onExpandedChange?: (expanded: boolean) => void;
|
|
onFocusRequestHandled?: (requestId: number) => void;
|
|
onSelectedConditionChange?: (conditionId: string | null) => void;
|
|
onExpandAgent: () => void;
|
|
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
|
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
|
};
|
|
|
|
type ScheduledConditionFeedFocusRequest = {
|
|
conditionId: string;
|
|
requestId: number;
|
|
};
|
|
|
|
export function ScheduledConditionFeed({
|
|
conditions = [],
|
|
expanded: controlledExpanded,
|
|
focusRequest,
|
|
loading = false,
|
|
selectedConditionId: controlledSelectedConditionId,
|
|
onExpandedChange,
|
|
onFocusRequestHandled,
|
|
onSelectedConditionChange,
|
|
onExpandAgent,
|
|
onLoadHistorySession,
|
|
onSubmitPrompt
|
|
}: ScheduledConditionFeedProps) {
|
|
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
|
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
|
|
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
|
|
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
|
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
|
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
|
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
|
|
() => groupScheduledConditions(conditions),
|
|
[conditions]
|
|
);
|
|
const allTimelineConditions = useMemo(
|
|
() => [...allRecentConditions, ...allFutureWorkOrders],
|
|
[allFutureWorkOrders, allRecentConditions]
|
|
);
|
|
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
|
|
const taskFilteredConditions = useMemo(
|
|
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
|
|
[conditions, taskFilter]
|
|
);
|
|
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
|
|
const taskFilteredConditionCount = taskFilteredConditions.length;
|
|
const filteredConditions = useMemo(
|
|
() =>
|
|
taskFilteredConditions.filter(
|
|
(condition) => statusFilter === CONDITION_FILTER_ALL || condition.status === statusFilter
|
|
),
|
|
[statusFilter, taskFilteredConditions]
|
|
);
|
|
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
|
|
const timelineConditions = useMemo(
|
|
() => [...recentConditions, ...futureWorkOrders],
|
|
[futureWorkOrders, recentConditions]
|
|
);
|
|
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
|
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
|
|
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
|
|
const headerSummary = loading
|
|
? "正在加载工况任务"
|
|
: expanded && filterActive
|
|
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
|
|
: expanded
|
|
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
|
|
: `最近 ${recentConditions.length} 条`;
|
|
const selectedCondition =
|
|
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
|
recentConditions[0] ??
|
|
futureWorkOrders[0] ??
|
|
null;
|
|
const focusConditionId = focusRequest?.conditionId;
|
|
const focusRequestId = focusRequest?.requestId;
|
|
const updateExpanded = useCallback(
|
|
(nextExpanded: boolean) => {
|
|
if (controlledExpanded === undefined) {
|
|
setUncontrolledExpanded(nextExpanded);
|
|
}
|
|
|
|
onExpandedChange?.(nextExpanded);
|
|
},
|
|
[controlledExpanded, onExpandedChange]
|
|
);
|
|
const updateSelectedConditionId = useCallback(
|
|
(nextConditionId: string | null) => {
|
|
if (controlledSelectedConditionId === undefined) {
|
|
setUncontrolledSelectedConditionId(nextConditionId);
|
|
}
|
|
|
|
onSelectedConditionChange?.(nextConditionId);
|
|
},
|
|
[controlledSelectedConditionId, onSelectedConditionChange]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (taskFilter === CONDITION_FILTER_ALL) {
|
|
return;
|
|
}
|
|
|
|
if (!taskFilterOptions.some((option) => option.value === taskFilter)) {
|
|
setTaskFilter(CONDITION_FILTER_ALL);
|
|
}
|
|
}, [taskFilter, taskFilterOptions]);
|
|
|
|
useEffect(() => {
|
|
if (!expanded) {
|
|
return;
|
|
}
|
|
|
|
const nextSelectedConditionId = selectedCondition?.id ?? null;
|
|
if (selectedConditionId !== nextSelectedConditionId) {
|
|
updateSelectedConditionId(nextSelectedConditionId);
|
|
}
|
|
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
|
|
|
|
useEffect(() => {
|
|
if (!focusConditionId || focusRequestId === undefined) {
|
|
return;
|
|
}
|
|
|
|
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
|
|
if (!targetCondition) {
|
|
return;
|
|
}
|
|
|
|
setTaskFilter(CONDITION_FILTER_ALL);
|
|
setStatusFilter(CONDITION_FILTER_ALL);
|
|
updateSelectedConditionId(targetCondition.id);
|
|
updateExpanded(true);
|
|
onFocusRequestHandled?.(focusRequestId);
|
|
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
|
|
|
|
function handleToggleExpanded() {
|
|
updateExpanded(!expanded);
|
|
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
|
|
}
|
|
|
|
function handleSelectTimelineCondition(conditionId: string) {
|
|
updateSelectedConditionId(conditionId);
|
|
if (!expanded) {
|
|
updateExpanded(true);
|
|
}
|
|
}
|
|
|
|
function handleContinueConversation(condition: ScheduledConditionItem) {
|
|
if (condition.kind === "work_order") {
|
|
return;
|
|
}
|
|
|
|
onExpandAgent();
|
|
|
|
if (!condition.sessionId) {
|
|
showMapNotice({
|
|
id: "scheduled-condition-missing-session",
|
|
tone: "error",
|
|
title: "无法打开历史会话",
|
|
message: "这条工况没有可加载的会话记录。"
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
|
|
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
|
|
.then(() => {
|
|
showMapNotice({
|
|
id: "scheduled-condition-generated-session",
|
|
tone: "success",
|
|
title: "已发起工况对话",
|
|
message: "已将当前工况上下文发送到 Agent 面板。"
|
|
});
|
|
})
|
|
.catch((submitError) => {
|
|
showMapNotice({
|
|
id: "scheduled-condition-generated-session",
|
|
tone: "error",
|
|
title: "工况对话发起失败",
|
|
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
void Promise.resolve(onLoadHistorySession(condition.sessionId));
|
|
}
|
|
|
|
return (
|
|
<section
|
|
aria-label="工况任务"
|
|
className={cn(
|
|
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
|
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
|
"rounded-2xl border",
|
|
expanded ? "glass-panel" : "glass-control"
|
|
)}
|
|
>
|
|
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", expanded ? "bg-white/30" : MAP_READABLE_SURFACE_STRONG_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">
|
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
|
|
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-expanded={expanded}
|
|
onClick={handleToggleExpanded}
|
|
className={cn(
|
|
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
|
|
"rounded-lg"
|
|
)}
|
|
>
|
|
<ChevronDown
|
|
size={17}
|
|
aria-hidden="true"
|
|
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
|
/>
|
|
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
|
</button>
|
|
</header>
|
|
|
|
<div
|
|
className={cn(
|
|
"scheduled-feed-layout mt-3 grid min-h-0",
|
|
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"
|
|
)}
|
|
aria-hidden={!expanded}
|
|
>
|
|
{loading && expanded ? (
|
|
<ConditionDetailSkeleton />
|
|
) : expanded && selectedCondition ? (
|
|
<ConditionDetailPanel
|
|
condition={selectedCondition}
|
|
onContinueConversation={() => handleContinueConversation(selectedCondition)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div className="min-h-0 min-w-0 overflow-hidden">
|
|
<ConditionTimelinePanel
|
|
expanded={expanded}
|
|
loading={loading}
|
|
recentConditions={recentConditions}
|
|
futureWorkOrders={futureWorkOrders}
|
|
taskFilter={taskFilter}
|
|
statusFilter={statusFilter}
|
|
taskFilterOptions={taskFilterOptions}
|
|
statusFilterOptions={statusFilterOptions}
|
|
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
|
|
totalConditionCount={totalConditionCount}
|
|
taskFilteredConditionCount={taskFilteredConditionCount}
|
|
filteredConditionCount={filteredConditionCount}
|
|
onTaskFilterChange={setTaskFilter}
|
|
onStatusFilterChange={setStatusFilter}
|
|
onResetFilters={() => {
|
|
setTaskFilter(CONDITION_FILTER_ALL);
|
|
setStatusFilter(CONDITION_FILTER_ALL);
|
|
}}
|
|
onSelectCondition={handleSelectTimelineCondition}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
type ConditionTimelinePanelProps = {
|
|
expanded: boolean;
|
|
loading: boolean;
|
|
recentConditions: ScheduledConditionItem[];
|
|
futureWorkOrders: ScheduledConditionItem[];
|
|
taskFilter: ConditionTaskFilterValue;
|
|
statusFilter: ConditionStatusFilterValue;
|
|
taskFilterOptions: ConditionTaskFilterOption[];
|
|
statusFilterOptions: ConditionStatusFilterOption[];
|
|
selectedConditionId: string | null;
|
|
totalConditionCount: number;
|
|
taskFilteredConditionCount: number;
|
|
filteredConditionCount: number;
|
|
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
|
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
|
onResetFilters: () => void;
|
|
onSelectCondition: (conditionId: string) => void;
|
|
};
|
|
|
|
function ConditionTimelinePanel({
|
|
expanded,
|
|
loading,
|
|
recentConditions,
|
|
futureWorkOrders,
|
|
taskFilter,
|
|
statusFilter,
|
|
taskFilterOptions,
|
|
statusFilterOptions,
|
|
selectedConditionId,
|
|
totalConditionCount,
|
|
taskFilteredConditionCount,
|
|
filteredConditionCount,
|
|
onTaskFilterChange,
|
|
onStatusFilterChange,
|
|
onResetFilters,
|
|
onSelectCondition
|
|
}: ConditionTimelinePanelProps) {
|
|
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
|
|
|
return (
|
|
<section
|
|
aria-busy={loading}
|
|
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]",
|
|
"rounded-xl",
|
|
expanded
|
|
? "border border-white/30 bg-white/10"
|
|
: MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
|
)}
|
|
>
|
|
{expanded && !loading ? (
|
|
<ConditionFilterBar
|
|
taskFilter={taskFilter}
|
|
statusFilter={statusFilter}
|
|
taskFilterOptions={taskFilterOptions}
|
|
statusFilterOptions={statusFilterOptions}
|
|
filteredConditionCount={filteredConditionCount}
|
|
totalConditionCount={totalConditionCount}
|
|
taskFilteredConditionCount={taskFilteredConditionCount}
|
|
onTaskFilterChange={onTaskFilterChange}
|
|
onStatusFilterChange={onStatusFilterChange}
|
|
onResetFilters={onResetFilters}
|
|
/>
|
|
) : null}
|
|
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
|
<section className="mb-2 rounded-xl border border-blue-100 bg-blue-50/55 p-2">
|
|
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
|
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
|
<ConditionRows
|
|
conditions={futureWorkOrders}
|
|
selectedConditionId={selectedConditionId}
|
|
onSelectCondition={onSelectCondition}
|
|
/>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
<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} />
|
|
) : recentConditions.length > 0 ? (
|
|
<ConditionRows
|
|
conditions={recentConditions}
|
|
selectedConditionId={selectedConditionId}
|
|
onSelectCondition={onSelectCondition}
|
|
/>
|
|
) : (
|
|
<ConditionEmptyState
|
|
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
|
|
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
type ConditionFilterBarProps = {
|
|
taskFilter: ConditionTaskFilterValue;
|
|
statusFilter: ConditionStatusFilterValue;
|
|
taskFilterOptions: ConditionTaskFilterOption[];
|
|
statusFilterOptions: ConditionStatusFilterOption[];
|
|
filteredConditionCount: number;
|
|
totalConditionCount: number;
|
|
taskFilteredConditionCount: number;
|
|
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
|
|
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
|
|
onResetFilters: () => void;
|
|
};
|
|
|
|
function ConditionFilterBar({
|
|
taskFilter,
|
|
statusFilter,
|
|
taskFilterOptions,
|
|
statusFilterOptions,
|
|
filteredConditionCount,
|
|
totalConditionCount,
|
|
taskFilteredConditionCount,
|
|
onTaskFilterChange,
|
|
onStatusFilterChange,
|
|
onResetFilters
|
|
}: ConditionFilterBarProps) {
|
|
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
|
|
|
return (
|
|
<div className="mb-2 rounded-xl border border-slate-200/70 bg-slate-50/95 p-2 ring-1 ring-white/80">
|
|
<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">
|
|
{filteredConditionCount}/{totalConditionCount}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<ConditionFilterDropdown
|
|
label="任务"
|
|
value={taskFilter}
|
|
onValueChange={onTaskFilterChange}
|
|
options={[
|
|
{ value: CONDITION_FILTER_ALL, label: "全部任务", count: totalConditionCount },
|
|
...taskFilterOptions
|
|
]}
|
|
/>
|
|
<ConditionFilterDropdown
|
|
label="状态"
|
|
value={statusFilter}
|
|
onValueChange={(value) => onStatusFilterChange(value as ConditionStatusFilterValue)}
|
|
options={[
|
|
{ value: CONDITION_FILTER_ALL, label: "全部状态", count: taskFilteredConditionCount },
|
|
...statusFilterOptions
|
|
]}
|
|
/>
|
|
</div>
|
|
{filterActive ? (
|
|
<button
|
|
type="button"
|
|
onClick={onResetFilters}
|
|
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border border-blue-100 bg-white px-2 text-xs font-semibold text-blue-700 shadow-sm transition hover:border-blue-200 hover:bg-blue-50"
|
|
>
|
|
<XCircle size={12} aria-hidden="true" />
|
|
重置筛选
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ConditionFilterDropdownProps<TValue extends string> = {
|
|
label: string;
|
|
value: TValue;
|
|
options: {
|
|
value: TValue;
|
|
label: string;
|
|
count: number;
|
|
}[];
|
|
onValueChange: (value: TValue) => void;
|
|
};
|
|
|
|
function ConditionFilterDropdown<TValue extends string>({
|
|
label,
|
|
value,
|
|
options,
|
|
onValueChange
|
|
}: ConditionFilterDropdownProps<TValue>) {
|
|
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
|
|
|
return (
|
|
<div className="min-w-0">
|
|
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"group flex h-9 w-full min-w-0 items-center gap-2 border border-white/80 bg-white px-2 text-left text-xs shadow-sm outline-none transition hover:border-blue-100 hover:bg-blue-50/35 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50/60 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
|
|
"rounded-xl"
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<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>
|
|
<ChevronDown
|
|
size={13}
|
|
aria-hidden="true"
|
|
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600"
|
|
/>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
sideOffset={6}
|
|
className="glass-menu scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border p-1.5 text-slate-700"
|
|
>
|
|
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
|
{options.map((option) => (
|
|
<DropdownMenuRadioItem
|
|
key={option.value}
|
|
value={option.value}
|
|
className={cn(
|
|
"my-0.5 min-h-9 gap-2 border border-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50/80 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/90 data-[state=checked]:text-blue-800",
|
|
"rounded-xl"
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
|
|
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
|
|
{option.count}
|
|
</span>
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
|
|
return (
|
|
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-[var(--glass-readable)] px-4 py-5 text-center">
|
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
|
|
<ChevronRight size={15} aria-hidden="true" />
|
|
</span>
|
|
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
|
|
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
|
return (
|
|
<div className="space-y-1.5" role="status" aria-label="工况任务加载中">
|
|
{Array.from({ length: rows }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
|
|
>
|
|
<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" />
|
|
) : null}
|
|
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
|
</span>
|
|
<span className="min-w-0 rounded-xl border border-transparent bg-[var(--glass-menu)] px-2 py-1.5">
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="min-w-0 flex-1 space-y-1.5">
|
|
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
|
|
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
|
|
</span>
|
|
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
|
|
</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConditionDetailSkeleton() {
|
|
return (
|
|
<DetailScroll>
|
|
<div
|
|
className={cn(
|
|
"relative min-h-[420px] overflow-hidden border border-slate-200/60 bg-white/30 px-4 py-3",
|
|
"rounded-xl"
|
|
)}
|
|
role="status"
|
|
aria-label="工况详情加载中"
|
|
>
|
|
<div className="absolute inset-y-0 left-0 w-1 bg-blue-300" aria-hidden="true" />
|
|
<div className="flex gap-2.5">
|
|
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div className="flex gap-2">
|
|
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
|
|
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
|
|
</div>
|
|
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
|
|
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
|
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-2">
|
|
{Array.from({ length: 5 }).map((_, index) => (
|
|
<div key={index} className="rounded-xl border border-slate-200/70 bg-slate-50/80 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" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</DetailScroll>
|
|
);
|
|
}
|
|
|
|
function DetailScroll({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
type ConditionGroupHeaderProps = {
|
|
icon: typeof History;
|
|
title: string;
|
|
count: number;
|
|
compact?: boolean;
|
|
};
|
|
|
|
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")}>
|
|
<span className="flex items-center gap-1.5">
|
|
<Icon size={13} aria-hidden="true" />
|
|
<span>{title}</span>
|
|
</span>
|
|
<span>{count}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ConditionRowsProps = {
|
|
conditions: ScheduledConditionItem[];
|
|
selectedConditionId: string | null;
|
|
onSelectCondition: (conditionId: string) => void;
|
|
};
|
|
|
|
function ConditionRows({ conditions, selectedConditionId, onSelectCondition }: ConditionRowsProps) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
{conditions.map((condition, index) => (
|
|
<ConditionTimelineRow
|
|
key={condition.id}
|
|
condition={condition}
|
|
selected={selectedConditionId === condition.id}
|
|
isLast={index === conditions.length - 1}
|
|
onSelect={() => onSelectCondition(condition.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ConditionTimelineRowProps = {
|
|
condition: ScheduledConditionItem;
|
|
selected: boolean;
|
|
isLast: boolean;
|
|
onSelect: () => void;
|
|
};
|
|
|
|
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
|
|
const StatusIcon = getStatusIcon(condition.status);
|
|
const isWorkOrder = condition.kind === "work_order";
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-pressed={selected}
|
|
onClick={onSelect}
|
|
className={cn(
|
|
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50/40 focus-visible:ring-2 focus-visible:ring-blue-200/80 focus-visible:ring-offset-1 focus-visible:ring-offset-white",
|
|
selected ? "bg-blue-50/70" : "hover:bg-blue-50/45"
|
|
)}
|
|
>
|
|
<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"
|
|
aria-hidden="true"
|
|
/>
|
|
) : null}
|
|
<span
|
|
className={cn(
|
|
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
|
|
selected
|
|
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
|
|
: isWorkOrder
|
|
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
|
|
: getStatusIconClassName(condition.status)
|
|
)}
|
|
>
|
|
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
|
|
</span>
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"min-w-0 rounded-xl border px-2 py-1.5 transition-colors group-focus-visible:border-blue-300 group-focus-visible:bg-white",
|
|
selected
|
|
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
|
|
: "border-transparent bg-[var(--glass-menu)] text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
|
|
)}
|
|
>
|
|
<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>
|
|
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|