feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
@@ -1,5 +1,6 @@
import {
CalendarClock,
CalendarPlus,
ChevronDown,
ChevronRight,
ClipboardList,
@@ -17,6 +18,8 @@ import type {
WorkbenchWorkOrderDataSource
} from "../types";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type { OperationalTaskDataSnapshot } from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
@@ -26,7 +29,18 @@ import {
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
WORKBENCH_TASK_CATEGORIES,
getWorkbenchTaskCategory,
groupConditionsByTaskCategory,
type WorkbenchTaskCategory
} from "./task-category";
import {
CONDITION_FILTER_ALL,
createStatusFilterOptions,
@@ -56,12 +70,22 @@ export type ScheduledConditionFeedPresentation =
| "desktop-floating"
| "mobile-sheet";
export type ScheduleCreateRequest = {
scheduleKey: string;
title: string;
summary: string;
intervalSeconds: number;
nextRunAt: string;
};
type ScheduledConditionFeedProps = {
presentation?: ScheduledConditionFeedPresentation;
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
loading?: boolean;
backendLoading?: boolean;
backendError?: string | null;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
visible?: boolean;
@@ -73,6 +97,13 @@ type ScheduledConditionFeedProps = {
onClose?: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>;
};
type ScheduledConditionFeedFocusRequest = {
@@ -86,6 +117,8 @@ export function ScheduledConditionFeed({
expanded: controlledExpanded,
focusRequest,
loading = false,
backendLoading = false,
backendError = null,
dataSource = "mock",
workOrderDataSource = "mock",
visible = true,
@@ -96,7 +129,14 @@ export function ScheduledConditionFeed({
onExpandAgent,
onClose,
onLoadHistorySession,
onSubmitPrompt
onSubmitPrompt,
onOpenTaskCollaboration,
onTaskDecision,
onTaskExecute,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh,
onCreateSchedule
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
@@ -105,27 +145,46 @@ export function ScheduledConditionFeed({
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("scheduled");
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleTitle, setScheduleTitle] = useState("");
const [scheduleKey, setScheduleKey] = useState("scada-diagnosis");
const [scheduleSummary, setScheduleSummary] = useState("");
const [scheduleIntervalMinutes, setScheduleIntervalMinutes] = useState("15");
const [scheduleIntervalPreset, setScheduleIntervalPreset] = useState("15");
const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000)));
const [scheduleSubmitting, setScheduleSubmitting] = useState(false);
const [scheduleError, setScheduleError] = useState<string | null>(null);
const mobileSheet = presentation === "mobile-sheet";
const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating";
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(conditions),
const conditionsByCategory = useMemo(
() => groupConditionsByTaskCategory(conditions),
[conditions]
);
const categoryConditions = conditionsByCategory[taskCategory];
const activeLoading = taskCategory === "scheduled" ? loading : backendLoading;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(categoryConditions),
[categoryConditions]
);
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilterOptions = useMemo(
() => createTaskFilterOptions(categoryConditions),
[categoryConditions]
);
const taskFilteredConditions = useMemo(
() =>
conditions.filter(
categoryConditions.filter(
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
),
[conditions, taskFilter]
[categoryConditions, taskFilter]
);
const statusFilterOptions = useMemo(
() => createStatusFilterOptions(taskFilteredConditions),
@@ -150,24 +209,9 @@ export function ScheduledConditionFeed({
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const conditionSourceLabel = {
mock: "工况演示数据",
backend: "后端当天数据",
scheduled_tasks: "后端定时任务"
}[dataSource];
const workOrderSourceLabel = {
mock: "工单演示回退",
backend: "后端工单",
none: "未接入工单"
}[workOrderDataSource];
const sourceLabel = `${conditionSourceLabel} · ${workOrderSourceLabel}`;
const headerSummary = loading
const headerSummary = loading && backendLoading
? "正在加载工况任务"
: expanded && filterActive
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条 · ${sourceLabel}`
: expanded
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`;
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
@@ -222,20 +266,24 @@ export function ScheduledConditionFeed({
return;
}
const targetCondition = allTimelineConditions.find(
const targetCondition = conditions.find(
(condition) => condition.id === focusConditionId
);
if (!targetCondition) {
return;
}
const targetCategory = getWorkbenchTaskCategory(targetCondition);
if (!targetCategory) return;
setTaskCategory(targetCategory);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [
allTimelineConditions,
conditions,
focusConditionId,
focusRequestId,
onFocusRequestHandled,
@@ -250,6 +298,42 @@ export function ScheduledConditionFeed({
);
}
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
setTaskCategory(nextCategory);
if (nextCategory !== "scheduled") setScheduleOpen(false);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(conditionsByCategory[nextCategory][0]?.id ?? null);
}
function handleToggleSchedule() {
if (!scheduleOpen) {
setTaskCategory("scheduled");
updateExpanded(true);
}
setScheduleOpen((current) => !current);
}
async function handleCreateSchedule() {
const title = scheduleTitle.trim();
const summary = scheduleSummary.trim();
const key = scheduleKey.trim();
const intervalMinutes = Number(scheduleIntervalMinutes);
const nextRunAt = new Date(scheduleNextRunAt);
if (!title || !summary || !key) { setScheduleError("请填写名称、流程和任务说明。"); return; }
if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { setScheduleError("执行周期不能少于 1 分钟。"); return; }
if (Number.isNaN(nextRunAt.getTime())) { setScheduleError("请选择有效的下次执行时间。"); return; }
if (!onCreateSchedule) { setScheduleError("当前未连接定时流程配置接口。"); return; }
setScheduleSubmitting(true); setScheduleError(null);
try {
await onCreateSchedule({ scheduleKey: key, title, summary, intervalSeconds: intervalMinutes * 60, nextRunAt: nextRunAt.toISOString() });
setScheduleOpen(false); setScheduleTitle(""); setScheduleSummary("");
showMapNotice({ tone: "success", title: "定时任务已添加", message: "仅保存在当前演示工作台,不会提交后端调度。" });
} catch (error) {
setScheduleError(error instanceof Error ? error.message : "定时流程创建失败。");
} finally { setScheduleSubmitting(false); }
}
function handleSelectTimelineCondition(conditionId: string) {
updateSelectedConditionId(conditionId);
if (!expanded) {
@@ -262,6 +346,18 @@ export function ScheduledConditionFeed({
return;
}
if (condition.operationalTask) {
onExpandAgent();
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id))
.catch((error) => showMapNotice({
id: "operational-task-collaboration",
tone: "error",
title: "Agent 协作打开失败",
message: error instanceof Error ? error.message : "无法打开这条任务的协作问答。"
}));
return;
}
onExpandAgent();
if (!condition.sessionId) {
@@ -346,6 +442,17 @@ export function ScheduledConditionFeed({
<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>
{onCreateSchedule ? (
<button
type="button"
aria-label="配置定时流程"
aria-expanded={scheduleOpen}
onClick={handleToggleSchedule}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700"
>
<CalendarPlus size={17} aria-hidden="true" />
</button>
) : null}
<button
type="button"
aria-expanded={expanded}
@@ -375,6 +482,38 @@ export function ScheduledConditionFeed({
) : null}
</header>
{expanded ? (
<TaskCategorySwitch
activeCategory={taskCategory}
counts={{
scheduled: conditionsByCategory.scheduled.length,
investigation: conditionsByCategory.investigation.length,
appointment: conditionsByCategory.appointment.length
}}
onChange={handleTaskCategoryChange}
/>
) : null}
{scheduleOpen ? (
<section aria-label="配置定时流程" className={cn("mt-2 p-3", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
<label className="block text-xs font-medium text-slate-600">定时流程名称<Input className="mt-1 h-9" placeholder="例如夜间低压巡检" value={scheduleTitle} onChange={(event) => setScheduleTitle(event.target.value)} /></label>
<div className="mt-2"><FieldSelect label="执行流程" value={scheduleKey} onValueChange={setScheduleKey} options={[{ value: "scada-diagnosis", label: "SCADA 数据诊断" }, { value: "smart-dispatch", label: "管网智能调度" }, { value: "network-simulation", label: "管网定时模拟" }, { value: "pump-energy", label: "泵站能耗检查" }]} /></div>
<label className="mt-2 block text-xs font-medium text-slate-600">任务说明<Textarea className="mt-1 min-h-16" placeholder="说明本流程核查什么以及预期输出" value={scheduleSummary} onChange={(event) => setScheduleSummary(event.target.value)} /></label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div>
<FieldSelect label="执行周期" value={scheduleIntervalPreset} onValueChange={(value) => { setScheduleIntervalPreset(value); if (value !== "custom") setScheduleIntervalMinutes(value); }} options={[{ value: "5", label: "每 5 分钟" }, { value: "15", label: "每 15 分钟" }, { value: "30", label: "每 30 分钟" }, { value: "60", label: "每 60 分钟" }, { value: "custom", label: "自定义周期" }]} />
{scheduleIntervalPreset === "custom" ? <label className="mt-2 block text-xs font-medium text-slate-600">自定义分钟数<Input className="mt-1 h-9" inputMode="numeric" value={scheduleIntervalMinutes} onChange={(event) => setScheduleIntervalMinutes(event.target.value.replace(/\D/g, ""))} /></label> : null}
</div>
<DateTimeField label="下次执行时间" value={scheduleNextRunAt} onChange={setScheduleNextRunAt} />
</div>
{scheduleError ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{scheduleError}</p> : null}
<div className="mt-2 flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" disabled={scheduleSubmitting} onClick={() => setScheduleOpen(false)}>取消</Button>
<Button type="button" size="sm" disabled={scheduleSubmitting} onClick={() => void handleCreateSchedule()}>{scheduleSubmitting ? "创建中" : "创建定时流程"}</Button>
</div>
</section>
) : null}
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
@@ -398,7 +537,7 @@ export function ScheduledConditionFeed({
)}
aria-hidden={!expanded}
>
{loading && expanded ? (
{activeLoading && expanded ? (
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
@@ -406,6 +545,11 @@ export function ScheduledConditionFeed({
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
/>
) : null}
</div>
@@ -418,7 +562,17 @@ export function ScheduledConditionFeed({
<ConditionTimelinePanel
expanded={expanded}
presentation={presentation}
loading={loading}
loading={activeLoading}
emptyTitle={
backendError && taskCategory !== "scheduled"
? `${WORKBENCH_TASK_CATEGORIES.find((item) => item.id === taskCategory)?.label ?? "后端任务"}暂不可用`
: undefined
}
emptyDescription={
backendError && taskCategory !== "scheduled"
? backendError
: undefined
}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
taskFilter={taskFilter}
@@ -443,6 +597,54 @@ export function ScheduledConditionFeed({
);
}
function TaskCategorySwitch({
activeCategory,
counts,
onChange
}: {
activeCategory: WorkbenchTaskCategory;
counts: Record<WorkbenchTaskCategory, number>;
onChange: (category: WorkbenchTaskCategory) => void;
}) {
return (
<nav
aria-label="工况任务分类"
className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mt-2 grid grid-cols-3 gap-1 p-1")}
>
{WORKBENCH_TASK_CATEGORIES.map((category) => {
const active = category.id === activeCategory;
return (
<button
key={category.id}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onChange(category.id)}
className={cn(
"min-h-12 min-w-0 rounded-lg px-1.5 py-1.5 text-left transition-[background-color,color,box-shadow] duration-150 motion-reduce:transition-none! focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-200",
active
? "bg-white text-slate-900 shadow-[0_1px_3px_rgba(15,23,42,0.1)]"
: "text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-800"
)}
>
<span className="flex min-w-0 items-center justify-between gap-1">
<span className="truncate text-xs font-semibold">{category.label}</span>
<span className="shrink-0 text-xs tabular-nums text-slate-400">{counts[category.id]}</span>
</span>
<span className="mt-0.5 block truncate text-[10px] leading-3 text-slate-400">
{category.sourceLabel}
</span>
</button>
);
})}
</nav>
);
}
function formatLocalDateTimeInput(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
@@ -457,6 +659,8 @@ type ConditionTimelinePanelProps = {
totalConditionCount: number;
taskFilteredConditionCount: number;
filteredConditionCount: number;
emptyTitle?: string;
emptyDescription?: string;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
@@ -477,6 +681,8 @@ function ConditionTimelinePanel({
totalConditionCount,
taskFilteredConditionCount,
filteredConditionCount,
emptyTitle,
emptyDescription,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters,
@@ -545,11 +751,11 @@ function ConditionTimelinePanel({
/>
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
title={emptyTitle ?? (filterActive ? "没有符合筛选的任务" : "暂无任务")}
description={
filterActive
? "调整任务或状态筛选后查看其他工况记录。"
: "新的工况任务完成后会出现在这里。"
emptyDescription ?? (filterActive
? "调整任务或状态筛选后查看其他记录。"
: "新任务进入当前分类后会出现在这里。")
}
/>
)}