feat: extend supply workbench demo and map navigation

This commit is contained in:
2026-09-23 13:19:30 +08:00
parent bebcf2a06c
commit 1752b9ef84
45 changed files with 2137 additions and 449 deletions
@@ -80,4 +80,33 @@ describe("scheduled condition backend boundaries", () => {
expect(screen.getByText(/只保存在当前页面/)).toBeVisible();
expect(screen.queryByText("系统工单已提交")).not.toBeInTheDocument();
});
it("renders an event-triggered water-quality task as a workflow before opening chat", () => {
const onContinueConversation = vi.fn();
render(
<ConditionDetailPanel
condition={{
...baseCondition,
id: "peripheral-water-quality-order-1",
taskId: "water-quality-complaint",
dataSource: "mock",
sessionId: "agent-session-water-quality",
title: "水质投诉调查",
status: "running",
riskLevel: "attention",
scheduledAt: new Date().toISOString(),
durationMinutes: 15,
evidence: ["来源工单:order-1", "投诉位置:演示小区"]
}}
dataSource="mock"
onContinueConversation={onContinueConversation}
/>
);
expect(screen.getByText("演示执行流程 · 水质投诉调查流程")).toBeVisible();
expect(screen.getAllByText("校验上游水质监测").at(-1)).toBeVisible();
expect(screen.getAllByText("核查二次供水").at(-1)).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "与 Agent 协同" }));
expect(onContinueConversation).toHaveBeenCalledOnce();
});
});
@@ -226,7 +226,10 @@ function ConditionRecordDetailPanel({
backendCondition ? (
<PersistedRunningConditionBody condition={condition} />
) : (
<RunningConditionBody condition={condition} />
<RunningConditionBody
condition={condition}
onContinueConversation={onContinueConversation}
/>
)
) : backendCondition ? (
<PersistedConditionBody
@@ -416,8 +419,15 @@ function WorkOrderDetailPanel({
);
}
function RunningConditionBody({ condition }: { condition: ScheduledConditionRecord }) {
function RunningConditionBody({
condition,
onContinueConversation
}: {
condition: ScheduledConditionRecord;
onContinueConversation: () => void;
}) {
const runningEvidence = (condition.evidence ?? []).slice(0, 2);
const conversationAvailable = condition.taskId === "water-quality-complaint";
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
@@ -435,11 +445,12 @@ function RunningConditionBody({ condition }: { condition: ScheduledConditionReco
<RunningEvidencePreview items={runningEvidence} />
<DecisionActionStrip
icon={Bot}
title="阶段判断"
title={conversationAvailable ? "Agent 协同" : "阶段判断"}
value={condition.recommendation ?? "等待本轮工况完成后再确认是否生成处置建议。"}
actionLabel="等待完成"
actionDisabled
actionTitle="任务执行中,完成后可继续对话"
actionLabel={conversationAvailable ? "与 Agent 协同" : "等待完成"}
actionDisabled={!conversationAvailable}
actionTitle={conversationAvailable ? "打开这条任务关联的 Agent 会话" : "任务执行中,完成后可继续对话"}
onAction={conversationAvailable ? onContinueConversation : undefined}
/>
</>
);
@@ -142,6 +142,37 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
detail: "输出启停优化、压力设定和继续观察条件。"
}
]
},
"water-quality-complaint": {
title: "水质投诉调查流程",
reportLabel: "形成调查结论",
steps: [
{
id: "accept-work-order",
title: "接收投诉工单",
detail: "读取投诉类型、发生位置、时间和问题描述,建立事件与工单的可追溯关联。"
},
{
id: "check-upstream-quality",
title: "校验上游水质监测",
detail: "核查上游监测点的数据完整性、时效性及关键水质指标是否处于正常范围。"
},
{
id: "locate-supply-segment",
title: "研判供水链路",
detail: "若上游存在异常,转入水厂或管网排查;若上游正常,继续核查小区二次供水环节。"
},
{
id: "check-secondary-supply",
title: "核查二次供水",
detail: "检查水箱、泵房、消毒记录、清洗记录及同区域投诉,判断是否为局部设施问题。"
},
{
id: "prepare-disposition",
title: "汇总结论与处置",
detail: "整理证据、责任环节和建议动作,明确需要人工确认、采样或现场处置的事项。"
}
]
}
};
@@ -21,6 +21,7 @@ import {
type AgentApprovalMode,
type AgentChatMessage,
type AgentConnectionStatus,
type AgentEventTriggerContext,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
@@ -694,6 +695,70 @@ export function useWorkbenchAgent({
[approvalMode, chat, selectedModel, streaming]
);
const triggerEvent = useCallback(
async (trigger: AgentEventTriggerContext, prompt: string) => {
if (streaming) {
throw new Error("Agent 正在处理上一条指令,请稍后重试事件触发。");
}
stopSessionStreamSubscription();
const reserved = await clientRef.current.createSession({ triggerContext: trigger });
const nextSessionId = reserved.session_id;
const loadedSession = await clientRef.current.loadSession(nextSessionId);
if (!loadedSession) {
throw new Error("Agent 事件会话创建后无法读取。");
}
applySessionId(nextSessionId);
setSessionTitle(loadedSession.title || "水质投诉事件调查");
setPermissionOverrides({});
setQuestionOverrides({});
const nextMessages = toAgentUiMessages(loadedSession.messages);
chat.setMessages(nextMessages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
setConnectionStatus("online");
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
setStatusLabel("Agent 事件调查进行中");
startSessionStreamSubscription(nextSessionId);
return { sessionId: nextSessionId, reused: true };
}
if (nextMessages.length > 0) {
setStatusLabel("Agent 事件调查会话已打开");
await refreshSessionHistory();
return { sessionId: nextSessionId, reused: true };
}
setStatusLabel("Agent 正在调查水质投诉事件");
const eventRun = chat.sendMessage(
{ text: prompt },
{
body: {
session_id: nextSessionId,
model: selectedModel || undefined,
approval_mode: approvalMode,
trigger_context: trigger
}
}
);
void eventRun
.then(() => refreshSessionHistory())
.catch(() => undefined);
return { sessionId: nextSessionId, reused: reserved.event_duplicate === true };
},
[
approvalMode,
applySessionId,
chat,
refreshSessionHistory,
selectedModel,
startSessionStreamSubscription,
stopSessionStreamSubscription,
streaming
]
);
const stopPrompt = useCallback(() => {
chat.stop();
stopSessionStreamSubscription();
@@ -845,6 +910,7 @@ export function useWorkbenchAgent({
replyPermission,
replyQuestion,
resolveRenderRef,
triggerEvent,
submitPrompt
};
}
@@ -50,6 +50,7 @@ import {
scadaInteractionLayers
} from "../map/scada";
import { registerSupplyAssetImages } from "../map/supply-icons";
import { installCityScene } from "../map/city-scene-control";
import { useMapInteractions } from "./use-map-interactions";
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
@@ -79,7 +80,10 @@ type UseWorkbenchMapResult = {
sourceStatuses: WorkbenchSourceStatus[];
availableSourceIds: WaterNetworkSourceId[];
sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>;
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
getScadaFeatures: (
deviceIds?: string[],
signal?: AbortSignal
) => Promise<FeatureCollection<Point, ScadaDevice>>;
scadaObservedAt: string | null;
refreshGeoServerLayers: () => boolean;
fitNetworkBounds: () => void;
@@ -96,6 +100,7 @@ export type WorkbenchSourceStatus = {
const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图",
"tianditu-base": "天地图底图",
"geoserver-mvt": "GeoServer MVT",
"geoserver-catalog": "GeoServer 图层目录",
"scada-api": "SCADA API",
@@ -106,6 +111,10 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
"tianditu-vector": "tianditu-base",
"tianditu-vector-annotation": "tianditu-base",
"tianditu-image": "tianditu-base",
"tianditu-image-annotation": "tianditu-base",
...Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
@@ -197,9 +206,11 @@ export function useWorkbenchMap({
? current
: availability.availableSourceIds
);
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts);
setSourceFeatureCounts((current) =>
areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts
);
if (availability.failedSourceIds.length > 0) {
updateSourceStatus(
@@ -251,12 +262,13 @@ export function useWorkbenchMap({
setMapReady(false);
setMapError(null);
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const tiandituToken = env.TJWATER_TIANDITU_TOKEN || undefined;
const scadaAbortController = new AbortController();
let scadaRefreshTimeoutId: number | undefined;
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
style: createBaseStyle(mapboxToken, tiandituToken),
pitch: 0,
bearing: 0,
maxZoom: MAP_MAX_ZOOM,
@@ -265,9 +277,11 @@ export function useWorkbenchMap({
});
window.__waterNetworkMap = map;
let removeCityScene: (() => void) | undefined;
map.on("load", async () => {
map.resize();
removeCityScene = installCityScene(map);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
@@ -279,11 +293,7 @@ export function useWorkbenchMap({
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(
map,
simulationOverlayRef.current,
allowDemoSimulationRef.current
);
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers =
@@ -340,10 +350,7 @@ export function useWorkbenchMap({
});
const refreshScada = async () => {
const snapshotPromise = fetchScadaSnapshot(
getAccessToken,
scadaAbortController.signal
);
const snapshotPromise = fetchScadaSnapshot(getAccessToken, scadaAbortController.signal);
scadaLoadPromiseRef.current = snapshotPromise
.then((snapshot) => snapshot.collection)
.catch(() => scadaCollectionRef.current);
@@ -428,6 +435,7 @@ export function useWorkbenchMap({
if (scadaRefreshTimeoutId !== undefined) {
window.clearTimeout(scadaRefreshTimeoutId);
}
removeCityScene?.();
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
@@ -500,8 +508,10 @@ function areFeatureCountsEqual(
) {
const currentEntries = Object.entries(current);
const nextEntries = Object.entries(next);
return currentEntries.length === nextEntries.length
&& nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count);
return (
currentEntries.length === nextEntries.length &&
nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count)
);
}
function networkViewKey(view: NetworkViewParams) {
@@ -549,8 +559,9 @@ function syncDataDependentGeoServerLayers(
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIdSet.has(sourceId)) return;
map.getStyle().layers
.filter((layer) => "source" in layer && layer.source === sourceId)
map
.getStyle()
.layers.filter((layer) => "source" in layer && layer.source === sourceId)
.reverse()
.forEach((layer) => map.removeLayer(layer.id));
if (map.getSource(sourceId)) map.removeSource(sourceId);
@@ -711,11 +722,15 @@ function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { mess
return "mapbox-base";
}
if (message.includes("tianditu.gov.cn") || message.includes("tianditu")) {
return "tianditu-base";
}
return null;
}
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
if (sourceGroupId === "mapbox-base") {
if (["mapbox-base", "tianditu-base"].includes(sourceGroupId)) {
return status === "online"
? "底图瓦片已恢复正常。"
: "底图瓦片请求中断,业务图层仍可继续使用。";
@@ -13,6 +13,8 @@ describe("workbench basemap surface tone", () => {
it.each([
["mapbox-light", "light"],
["mapbox-satellite", "satellite"],
["tianditu-vector", "light"],
["tianditu-image", "satellite"],
["none", "none"],
["unknown", "none"]
])("maps %s to %s", (baseLayerId, expectedTone) => {
@@ -37,11 +37,8 @@ export const WORKBENCH_LAYOUT = {
} as const;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
const { edgeInset, gap, dockHeight, timelineSummaryHeight } = WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible ? edgeInset + timelineSummaryHeight + gap : edgeInset;
return {
timelineBottom: edgeInset,
@@ -118,11 +115,11 @@ export function getWorkbenchViewportLayout(viewportWidth: number): WorkbenchView
}
export function getWorkbenchBasemapTone(activeBaseLayerId: string): WorkbenchBasemapTone {
if (activeBaseLayerId === "mapbox-light") {
if (["mapbox-light", "tianditu-vector"].includes(activeBaseLayerId)) {
return "light";
}
if (activeBaseLayerId === "mapbox-satellite") {
if (["mapbox-satellite", "tianditu-image"].includes(activeBaseLayerId)) {
return "satellite";
}
+211 -134
View File
@@ -35,6 +35,12 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import {
toWaterQualityConditionTask,
toWaterQualityAgentTrigger,
waterQualityTriggerPrompt
} from "./peripheral-sample/work-order-agent-trigger";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
@@ -56,9 +62,7 @@ import {
type ToolbarToolId
} from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar";
import {
WorkbenchMainFrame
} from "./workspace/workbench-main-frame";
import { WorkbenchMainFrame } from "./workspace/workbench-main-frame";
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
import {
createInitialWorkspaceContext,
@@ -71,10 +75,7 @@ import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import {
formatHeaderDataTime,
useWorkbenchRuntimeData
} from "./hooks/use-workbench-runtime-data";
import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import {
useWorkbenchMeasurement,
@@ -121,6 +122,8 @@ export function MapWorkbenchPage({
getAccessToken?: AccessTokenProvider;
}) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
@@ -138,7 +141,7 @@ export function MapWorkbenchPage({
requestId: number;
} | null>(null);
const [activeBaseLayerId, setActiveBaseLayerId] = useState(
hasMapboxToken ? "mapbox-light" : "none"
hasMapboxToken ? "mapbox-light" : hasTiandituToken ? "tianditu-vector" : "none"
);
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
@@ -149,7 +152,7 @@ export function MapWorkbenchPage({
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const {
headerDataTime,
scheduledConditions,
scheduledConditions: runtimeScheduledConditions,
scheduledConditionsLoading,
workbenchAlerts,
runtimeDataSource,
@@ -161,13 +164,22 @@ export function MapWorkbenchPage({
operationalDate,
operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken);
const [eventConditions, setEventConditions] = useState<ScheduledConditionRecord[]>([]);
const scheduledConditions = useMemo(
() => [...eventConditions, ...runtimeScheduledConditions],
[eventConditions, runtimeScheduledConditions]
);
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
...option,
disabled: option.id !== "none" && !hasMapboxToken
disabled: option.id.startsWith("mapbox-")
? !hasMapboxToken
: option.id.startsWith("tianditu-")
? !hasTiandituToken
: false
})),
[hasMapboxToken]
[hasMapboxToken, hasTiandituToken]
);
const handleSelectFeature = useCallback((feature: DetailFeature) => {
@@ -215,19 +227,18 @@ export function MapWorkbenchPage({
expandAgentPanel,
onClearActiveTool: clearActiveTool
});
const currentAgentWidth = leftPanelOpen
? agentPanelWidth
: WORKBENCH_LAYOUT.collapsedAgentWidth;
const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
const allowDemoSimulation = artifactWorkspace.activeArtifact === null
|| artifactWorkspace.activeArtifact.origin === "demo";
const allowDemoSimulation =
artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo";
const latestAnalysisAvailable = Boolean(
latestAnalysisArtifact?.blocks.some((block) =>
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
latestAnalysisArtifact?.blocks.some(
(block) =>
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
)
);
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
@@ -278,15 +289,62 @@ export function MapWorkbenchPage({
useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) => current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView });
setLayerVisibility((current) =>
current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView }
);
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() {
openAgentPanelForViewport();
}
async function handlePeripheralWorkOrderAgentTrigger(workOrder: PeripheralWorkOrder) {
const trigger = toWaterQualityAgentTrigger(workOrder);
if (!trigger) {
throw new Error("工单不符合水质投诉事件触发条件。");
}
showMapNotice({
id: "water-quality-agent-trigger",
tone: "loading",
title: "水质投诉事件已接收",
message: "正在建立结构化工况任务。",
duration: Infinity
});
try {
const result = await agent.triggerEvent(trigger, waterQualityTriggerPrompt(workOrder));
const condition = toWaterQualityConditionTask(workOrder, result.sessionId);
setEventConditions((current) => [
condition,
...current.filter((item) => item.id !== condition.id)
]);
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
setSelectedConditionId(condition.id);
setConditionFocusRequest((current) => ({
conditionId: condition.id,
requestId: (current?.requestId ?? 0) + 1
}));
showMapNotice({
id: "water-quality-agent-trigger",
tone: "success",
title: result.reused ? "已恢复水质工况任务" : "已建立水质工况任务",
message: `关联模拟工单 ${workOrder.work_order_id.slice(0, 8)},可在任务中查看处理流程。`
});
} catch (error) {
showMapNotice({
id: "water-quality-agent-trigger",
tone: "error",
title: "工单已创建,Agent 待触发",
message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。"
});
throw error;
}
}
function openConditionWorkspaceForViewport() {
openConditionFeedForViewport();
}
@@ -325,10 +383,10 @@ export function MapWorkbenchPage({
networkView: projectPresentation.networkView,
getAccessToken
});
const displayedDataTime = !["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE)
&& scadaObservedAt
? formatHeaderDataTime(new Date(scadaObservedAt), false)
: headerDataTime;
const displayedDataTime =
!["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE) && scadaObservedAt
? formatHeaderDataTime(new Date(scadaObservedAt), false)
: headerDataTime;
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
mapReady,
@@ -378,12 +436,12 @@ export function MapWorkbenchPage({
});
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker =
!artifactActive
&& mobileSheet === null
&& !conditionFeedExpanded
&& detailFeature === null
&& taskTickerVisible
&& taskTickerAvailable;
!artifactActive &&
mobileSheet === null &&
!conditionFeedExpanded &&
detailFeature === null &&
taskTickerVisible &&
taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
? {
opacity: 1,
@@ -436,41 +494,44 @@ export function MapWorkbenchPage({
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
}, []);
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
dispatchWorkspaceContext({
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
const mapTarget = event.mapTargets[0];
if (mapTarget) {
const handleSelectOperationalEvent = useCallback(
(event: OperationalEvent) => {
dispatchWorkspaceContext({
type: "select-spatial-feature",
featureId: mapTarget.featureId,
sourceId: mapTarget.sourceId
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
void mapController.locateAndHighlight(mapTarget).then(() => {
if (mapController.getSnapshot().errorCode) {
showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) {
setSelectedConditionId(event.conditionId);
setConditionFocusRequest((current) => ({
conditionId: event.conditionId!,
requestId: (current?.requestId ?? 0) + 1
}));
openConditionFeedForViewport();
handleConditionExpandedChange(true);
}
}, [handleConditionExpandedChange, mapController, openConditionFeedForViewport]);
const mapTarget = event.mapTargets[0];
if (mapTarget) {
dispatchWorkspaceContext({
type: "select-spatial-feature",
featureId: mapTarget.featureId,
sourceId: mapTarget.sourceId
});
void mapController.locateAndHighlight(mapTarget).then(() => {
if (mapController.getSnapshot().errorCode) {
showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) {
setSelectedConditionId(event.conditionId);
setConditionFocusRequest((current) => ({
conditionId: event.conditionId!,
requestId: (current?.requestId ?? 0) + 1
}));
openConditionFeedForViewport();
handleConditionExpandedChange(true);
}
},
[handleConditionExpandedChange, mapController, openConditionFeedForViewport]
);
const handleClearOperationalSelection = useCallback(() => {
dispatchWorkspaceContext({ type: "clear-operational-event" });
@@ -902,16 +963,18 @@ export function MapWorkbenchPage({
onShowShortcuts: handleShowShortcuts,
onExportConfig: handleExportConfig
};
const selectedOperationalEvent = operationalEvents.find(
(event) => event.id === workspaceContext.operational.selectedEventId
) ?? null;
const selectedOperationalEvent =
operationalEvents.find((event) => event.id === workspaceContext.operational.selectedEventId) ??
null;
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
const items: AgentWorkspaceContextItem[] = [{
id: "operational-time",
label: "运行时点",
value: formatContextClock(workspaceContext.operational.cursorTime),
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
}];
const items: AgentWorkspaceContextItem[] = [
{
id: "operational-time",
label: "运行时点",
value: formatContextClock(workspaceContext.operational.cursorTime),
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
}
];
if (workspaceContext.spatial.featureId) {
items.push({
@@ -980,9 +1043,11 @@ export function MapWorkbenchPage({
>
<WorkbenchTopBar
dataTime={displayedDataTime}
modelName={agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label
|| agent.selectedModel
|| "Agent 后端连接中"}
modelName={
agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label ||
agent.selectedModel ||
"Agent 后端连接中"
}
activeAnalysisArtifact={artifactWorkspace.activeArtifact}
alerts={workbenchAlerts}
alertDataSource={alertDataSource}
@@ -999,8 +1064,14 @@ export function MapWorkbenchPage({
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleAnalysisResultsPanel={() => { setPeripheralSamplePanelOpen(false); setAnalysisResultsPanelOpen((current) => !current); }}
onTogglePeripheralSamplePanel={() => { setAnalysisResultsPanelOpen(false); setPeripheralSamplePanelOpen((current) => !current); }}
onToggleAnalysisResultsPanel={() => {
setPeripheralSamplePanelOpen(false);
setAnalysisResultsPanelOpen((current) => !current);
}}
onTogglePeripheralSamplePanel={() => {
setAnalysisResultsPanelOpen(false);
setPeripheralSamplePanelOpen((current) => !current);
}}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
latestAnalysisAvailable={latestAnalysisAvailable}
@@ -1029,6 +1100,7 @@ export function MapWorkbenchPage({
open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
/>
<WorkbenchMainFrame
@@ -1043,56 +1115,58 @@ export function MapWorkbenchPage({
onDestroyArtifact={artifactWorkspace.destroyArtifact}
timelineMode={timelineMode}
timelineBottom={timelineBottom}
timelineContent={(
timelineVisible ? <>
<div className="hidden lg:block">
<OperationalTimeline
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode={timelineMode}
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={setTimelineMode}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
</div>
<div
role="region"
aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
>
<button
type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport}
className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
timelineContent={
timelineVisible ? (
<>
<div className="hidden lg:block">
<OperationalTimeline
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode={timelineMode}
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={setTimelineMode}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
</div>
<div
role="region"
aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
>
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold">运行时间轴</span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
<span className="text-slate-500">执行 {operationalSummary.active}</span>
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
查看全天
</span>
</button>
</div>
</> : null
)}
mapContent={(
<button
type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport}
className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
>
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold">运行时间轴</span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
<span className="text-slate-500">执行 {operationalSummary.active}</span>
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
查看全天
</span>
</button>
</div>
</>
) : null
}
mapContent={
<div
ref={mapContainerRef}
className="map-grid absolute inset-0"
style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图"
/>
)}
mapOverlay={(
}
mapOverlay={
<div className="pointer-events-none absolute inset-0">
{!devPanelOpen ? (
<>
@@ -1106,22 +1180,25 @@ export function MapWorkbenchPage({
) : null}
<div
className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties}
style={
{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties
}
>
<div className="flex items-end gap-2 lg:pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
<MapZoom
mapRef={mapRef}
mapReady={mapReady}
onHome={fitNetworkBounds}
reducedMotion={prefersReducedMotion ?? false}
/>
</div>
<MapScaleLine
className="hidden lg:inline-flex"
mapRef={mapRef}
mapReady={mapReady}
/>
<MapScaleLine className="hidden lg:inline-flex" mapRef={mapRef} mapReady={mapReady} />
</div>
</div>
)}
}
/>
<WorkbenchAgentPanels
@@ -1307,11 +1384,11 @@ export function MapWorkbenchPage({
{!mapReady && !mapError ? (
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />
) : null}
{!hasMapboxToken ? (
{!hasOnlineBasemap ? (
<MapSourceStatusNotice
sourceName="Mapbox 底图"
sourceName="在线底图"
status="degraded"
message="未检测到 Mapbox token,当前已切换为无底图模式,仅展示业务图层。"
message="未检测到 Mapbox 或天地图令牌,当前已切换为无底图模式,仅展示业务图层。"
/>
) : null}
{sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => (
+15 -3
View File
@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from "vitest";
import { fitNetworkBounds, getResponsiveWorkbenchPadding, getWorkbenchPadding } from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
function createMap(width = 1280, height = 720) {
function createMap(width = 1280, height = 720, bearing = 0, pitch = 0) {
const fitBounds = vi.fn();
const map = {
fitBounds,
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height })
getBearing: () => bearing,
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height }),
getPitch: () => pitch
} as unknown as MapLibreMap;
return { fitBounds, map };
@@ -87,10 +89,20 @@ describe("fitNetworkBounds", () => {
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
expect(options).toMatchObject({
bearing: 0,
duration: 600,
maxZoom: 13,
padding: { top: 96, right: 48, bottom: 48, left: 48 }
padding: { top: 96, right: 48, bottom: 48, left: 48 },
pitch: 0
});
expect(options).not.toHaveProperty("zoom");
});
it("preserves the current 3D orientation when returning to the global extent", () => {
const { fitBounds, map } = createMap(1280, 720, 42, 50);
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
expect(fitBounds.mock.calls[0]?.[1]).toMatchObject({ bearing: 42, pitch: 50 });
});
});
+2
View File
@@ -53,6 +53,8 @@ export function fitNetworkBounds(
map.fitBounds(getLngLatBoundsFromBbox3857(viewParams.bbox3857), {
padding: getResponsivePadding(map, padding),
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
bearing: map.getBearing(),
pitch: map.getPitch(),
duration: 600
});
}
@@ -0,0 +1,87 @@
import maplibregl, { type Map as MapLibreMap } from "maplibre-gl";
import {
CITY_LAYERS,
CITY_ROOT,
cityManifestSchema,
citySources,
findCitySceneInsertionLayerId
} from "./city-scene-layers";
import { Protocol } from "pmtiles";
const cityTileProtocol = new Protocol();
maplibregl.addProtocol("pmtiles", cityTileProtocol.tile);
class CitySceneControl {
private map?: MapLibreMap;
private initialized = false;
private attribution?: maplibregl.AttributionControl;
attach(map: MapLibreMap) {
this.map = map;
map.on("pitchend", this.syncVisibility);
this.syncVisibility();
}
private syncVisibility = () => {
const map = this.map;
if (!map) return;
const enabled = map.getPitch() > 0;
if (enabled && !this.initialized) this.addLayers();
if (!this.initialized) return;
CITY_LAYERS.forEach(({ id }) =>
map.setLayoutProperty(id, "visibility", enabled ? "visible" : "none")
);
};
private addLayers() {
const map = this.map;
if (!map) return;
Object.entries(citySources()).forEach(([id, source]) => map.addSource(id, source));
const firstBusinessLayer = findCitySceneInsertionLayerId(map.getStyle().layers);
CITY_LAYERS.forEach((layer) => map.addLayer(layer, firstBusinessLayer));
this.attribution = new maplibregl.AttributionControl({
compact: true,
customAttribution:
'<a href="/city-model/ATTRIBUTION.md" target="_blank" rel="noopener noreferrer">Qian Shi et al. · 数据来源与许可</a>'
});
map.addControl(this.attribution, "bottom-left");
this.initialized = true;
}
onRemove() {
const map = this.map;
map?.off("pitchend", this.syncVisibility);
if (this.attribution && map?.hasControl(this.attribution)) map.removeControl(this.attribution);
if (map && this.initialized) {
[...CITY_LAYERS].reverse().forEach(({ id }) => {
if (map.getLayer(id)) map.removeLayer(id);
});
Object.keys(citySources()).forEach((id) => {
if (map.getSource(id)) map.removeSource(id);
});
}
this.map = undefined;
}
}
/** Scene is optional; absent local data must not break the operational map. */
export function installCityScene(map: MapLibreMap): () => void {
const abort = new AbortController();
let control: CitySceneControl | undefined;
void fetch(`${CITY_ROOT}/manifest.json`, { signal: abort.signal })
.then(async (response) => {
if (!response.ok) return;
const manifest = cityManifestSchema.safeParse(await response.json());
if (!manifest.success || abort.signal.aborted) return;
control = new CitySceneControl();
control.attach(map);
})
.catch(() => {
/* Optional scene is not deployed on every installation. */
});
return () => {
abort.abort();
control?.onRemove();
};
}
@@ -0,0 +1,56 @@
import type { LayerSpecification } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import {
CITY_LAYERS,
cityManifestSchema,
citySources,
findCitySceneInsertionLayerId
} from "./city-scene-layers";
const manifest = {
schema_version: 1,
name: "奉贤",
bbox: [121.34, 30.68, 121.85, 31.02],
building_count: 1,
pipe_count: 1,
asset_count: 1,
notice: "参考模型",
attribution: "OSM"
};
describe("city scene geographic contract", () => {
it("inserts buildings above every basemap layer and below business layers", () => {
const layers = [
{ id: "background", type: "background" },
{ id: "mapbox-light", type: "raster", source: "mapbox-light" },
{ id: "tianditu-vector", type: "raster", source: "tianditu-vector" },
{
id: "tianditu-vector-annotation",
type: "raster",
source: "tianditu-vector-annotation"
},
{ id: "pipes", type: "line", source: "pipes", "source-layer": "pipes" }
] satisfies LayerSpecification[];
expect(findCitySceneInsertionLayerId(layers)).toBe("pipes");
});
it("adds only the building background and leaves business layers to their existing controls", () => {
expect(Object.keys(citySources())).toEqual(["fengxian-city-buildings"]);
expect(CITY_LAYERS.map((layer) => layer.id)).toEqual(["fengxian-city-buildings"]);
});
it("accepts WGS84 scene bounds", () => {
expect(cityManifestSchema.safeParse(manifest).success).toBe(true);
});
it("rejects 3857 meters passed as longitude and latitude", () => {
expect(
cityManifestSchema.safeParse({ ...manifest, bbox: [13508801, 3608163, 13555650, 3633685] })
.success
).toBe(false);
});
it("rejects reversed extents and malformed counts", () => {
expect(
cityManifestSchema.safeParse({ ...manifest, bbox: [121.85, 30.68, 121.34, 31.02] }).success
).toBe(false);
expect(cityManifestSchema.safeParse({ ...manifest, pipe_count: -1 }).success).toBe(false);
});
});
@@ -0,0 +1,60 @@
import type { LayerSpecification, SourceSpecification } from "maplibre-gl";
import { z } from "zod";
import { BASE_LAYER_IDS } from "./map-control-config";
import { MAP_STYLE_TOKENS as colors } from "./map-colors";
export const CITY_ROOT = "/city-model";
export const CITY_PREFIX = "fengxian-city-";
export function findCitySceneInsertionLayerId(layers: readonly LayerSpecification[]) {
const basemapLayerIds = new Set<string>(BASE_LAYER_IDS);
return layers.find((layer) => layer.id !== "background" && !basemapLayerIds.has(layer.id))?.id;
}
export const cityManifestSchema = z
.object({
schema_version: z.literal(1),
name: z.string(),
bbox: z.tuple([
z.number().min(-180).max(180),
z.number().min(-90).max(90),
z.number().min(-180).max(180),
z.number().min(-90).max(90)
]),
building_count: z.number().int().nonnegative(),
pipe_count: z.number().int().nonnegative(),
asset_count: z.number().int().nonnegative(),
notice: z.string(),
attribution: z.string()
})
.refine(({ bbox: [w, s, e, n] }) => w < e && s < n, "Invalid scene bounds");
export function citySources(): Record<string, SourceSpecification> {
return {
[CITY_PREFIX + "buildings"]: {
type: "vector",
url: `pmtiles://${window.location.origin}${CITY_ROOT}/buildings.pmtiles`,
minzoom: 12,
maxzoom: 16,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a> · <a href="https://docs.overturemaps.org/attribution/">Overture Maps</a>',
promoteId: "id"
}
};
}
export const CITY_LAYERS: LayerSpecification[] = [
{
id: CITY_PREFIX + "buildings",
source: CITY_PREFIX + "buildings",
"source-layer": "buildings",
type: "fill-extrusion",
minzoom: 12,
paint: {
"fill-extrusion-color": colors.canvas.secondary,
"fill-extrusion-height": ["to-number", ["get", "height_m"], 6],
"fill-extrusion-base": 0,
"fill-extrusion-opacity": 1,
"fill-extrusion-vertical-gradient": true
}
}
];
@@ -3,30 +3,61 @@ import { describe, expect, it } from "vitest";
import { waterNetworkLayers } from "./layers";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
applyBaseLayerVisibility,
createLayerControlItems,
createVisibleMapLegendItems,
getWorkbenchLayerIds
} from "./map-control-config";
describe("workbench supply layer controls", () => {
it("switches Tianditu basemaps as base and annotation layer pairs", () => {
const visibility = new Map<string, string>();
const map = {
getLayer: () => ({}),
setLayoutProperty: (layerId: string, _property: string, value: string) => {
visibility.set(layerId, value);
}
} as unknown as MapLibreMap;
expect(BASE_LAYER_OPTIONS.map((option) => option.id)).toEqual([
"mapbox-light",
"mapbox-satellite",
"tianditu-vector",
"tianditu-image",
"none"
]);
applyBaseLayerVisibility(map, "tianditu-image");
expect(visibility.get("tianditu-image")).toBe("visible");
expect(visibility.get("tianditu-image-annotation")).toBe("visible");
expect(visibility.get("tianditu-vector")).toBe("none");
expect(visibility.get("tianditu-vector-annotation")).toBe("none");
expect(visibility.get("mapbox-light")).toBe("none");
expect(visibility.get("mapbox-satellite")).toBe("none");
});
it("creates controls only for available supply system sources", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {
fx_20200423_pipes: 215_797,
fx_20200811_dn100_pipes: 122_686
});
expect(items.map((item) => item.id)).toEqual(expect.arrayContaining([
"pipes",
"junctions",
"scada-pressure",
"scada-flow",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
]));
expect(items.map((item) => item.id)).toEqual(
expect.arrayContaining([
"pipes",
"junctions",
"scada-pressure",
"scada-flow",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
])
);
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
group: "source-gis",
groupLabel: "GIS 原始数据",
@@ -99,15 +130,25 @@ describe("workbench supply layer controls", () => {
"source-non-residential-meter",
"impact-area"
]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({ imageSrc: "/map/valve.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
imageSrc: "/map/valve.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "reservoir")).toMatchObject({
label: "水库",
imageSrc: "/map/reservoir.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({ imageSrc: "/map/tank.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({
imageSrc: "/map/pump.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({
imageSrc: "/map/tank.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({
imageSrc: "/map/scada-flow.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({
imageSrc: "/map/scada-pressure.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2.5,
@@ -133,7 +174,9 @@ describe("workbench supply layer controls", () => {
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-hydrant")).toMatchObject({
imageSrc: "/map/gis-hydrant.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")).toMatchObject({
expect(
MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")
).toMatchObject({
imageSrc: "/map/gis-non-residential-meter.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
@@ -147,32 +190,27 @@ describe("workbench supply layer controls", () => {
const initialItems = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
const initialLegendIds = createVisibleMapLegendItems(initialItems).map((item) => item.id);
expect(initialLegendIds).toEqual(expect.arrayContaining([
"major-pipe",
"valve",
"scada-flow",
"water-quality-monitoring"
]));
expect(initialLegendIds).not.toEqual(expect.arrayContaining([
"source-major-pipe",
"source-inventory-pipe",
"impact-area"
]));
expect(initialLegendIds).toEqual(
expect.arrayContaining(["major-pipe", "valve", "scada-flow", "water-quality-monitoring"])
);
expect(initialLegendIds).not.toEqual(
expect.arrayContaining(["source-major-pipe", "source-inventory-pipe", "impact-area"])
);
const sourcePipeItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
fx_20200423_pipes: true
});
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map(
(item) => item.id
);
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map((item) => item.id);
expect(sourcePipeLegendIds).toEqual(expect.arrayContaining([
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe"
]));
expect(sourcePipeLegendIds).toEqual(
expect.arrayContaining([
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe"
])
);
expect(sourcePipeLegendIds).not.toContain("source-inventory-pipe");
const reducedItems = createLayerControlItems({
+184 -49
View File
@@ -11,11 +11,7 @@ import {
SUPPLY_LAYER_CATALOG,
type WaterNetworkSourceId
} from "./sources";
import {
SCADA_ICON_PATHS,
SCADA_LAYER_CONTROL_ITEMS,
SCADA_LAYER_GROUPS
} from "./scada";
import { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada";
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
@@ -33,18 +29,17 @@ export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.id !== "scada")
.map((layer) => [layer.id, layer.initialVisible])
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
layer.id,
layer.initialVisible
])
),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
simulation: false
};
const SOURCE_CONTROL_LABELS = Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => [layer.id, layer.label])
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [layer.id, layer.label])
) as Record<WaterNetworkSourceId, string>;
type LayerControlPresentation = {
@@ -55,7 +50,10 @@ type LayerControlPresentation = {
visual: MapLayerControlVisual;
};
const LAYER_CONTROL_PRESENTATION: Record<Exclude<WaterNetworkSourceId, "scada">, LayerControlPresentation> = {
const LAYER_CONTROL_PRESENTATION: Record<
Exclude<WaterNetworkSourceId, "scada">,
LayerControlPresentation
> = {
pipes: {
description: "按管径分级",
minZoom: 8.5,
@@ -258,14 +256,70 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot", layerControlId: "junctions", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve, layerControlId: "valves", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir, layerControlId: "reservoirs", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump, layerControlId: "pumps", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank, layerControlId: "tanks", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "scada-flow", label: "流量监测", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow, layerControlId: "scada-flow", ...MONITORING_LEGEND_GROUP },
{ id: "scada-pressure", label: "压力监测", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure, layerControlId: "scada-pressure", ...MONITORING_LEGEND_GROUP },
{ id: "water-quality-monitoring", label: "水质监测", color: MAP_STYLE_TOKENS.supply.waterQuality, imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality, layerControlId: "water_quality_monitoring_points", ...MONITORING_LEGEND_GROUP },
{
id: "junction",
label: "节点",
color: MAP_STYLE_TOKENS.supply.junction,
shape: "dot",
layerControlId: "junctions",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "valve",
label: "阀门",
color: MAP_STYLE_TOKENS.supply.valve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.valve,
layerControlId: "valves",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "reservoir",
label: "水库",
color: MAP_STYLE_TOKENS.supply.reservoir,
imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir,
layerControlId: "reservoirs",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "pump",
label: "水泵",
color: MAP_STYLE_TOKENS.supply.pump,
imageSrc: SUPPLY_ASSET_ICON_PATHS.pump,
layerControlId: "pumps",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "tank",
label: "水箱",
color: MAP_STYLE_TOKENS.supply.tank,
imageSrc: SUPPLY_ASSET_ICON_PATHS.tank,
layerControlId: "tanks",
...MODEL_FACILITY_LEGEND_GROUP
},
{
id: "scada-flow",
label: "流量监测",
color: MAP_STYLE_TOKENS.supply.scadaFlow,
imageSrc: SCADA_ICON_PATHS.flow,
layerControlId: "scada-flow",
...MONITORING_LEGEND_GROUP
},
{
id: "scada-pressure",
label: "压力监测",
color: MAP_STYLE_TOKENS.supply.scadaPressure,
imageSrc: SCADA_ICON_PATHS.pressure,
layerControlId: "scada-pressure",
...MONITORING_LEGEND_GROUP
},
{
id: "water-quality-monitoring",
label: "水质监测",
color: MAP_STYLE_TOKENS.supply.waterQuality,
imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality,
layerControlId: "water_quality_monitoring_points",
...MONITORING_LEGEND_GROUP
},
{
id: "source-major-pipe",
label: "DN600 及以上",
@@ -311,36 +365,101 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
layerControlId: "fx_20200811_dn100_pipes",
...SOURCE_AUGUST_PIPE_LEGEND_GROUP
},
{ id: "source-valve", label: "GIS 阀门", color: MAP_STYLE_TOKENS.supply.sourceValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve, layerControlId: "fx_20200423_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-drain-valve", label: "排水阀", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve, layerControlId: "fx_20200423_drain_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-flow-meter", label: "流量仪", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter, layerControlId: "fx_20200423_flow_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-hydrant", label: "消火栓", color: MAP_STYLE_TOKENS.supply.sourceHydrant, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant, layerControlId: "fx_20200423_hydrants", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-connection", label: "接水点", color: MAP_STYLE_TOKENS.supply.sourceConnection, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection, layerControlId: "fx_20200423_service_connections", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-customer-meter", label: "用户水表", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter, layerControlId: "fx_20200423_customer_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-non-residential-meter", label: "非居民水表", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter, layerControlId: "fx_20200423_non_residential_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "impact-area", label: "影响范围", description: "事故或分析结果", color: MAP_STYLE_TOKENS.state.incident, shape: "area", layerControlId: "simulation", ...ANALYSIS_LEGEND_GROUP }
{
id: "source-valve",
label: "GIS 阀门",
color: MAP_STYLE_TOKENS.supply.sourceValve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve,
layerControlId: "fx_20200423_valves",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-drain-valve",
label: "排水阀",
color: MAP_STYLE_TOKENS.supply.sourceDrainValve,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve,
layerControlId: "fx_20200423_drain_valves",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-flow-meter",
label: "流量仪",
color: MAP_STYLE_TOKENS.supply.sourceFlowMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter,
layerControlId: "fx_20200423_flow_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-hydrant",
label: "消火栓",
color: MAP_STYLE_TOKENS.supply.sourceHydrant,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant,
layerControlId: "fx_20200423_hydrants",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-connection",
label: "接水点",
color: MAP_STYLE_TOKENS.supply.sourceConnection,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection,
layerControlId: "fx_20200423_service_connections",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-customer-meter",
label: "用户水表",
color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter,
layerControlId: "fx_20200423_customer_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "source-non-residential-meter",
label: "非居民水表",
color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter,
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
layerControlId: "fx_20200423_non_residential_meters",
...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "impact-area",
label: "影响范围",
description: "事故或分析结果",
color: MAP_STYLE_TOKENS.state.incident,
shape: "area",
layerControlId: "simulation",
...ANALYSIS_LEGEND_GROUP
}
];
export function createVisibleMapLegendItems(
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
) {
const visibleLayerIds = new Set(
layerItems.filter((item) => item.visible).map((item) => item.id)
);
const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
return MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
);
}
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export const BASE_LAYER_GROUPS = {
"mapbox-light": ["mapbox-light"],
"mapbox-satellite": ["mapbox-satellite"],
"tianditu-vector": ["tianditu-vector", "tianditu-vector-annotation"],
"tianditu-image": ["tianditu-image", "tianditu-image-annotation"]
} as const;
export const BASE_LAYER_IDS = Object.values(BASE_LAYER_GROUPS).flat();
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
const activeLayerIds = new Set<string>(
BASE_LAYER_GROUPS[activeBaseLayerId as keyof typeof BASE_LAYER_GROUPS] ?? []
);
BASE_LAYER_IDS.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(
layerId,
"visibility",
activeBaseLayerId === layerId ? "visible" : "none"
activeLayerIds.has(layerId) ? "visible" : "none"
);
}
});
@@ -359,6 +478,18 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
description: "卫星街道",
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
},
{
id: "tianditu-vector",
label: "天地图矢量",
description: "矢量与中文注记",
swatchClassName: "bg-gradient-to-br from-sky-50 via-stone-100 to-emerald-100"
},
{
id: "tianditu-image",
label: "天地图影像",
description: "影像与中文注记",
swatchClassName: "bg-gradient-to-br from-slate-950 via-emerald-800 to-stone-300"
},
{
id: "none",
label: "无底图",
@@ -390,20 +521,23 @@ export function createLayerControlItems(
groupLabel: "管网模型",
groupDescription: "计算模型与基础设施"
})),
...(available.has("scada") ? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
id: item.id,
label: item.label,
description: item.description,
minZoom: 8.5,
visual: {
kind: "image" as const,
imageSrc: item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
},
visible: layerVisibility[item.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
})) : []),
...(available.has("scada")
? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
id: item.id,
label: item.label,
description: item.description,
minZoom: 8.5,
visual: {
kind: "image" as const,
imageSrc:
item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
},
visible: layerVisibility[item.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
}))
: []),
...monitoringItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
@@ -443,8 +577,9 @@ export function getWorkbenchLayerIds(
groupId: string
) {
if (groupId in SCADA_LAYER_GROUPS) {
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS]
.filter((layerId) => map.getLayer(layerId));
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS].filter((layerId) =>
map.getLayer(layerId)
);
}
if (isWaterNetworkSourceId(groupId)) {
return map
+43 -2
View File
@@ -24,8 +24,9 @@ describe("createWaterNetworkSources", () => {
});
expect(WATER_NETWORK_SOURCE_IDS).toContain("water_quality_monitoring_points");
expect(WATER_NETWORK_SOURCE_IDS).toContain("fx_20200423_non_residential_meters");
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points"))
.toMatchObject({ available: true, availability: "probe", group: "monitoring" });
expect(
SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points")
).toMatchObject({ available: true, availability: "probe", group: "monitoring" });
expect(SUPPLY_LAYER_CATALOG.filter((layer) => layer.group === "source-gis")).toHaveLength(9);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
@@ -63,6 +64,46 @@ describe("createWaterNetworkSources", () => {
});
});
it("creates paired Tianditu vector and imagery sources only when configured", () => {
expect(createBaseStyle().sources).not.toHaveProperty("tianditu-vector");
const style = createBaseStyle(undefined, "test-tianditu-token");
expect(style.sources["tianditu-vector"]).toMatchObject({
type: "raster",
tileSize: 256,
maxzoom: 18,
tiles: [expect.stringContaining("/vec_w/wmts?")]
});
expect(style.sources["tianditu-vector-annotation"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=cva.*tk=test-tianditu-token/)]
});
expect(style.sources["tianditu-image"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=img.*tk=test-tianditu-token/)]
});
expect(style.sources["tianditu-image-annotation"]).toMatchObject({
tiles: [expect.stringMatching(/LAYER=cia.*tk=test-tianditu-token/)]
});
expect(style.layers?.find((layer) => layer.id === "tianditu-vector")?.layout).toMatchObject({
visibility: "visible"
});
expect(
style.layers?.find((layer) => layer.id === "tianditu-vector-annotation")?.layout
).toMatchObject({ visibility: "visible" });
expect(style.layers?.find((layer) => layer.id === "tianditu-image")?.layout).toMatchObject({
visibility: "none"
});
});
it("keeps Tianditu hidden when Mapbox is the initial basemap", () => {
const style = createBaseStyle("mapbox-token", "tianditu-token");
expect(style.layers?.find((layer) => layer.id === "tianditu-vector")?.layout).toMatchObject({
visibility: "none"
});
expect(style.layers?.find((layer) => layer.id === "mapbox-light")?.layout).toBeUndefined();
});
it("creates only the requested GeoServer vector sources", () => {
const sources = createWaterNetworkSources(["pipes", "junctions"]);
+63 -6
View File
@@ -2,10 +2,7 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection
} from "./scada-analysis";
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = {
@@ -280,7 +277,10 @@ export function isAvailableWaterNetworkSourceId(
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
}
export function createWaterNetworkSources(): Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
export function createWaterNetworkSources(): Record<
GeoServerWaterNetworkSourceId,
VectorSourceSpecification
>;
export function createWaterNetworkSources(
sourceIds: readonly GeoServerWaterNetworkSourceId[]
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
@@ -312,7 +312,11 @@ function createGeoServerVectorSource(
};
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
function createTiandituTileUrl(layer: "vec" | "cva" | "img" | "cia", token: string) {
return `https://t0.tianditu.gov.cn/${layer}_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=${layer}&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${token}`;
}
export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {
[SCADA_SOURCE_ID]: {
type: "geojson",
@@ -379,6 +383,59 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
});
}
if (tiandituToken) {
const tiandituSources = [
["tianditu-vector", "vec"],
["tianditu-vector-annotation", "cva"],
["tianditu-image", "img"],
["tianditu-image-annotation", "cia"]
] as const;
tiandituSources.forEach(([sourceId, layer]) => {
sources[sourceId] = {
type: "raster",
tiles: [createTiandituTileUrl(layer, tiandituToken)],
tileSize: 256,
maxzoom: 18,
attribution: '© <a href="https://www.tianditu.gov.cn/">天地图</a>'
};
});
const vectorVisible = mapboxToken ? "none" : "visible";
layers.push(
{
id: "tianditu-vector",
type: "raster",
source: "tianditu-vector",
layout: { visibility: vectorVisible },
paint: { "raster-opacity": 0.9 }
},
{
id: "tianditu-vector-annotation",
type: "raster",
source: "tianditu-vector-annotation",
layout: { visibility: vectorVisible }
},
{
id: "tianditu-image",
type: "raster",
source: "tianditu-image",
layout: { visibility: "none" },
paint: {
"raster-opacity": 0.9,
"raster-saturation": -0.12,
"raster-contrast": 0.06
}
},
{
id: "tianditu-image-annotation",
type: "raster",
source: "tianditu-image-annotation",
layout: { visibility: "none" }
}
);
}
return {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
@@ -147,6 +147,7 @@ describe("PeripheralSampleClient", () => {
work_order_id: "50000000-0000-4000-8000-000000000001", project_id: "project-1", revision: 2,
title: "演示 测试工单", summary: "外围联调", status: "in_progress" as const,
priority: "high" as const, assignee: null, scheduled_at: null, origin: "synthetic" as const,
work_order_type: "general" as const, location: null, scenario_code: null,
created_at: "2026-09-18T00:00:00Z", updated_at: "2026-09-18T00:00:00Z",
created_by: "user", updated_by: "user"
};
@@ -446,6 +446,8 @@ export interface components {
CreateWorkOrder: {
/** Assignee */
assignee?: string | null;
/** Location */
location?: string | null;
/**
* Priority
* @default normal
@@ -459,6 +461,8 @@ export interface components {
request_id: string;
/** Scheduled At */
scheduled_at?: string | null;
/** Scenario Code */
scenario_code?: string | null;
/**
* Status
* @default draft
@@ -469,6 +473,8 @@ export interface components {
summary: string;
/** Title */
title: string;
/** Work Order Type */
work_order_type?: "general" | "water_quality_complaint";
};
/** DeleteResource */
DeleteResource: {
@@ -1066,6 +1072,8 @@ export interface components {
created_at: string;
/** Created By */
created_by: string;
/** Location */
location?: string | null;
/**
* Origin
* @constant
@@ -1085,6 +1093,8 @@ export interface components {
revision: number;
/** Scheduled At */
scheduled_at: string | null;
/** Scenario Code */
scenario_code?: string | null;
/**
* Status
* @enum {string}
@@ -1106,6 +1116,8 @@ export interface components {
* Format: uuid
*/
work_order_id: string;
/** Work Order Type */
work_order_type?: "general" | "water_quality_complaint";
};
/** WorkOrderPage */
WorkOrderPage: {
@@ -2,17 +2,17 @@ import { useCallback, useEffect, useState, type ReactNode } from "react";
import {
Ban,
CalendarClock,
Check,
CheckCircle2,
CircleDot,
ClipboardList,
Clock3,
Database,
GitBranch,
LoaderCircle,
Pencil,
Plus,
RefreshCw,
Sparkles,
Trash2,
Wrench,
X
} from "lucide-react";
import { Button } from "@/shared/ui/button";
@@ -33,11 +33,12 @@ import type {
type ResourceView = "orders" | "data";
type Inventory = { workOrders: number; testData: number };
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange }: {
export function ManagedTestResources({ client, canWrite, capabilities, onInventoryChange, onAgentTrigger }: {
client: PeripheralSampleClient;
canWrite: boolean;
capabilities: PeripheralContext["managed_resources"];
onInventoryChange?: (inventory: Inventory) => void;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
const [records, setRecords] = useState<PeripheralTestDataRecord[]>([]);
@@ -81,9 +82,9 @@ export function ManagedTestResources({ client, canWrite, capabilities, onInvento
};
return <WorkbenchSection
title="外围任务编排"
description="一个任务包含多个子任务,每个子任务按派发、执行、复令推进。"
icon={<GitBranch size={15} />}
title="外围工单与测试数据"
description="管理独立的合成工单和测试数据;仅用于演示,不代表正式工单系统。"
icon={<ClipboardList size={15} />}
tone="teal"
bodyClassName="space-y-3"
actions={<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单和数据" disabled={Boolean(busy)} onClick={() => void refresh()}><RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} /></Button>}
@@ -92,12 +93,12 @@ export function ManagedTestResources({ client, canWrite, capabilities, onInvento
{capabilities.work_orders !== "available" ? <StoragePending resource="测试工单" tables="work_orders / work_order_revisions" /> : null}
{capabilities.test_data !== "available" ? <StoragePending resource="测试数据" tables="test_data_records / test_data_record_revisions" /> : null}
<div role="group" aria-label="外围样板资源类型" className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<GitBranch size={13} />} onClick={() => setView("orders")}>任务结构</ResourceSwitch>
<ResourceSwitch active={view === "orders"} count={orders.length} icon={<ClipboardList size={13} />} onClick={() => setView("orders")}>工单列表</ResourceSwitch>
<ResourceSwitch active={view === "data"} count={records.length} icon={<Database size={13} />} onClick={() => setView("data")}>测试数据</ResourceSwitch>
</div>
{view === "orders" ? (
capabilities.work_orders === "available"
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} />
? <WorkOrders orders={orders} canWrite={canWrite} busy={busy} mutate={mutate} client={client} onAgentTrigger={onAgentTrigger} />
: null
) : (
capabilities.test_data === "available"
@@ -133,8 +134,9 @@ function StoragePending({ resource, tables }: { resource: string; tables: string
</WorkbenchNotice>;
}
function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps & {
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: ResourceProps & {
orders: PeripheralWorkOrder[];
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null);
@@ -142,6 +144,10 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
const [summary, setSummary] = useState("");
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{
message: string;
workOrder: PeripheralWorkOrder;
} | null>(null);
const clear = () => {
setEditing(null);
@@ -163,29 +169,67 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
setStatus(item.status);
setPriority(item.priority);
};
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
if (!onAgentTrigger) {
setAgentTriggerFailure({ message: "当前工作台未连接 Agent 事件入口。", workOrder });
return;
}
try {
await onAgentTrigger(workOrder);
setAgentTriggerFailure(null);
} catch (error) {
setAgentTriggerFailure({ message: message(error), workOrder });
}
};
const createWaterQualityComplaint = () => void mutate("create-water-quality-order", async () => {
const workOrder = await client.createWorkOrder({
title: "居民反映自来水有异味",
summary: "演示小区多户居民反映晨间用水有异味,请核查上游水质及二次供水环节。",
status: "open",
priority: "high",
assignee: null,
scheduled_at: null,
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: "WQ-SECONDARY-SUPPLY-001"
});
await triggerAgent(workOrder);
});
return <div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">任务层级</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">管理主任务下的子任务及其阶段状态。</p></div>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增子任务</Button>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"><h4 className="text-xs font-semibold text-slate-800">测试工单</h4><p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张工单独立展示派发、执行和复令步骤,步骤状态互不混用。</p></div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={createWaterQualityComplaint}>
{busy === "create-water-quality-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
水质投诉演示
</Button>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}><Plus size={13} />新增工单</Button>
</div>
</div>
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200">
<EditorHeading title={editing ? "编辑子任务" : "新增子任务"} onClose={clear} />
{agentTriggerFailure ? <WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,Agent 待触发">
<p>{agentTriggerFailure.message}</p>
<Button className="mt-2" size="sm" variant="outline" disabled={Boolean(busy)} onClick={() => void mutate("retry-agent-trigger", () => triggerAgent(agentTriggerFailure.workOrder))}>
{busy === "retry-agent-trigger" ? <LoaderCircle className="animate-spin" /> : <RefreshCw size={13} />}重试触发
</Button>
</WorkbenchNotice> : null}
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑工单" : "新增工单"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-slate-600">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="子任务状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
<div className="mt-2 grid grid-cols-2 gap-2"><FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "draft", label: "草稿" }, { value: "open", label: "待处理" }, { value: "in_progress", label: "处理中" }, { value: "completed", label: "已完成" }, { value: "cancelled", label: "已取消" }
]} /><FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={[
{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }
]} /></div>
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!canWrite || Boolean(busy) || !title.trim() || !summary.trim()} onClick={() => void mutate("save-order", async () => {
if (editing) await client.updateWorkOrder(editing.work_order_id, { expected_revision: editing.revision, title: title.trim(), summary: summary.trim(), status, priority, clear_assignee: false, clear_scheduled_at: false });
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null });
else await client.createWorkOrder({ title: title.trim(), summary: summary.trim(), status, priority, assignee: null, scheduled_at: null, work_order_type: "general", location: null, scenario_code: null });
clear();
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建子任务"}</Button>
})}>{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil /> : <Plus />}{editing ? "保存修改" : "创建工单"}</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button></div>
</div> : null}
<TaskHierarchy
<WorkOrderList
orders={orders}
canWrite={canWrite}
busy={busy}
@@ -198,36 +242,23 @@ function WorkOrders({ orders, canWrite, busy, mutate, client }: ResourceProps &
</div>;
}
function TaskHierarchy({ orders, canWrite, busy, onEdit, onDelete }: {
function WorkOrderList({ orders, canWrite, busy, onEdit, onDelete }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
onEdit: (item: PeripheralWorkOrder) => void;
onDelete: (item: PeripheralWorkOrder) => void;
}) {
const completed = orders.filter((item) => item.status === "completed").length;
const parentStatus = taskStatus(orders);
return <section aria-label="任务层级" className="mt-3 overflow-hidden rounded-xl bg-slate-50/80 ring-1 ring-inset ring-slate-200">
<header className="border-b border-cyan-100 bg-cyan-50/70 px-3.5 py-3 text-slate-900">
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-xs ring-1 ring-inset ring-cyan-100"><ClipboardList size={16} /></span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"><span className="text-[10px] font-semibold text-cyan-700">任务</span><StatusBadge tone={parentStatus.tone}>{parentStatus.label}</StatusBadge></div>
<h5 className="mt-1 truncate text-sm font-semibold">外围合成数据核查</h5>
<p className="mt-1 text-[10px] text-slate-500"><span className="font-mono">DEMO-WO-001</span> · {orders.length} 个子任务 · {completed}/{orders.length} 完成</p>
</div>
</div>
</header>
{orders.length ? <ol className="relative space-y-2 p-3 before:absolute before:bottom-8 before:left-[25px] before:top-3 before:w-px before:bg-cyan-200">
{orders.map((item, index) => <li key={item.work_order_id} className="relative pl-7">
<span className="absolute left-0 top-3 z-10 grid size-5 place-items-center rounded-full bg-white text-[10px] font-semibold tabular-nums text-cyan-800 ring-1 ring-inset ring-cyan-300">{index + 1}</span>
<SubtaskCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
return <section aria-label="工单列表" className="mt-3">
{orders.length ? <ol className="space-y-2">
{orders.map((item, index) => <li key={item.work_order_id}>
<WorkOrderCard item={item} index={index} canWrite={canWrite} busy={busy} onEdit={() => onEdit(item)} onDelete={() => onDelete(item)} />
</li>)}
</ol> : <div className="p-3"><WorkbenchEmptyState icon={<Wrench size={15} />} title="当前任务还没有子任务" description="新增子任务后,可在这里查看每项任务的派发、执行和复令状态。" /></div>}
</ol> : <WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="新增工单后,可在这里查看接口返回的工单字段和状态。" />}
</section>;
}
function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: {
function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
item: PeripheralWorkOrder;
index: number;
canWrite: boolean;
@@ -235,47 +266,75 @@ function SubtaskCard({ item, index, canWrite, busy, onEdit, onDelete }: {
onEdit: () => void;
onDelete: () => void;
}) {
return <article className="rounded-xl bg-white p-3 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
return <article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5"><span className="text-[10px] font-semibold text-slate-400">子任务 {String(index + 1).padStart(2, "0")}</span><StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge></div>
<div className="flex flex-wrap items-center gap-1.5"><span className="text-[10px] font-semibold text-slate-400">工单 {String(index + 1).padStart(2, "0")}</span><StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>{item.work_order_type === "water_quality_complaint" ? <StatusBadge tone="warning">水质投诉</StatusBadge> : null}</div>
<h6 className="mt-1 truncate text-xs font-semibold text-slate-900">{item.title}</h6>
<p className="mt-1 line-clamp-2 text-[11px] leading-4 text-slate-500">{item.summary}</p>
{item.location ? <p className="mt-1 text-[10px] text-slate-400">位置:{item.location}</p> : null}
</div>
<div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑子任务 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除子任务 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button></div>
<div className="flex shrink-0 gap-0.5"><Button size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button><Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button></div>
</div>
<StageProgress status={item.status} title={item.title} />
<WorkOrderSteps status={item.status} title={item.title} />
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-100 pt-2 text-[10px] text-slate-500">
<span>{priorityLabel(item.priority)}优先级</span><span>r{item.revision}</span>{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
<span>{priorityLabel(item.priority)}优先级</span>
<span className="max-w-full truncate font-mono" title={item.work_order_id}>工单号 {item.work_order_id}</span>
<span>r{item.revision}</span>
{item.assignee ? <span>处理人:{item.assignee}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
</div>
</article>;
}
type StageStatus = "done" | "current" | "pending" | "skipped";
type WorkOrderStepStatus = "done" | "current" | "pending" | "skipped";
function StageProgress({ status, title }: { status: PeripheralWorkOrder["status"]; title: string }) {
const statuses = stageStatuses(status);
const stages = ["派发", "执行", "复令"];
return <div className="mt-3 rounded-lg bg-slate-50 px-2.5 py-2" aria-label={`子任务阶段 ${title}`}>
<p className="mb-2 text-[10px] font-semibold text-slate-500">子任务阶段</p>
<ol className="grid grid-cols-3">
{stages.map((stage, index) => {
const stageStatus = statuses[index];
return <li key={stage} className="relative min-w-0 text-center">
{index < stages.length - 1 ? <span className={cn("absolute left-[calc(50%+9px)] right-[calc(-50%+9px)] top-2 h-px", stageStatus === "done" ? "bg-cyan-400" : "bg-slate-300")} aria-hidden="true" /> : null}
<span className={cn(
"relative z-10 mx-auto grid size-4 place-items-center rounded-full text-[9px] font-semibold",
stageStatus === "done" && "bg-cyan-700 text-white",
stageStatus === "current" && "bg-white text-cyan-700 ring-2 ring-inset ring-cyan-500",
stageStatus === "pending" && "bg-white text-slate-400 ring-1 ring-inset ring-slate-300",
stageStatus === "skipped" && "bg-slate-200 text-slate-500"
)}>{stageStatus === "done" ? <Check size={10} /> : stageStatus === "current" ? <CircleDot size={10} /> : stageStatus === "skipped" ? <Ban size={9} /> : index + 1}</span>
<p className="mt-1 truncate text-[10px] font-semibold text-slate-700">{stage}</p>
<p className={cn("truncate text-[9px]", stageStatus === "current" ? "font-semibold text-cyan-700" : stageStatus === "done" ? "text-cyan-700" : "text-slate-400")}>{stageStatusLabel(stageStatus)}</p>
const WORK_ORDER_STEPS = [
{ id: "dispatch", title: "派发", description: "确认处理班组与计划窗口" },
{ id: "execution", title: "执行", description: "按工单要求处置并留痕" },
{ id: "reply", title: "复令", description: "回填结果并完成调度复核" }
] as const;
function WorkOrderSteps({ status, title }: {
status: PeripheralWorkOrder["status"];
title: string;
}) {
const statuses = workOrderStepStatuses(status);
const completed = statuses.filter((item) => item === "done").length;
return <section aria-label={`工单步骤 ${title}`} className="mt-3 border-t border-slate-200/80 pt-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[10px] font-semibold text-slate-500">工单步骤</p>
<span className="text-[10px] tabular-nums text-slate-400">{completed}/{WORK_ORDER_STEPS.length} 完成</span>
</div>
<ol className="grid gap-1.5">
{WORK_ORDER_STEPS.map((step, index) => {
const stepStatus = statuses[index];
return <li key={step.id} className="grid min-w-0 grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-lg bg-white px-2 py-2 shadow-[0_1px_3px_rgba(15,23,42,0.1)]">
<WorkOrderStepIcon status={stepStatus} step={index + 1} />
<span className="min-w-0">
<span className="block text-[11px] font-semibold text-slate-800">{step.title}</span>
<span className="block truncate text-[10px] text-slate-400">{step.description}</span>
</span>
<StatusBadge tone={workOrderStepTone(stepStatus)}>{workOrderStepStatusLabel(stepStatus)}</StatusBadge>
</li>;
})}
</ol>
</div>;
</section>;
}
function WorkOrderStepIcon({ status, step }: { status: WorkOrderStepStatus; step: number }) {
const className = cn(
"grid size-6 place-items-center rounded-full",
status === "done" && "bg-emerald-600 text-white",
status === "current" && "bg-cyan-50 text-cyan-700 ring-2 ring-inset ring-cyan-500",
status === "pending" && "bg-slate-100 text-slate-500 ring-1 ring-inset ring-slate-200",
status === "skipped" && "bg-slate-200 text-slate-500"
);
if (status === "done") return <span className={className}><CheckCircle2 size={13} /></span>;
if (status === "current") return <span className={className}><CircleDot size={13} /></span>;
if (status === "skipped") return <span className={className}><Ban size={12} /></span>;
return <span className={className}><Clock3 size={12} /><span className="sr-only">步骤 {step}</span></span>;
}
function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
@@ -316,7 +375,7 @@ function TestData({ records, canWrite, busy, mutate, client }: ResourceProps & {
return <div>
<ResourceHeading title="测试数据记录" description="用于验证测点回放、缺失值和质量状态。" actionLabel="新增测试数据" canWrite={canWrite} busy={busy} onAdd={create} />
{editorOpen ? <div className="mt-3 rounded-xl bg-cyan-50/60 p-3 ring-1 ring-inset ring-cyan-200">
{editorOpen ? <div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑测试数据" : "新增测试数据"} onClose={clear} />
<label className="mt-2 block text-xs font-medium text-slate-600">名称<Input className="mt-1 h-9" value={label} onChange={(event) => setLabel(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-2 grid grid-cols-2 gap-2"><label className="block text-xs font-medium text-slate-600">测点<Input className="mt-1 h-9" value={pointId} onChange={(event) => setPointId(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label><label className="block text-xs font-medium text-slate-600">单位<Input className="mt-1 h-9" value={unit} onChange={(event) => setUnit(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label></div>
@@ -351,7 +410,7 @@ function ResourceHeading({ title, description, actionLabel, canWrite, busy, onAd
}
function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-cyan-950">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
}
type ResourceProps = {
@@ -366,21 +425,15 @@ function formatTimestamp(value: string) { return value.replace("T", " ").replace
function message(error: unknown) { return error instanceof Error ? error.message : "外围测试资源请求失败"; }
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", completed: "success", cancelled: "danger" } satisfies Record<typeof value, StatusTone>)[value]; }
function taskStatus(orders: PeripheralWorkOrder[]): { label: string; tone: StatusTone } {
if (!orders.length) return { label: "待编排", tone: "neutral" };
if (orders.every((item) => item.status === "completed")) return { label: "已完成", tone: "success" };
if (orders.every((item) => item.status === "cancelled")) return { label: "已取消", tone: "danger" };
if (orders.some((item) => item.status === "in_progress")) return { label: "进行中", tone: "info" };
return { label: "待处理", tone: "warning" };
}
function stageStatuses(status: PeripheralWorkOrder["status"]): [StageStatus, StageStatus, StageStatus] {
function workOrderStepStatuses(status: PeripheralWorkOrder["status"]): [WorkOrderStepStatus, WorkOrderStepStatus, WorkOrderStepStatus] {
if (status === "completed") return ["done", "done", "done"];
if (status === "in_progress") return ["done", "current", "pending"];
if (status === "open") return ["current", "pending", "pending"];
if (status === "cancelled") return ["skipped", "skipped", "skipped"];
return ["pending", "pending", "pending"];
}
function stageStatusLabel(status: StageStatus) { return ({ done: "已完成", current: "当前阶段", pending: "待开始", skipped: "已跳过" } satisfies Record<StageStatus, string>)[status]; }
function workOrderStepTone(status: WorkOrderStepStatus): StatusTone { return ({ done: "success", current: "info", pending: "neutral", skipped: "neutral" } satisfies Record<WorkOrderStepStatus, StatusTone>)[status]; }
function workOrderStepStatusLabel(status: WorkOrderStepStatus) { return ({ done: "已完成", current: "当前步骤", pending: "待开始", skipped: "已跳过" } satisfies Record<WorkOrderStepStatus, string>)[status]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function qualityLabel(value: PeripheralTestDataRecord["quality"]) { return ({ valid: "有效", unknown: "未知", missing: "缺失" } satisfies Record<typeof value, string>)[value]; }
function qualityTone(value: PeripheralTestDataRecord["quality"]): StatusTone { return ({ valid: "success", unknown: "neutral", missing: "warning" } satisfies Record<typeof value, StatusTone>)[value]; }
@@ -18,14 +18,16 @@ import {
type PeripheralContext,
type PeripheralObservations,
type PeripheralPointPage,
type PeripheralScenario
type PeripheralScenario,
type PeripheralWorkOrder
} from "./client";
const DISCLAIMER = "外围系统模拟接入,不是正式工单/现场结果";
export function PeripheralSamplePanel({ open, onClose, getAccessToken }: {
export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTrigger }: {
open: boolean;
onClose: () => void;
getAccessToken?: AccessTokenProvider;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
@@ -145,6 +147,7 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken }: {
canWrite={context.can_write}
capabilities={context.managed_resources}
onInventoryChange={setInventory}
onAgentTrigger={onAgentTrigger}
/>
<SyntheticDemoSection client={client} />
<Disclosure label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>} className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"><p className="pb-2 text-xs leading-5 text-slate-500">结果未知时请保存请求号。此处只查询已经提交的请求,不会重复写入。</p><div className="flex gap-2 pb-3"><input aria-label="提交请求号" value={receiptRequestId} onChange={(event) => setReceiptRequestId(event.target.value.trim())} placeholder="request_id" className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" /><Button variant="outline" disabled={!receiptRequestId || Boolean(busy)} onClick={async () => { setBusy("receipt"); setError(null); try { const result = await client.getReceipt(receiptRequestId); if ("scenario" in result) setScenario(result.scenario); else setError("回执已确认,请刷新测试工单与数据列表。"); } catch (nextError) { setError(errorMessage(nextError)); } finally { setBusy(null); } }}>查询</Button></div></Disclosure>
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { PeripheralWorkOrder } from "./client";
import {
toWaterQualityConditionTask,
toWaterQualityAgentTrigger,
WATER_QUALITY_SCENARIO_CODE
} from "./work-order-agent-trigger";
const order = (overrides: Partial<PeripheralWorkOrder> = {}): PeripheralWorkOrder => ({
work_order_id: "50000000-0000-4000-8000-000000000001",
project_id: "60000000-0000-4000-8000-000000000001",
revision: 1,
title: "居民反映自来水有异味",
summary: "演示小区多户居民反映晨间用水有异味。",
status: "open",
priority: "high",
assignee: null,
scheduled_at: null,
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: WATER_QUALITY_SCENARIO_CODE,
origin: "synthetic",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
created_by: "user",
updated_by: "user",
...overrides
});
describe("water-quality work-order trigger", () => {
it("derives a stable event identity from the persisted first revision", () => {
const first = toWaterQualityAgentTrigger(order());
const recovered = toWaterQualityAgentTrigger(order());
expect(first).toEqual(recovered);
expect(first?.trigger_id).toBe(
"peripheral.work_order.created:60000000-0000-4000-8000-000000000001:50000000-0000-4000-8000-000000000001:1"
);
expect(first?.payload.origin).toBe("synthetic");
});
it("does not trigger general, draft, revised, or unknown-scenario work orders", () => {
expect(toWaterQualityAgentTrigger(order({ work_order_type: "general" }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ status: "draft" }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ revision: 2 }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ scenario_code: null }))).toBeNull();
});
it("maps the event into a structured condition task linked to the Agent session", () => {
const condition = toWaterQualityConditionTask(
order(),
"agent-session-water-quality",
new Date("2026-09-22T02:01:00Z")
);
expect(condition).toMatchObject({
id: "peripheral-water-quality-50000000-0000-4000-8000-000000000001",
kind: "condition",
taskId: "water-quality-complaint",
sessionId: "agent-session-water-quality",
status: "running",
riskLevel: "attention",
scheduledAt: "2026-09-22T02:01:00.000Z"
});
expect(condition.evidence).toContain("投诉位置:演示小区");
});
});
@@ -0,0 +1,75 @@
import type { AgentEventTriggerContext } from "@/features/agent";
import type { ScheduledConditionRecord } from "../types";
import type { PeripheralWorkOrder } from "./client";
export const WATER_QUALITY_SCENARIO_CODE = "WQ-SECONDARY-SUPPLY-001" as const;
export function toWaterQualityAgentTrigger(
workOrder: PeripheralWorkOrder
): AgentEventTriggerContext | null {
if (
workOrder.revision !== 1 ||
workOrder.status !== "open" ||
workOrder.work_order_type !== "water_quality_complaint" ||
workOrder.scenario_code !== WATER_QUALITY_SCENARIO_CODE
) {
return null;
}
return {
schema_version: "agent-trigger@1",
kind: "event",
trigger_id: [
"peripheral.work_order.created",
workOrder.project_id,
workOrder.work_order_id,
workOrder.revision
].join(":"),
occurred_at: workOrder.created_at,
source: "peripheral_sample",
subject_type: "work_order",
subject_id: workOrder.work_order_id,
workflow: "water_quality_complaint",
payload: {
title: workOrder.title,
summary: workOrder.summary,
location: workOrder.location,
priority: workOrder.priority,
scenario_code: WATER_QUALITY_SCENARIO_CODE,
origin: "synthetic"
}
};
}
export function waterQualityTriggerPrompt(workOrder: PeripheralWorkOrder) {
return `外围样板新增水质投诉工单:${workOrder.title}。请启动水质事件调查。`;
}
export function toWaterQualityConditionTask(
workOrder: PeripheralWorkOrder,
sessionId: string,
now = new Date()
): ScheduledConditionRecord {
return {
id: `peripheral-water-quality-${workOrder.work_order_id}`,
kind: "condition",
taskId: "water-quality-complaint",
dataSource: "mock",
sessionId,
title: "水质投诉调查",
summary: workOrder.summary,
status: "running",
riskLevel: workOrder.priority === "urgent" ? "critical" : "attention",
scheduledAt: now.toISOString(),
updatedAt: now.getTime(),
durationMinutes: 15,
detail: `${workOrder.location ?? "位置待确认"} · 由外围工单事件触发`,
recommendation: "优先校验上游监测点数据质量;若上游指标正常,再转向二次供水设施和运维记录核查。",
evidence: [
`来源工单:${workOrder.work_order_id}`,
`投诉位置:${workOrder.location ?? "待确认"}`,
`投诉内容:${workOrder.title}`
],
modelName: "TJWater Agent 事件任务"
};
}
+2 -1
View File
@@ -65,7 +65,8 @@ export type ScheduledConditionTaskId =
| "scada-diagnosis"
| "smart-dispatch"
| "pump-energy"
| "network-simulation";
| "network-simulation"
| "water-quality-complaint";
export type ScheduledConditionRecordTaskId =
| ScheduledConditionTaskId