feat: extend supply workbench demo and map navigation
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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 事件任务"
|
||||
};
|
||||
}
|
||||
@@ -65,7 +65,8 @@ export type ScheduledConditionTaskId =
|
||||
| "scada-diagnosis"
|
||||
| "smart-dispatch"
|
||||
| "pump-energy"
|
||||
| "network-simulation";
|
||||
| "network-simulation"
|
||||
| "water-quality-complaint";
|
||||
|
||||
export type ScheduledConditionRecordTaskId =
|
||||
| ScheduledConditionTaskId
|
||||
|
||||
Reference in New Issue
Block a user