feat: refresh workbench visual layout

This commit is contained in:
2026-07-27 11:50:29 +08:00
parent b9bc9c3d39
commit f0485ca86e
33 changed files with 1624 additions and 353 deletions
@@ -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>