feat: add project-aware agent workspace
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { getWorkbenchAccessToken } from "./request-context";
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import {
|
||||
@@ -48,13 +49,13 @@ export async function fetchElementGisProperties(
|
||||
elementType: WaterNetworkSourceId,
|
||||
elementId: string,
|
||||
projectId: string | null,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<GisAttributeGroup[]> {
|
||||
const ownedProject = projectId
|
||||
? null
|
||||
: await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
|
||||
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
|
||||
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
|
||||
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
|
||||
const path = [
|
||||
@@ -82,13 +83,13 @@ export async function fetchSourceGisFeatureProperties(
|
||||
layerKey: string,
|
||||
sourceFid: number,
|
||||
projectId: string | null,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<GisAttributeGroup[]> {
|
||||
const ownedProject = projectId
|
||||
? null
|
||||
: await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
|
||||
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
|
||||
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
|
||||
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
|
||||
const path = [
|
||||
|
||||
@@ -74,3 +74,12 @@ describe("investigation report read contract", () => {
|
||||
expect(() => investigationReportSchema.parse({ ...result, ...changes })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("retains fallback text from a future component while reading the fixed report facts", () => {
|
||||
const value = investigationReportSchema.parse({ ...base, ui_blocks: [{ evidence_refs: ["query-1"], block: {
|
||||
version: "answer-ui@future", block_id: "future", fallback_text: "旧版客户端仍可阅读。", spec: { future: true },
|
||||
} }] });
|
||||
expect(value.current_conclusion).toBe(base.current_conclusion);
|
||||
expect(value.ui_blocks?.[0].block.fallback_text).toBe("旧版客户端仍可阅读。");
|
||||
expect(investigationReportSchema.parse(base).ui_blocks).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentTaskDiscussionContext } from "@/features/agent";
|
||||
import type { AgentTaskDiscussionContext, AnswerUiSelection } from "@/features/agent";
|
||||
import { reportUiBlocksSchema } from "@/features/agent";
|
||||
|
||||
export type InvestigationDiscussion = {
|
||||
prompt?: string;
|
||||
context?: AgentTaskDiscussionContext;
|
||||
// Display metadata from the selected report, never the task's current round.
|
||||
reportGeneration?: number | null;
|
||||
reference?: AnswerUiSelection;
|
||||
};
|
||||
|
||||
const text = z.string().trim().min(1);
|
||||
@@ -74,7 +76,8 @@ export const investigationReportSchema = z
|
||||
outcome: z.enum(["concluded", "insufficient_data"]).nullish(),
|
||||
stop_reason: text.nullish(),
|
||||
data_gaps: z.array(dataGapSchema).nullish(),
|
||||
diagram: investigationDiagramSchema.nullish()
|
||||
diagram: investigationDiagramSchema.nullish(),
|
||||
ui_blocks: reportUiBlocksSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((report, ctx) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
|
||||
import { investigationPresentationCursorSchema } from "./investigation-presentation";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
|
||||
@@ -316,6 +316,39 @@ const taskDataSnapshotSchema = z.object({
|
||||
"run_burst_location",
|
||||
"analyze_valve_isolation",
|
||||
"run_leakage_identification",
|
||||
"get_mutation_status",
|
||||
"query_nearby_elements",
|
||||
"list_regions",
|
||||
"get_region",
|
||||
"get_region_nodes",
|
||||
"get_node_connections",
|
||||
"query_network_extent",
|
||||
"list_major_nodes",
|
||||
"get_model_configuration",
|
||||
"list_model_configurations",
|
||||
"search_project_places",
|
||||
"reverse_geocode",
|
||||
"get_project_info",
|
||||
"export_project_model",
|
||||
"get_sensor_placement_candidate",
|
||||
"export_sensor_placement",
|
||||
"list_task_schedules",
|
||||
"get_task_run_history",
|
||||
"calculate_demand_allocation",
|
||||
"run_burst_simulation",
|
||||
"edit_model_element",
|
||||
"edit_model_configuration",
|
||||
"clean_scada_readings",
|
||||
"write_scada_readings",
|
||||
"delete_scada_readings",
|
||||
"save_simulation_scenario",
|
||||
"create_sensor_placement",
|
||||
"update_sensor_placement",
|
||||
"create_operational_task",
|
||||
"create_task_schedule",
|
||||
"update_task_schedule",
|
||||
"retry_operational_task",
|
||||
"cancel_task_run",
|
||||
]),
|
||||
source_ref: z.string().min(1),
|
||||
queried_at: workbenchDateTimeSchema,
|
||||
@@ -366,7 +399,7 @@ export type CreateOperationalTaskInput = {
|
||||
|
||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
|
||||
async function operationalTaskRequest(path: string, getAccessToken?: AccessTokenProvider, init?: RequestInit) {
|
||||
async function operationalTaskRequest(path: string, getAccessToken?: WorkbenchAccess, init?: RequestInit) {
|
||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
|
||||
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
|
||||
if (init?.body) headers.set("Content-Type", "application/json");
|
||||
@@ -376,7 +409,7 @@ async function operationalTaskRequest(path: string, getAccessToken?: AccessToken
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchOperationalTask(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||
export async function fetchOperationalTask(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
|
||||
return operationalTaskSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}`,
|
||||
getAccessToken,
|
||||
@@ -384,19 +417,19 @@ export async function fetchOperationalTask(taskId: string, getAccessToken?: Acce
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: AccessTokenProvider) {
|
||||
export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: WorkbenchAccess) {
|
||||
return reportPageSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/reports?limit=20&offset=${offset}`, getAccessToken,
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||
export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
|
||||
return investigationPresentationCursorSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-cursor`, getAccessToken, { signal }
|
||||
));
|
||||
}
|
||||
|
||||
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
|
||||
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: WorkbenchAccess) {
|
||||
return operationalTaskSchema.parse(await operationalTaskRequest(
|
||||
"/operational-tasks",
|
||||
getAccessToken,
|
||||
@@ -409,7 +442,7 @@ export async function decideOperationalTaskAction(taskId: string, actionId: stri
|
||||
expected_version: number;
|
||||
decision: OperationalTaskDecision;
|
||||
comment?: string;
|
||||
}, getAccessToken?: AccessTokenProvider) {
|
||||
}, getAccessToken?: WorkbenchAccess) {
|
||||
return actionSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/decision`,
|
||||
getAccessToken,
|
||||
@@ -420,7 +453,7 @@ export async function decideOperationalTaskAction(taskId: string, actionId: stri
|
||||
export async function executeOperationalTaskAction(taskId: string, actionId: string, input: {
|
||||
request_id: string;
|
||||
expected_version: number;
|
||||
}, getAccessToken?: AccessTokenProvider) {
|
||||
}, getAccessToken?: WorkbenchAccess) {
|
||||
return actionSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/execute`,
|
||||
getAccessToken,
|
||||
@@ -430,7 +463,7 @@ export async function executeOperationalTaskAction(taskId: string, actionId: str
|
||||
|
||||
export async function updateOperationalWorkOrderDraft(taskId: string, actionId: string, input: {
|
||||
request_id: string; expected_version: number; payload: Record<string, unknown>;
|
||||
}, getAccessToken?: AccessTokenProvider) {
|
||||
}, getAccessToken?: WorkbenchAccess) {
|
||||
return actionSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/draft`,
|
||||
getAccessToken, { method: "PATCH", body: JSON.stringify(input) }
|
||||
@@ -439,7 +472,7 @@ export async function updateOperationalWorkOrderDraft(taskId: string, actionId:
|
||||
|
||||
export async function confirmOperationalWorkOrderDispatch(taskId: string, actionId: string, input: {
|
||||
request_id: string; expected_version: number;
|
||||
}, getAccessToken?: AccessTokenProvider) {
|
||||
}, getAccessToken?: WorkbenchAccess) {
|
||||
return actionSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/confirm-dispatch`,
|
||||
getAccessToken, { method: "POST", body: JSON.stringify(input) }
|
||||
@@ -449,14 +482,14 @@ export async function confirmOperationalWorkOrderDispatch(taskId: string, action
|
||||
export async function decideOperationalTaskAccess(taskId: string, accessRequestId: string, input: {
|
||||
request_id: string; expected_version: number; decision: "approved" | "rejected";
|
||||
approved_scopes: string[]; comment?: string;
|
||||
}, getAccessToken?: AccessTokenProvider) {
|
||||
}, getAccessToken?: WorkbenchAccess) {
|
||||
return accessRequestSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/access-requests/${encodeURIComponent(accessRequestId)}/decision`,
|
||||
getAccessToken, { method: "POST", body: JSON.stringify(input) }
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||
export async function fetchOperationalTaskNotifications(getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
|
||||
return notificationPageSchema.parse(await operationalTaskRequest(
|
||||
"/operational-task-notifications?unread_only=false&limit=100&offset=0",
|
||||
getAccessToken,
|
||||
@@ -464,7 +497,7 @@ export async function fetchOperationalTaskNotifications(getAccessToken?: AccessT
|
||||
)).items;
|
||||
}
|
||||
|
||||
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
|
||||
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: WorkbenchAccess) {
|
||||
return notificationSchema.parse(await operationalTaskRequest(
|
||||
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
|
||||
getAccessToken,
|
||||
@@ -472,7 +505,7 @@ export async function acknowledgeOperationalTaskNotification(notificationId: str
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||
export async function fetchOperationalTaskSchedules(getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
|
||||
return schedulePageSchema.parse(await operationalTaskRequest(
|
||||
"/operational-tasks/schedules",
|
||||
getAccessToken,
|
||||
@@ -488,7 +521,7 @@ export async function createOperationalTaskSchedule(input: {
|
||||
interval_seconds: number;
|
||||
next_run_at: string;
|
||||
priority: "normal" | "urgent";
|
||||
}, getAccessToken?: AccessTokenProvider) {
|
||||
}, getAccessToken?: WorkbenchAccess) {
|
||||
return scheduleSchema.parse(await operationalTaskRequest(
|
||||
"/operational-tasks/schedules",
|
||||
getAccessToken,
|
||||
@@ -499,7 +532,7 @@ export async function createOperationalTaskSchedule(input: {
|
||||
export async function reconcileOperationalTaskAction(taskId: string, actionId: string, input: {
|
||||
request_id: string;
|
||||
expected_version: number;
|
||||
}, getAccessToken?: AccessTokenProvider) {
|
||||
}, getAccessToken?: WorkbenchAccess) {
|
||||
return actionSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/reconcile`,
|
||||
getAccessToken,
|
||||
@@ -507,7 +540,7 @@ export async function reconcileOperationalTaskAction(taskId: string, actionId: s
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: AccessTokenProvider) {
|
||||
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: WorkbenchAccess) {
|
||||
return taskDataSnapshotSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/snapshot`,
|
||||
getAccessToken
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { searchPlaces } from "./place-search-client";
|
||||
import { reverseGeocodePoint, searchPlaces } from "./place-search-client";
|
||||
|
||||
const item = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.441891, latitude: 31.220188, category: "道路名", district: "奉贤区" };
|
||||
const results = { items: [item], total: 1, truncated: false, region: "奉贤区" };
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("Fengxian place search client", () => {
|
||||
describe("project place search client", () => {
|
||||
it("uses the new endpoint with trimmed keywords, identity and project headers", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(results));
|
||||
expect(await searchPlaces(" 南桥路 ", "project-1", async () => "read-token", undefined)).toEqual(results);
|
||||
@@ -22,7 +22,7 @@ describe("Fengxian place search client", () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...results, items: [], total: 0 }));
|
||||
expect((await searchPlaces("未知位置", "project-1")).items).toEqual([]);
|
||||
});
|
||||
it("accepts at most ten Fengxian candidates", async () => {
|
||||
it("accepts at most ten candidates", async () => {
|
||||
const capped = { ...results, items: Array.from({ length: 10 }, (_, index) => ({ ...item, id: `place-${index}` })), total: 10, truncated: true };
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(capped));
|
||||
expect(await searchPlaces("南桥路", "project-1")).toEqual(capped);
|
||||
@@ -39,7 +39,21 @@ describe("Fengxian place search client", () => {
|
||||
{ status: "0", location: { lon: "121", lat: "31" } }
|
||||
])("rejects malformed, mismatched or legacy responses: %j", async (payload) => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload));
|
||||
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("未获得有效的地名搜索结果");
|
||||
await expect(searchPlaces("南桥路", "project-1", undefined, undefined, { enabled: true, provider: "tianditu", admin_code: "310120", region: "奉贤区" })).rejects.toThrow("未获得有效的地名搜索结果");
|
||||
});
|
||||
it("accepts candidates in different districts within a configured city", async () => {
|
||||
const cityResults = { ...results, region: "上海市", items: [item, { ...item, id: "place-2", district: "青浦区" }], total: 2 };
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(cityResults));
|
||||
expect(await searchPlaces("路", "project-1", undefined, undefined, {
|
||||
enabled: true, provider: "tianditu", admin_code: "310000", region: "上海市"
|
||||
})).toEqual(cityResults);
|
||||
});
|
||||
it("accepts a configured project outside Fengxian", async () => {
|
||||
const value = { ...results, region: "凤阳县", items: [{ ...item, name: "府城镇", district: "凤阳县" }] };
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(value));
|
||||
expect(await searchPlaces("府城镇", "fengyang", undefined, undefined, {
|
||||
enabled: true, provider: "tianditu", admin_code: "341126", region: "凤阳县"
|
||||
})).toEqual(value);
|
||||
});
|
||||
it("does not fetch blank keywords", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
@@ -59,3 +73,29 @@ describe("Fengxian place search client", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("reverse geocoding point client", () => {
|
||||
it("sends coordinates with project and authentication headers", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(item));
|
||||
expect(await reverseGeocodePoint(item.longitude, item.latitude, "project-1", async () => "token")).toEqual(item);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain("/api/v1/reverse-geocoding-requests");
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ longitude: item.longitude, latitude: item.latitude });
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("project-1");
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token");
|
||||
});
|
||||
it.each([[181, 31], [121, 91], [NaN, 31], [121, Infinity]])("rejects invalid input %s, %s", async (longitude, latitude) => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
await expect(reverseGeocodePoint(longitude, latitude, "project-1")).rejects.toThrow("坐标无效");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects a result that changes the clicked point", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...item, longitude: item.longitude + 0.01 }));
|
||||
await expect(reverseGeocodePoint(item.longitude, item.latitude, "project-1")).rejects.toThrow("未获得有效");
|
||||
});
|
||||
it("preserves an out of project scope message", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ detail: "取点位置不在当前项目的地名搜索范围内(凤阳县)" }, { status: 422 }));
|
||||
await expect(reverseGeocodePoint(117, 32, "project-1")).rejects.toThrow("凤阳县");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getWorkbenchAccessToken } from "./request-context";
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
|
||||
|
||||
export const MAX_PLACE_SEARCH_RESULTS = 10;
|
||||
|
||||
@@ -9,11 +10,11 @@ const placeSchema = z.object({
|
||||
id: z.string().min(1), name: z.string().trim().min(1), address: z.string(),
|
||||
longitude: z.number().finite().min(-180).max(180),
|
||||
latitude: z.number().finite().min(-90).max(90),
|
||||
category: z.string().nullable(), district: z.literal("奉贤区")
|
||||
category: z.string().nullable(), district: z.string().trim().min(1)
|
||||
});
|
||||
const resultsSchema = z.object({
|
||||
items: z.array(placeSchema).max(MAX_PLACE_SEARCH_RESULTS), total: z.number().int().min(0).max(MAX_PLACE_SEARCH_RESULTS),
|
||||
truncated: z.boolean().default(false), region: z.literal("奉贤区")
|
||||
truncated: z.boolean().default(false), region: z.string().trim().min(1)
|
||||
});
|
||||
export type PlaceSearchItem = z.infer<typeof placeSchema>;
|
||||
export type PlaceSearchResults = z.infer<typeof resultsSchema>;
|
||||
@@ -21,33 +22,70 @@ export type PlaceSearchResults = z.infer<typeof resultsSchema>;
|
||||
export async function searchPlaces(
|
||||
keyword: string,
|
||||
projectId: string | null,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
signal?: AbortSignal
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal,
|
||||
configuration?: PlaceSearchConfiguration | null
|
||||
): Promise<PlaceSearchResults> {
|
||||
const query = keyword.trim();
|
||||
if (!query) throw new Error("请输入需要搜索的地址或地名");
|
||||
if (query.length > 120) throw new Error("查询关键字不能超过120个字符");
|
||||
const requestSignal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
|
||||
: AbortSignal.timeout(20_000);
|
||||
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
|
||||
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
|
||||
requestSignal.throwIfAborted();
|
||||
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
|
||||
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
|
||||
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/place-search-requests`, {
|
||||
method: "POST",
|
||||
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
|
||||
body: JSON.stringify({ keyword: query }),
|
||||
signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
|
||||
const payload = await requestPlaceData("place-search-requests", { keyword: query }, projectId, getAccessToken, signal);
|
||||
const parsed = resultsSchema.safeParse(payload);
|
||||
if (!parsed.success
|
||||
|| new Set(parsed.data.items.map((item) => item.id)).size !== parsed.data.items.length
|
||||
|| parsed.data.total !== parsed.data.items.length) {
|
||||
|| parsed.data.total !== parsed.data.items.length
|
||||
|| (configuration && (parsed.data.region !== configuration.region
|
||||
|| (!configuration.admin_code.endsWith("00") && parsed.data.items.some((item) => item.district !== configuration.region))))) {
|
||||
throw new Error("未获得有效的地名搜索结果,请稍后重试");
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
|
||||
export async function reverseGeocodePoint(
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
projectId: string | null,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal,
|
||||
configuration?: PlaceSearchConfiguration | null
|
||||
): Promise<PlaceSearchItem> {
|
||||
const coordinates = z.object({
|
||||
longitude: z.number().finite().min(-180).max(180),
|
||||
latitude: z.number().finite().min(-90).max(90)
|
||||
}).safeParse({ longitude, latitude });
|
||||
if (!coordinates.success) throw new Error("地图取点坐标无效");
|
||||
const payload = await requestPlaceData("reverse-geocoding-requests", coordinates.data, projectId, getAccessToken, signal);
|
||||
const parsed = placeSchema.safeParse(payload);
|
||||
if (!parsed.success || parsed.data.longitude !== longitude || parsed.data.latitude !== latitude
|
||||
|| (configuration && !configuration.admin_code.endsWith("00") && parsed.data.district !== configuration.region)) {
|
||||
throw new Error("未获得有效的地图取点结果,请重新取点");
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function requestPlaceData(
|
||||
path: string,
|
||||
body: Record<string, string | number>,
|
||||
projectId: string | null,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown> {
|
||||
const requestSignal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
|
||||
: AbortSignal.timeout(20_000);
|
||||
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
|
||||
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
|
||||
requestSignal.throwIfAborted();
|
||||
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
|
||||
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
|
||||
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/${path}`, {
|
||||
method: "POST",
|
||||
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
|
||||
body: JSON.stringify(body),
|
||||
signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchWorkbenchProjectPresentation,
|
||||
parsePlaceSearchConfiguration,
|
||||
fetchWorkbenchProjects,
|
||||
clearWorkbenchProjectCache,
|
||||
findOwnedWorkbenchProject,
|
||||
parseProjectMapExtent
|
||||
} from "./project-client";
|
||||
|
||||
describe("workbench project client", () => {
|
||||
it("caches complete authorized lists across repeated openings and refreshes explicitly or after expiry", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
const fetch = vi.fn(async () => Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const provider = async () => "owner-token";
|
||||
await Promise.all([fetchWorkbenchProjects(provider), fetchWorkbenchProjects(provider)]);
|
||||
for (let i = 0; i < 10; i++) await fetchWorkbenchProjects(provider);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
await fetchWorkbenchProjects(provider, undefined, true);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
clock.mockReturnValue(300_101);
|
||||
await fetchWorkbenchProjects(provider);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
clearWorkbenchProjectCache(provider);
|
||||
await fetchWorkbenchProjects(provider);
|
||||
expect(fetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it("uses the explicitly selected project for each request without rediscovering the demo project", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn(async () => "token");
|
||||
const project = { project_id: "fengyang", gs_workspace: "fengyang_v2", status: "active" };
|
||||
const scoped = await findOwnedWorkbenchProject({ project, getAccessToken });
|
||||
const other = await findOwnedWorkbenchProject({ project: { ...project, project_id: "demo" }, getAccessToken });
|
||||
expect(scoped).toEqual({ project, accessToken: "token" });
|
||||
expect(other.project.project_id).toBe("demo");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads all authorized project pages and excludes inactive projects", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [
|
||||
{ project_id: "old", gs_workspace: "old", status: "inactive" },
|
||||
{ project_id: "demo", gs_workspace: "tjwater_next", status: "active" }
|
||||
], total: 3, limit: 1000, offset: 0 })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [
|
||||
{ project_id: "fengyang", gs_workspace: "fengyang_v2", status: "active" }
|
||||
], total: 3, limit: 1000, offset: 2 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect((await fetchWorkbenchProjects()).map((project) => project.project_id)).toEqual(["demo", "fengyang"]);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain("offset=2");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
@@ -158,3 +204,16 @@ describe("workbench project client", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("project place search configuration", () => {
|
||||
it("reads configured regions and preserves unrelated map configuration", () => {
|
||||
const config = { enabled: true, provider: "tianditu", admin_code: "341126", region: "凤阳县" };
|
||||
expect(parsePlaceSearchConfiguration({ legend: "existing", place_search: config })).toEqual(config);
|
||||
});
|
||||
it.each([undefined, {}, { place_search: null }, { place_search: { enabled: false } },
|
||||
{ place_search: { enabled: true, provider: "tianditu", admin_code: "invalid", region: "凤阳县" } }
|
||||
])("disables absent or invalid configuration: %j", (config) => {
|
||||
expect(parsePlaceSearchConfiguration(config)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ResourceCache } from "@/shared/cache/resource-cache";
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { NetworkViewParams } from "../map/camera";
|
||||
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
|
||||
import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context";
|
||||
|
||||
const projectSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
@@ -22,12 +24,27 @@ const projectPageSchema = z.object({
|
||||
|
||||
export type WorkbenchProject = z.infer<typeof projectSchema>;
|
||||
|
||||
const placeSearchConfigurationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
provider: z.literal("tianditu"),
|
||||
admin_code: z.string().regex(/^(?:156)?[0-9]{6}$/),
|
||||
region: z.string().trim().min(1)
|
||||
});
|
||||
export type PlaceSearchConfiguration = z.infer<typeof placeSearchConfigurationSchema>;
|
||||
|
||||
export function parsePlaceSearchConfiguration(mapConfig: unknown): PlaceSearchConfiguration | null {
|
||||
if (!mapConfig || typeof mapConfig !== "object" || !("place_search" in mapConfig)) return null;
|
||||
const parsed = placeSearchConfigurationSchema.safeParse(mapConfig.place_search);
|
||||
return parsed.success && parsed.data.enabled ? parsed.data : null;
|
||||
}
|
||||
|
||||
const projectMetadataSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
code: z.string().trim().min(1),
|
||||
gs_workspace: z.string().trim().min(1),
|
||||
map_extent: z.unknown().nullable().optional(),
|
||||
map_config: z.unknown().optional(),
|
||||
status: z.string(),
|
||||
project_role: z.string().trim().min(1)
|
||||
});
|
||||
@@ -64,13 +81,14 @@ export type WorkbenchProjectPresentation = {
|
||||
systemRole: string | null;
|
||||
isSystemAdmin: boolean;
|
||||
networkView: NetworkViewParams | null;
|
||||
placeSearch: PlaceSearchConfiguration | null;
|
||||
metadataAvailable: boolean;
|
||||
accessContextAvailable: boolean;
|
||||
};
|
||||
|
||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
const PROJECT_CACHE_TTL_MS = 5 * 60_000;
|
||||
const projectContextPromises = new WeakMap<AccessTokenProvider, {
|
||||
const projectContextPromises = new WeakMap<WorkbenchAccess, {
|
||||
promise: Promise<WorkbenchProject>;
|
||||
expiresAt: number;
|
||||
}>();
|
||||
@@ -86,11 +104,13 @@ function awaitProject(promise: Promise<WorkbenchProject>, signal?: AbortSignal)
|
||||
}
|
||||
|
||||
export async function findOwnedWorkbenchProject(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
|
||||
const accessToken = await getAccessToken?.();
|
||||
const accessToken = await getWorkbenchAccessToken(getAccessToken);
|
||||
signal?.throwIfAborted();
|
||||
const scopedProject = getWorkbenchProject(getAccessToken);
|
||||
if (scopedProject) return { project: scopedProject, accessToken };
|
||||
const existing = getAccessToken
|
||||
? projectContextPromises.get(getAccessToken)
|
||||
: undefined;
|
||||
@@ -123,7 +143,7 @@ export async function findOwnedWorkbenchProject(
|
||||
}
|
||||
|
||||
export async function fetchWorkbenchProjectPresentation(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkbenchProjectPresentation> {
|
||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||
@@ -147,11 +167,53 @@ export async function fetchWorkbenchProjectPresentation(
|
||||
systemRole: accessContext?.system_role ?? null,
|
||||
isSystemAdmin: accessContext?.is_system_admin ?? false,
|
||||
networkView: parseProjectMapExtent(metadata?.map_extent),
|
||||
placeSearch: parsePlaceSearchConfiguration(metadata?.map_config),
|
||||
metadataAvailable: metadata !== null,
|
||||
accessContextAvailable: accessContext !== null
|
||||
};
|
||||
}
|
||||
|
||||
const projectListCaches = new WeakMap<AccessTokenProvider, ResourceCache>();
|
||||
|
||||
export function clearWorkbenchProjectCache(provider?: AccessTokenProvider) {
|
||||
if (provider) { projectListCaches.delete(provider); projectContextPromises.delete(provider); }
|
||||
}
|
||||
|
||||
export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal, force = false) {
|
||||
if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal);
|
||||
let cache = projectListCaches.get(getAccessToken);
|
||||
if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); }
|
||||
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(15_000)),
|
||||
PROJECT_CACHE_TTL_MS, { force });
|
||||
if (!signal) return pending;
|
||||
signal.throwIfAborted();
|
||||
return new Promise<WorkbenchProject[]>((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||
const token = await getAccessToken?.();
|
||||
signal?.throwIfAborted();
|
||||
const projects: WorkbenchProject[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
|
||||
headers: createWorkbenchHeaders(token), signal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
||||
const page = projectPageSchema.parse(payload);
|
||||
projects.push(...page.items.filter((project) => project.status === "active"));
|
||||
offset += page.items.length;
|
||||
if (offset >= page.total) break;
|
||||
if (!page.items.length) throw new Error("项目列表分页未返回后续数据");
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
export function parseProjectMapExtent(value: unknown): NetworkViewParams | null {
|
||||
let bbox: z.infer<typeof bboxSchema> | null = null;
|
||||
if (value && typeof value === "object" && "bbox" in value) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { PipeFlowCalculation } from "../map/flow-overlay";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
@@ -20,7 +20,7 @@ export type RealtimePipeFlowSnapshot = {
|
||||
};
|
||||
|
||||
export async function fetchRealtimePipeFlows(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal,
|
||||
now = new Date()
|
||||
): Promise<RealtimePipeFlowSnapshot> {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchProject, WorkbenchProjectPresentation } from "./project-client";
|
||||
|
||||
export type WorkbenchRequestContext = {
|
||||
project: WorkbenchProject;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
presentation?: WorkbenchProjectPresentation;
|
||||
};
|
||||
|
||||
// Unscoped callers are retained for isolated tools and existing integrations.
|
||||
export type WorkbenchAccess = WorkbenchRequestContext | AccessTokenProvider;
|
||||
|
||||
export function getWorkbenchTokenProvider(access?: WorkbenchAccess): AccessTokenProvider | undefined {
|
||||
return typeof access === "function" ? access : access?.getAccessToken;
|
||||
}
|
||||
|
||||
export function getWorkbenchAccessToken(access?: WorkbenchAccess) {
|
||||
return getWorkbenchTokenProvider(access)?.();
|
||||
}
|
||||
|
||||
export function getWorkbenchProject(access?: WorkbenchAccess) {
|
||||
return typeof access === "object" ? access.project : undefined;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchScadaDeviceCollection, fetchScadaSnapshot } from "./scada-client";
|
||||
import { fetchScadaDeviceCollection, fetchScadaSnapshot, ScadaSystemClient } from "./scada-client";
|
||||
|
||||
const device = {
|
||||
device_id: "SCADA-1",
|
||||
@@ -119,4 +119,47 @@ describe("SCADA API client", () => {
|
||||
await expect(fetchScadaDeviceCollection()).rejects.toThrow("未找到GeoServer工作空间");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the bounded latest-value projection and reuses the resolved project", async () => {
|
||||
const latestPayload = {
|
||||
items: [{
|
||||
device_id: "SCADA-1",
|
||||
device_type: "pressure",
|
||||
measurement_unit: "MPa",
|
||||
transmission_mode: "realtime",
|
||||
transmission_frequency: "15min",
|
||||
observed_at: "2026-10-22T01:02:00Z",
|
||||
monitored_value: 0.31,
|
||||
cleaned_value: 0.3
|
||||
}],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
observed_count: 1,
|
||||
latest_observed_at: "2026-10-22T01:02:00Z"
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(latestPayload), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(latestPayload), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn().mockResolvedValue("access-token");
|
||||
const client = new ScadaSystemClient(getAccessToken);
|
||||
|
||||
await expect(client.getLatestReadings()).resolves.toEqual(latestPayload);
|
||||
await expect(client.getLatestReadings()).resolves.toEqual(latestPayload);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
|
||||
"/api/v1/timeseries/scada-readings/latest?limit=500&offset=0"
|
||||
);
|
||||
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("X-Project-Id")).toBe("project-1");
|
||||
expect(getAccessToken).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { z } from "zod";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
@@ -36,7 +36,29 @@ const scadaReadingSchema = z.object({
|
||||
|
||||
const scadaReadingsSchema = z.array(scadaReadingSchema);
|
||||
|
||||
const latestScadaReadingSchema = z.object({
|
||||
device_id: z.string().trim().min(1),
|
||||
device_type: z.string().trim().min(1),
|
||||
measurement_unit: z.string(),
|
||||
transmission_mode: z.string(),
|
||||
transmission_frequency: z.string(),
|
||||
observed_at: z.string().datetime({ offset: true }).nullable(),
|
||||
monitored_value: z.number().finite().nullable(),
|
||||
cleaned_value: z.number().finite().nullable()
|
||||
});
|
||||
|
||||
const latestScadaReadingsSchema = z.object({
|
||||
items: z.array(latestScadaReadingSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
limit: z.number().int().min(1).max(500),
|
||||
offset: z.number().int().nonnegative(),
|
||||
observed_count: z.number().int().nonnegative(),
|
||||
latest_observed_at: z.string().datetime({ offset: true }).nullable()
|
||||
});
|
||||
|
||||
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
|
||||
export type LatestScadaReading = z.infer<typeof latestScadaReadingSchema>;
|
||||
export type LatestScadaReadings = z.infer<typeof latestScadaReadingsSchema>;
|
||||
|
||||
export type ScadaSnapshot = {
|
||||
collection: FeatureCollection<Point, ScadaDevice>;
|
||||
@@ -49,15 +71,49 @@ const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
const SCADA_LOOKBACK_MS = 15 * 60 * 1000;
|
||||
const SCADA_READING_BATCH_SIZE = 100;
|
||||
|
||||
export class ScadaSystemClient {
|
||||
private projectIdPromise: Promise<string> | null = null;
|
||||
|
||||
constructor(private readonly getAccessToken?: WorkbenchAccess) {}
|
||||
|
||||
async getLatestReadings(signal?: AbortSignal): Promise<LatestScadaReadings> {
|
||||
const [projectId, accessToken] = await Promise.all([
|
||||
this.resolveProjectId(),
|
||||
getWorkbenchAccessToken(this.getAccessToken)
|
||||
]);
|
||||
return latestScadaReadingsSchema.parse(
|
||||
await requestJson(
|
||||
"/api/v1/timeseries/scada-readings/latest?limit=500&offset=0",
|
||||
accessToken,
|
||||
projectId,
|
||||
signal
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private resolveProjectId() {
|
||||
// Project identity is stable for this panel instance. Do not bind the shared
|
||||
// promise to an individual refresh signal because React StrictMode cancels
|
||||
// and immediately restarts effects during development.
|
||||
this.projectIdPromise ??= findOwnedWorkbenchProject(this.getAccessToken)
|
||||
.then(({ project }) => project.project_id)
|
||||
.catch((error) => {
|
||||
this.projectIdPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return this.projectIdPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchScadaDeviceCollection(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<FeatureCollection<Point, ScadaDevice>> {
|
||||
return (await fetchScadaSnapshot(getAccessToken, signal)).collection;
|
||||
}
|
||||
|
||||
export async function fetchScadaSnapshot(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal,
|
||||
now = new Date()
|
||||
): Promise<ScadaSnapshot> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function fetchWorkbenchAnalysisHistory(
|
||||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
signal?: AbortSignal;
|
||||
} = {}
|
||||
): Promise<WorkbenchAnalysisHistoryPage> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import type { FeatureCollection, Geometry, Position } from "geojson";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { formatDateTime } from "@/shared/utils/date-time";
|
||||
import type {
|
||||
@@ -254,7 +254,7 @@ export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntim
|
||||
|
||||
export async function fetchWorkbenchAnalysisRun(
|
||||
runId: string,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkbenchAnalysisProjection> {
|
||||
const normalizedRunId = runId.trim();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import {
|
||||
parseOperationalTaskNotification,
|
||||
@@ -60,7 +60,7 @@ export type WorkbenchSyncSnapshot = {
|
||||
};
|
||||
|
||||
export async function fetchWorkbenchSyncSnapshot(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkbenchSyncSnapshot> {
|
||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||
@@ -82,7 +82,7 @@ export async function fetchWorkbenchSyncSnapshot(
|
||||
|
||||
export async function consumeWorkbenchEvents(input: {
|
||||
after: number;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onEvents: (events: WorkbenchEvent[]) => void;
|
||||
onOpen?: () => void;
|
||||
onReset: () => Promise<void>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
|
||||
@@ -22,7 +22,7 @@ export function AnalysisHistorySection({
|
||||
getAccessToken,
|
||||
onOpenArtifact
|
||||
}: {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onOpenArtifact: (artifact: AnalysisArtifact) => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<WorkbenchAnalysisHistoryItem[]>([]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BarChart3, X } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { AnalysisArtifact } from "../workspace/workspace-model";
|
||||
import { AnalysisHistorySection } from "./analysis-history-section";
|
||||
@@ -12,7 +12,7 @@ export function AnalysisResultsPanel({
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onOpenArtifact: (artifact: AnalysisArtifact) => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Check, Copy, Database, X } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
@@ -36,7 +36,7 @@ import { FeaturePropertyList } from "./feature-property-list";
|
||||
type FeaturePopoverProps = {
|
||||
feature: DetailFeature | null;
|
||||
projectId: string | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
|
||||
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
|
||||
import { useId, useLayoutEffect, useRef } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
@@ -31,8 +31,9 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
const candidateListRef = useRef<HTMLUListElement | null>(null);
|
||||
const changeSelectionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const { address, result, results, pending, error, enabled } = geocoding;
|
||||
const mapResult = geocoding.origin === "map";
|
||||
const selectionCollapsed = Boolean(result && !geocoding.choosing);
|
||||
const hideSelectionHint = Boolean(results?.items.length && enabled && !error);
|
||||
const hideSelectionHint = Boolean(results?.items.length && enabled && !error && !geocoding.picking);
|
||||
useLayoutEffect(() => {
|
||||
if (selectionCollapsed || !result) return;
|
||||
const list = candidateListRef.current;
|
||||
@@ -42,12 +43,13 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
const row = selected.getBoundingClientRect();
|
||||
list.scrollTop += row.top - viewport.top - (list.clientHeight - row.height) / 2;
|
||||
}, [selectionCollapsed, result, results]);
|
||||
const status = error
|
||||
?? (pending ? "正在搜索地名…"
|
||||
const status = !geocoding.configured ? "当前项目暂未配置地名搜索" : error
|
||||
?? (pending ? (mapResult ? "正在查询取点位置…" : "正在搜索地名…")
|
||||
: !enabled ? "等待地图加载后即可定位"
|
||||
: geocoding.picking ? "点击地图查询地名,按 Esc 结束取点"
|
||||
: result ? "已在地图标出所选位置"
|
||||
: results ? results.items.length ? "选择一个候选位置进行定位" : "奉贤区内未找到匹配位置,请细化关键字"
|
||||
: "搜索奉贤区内地址或地名,支持回车查询");
|
||||
: results ? results.items.length ? "选择一个候选位置进行定位" : `${geocoding.region}内未找到匹配位置,请细化关键字`
|
||||
: `搜索${geocoding.region}内地址或地名,支持回车查询`);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
@@ -79,12 +81,17 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
aria-label="搜索地名"
|
||||
className="shrink-0 gap-1.5 px-3 text-xs"
|
||||
disabled={!enabled || !address.trim()}
|
||||
loading={pending}
|
||||
loading={pending && !mapResult}
|
||||
loadingLabel="正在搜索地名"
|
||||
>
|
||||
<Search aria-hidden="true" />查询
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" className="w-full text-xs"
|
||||
aria-label="从地图取点查找地名" aria-pressed={geocoding.picking} disabled={!enabled}
|
||||
onClick={geocoding.togglePicking}>
|
||||
<Crosshair aria-hidden="true" />{geocoding.picking ? "结束取点" : "地图取点"}
|
||||
</Button>
|
||||
<div
|
||||
id={statusId}
|
||||
role={error ? "alert" : "status"}
|
||||
@@ -97,9 +104,9 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
</form>
|
||||
{results && results.items.length > 0 ? (
|
||||
<section aria-label="搜索结果" className="flex min-h-0 flex-1 flex-col gap-2 border-t border-[var(--surface-divider)] pt-3">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]">
|
||||
<span className="text-xs font-semibold">{selectionCollapsed ? "已选位置" : "选择位置"}</span>
|
||||
{selectionCollapsed ? (
|
||||
<div className={cn("flex shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]", mapResult ? "h-6" : "h-10")}>
|
||||
<span className="text-xs font-semibold">{selectionCollapsed ? mapResult ? "取点结果" : "已选位置" : "选择位置"}</span>
|
||||
{selectionCollapsed && !mapResult ? (
|
||||
<Button ref={changeSelectionRef} type="button" variant="secondary" className="gap-1.5 px-3 text-xs" aria-expanded={false}
|
||||
onClick={(event) => {
|
||||
geocoding.changeSelection();
|
||||
@@ -111,7 +118,7 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
}}>
|
||||
更换位置<ChevronDown aria-hidden="true" />
|
||||
</Button>
|
||||
) : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
|
||||
) : mapResult ? <span>地图位置</span> : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
|
||||
</div>
|
||||
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
|
||||
{results.items.map((item, index) => (
|
||||
@@ -132,8 +139,8 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
{result && selectionCollapsed ? (
|
||||
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-h-16 items-center gap-3 border-b border-[var(--surface-divider)] px-1 py-3 text-xs">
|
||||
<div className={mapResult ? "space-y-2" : "space-y-3"}>
|
||||
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
|
||||
<PlaceSummary item={result} annotation={result.category ?? result.district} />
|
||||
</div>
|
||||
<dl aria-label="经纬度坐标" className="space-y-1 px-1">
|
||||
|
||||
@@ -55,7 +55,7 @@ export function InvestigationDiagramView({
|
||||
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
|
||||
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
|
||||
running?: boolean;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cn } from "@/shared/ui/cn";
|
||||
/** Report-specific progressive disclosure. The source stays mounted while reading. */
|
||||
export function InvestigationExpandCard({
|
||||
title, label = title, preview, children, wide = false, onOpen, contentRef,
|
||||
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false
|
||||
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false, headingAccessory
|
||||
}: {
|
||||
title: string;
|
||||
label?: string;
|
||||
@@ -25,6 +25,7 @@ export function InvestigationExpandCard({
|
||||
disabled?: boolean;
|
||||
/** Move one live content host between slots, preserving expensive renderers. */
|
||||
reuseContent?: boolean;
|
||||
headingAccessory?: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -77,7 +78,10 @@ export function InvestigationExpandCard({
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<div className="investigation-card-heading">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
|
||||
{headingAccessory}
|
||||
</div>
|
||||
<Dialog.Trigger asChild>
|
||||
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
|
||||
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { InvestigationReportBlocks } from "./investigation-report-blocks";
|
||||
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
|
||||
|
||||
const now = "2026-10-20T08:00:00Z";
|
||||
const task: Pick<OperationalTask, "evidence"> = { evidence: [{
|
||||
evidence_id: "saved-evidence", kind: "synthetic", summary: "保存的演练压力,不代表现场事实。",
|
||||
source_label: "保存的监测来源", observed_at: now, recorded_at: now, recorded_by: "agent", source_ref: null, has_snapshot: false,
|
||||
}] };
|
||||
const snapshot: OperationalTaskReport = {
|
||||
revision: 3, execution_generation: 2, source_session_id: "session", published_at: now, published_by: "agent",
|
||||
report: { title: "演练调查报告", current_conclusion: "待核验", conclusion_boundary: "演练数据",
|
||||
evidence_refs: ["saved-evidence"], recommendations: [], pending_items: [], ui_blocks: [{
|
||||
evidence_refs: ["saved-evidence"], block: { version: "answer-ui@1", block_id: "analysis", fallback_text: "分析待核验。",
|
||||
spec: { root: "evidence", elements: { evidence: { type: "Evidence", children: [], props: {
|
||||
title: "监测依据", summary: "模型填写的错误摘要", source: "模型填写的错误来源", kind: "observed",
|
||||
observed_at: null, evidence_id: "saved-evidence",
|
||||
} } } },
|
||||
},
|
||||
}] },
|
||||
};
|
||||
|
||||
describe("formal report interactive content", () => {
|
||||
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => {
|
||||
const onContinue = vi.fn();
|
||||
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
|
||||
expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible();
|
||||
expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "针对这部分提问" }));
|
||||
expect(onContinue).toHaveBeenCalledWith(expect.objectContaining({
|
||||
context: { report_revision: 3 }, reportGeneration: 2,
|
||||
reference: { title: "演练调查报告 · 报告第 3 版 · 监测依据",
|
||||
text: expect.stringContaining("保存的监测来源\n类别:演练数据") },
|
||||
}));
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
|
||||
});
|
||||
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].block.version = "answer-ui@future";
|
||||
next.report.ui_blocks![0].block.spec = { type: "iframe", src: "https://example.invalid" };
|
||||
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByText("分析待核验。")).toBeVisible();
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
it("turns report form input into a versioned conversation draft", async () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: {
|
||||
type: "FollowUpForm", children: [], props: { title: "补充资料", description: "填写核验位置",
|
||||
fields: [{ name: "site", label: "位置", kind: "text", required: true }] },
|
||||
} } };
|
||||
const onContinue = vi.fn();
|
||||
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={onContinue} />);
|
||||
fireEvent.change(screen.getByLabelText("位置(必填)"), { target: { value: "演练位置" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "带入对话" }));
|
||||
expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible();
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置");
|
||||
expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent";
|
||||
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
|
||||
import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
|
||||
|
||||
export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: {
|
||||
task: Pick<OperationalTask, "evidence">;
|
||||
snapshot: OperationalTaskReport;
|
||||
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
}) {
|
||||
const blocks = snapshot.report.ui_blocks ?? [];
|
||||
if (!blocks.length) return null;
|
||||
const ask = async (selection: AnswerUiSelection) => {
|
||||
const result = await onContinue({
|
||||
reference: {
|
||||
title: `${snapshot.report.title} · 报告第 ${snapshot.revision} 版 · ${selection.title}`,
|
||||
text: `${selection.text}\n报告第 ${snapshot.revision} 版${snapshot.execution_generation ? ` · 第 ${snapshot.execution_generation} 轮` : ""}`
|
||||
},
|
||||
context: { report_revision: snapshot.revision },
|
||||
reportGeneration: snapshot.execution_generation
|
||||
});
|
||||
if (result === false) throw new Error("引用暂未带入对话,请重试。");
|
||||
};
|
||||
return <section className="investigation-report-section space-y-4" aria-label="交互分析">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">数据与分析</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">探索报告中的数据,选择内容后可引用到对话。</p>
|
||||
</div>
|
||||
{blocks.map((item) => {
|
||||
const parsed = parseAnswerUiBlock(item.block);
|
||||
const evidence = task.evidence.filter((entry) => item.evidence_refs.includes(entry.evidence_id));
|
||||
const quote = (selection: AnswerUiSelection) => ask({
|
||||
...selection,
|
||||
text: `${selection.text}\n引用依据:${item.evidence_refs.map((id) => {
|
||||
const record = evidence.find((entry) => entry.evidence_id === id);
|
||||
return record ? `${record.source_label}(${id})` : `${id}(记录当前不可用)`;
|
||||
}).join("、")}`
|
||||
});
|
||||
// Evidence facts are supplied by the saved task record, not generated presentation props.
|
||||
const block: AnswerUiBlock | null = parsed ? { ...parsed, spec: {
|
||||
...parsed.spec,
|
||||
elements: Object.fromEntries(Object.entries(parsed.spec.elements).map(([id, node]) => {
|
||||
if (node.type !== "Evidence") return [id, node];
|
||||
const record = evidence.find((entry) => entry.evidence_id === node.props.evidence_id);
|
||||
return [id, record ? { ...node, props: {
|
||||
...node.props, summary: record.summary, source: record.source_label,
|
||||
observed_at: record.observed_at,
|
||||
kind: record.kind === "synthetic" ? "demo" : record.kind === "human_input" ? "human_input" : "observed"
|
||||
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
|
||||
}))
|
||||
} } : null;
|
||||
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
|
||||
{block ? <AnswerUiView block={block} onAsk={quote} submitMode="draft"
|
||||
onSubmit={(text) => quote({ title: "补充资料", text })}
|
||||
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
|
||||
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
引用依据:{evidence.map((entry) => entry.source_label).join("、") || "关联记录当前不可用"}
|
||||
</p>
|
||||
</div>;
|
||||
})}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
|
||||
|
||||
const labels = {
|
||||
任务报告: "结论",
|
||||
交互分析: "分析",
|
||||
当前调查进展: "调查进展",
|
||||
当前需处理: "待办",
|
||||
处置进展: "处置",
|
||||
数据缺口: "缺口",
|
||||
调查脉络: "脉络",
|
||||
主要依据: "依据",
|
||||
任务时间线: "时间线",
|
||||
报告版本: "版本",
|
||||
};
|
||||
export type InvestigationReportSection = keyof typeof labels;
|
||||
|
||||
export function InvestigationReportNavigation({ reportRef, sections }: {
|
||||
reportRef: RefObject<HTMLElement | null>;
|
||||
sections: InvestigationReportSection[];
|
||||
}) {
|
||||
const navigation = useRef<HTMLElement>(null);
|
||||
const lastJump = useRef<{ section: string; scrollTop: number } | null>(null);
|
||||
const reading = useReadingWorkspace();
|
||||
const [current, setCurrent] = useState<string>("任务报告");
|
||||
const sectionKey = sections.join("|");
|
||||
|
||||
useEffect(() => {
|
||||
const report = reportRef.current;
|
||||
const nav = navigation.current;
|
||||
const scroll = report?.closest<HTMLElement>("[data-reading-scroll]");
|
||||
if (!report || !nav || !scroll) return;
|
||||
const targets = sectionKey.split("|").flatMap((label) => {
|
||||
const element = report.querySelector<HTMLElement>(`section[aria-label="${label}"]`);
|
||||
return element ? [{ label, element }] : [];
|
||||
});
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
// Near the document end an anchor cannot always reach the top. Keep the
|
||||
// chosen chapter current until the reader actually moves away from it.
|
||||
if (lastJump.current && Math.abs(lastJump.current.scrollTop - scroll.scrollTop) < 1
|
||||
&& targets.some(({ label }) => label === lastJump.current?.section)) {
|
||||
setCurrent(lastJump.current.section);
|
||||
return;
|
||||
}
|
||||
lastJump.current = null;
|
||||
const edge = scroll.getBoundingClientRect().top + nav.offsetHeight + 20;
|
||||
const active = [...targets].reverse().find(({ element }) => element.getBoundingClientRect().top <= edge);
|
||||
setCurrent(active?.label ?? targets[0]?.label ?? "");
|
||||
};
|
||||
const schedule = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(update);
|
||||
};
|
||||
scroll.addEventListener("scroll", schedule, { passive: true });
|
||||
const observer = new ResizeObserver(schedule);
|
||||
observer.observe(report);
|
||||
observer.observe(scroll);
|
||||
schedule();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
scroll.removeEventListener("scroll", schedule);
|
||||
};
|
||||
}, [reportRef, sectionKey, reading?.active, reading?.view]);
|
||||
|
||||
// Keep the active chapter reachable when the navigation overflows on a phone.
|
||||
useEffect(() => {
|
||||
const nav = navigation.current;
|
||||
const button = nav?.querySelector<HTMLElement>('[aria-current="location"]');
|
||||
if (!nav || !button) return;
|
||||
const bounds = nav.getBoundingClientRect();
|
||||
const target = button.getBoundingClientRect();
|
||||
if (target.left < bounds.left) nav.scrollLeft -= bounds.left - target.left + 4;
|
||||
else if (target.right > bounds.right) nav.scrollLeft += target.right - bounds.right + 4;
|
||||
}, [current]);
|
||||
|
||||
return <nav ref={navigation} className="investigation-report-navigation" aria-label="报告章节">
|
||||
{sections.map((section) => <Button key={section} type="button" variant="ghost" size="sm"
|
||||
aria-current={current === section ? "location" : undefined}
|
||||
className="shrink-0 font-normal"
|
||||
onClick={() => {
|
||||
const target = reportRef.current?.querySelector<HTMLElement>(`section[aria-label="${section}"]`);
|
||||
if (!target) return;
|
||||
target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
const scroll = target.closest<HTMLElement>("[data-reading-scroll]");
|
||||
if (scroll) lastJump.current = { section, scrollTop: scroll.scrollTop };
|
||||
setCurrent(section);
|
||||
}}>
|
||||
{labels[section]}
|
||||
</Button>)}
|
||||
</nav>;
|
||||
}
|
||||
@@ -21,17 +21,20 @@ import {
|
||||
import { InvestigationDiagramView } from "./investigation-diagram-view";
|
||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||
import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
|
||||
import { ExpandableReading } from "@/shared/ui/reading-workspace";
|
||||
import { InvestigationReportBlocks } from "./investigation-report-blocks";
|
||||
import { InvestigationReportNavigation, type InvestigationReportSection } from "./investigation-report-navigation";
|
||||
|
||||
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
|
||||
type Props = {
|
||||
task: OperationalTask;
|
||||
onContinue: (discussion?: InvestigationDiscussion) => void;
|
||||
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
renderAction: (action: OperationalTaskAction) => ReactNode;
|
||||
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (generation: number, revision: number) => void;
|
||||
focusActionId?: string | null;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
};
|
||||
|
||||
const roundLabel = (snapshot: OperationalTaskReport) =>
|
||||
@@ -167,6 +170,17 @@ export function InvestigationTaskReport({
|
||||
const timeline = investigationTimeline(task, snapshot);
|
||||
const reportRef = useReportUpdateMotion(task.task_id,
|
||||
`${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live);
|
||||
const sections: InvestigationReportSection[] = [
|
||||
"任务报告",
|
||||
...(report?.ui_blocks?.length ? ["交互分析" as const] : []),
|
||||
...(live ? ["当前调查进展" as const] : []),
|
||||
...(pendingActions.length ? ["当前需处理" as const] : []),
|
||||
...(completedActions.length ? ["处置进展" as const] : []),
|
||||
...(report?.data_gaps?.length ? ["数据缺口" as const] : []),
|
||||
...(diagram ? ["调查脉络" as const] : []),
|
||||
"主要依据", "任务时间线",
|
||||
...(onLoadReports ? ["报告版本" as const] : []),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot?.execution_generation == null || !onReportViewed) return;
|
||||
@@ -209,6 +223,8 @@ export function InvestigationTaskReport({
|
||||
};
|
||||
|
||||
return (
|
||||
<ExpandableReading title={snapshot?.report.title ?? task.title}
|
||||
summary={<p>{snapshot?.report.current_conclusion ?? task.summary}</p>}>
|
||||
<article ref={reportRef} aria-label="调查任务报告" className="investigation-report surface-reading">
|
||||
<header className="investigation-report-header">
|
||||
<div className="min-w-0">
|
||||
@@ -244,6 +260,8 @@ export function InvestigationTaskReport({
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
|
||||
|
||||
{latestAvailable ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>正在阅读历史报告{olderRevision ? ",已有新报告" : ""}</span>
|
||||
@@ -254,9 +272,8 @@ export function InvestigationTaskReport({
|
||||
) : null}
|
||||
|
||||
<InvestigationExpandCard title="当前结果" label="任务报告" disabled={!report}
|
||||
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`} preview={<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{report ? (
|
||||
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`}
|
||||
headingAccessory={report ? (
|
||||
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
|
||||
{insufficient
|
||||
? "证据不足"
|
||||
@@ -265,9 +282,10 @@ export function InvestigationTaskReport({
|
||||
: "结果未记录"}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
preview={<>
|
||||
{report && snapshot ? (
|
||||
<>
|
||||
{previousRound || olderRevision || insufficient || report.stage !== "final" || report.outcome !== "concluded" ? (
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--ink)]">
|
||||
{previousRound
|
||||
? task.status === "queued" || task.status === "running"
|
||||
@@ -283,11 +301,10 @@ export function InvestigationTaskReport({
|
||||
: "无法形成结论 · 正在结束本轮"
|
||||
: report.stage === "interim"
|
||||
? "阶段性判断"
|
||||
: report.outcome === "concluded"
|
||||
? "调查结论"
|
||||
: "历史报告内容"}
|
||||
: "历史报告内容"}
|
||||
</p>
|
||||
<p className="investigation-conclusion-preview mt-1 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
|
||||
) : null}
|
||||
<p className="investigation-conclusion-preview mt-2 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
结论边界:{formatInvestigationText(report.conclusion_boundary)}
|
||||
</p>
|
||||
@@ -318,6 +335,8 @@ export function InvestigationTaskReport({
|
||||
</> : <p className="text-sm text-[var(--ink-muted)]">尚未发布本轮调查报告。</p>}
|
||||
</InvestigationExpandCard>
|
||||
|
||||
{snapshot ? <InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} onLocate={onLocate} /> : null}
|
||||
|
||||
{live ? <section aria-label="当前调查进展" className="investigation-report-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">当前调查进展</h3>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}</p>
|
||||
@@ -622,6 +641,7 @@ export function InvestigationTaskReport({
|
||||
任务更新于 {dateLabel(task.updated_at)}
|
||||
</p>
|
||||
</article>
|
||||
</ExpandableReading>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type MobileWorkbenchSheetProps = {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
suspended?: boolean;
|
||||
};
|
||||
|
||||
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
|
||||
@@ -31,7 +32,8 @@ export function MobileWorkbenchSheet({
|
||||
onSnapChange,
|
||||
onClose,
|
||||
children,
|
||||
className
|
||||
className,
|
||||
suspended = false
|
||||
}: MobileWorkbenchSheetProps) {
|
||||
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
@@ -52,6 +54,7 @@ export function MobileWorkbenchSheet({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (suspended) return;
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||
onClose();
|
||||
@@ -60,7 +63,7 @@ export function MobileWorkbenchSheet({
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
}, [onClose, suspended]);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||
pointerStartRef.current = {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Droplet } from "lucide-react";
|
||||
import { StartupScreen } from "@/shared/ui/startup-screen";
|
||||
|
||||
export type ProjectStartupState = { phase: "projects" } | { phase: "context"; projectName: string };
|
||||
|
||||
export function ProjectStartupScreen({ state, error, onRetry }: {
|
||||
state: ProjectStartupState;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const readingProjects = state.phase === "projects";
|
||||
|
||||
return (
|
||||
<StartupScreen
|
||||
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
|
||||
title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"}
|
||||
description={error ? "请查看原因后重试。" : "正在准备你的项目工作台,完成后自动进入。"}
|
||||
progressLabel="项目加载进度"
|
||||
steps={[
|
||||
{ id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete",
|
||||
detail: readingProjects ? "获取当前账号可访问的项目列表" : "已获取可访问的项目" },
|
||||
{ id: "context", label: "准备项目环境", state: readingProjects ? "waiting" : "loading",
|
||||
detail: state.phase === "context" ? `${state.projectName}\n正在读取项目配置与访问权限` : "等待项目列表返回" }
|
||||
]}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { motion, useIsPresent, useReducedMotion } from "motion/react";
|
||||
import { Droplet, RefreshCw, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogDescription, DialogPortal, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import type { WorkbenchProjectSelection } from "./workbench-top-bar-menus";
|
||||
|
||||
const roleLabels: Record<string, string> = { member: "项目成员", admin: "项目管理员", viewer: "只读成员", owner: "项目负责人" };
|
||||
|
||||
export function ProjectSwitchDialog({ selection, keyboardOpened, onClose, onRestoreFocus }: {
|
||||
selection: WorkbenchProjectSelection;
|
||||
keyboardOpened: boolean;
|
||||
onClose: () => void;
|
||||
onRestoreFocus: () => void;
|
||||
}) {
|
||||
const [projectId, setProjectId] = useState(selection.selectedProjectId);
|
||||
const [attemptedProjectId, setAttemptedProjectId] = useState<string | null>(null);
|
||||
const [keyboard, setKeyboard] = useState(keyboardOpened);
|
||||
const present = useIsPresent();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const labelId = useId();
|
||||
const projectTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pending = selection.pendingProjectId !== null;
|
||||
const current = selection.projects.find((project) => project.project_id === selection.selectedProjectId);
|
||||
const selected = selection.projects.find((project) => project.project_id === projectId);
|
||||
const error = attemptedProjectId === projectId || attemptedProjectId === null ? selection.error : null;
|
||||
const transition = { duration: reducedMotion || keyboard ? 0 : 0.18, ease: [0.22, 1, 0.36, 1] as const };
|
||||
|
||||
return (
|
||||
<Dialog open={present} onOpenChange={(open) => { if (!open && !pending) onClose(); }}>
|
||||
<DialogPortal forceMount>
|
||||
<DialogPrimitive.Overlay forceMount asChild>
|
||||
<motion.div className="fixed inset-0 z-[100] bg-slate-950/10 backdrop-blur-[3px]"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
|
||||
</DialogPrimitive.Overlay>
|
||||
<DialogPrimitive.Content forceMount asChild
|
||||
onOpenAutoFocus={(event) => { event.preventDefault(); projectTriggerRef.current?.focus({ preventScroll: true }); }}
|
||||
onEscapeKeyDown={() => setKeyboard(true)}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); onRestoreFocus(); }}>
|
||||
<motion.section
|
||||
className="acrylic-panel acrylic-panel-white fixed left-1/2 top-1/2 z-[101] max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[420px] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-border)] p-6 text-slate-900 shadow-[var(--shadow-panel)] outline-0 sm:p-7"
|
||||
initial={reducedMotion || keyboard ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reducedMotion || keyboard ? 0 : 4 }} transition={transition}
|
||||
onKeyDownCapture={() => setKeyboard(true)} onPointerDownCapture={() => setKeyboard(false)}>
|
||||
{selection.onRefresh ? <Button className="absolute right-14 top-3" variant="ghost" size="icon-sm"
|
||||
aria-label="刷新授权项目" loading={selection.refreshing} onClick={selection.onRefresh}><RefreshCw size={16} /></Button> : null}
|
||||
<Button className="absolute right-3 top-3" variant="ghost" size="icon-sm" aria-label="关闭项目切换" disabled={pending} onClick={onClose}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</Button>
|
||||
<header>
|
||||
<div className="flex items-center gap-2 pr-7 text-xs font-medium text-slate-500">
|
||||
<Droplet className="h-5 w-5 shrink-0 fill-[var(--action-blue)] text-[var(--action-blue)]" strokeWidth={1.5} aria-hidden="true" />
|
||||
<span><span className="font-semibold text-slate-700">TJWater</span><span className="mx-2 text-slate-300" aria-hidden="true">/</span>智慧水务平台</span>
|
||||
</div>
|
||||
<DialogTitle className="mt-6 text-[22px] font-semibold leading-8 tracking-normal">切换项目</DialogTitle>
|
||||
<DialogDescription className="mt-1.5 break-words text-xs leading-5 text-slate-500">当前项目:{current?.name ?? current?.code ?? "当前项目"}</DialogDescription>
|
||||
</header>
|
||||
<form className="mt-6" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (pending || projectId === selection.selectedProjectId) return;
|
||||
setAttemptedProjectId(projectId);
|
||||
selection.onSelectProject(projectId);
|
||||
}}>
|
||||
<div>
|
||||
<label id={labelId} className="mb-2 block text-xs font-medium leading-4 text-slate-600">项目环境</label>
|
||||
<Select value={projectId} disabled={pending} onValueChange={(value) => { setProjectId(value); setAttemptedProjectId(null); }}>
|
||||
<SelectTrigger ref={projectTriggerRef} aria-labelledby={labelId} aria-describedby={error ? `${labelId}-error` : undefined} aria-invalid={!!error}
|
||||
className="h-auto min-h-20 gap-3 whitespace-normal rounded-xl border-[var(--surface-divider)] bg-[var(--surface-reading)] px-4 py-3.5 text-left shadow-none transition-colors hover:bg-[var(--surface-control)] data-[state=open]:border-[var(--action-blue)] motion-reduce:transition-none [&>span]:line-clamp-none [&>svg]:shrink-0">
|
||||
<SelectValue>
|
||||
<span className="block min-w-0 space-y-1.5">
|
||||
<span className="block break-words text-base font-medium leading-6">{selected?.name ?? selected?.code ?? "请选择项目"}</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-5 text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full bg-emerald-600" aria-hidden="true" />可用</span>
|
||||
{selected?.project_role ? <span>{roleLabels[selected.project_role] ?? selected.project_role}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={8} animateContent={!keyboard}
|
||||
className="acrylic-panel acrylic-panel-white z-[102] max-w-[calc(100vw-2rem)] rounded-xl border-[var(--surface-border)] shadow-[var(--shadow-panel)]">
|
||||
{selection.projects.map((project) => (
|
||||
<SelectItem key={project.project_id} value={project.project_id} className="min-h-16 rounded-lg py-2.5 text-sm [&>span:last-child]:min-w-0 [&>span:last-child]:whitespace-normal">
|
||||
<span className="block break-words font-medium">{project.name ?? project.code ?? project.gs_workspace}</span>
|
||||
{project.project_id === selection.selectedProjectId ? <span className="mt-1 block text-xs leading-5 text-slate-500">正在使用</span> : project.project_role ? <span className="mt-1 block text-xs leading-5 text-slate-500">{roleLabels[project.project_role] ?? project.project_role}</span> : null}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error ? <p id={`${labelId}-error`} role="alert" className="mt-2 text-xs leading-5 text-rose-700">{error}</p> : null}
|
||||
<Button type="submit" className="mt-6 h-11 w-full disabled:border-[var(--surface-divider)] disabled:bg-[var(--surface-control)] disabled:text-[var(--ink-muted)] disabled:opacity-100 disabled:shadow-none" disabled={projectId === selection.selectedProjectId} loading={pending} loadingLabel="正在进入项目">进入项目</Button>
|
||||
</form>
|
||||
</motion.section>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -109,11 +109,11 @@ type ExecutionStep = {
|
||||
};
|
||||
|
||||
type ConditionDetailPanelProps = {
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
condition: ScheduledConditionItem;
|
||||
dataSource?: WorkbenchRuntimeDataSource;
|
||||
workOrderDataSource?: WorkbenchWorkOrderDataSource;
|
||||
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => void;
|
||||
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
@@ -94,7 +94,7 @@ type ScheduledConditionFeedProps = {
|
||||
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onTaskRefresh?: () => void;
|
||||
onLoadTaskReports?: (taskId: string, offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
};
|
||||
@@ -369,14 +369,15 @@ export function ScheduledConditionFeed({
|
||||
|
||||
if (condition.operationalTask) {
|
||||
onExpandAgent();
|
||||
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id, discussion))
|
||||
.catch((error) => showMapNotice({
|
||||
if (!onOpenTaskCollaboration) return false;
|
||||
return Promise.resolve(onOpenTaskCollaboration(condition.operationalTask.task_id, discussion))
|
||||
.then(() => true)
|
||||
.catch((error) => { showMapNotice({
|
||||
id: "operational-task-collaboration",
|
||||
tone: "error",
|
||||
title: "Agent 协作打开失败",
|
||||
message: error instanceof Error ? error.message : "无法打开这条任务的协作问答。"
|
||||
}));
|
||||
return;
|
||||
}); return false; });
|
||||
}
|
||||
|
||||
onExpandAgent();
|
||||
@@ -1103,6 +1104,7 @@ function ConditionTimelineRow({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-condition-id={condition.id}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
|
||||
@@ -232,7 +232,7 @@ export function ToolbarPanel({
|
||||
if (renderedToolId === "geocoding") {
|
||||
return (
|
||||
<MapControlPanel
|
||||
key="geocoding" title="地址定位" description="奉贤区 · 天地图地名搜索" icon={Icon}
|
||||
key="geocoding" title="地址定位" description={geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
|
||||
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col"
|
||||
visible={panelVisible}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import { useLayoutEffect, type ComponentProps } from "react";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
import { useEffect, useRef, type ComponentProps, type ReactNode } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/shared/ui/resizable";
|
||||
import {
|
||||
clampAgentPanelWidth,
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchViewportLayout,
|
||||
WORKBENCH_LAYOUT
|
||||
} from "../layout/workbench-layout";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
|
||||
type WorkbenchAgentPanelsProps = {
|
||||
children: ReactNode;
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
@@ -18,6 +26,7 @@ type WorkbenchAgentPanelsProps = {
|
||||
};
|
||||
|
||||
export function WorkbenchAgentPanels({
|
||||
children,
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
@@ -29,52 +38,100 @@ export function WorkbenchAgentPanels({
|
||||
onExpandPanel,
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const { handlePointerDown, panelRef, resizing, width } = useAgentPanelResize(
|
||||
viewportWidth,
|
||||
conditionExpanded
|
||||
);
|
||||
const panelRef = usePanelRef();
|
||||
const groupElement = useRef<HTMLDivElement>(null);
|
||||
const preferredWidth = useRef<number | null>(null);
|
||||
const desktop = viewportWidth >= WORKBENCH_LAYOUT.desktopMinWidth;
|
||||
const expanded = desktop && panelOpen;
|
||||
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onWidthCommit?.(width);
|
||||
}, [width, onWidthCommit]);
|
||||
// Restore pixels after the group's ResizeObserver has measured the new
|
||||
// viewport; a resize against its previous measurement would restore a ratio.
|
||||
useEffect(() => {
|
||||
const element = groupElement.current;
|
||||
if (!element) return;
|
||||
let frame = 0;
|
||||
const restore = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const target = expanded
|
||||
? clampAgentPanelWidth(
|
||||
preferredWidth.current ?? defaultWidth,
|
||||
element.clientWidth,
|
||||
conditionExpanded
|
||||
)
|
||||
: 0;
|
||||
panelRef.current?.resize(target);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(restore);
|
||||
observer.observe(element);
|
||||
restore();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [expanded, defaultWidth, viewportWidth, conditionExpanded, panelRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block${resizing ? " select-none" : ""}`}
|
||||
style={{ width: panelOpen ? width : 0 }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-dock"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
{!panelCollapsing ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
data-resizing={resizing}
|
||||
onPointerDown={handlePointerDown}
|
||||
className="group pointer-events-auto absolute inset-y-0 -right-1.5 z-10 w-3 cursor-col-resize touch-none"
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-y-0 left-[5px] w-px bg-transparent transition-colors duration-150 group-hover:bg-[var(--action-blue-hover)]/70 group-data-[resizing=true]:bg-[var(--action-blue)]/80 motion-reduce:transition-none" />
|
||||
<span className="agent-resize-grip pointer-events-none absolute left-1/2 top-1/2 h-11 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--action-blue)] opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[resizing=true]:bg-[var(--action-blue)] group-data-[resizing=true]:opacity-100 motion-reduce:transition-none" />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute left-3 top-5">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<ResizablePanelGroup
|
||||
id="workbench-columns"
|
||||
elementRef={groupElement}
|
||||
orientation="horizontal"
|
||||
disabled={!expanded || panelCollapsing}
|
||||
resizeTargetMinimumSize={{ fine: 12, coarse: 24 }}
|
||||
onLayoutChanged={(layout, { isUserInteraction }) => {
|
||||
if (isUserInteraction && expanded) {
|
||||
// Keyboard callbacks precede the panel's DOM update. Use the new
|
||||
// layout rather than getSize().inPixels, which still reads the old width.
|
||||
const groupWidth = groupElement.current?.clientWidth;
|
||||
if (groupWidth)
|
||||
preferredWidth.current = Math.round((layout["agent-column"] * groupWidth) / 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="agent-column"
|
||||
panelRef={panelRef}
|
||||
defaultSize={expanded ? defaultWidth : 0}
|
||||
minSize={expanded ? WORKBENCH_LAYOUT.minAgentWidth : 0}
|
||||
maxSize={expanded ? getAgentPanelMaxWidth(viewportWidth, conditionExpanded) : 0}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
onResize={({ inPixels }) => {
|
||||
if (expanded && inPixels > 0) onWidthCommit?.(Math.round(inPixels));
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-auto hidden h-full min-h-0 pt-14 lg:block">
|
||||
{panelOpen ? (
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-dock"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
withHandle
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
disabled={!expanded || panelCollapsing}
|
||||
className={`pointer-events-auto z-50 h-[calc(100%-3.5rem)] w-0 self-end ${expanded ? "" : "hidden"}`}
|
||||
/>
|
||||
<ResizablePanel id="main-column" minSize={0} className="relative h-full min-h-0">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
{!panelOpen ? (
|
||||
<div className="pointer-events-none absolute left-0 top-14 z-50 hidden p-3 lg:block">
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowRightLeft,
|
||||
Bell,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
UserRound,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { ProjectSwitchDialog } from "./project-switch-dialog";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchProject } from "../api/project-client";
|
||||
import type { WorkbenchAlert, WorkbenchUser } from "../types";
|
||||
import {
|
||||
compactHeaderControlButtonClassName,
|
||||
@@ -259,10 +263,22 @@ export function AlertMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export type WorkbenchProjectSelection = {
|
||||
projects: WorkbenchProject[];
|
||||
selectedProjectId: string;
|
||||
pendingProjectId: string | null;
|
||||
error: string | null;
|
||||
onSelectProject: (projectId: string) => void;
|
||||
onOpen?: () => void;
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
};
|
||||
|
||||
export function UserMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
projectSelection,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
@@ -272,16 +288,23 @@ export function UserMenu({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: WorkbenchUser;
|
||||
projectSelection?: WorkbenchProjectSelection;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
}) {
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||
const [projectDialogKeyboard, setProjectDialogKeyboard] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
|
||||
@@ -297,8 +320,18 @@ export function UserMenu({
|
||||
<ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
|
||||
<DropdownMenuContent align="end" className={cn("z-[90] w-72", menuSurfaceClassName)}
|
||||
onKeyDownCapture={() => setProjectDialogKeyboard(true)}
|
||||
onPointerDownCapture={() => setProjectDialogKeyboard(false)}
|
||||
onCloseAutoFocus={(event) => { if (projectDialogOpen) event.preventDefault(); }}>
|
||||
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
|
||||
{projectSelection ? (
|
||||
<>
|
||||
<MenuAction icon={ArrowRightLeft} label="切换项目" description="选择要进入的供水项目"
|
||||
onSelect={() => { onOpenChange(false); setProjectDialogOpen(true); projectSelection.onOpen?.(); }} />
|
||||
<MenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
||||
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
||||
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
||||
@@ -319,6 +352,12 @@ export function UserMenu({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AnimatePresence>
|
||||
{projectDialogOpen && projectSelection ? <ProjectSwitchDialog selection={projectSelection} keyboardOpened={projectDialogKeyboard}
|
||||
onClose={() => setProjectDialogOpen(false)}
|
||||
onRestoreFocus={() => triggerRef.current?.focus()} /> : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
WorkbenchUser
|
||||
} from "../types";
|
||||
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
|
||||
import { AlertMenu, AnalysisViewMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
|
||||
import {
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
@@ -34,6 +34,7 @@ export type WorkbenchTopBarProps = {
|
||||
alerts: WorkbenchAlert[];
|
||||
alertDataSource: WorkbenchAlertDataSource;
|
||||
user: WorkbenchUser;
|
||||
projectSelection?: WorkbenchProjectSelection;
|
||||
conditionFeedVisible: boolean;
|
||||
timelineVisible: boolean;
|
||||
taskTickerAvailable: boolean;
|
||||
@@ -71,6 +72,7 @@ export function WorkbenchTopBar({
|
||||
alerts,
|
||||
alertDataSource,
|
||||
user,
|
||||
projectSelection,
|
||||
conditionFeedVisible,
|
||||
timelineVisible,
|
||||
taskTickerAvailable,
|
||||
@@ -184,14 +186,14 @@ export function WorkbenchTopBar({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="外围样板管理"
|
||||
aria-label="外围系统"
|
||||
aria-pressed={peripheralSamplePanelOpen}
|
||||
title="外围样板管理"
|
||||
title="外围系统"
|
||||
onClick={onTogglePeripheralSamplePanel}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}><RadioTower size={14} aria-hidden="true" /></span>
|
||||
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围样板</span>
|
||||
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围系统</span>
|
||||
</button>
|
||||
<div className="lg:hidden">
|
||||
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
|
||||
@@ -250,6 +252,7 @@ export function WorkbenchTopBar({
|
||||
open={openMenu === "user"}
|
||||
onOpenChange={createMenuOpenChange("user")}
|
||||
user={user}
|
||||
projectSelection={projectSelection}
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/features/agent";
|
||||
|
||||
type AgentUiDataParts = {
|
||||
session_created: Record<string, unknown>;
|
||||
agent_message: Record<string, unknown>;
|
||||
task_context: Record<string, unknown>;
|
||||
execution_plan: Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ChatTransport, UIMessageChunk } from "ai";
|
||||
import type { AgentApiClient } from "@/features/agent";
|
||||
import { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
|
||||
import type { AgentUiMessage } from "./agent-session-message-state";
|
||||
|
||||
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
|
||||
|
||||
describe("independent conversation runtimes", () => {
|
||||
it("keeps the original response streaming after selecting another conversation", async () => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
|
||||
const transport: ChatTransport<AgentUiMessage> = {
|
||||
sendMessages: async () => new ReadableStream({ start(stream) { controller = stream; } }),
|
||||
reconnectToStream: async () => null,
|
||||
};
|
||||
const client = { refreshCredential: vi.fn(async () => {}), submitFrontendActionResult: vi.fn(async () => {}) } as unknown as AgentApiClient;
|
||||
const first = createAgentRuntime(resources, "project:a", client, transport);
|
||||
const second = createAgentRuntime(resources, "project:a", client, transport);
|
||||
resources.activeAgent = first;
|
||||
first.sessionId = "server-a";
|
||||
const response = first.chat.sendMessage({ text: "核对城南压力" });
|
||||
await vi.waitFor(() => expect(controller).toBeDefined());
|
||||
controller.enqueue({ type: "start", messageId: "answer-a" });
|
||||
controller.enqueue({ type: "text-start", id: "text-a" });
|
||||
controller.enqueue({ type: "text-delta", id: "text-a", delta: "第一段" });
|
||||
await vi.waitFor(() => expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段" })])));
|
||||
resources.activeAgent = second;
|
||||
controller.enqueue({ type: "data-progress", data: { session_id: "another-session", title: "迟到的其他会话事件" } });
|
||||
expect(runtimeRunning(first)).toBe(true);
|
||||
controller.enqueue({ type: "data-credential_refresh_required", data: { request_id: "refresh-a", session_id: "server-a" } });
|
||||
controller.enqueue({ type: "text-delta", id: "text-a", delta: ",后台完成" });
|
||||
controller.enqueue({ type: "text-end", id: "text-a" });
|
||||
controller.enqueue({ type: "finish", finishReason: "stop" });
|
||||
controller.close();
|
||||
await response;
|
||||
expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段,后台完成" })]));
|
||||
expect(second.chat.messages).toEqual([]);
|
||||
expect(first.sessionId).toBe("server-a");
|
||||
expect(client.refreshCredential).toHaveBeenCalledWith("server-a", "refresh-a");
|
||||
resources.dispose();
|
||||
});
|
||||
it("merges overlapping pages and a newer authoritative tail without duplicates", () => {
|
||||
expect(mergeHistoryMessages([message("a"), message("b", "old")], [message("b", "new"), message("c")]))
|
||||
.toEqual([message("a"), message("b", "new"), message("c")]);
|
||||
expect(mergeHistoryMessages([message("c"), message("d")], [message("a"), message("b"), message("c", "new")]))
|
||||
.toEqual([message("a"), message("b"), message("c", "new"), message("d")]);
|
||||
});
|
||||
it("routes background browser actions to a scoped refusal and keeps old message pages on resume", async () => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
const submit = vi.fn(async () => {});
|
||||
const client = { submitFrontendActionResult: submit,
|
||||
streamSession: vi.fn(async (_id, options) => {
|
||||
options.onEvent({ type: "state", sessionId: "a", messages: [message("b", "new"), message("c")], isStreaming: true });
|
||||
options.onEvent({ type: "frontend_action", sessionId: "a", data: {
|
||||
version: "frontend-action@1", sessionId: "a", actionId: "action-a", toolCallId: "call-a", name: "locate_features",
|
||||
params: {}, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
|
||||
} });
|
||||
options.onEvent({ type: "done", sessionId: "a", data: {} });
|
||||
}),
|
||||
} as unknown as AgentApiClient;
|
||||
const transport = {} as ChatTransport<AgentUiMessage>;
|
||||
const runtime = createAgentRuntime(resources, "project:a", client, transport, {
|
||||
id: "a", sessionId: "a", title: "压力复核", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat",
|
||||
isTitleManuallyEdited: false, messages: [message("a"), message("b", "old")],
|
||||
});
|
||||
resumeAgentRuntime(resources, runtime);
|
||||
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
|
||||
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
|
||||
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
|
||||
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
|
||||
}));
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Chat } from "@ai-sdk/react";
|
||||
import type { ChatTransport } from "ai";
|
||||
import { parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession,
|
||||
type AgentMessagePagination, type AgentSessionStreamEvent, type AgentSessionPurpose, type AgentTaskDiscussionContext, type AnswerUiSelection } from "@/features/agent";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiMessages,
|
||||
type AgentDataPart, type AgentUiMessage, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
|
||||
import type { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
export type AgentReadingPosition = { messageId?: string; offset: number; atBottom: boolean };
|
||||
export type AgentSessionRuntime = {
|
||||
key: string;
|
||||
scope: string;
|
||||
client: AgentApiClient;
|
||||
chat: Chat<AgentUiMessage>;
|
||||
sessionId: string | null;
|
||||
title: string;
|
||||
topicId?: string | null;
|
||||
purpose: AgentSessionPurpose;
|
||||
taskId?: string;
|
||||
draft: string;
|
||||
reference?: AnswerUiSelection;
|
||||
taskDiscussion?: { context: AgentTaskDiscussionContext; reportGeneration?: number | null };
|
||||
permissionOverrides: Record<string, PermissionOverride>;
|
||||
questionOverrides: Record<string, QuestionOverride>;
|
||||
messagePage?: AgentMessagePagination;
|
||||
readingPosition?: AgentReadingPosition;
|
||||
loadedAt: number;
|
||||
updatedAt: number;
|
||||
resumed: boolean;
|
||||
controller?: AbortController;
|
||||
error?: string;
|
||||
runStatus?: AgentRunStatus;
|
||||
handlers?: {
|
||||
onData: (part: AgentDataPart) => void;
|
||||
onEvent: (event: AgentSessionStreamEvent) => void;
|
||||
onError: (error: Error) => void;
|
||||
onFinish: (status: AgentRunStatus) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function agentRuntimes(resources: WorkbenchSessionResources) {
|
||||
return [...resources.agents.values()] as AgentSessionRuntime[];
|
||||
}
|
||||
|
||||
export function runtimeRunning(runtime: AgentSessionRuntime) {
|
||||
return runtime.resumed || runtime.chat.status === "submitted" || runtime.chat.status === "streaming";
|
||||
}
|
||||
|
||||
export function runtimeNeedsInput(runtime: AgentSessionRuntime) {
|
||||
return toAgentChatMessages(runtime.chat.messages, runtime.permissionOverrides, runtime.questionOverrides).some((message) =>
|
||||
message.permissions?.some((permission) => permission.status === "pending") || message.questions?.some((question) => question.status === "pending"));
|
||||
}
|
||||
|
||||
/** Server snapshots replace matching messages, while previously loaded pages retain their order. */
|
||||
export function mergeHistoryMessages(current: AgentUiMessage[], incoming: AgentUiMessage[]) {
|
||||
if (!current.length) return incoming;
|
||||
if (!incoming.length) return current;
|
||||
const firstOverlap = incoming.findIndex((message) => current.some((existing) => existing.id === message.id));
|
||||
if (firstOverlap < 0) return [...current, ...incoming];
|
||||
const index = current.findIndex((message) => message.id === incoming[firstOverlap].id);
|
||||
const incomingById = new Map(incoming.map((message) => [message.id, message]));
|
||||
const prefix = current.slice(0, index).filter((message) => !incomingById.has(message.id));
|
||||
const suffix = current.slice(index).map((message) => incomingById.get(message.id) ?? message);
|
||||
const known = new Set(suffix.map((message) => message.id));
|
||||
return [...prefix, ...incoming.slice(0, firstOverlap), ...suffix, ...incoming.filter((message) => !known.has(message.id) && !incoming.slice(0, firstOverlap).includes(message))];
|
||||
}
|
||||
|
||||
export function createAgentRuntime(resources: WorkbenchSessionResources, scope: string, client: AgentApiClient,
|
||||
transport: ChatTransport<AgentUiMessage>, loaded?: AgentLoadedChatSession) {
|
||||
const runtime = {
|
||||
key: crypto.randomUUID(), scope, client, sessionId: loaded?.sessionId ?? null,
|
||||
title: loaded?.title ?? "新对话", topicId: loaded?.topicId, purpose: loaded?.purpose ?? "ordinary_chat", taskId: loaded?.taskId,
|
||||
draft: "", permissionOverrides: {}, questionOverrides: {}, messagePage: loaded?.messagePage,
|
||||
loadedAt: loaded ? Date.now() : 0, updatedAt: loaded?.updatedAt ?? Date.now(), resumed: false, runStatus: loaded?.runStatus,
|
||||
} as AgentSessionRuntime;
|
||||
const active = () => resources.activeAgent === runtime;
|
||||
const notify = () => queueMicrotask(() => resources.notify());
|
||||
runtime.chat = new Chat<AgentUiMessage>({
|
||||
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
|
||||
onData: (part) => {
|
||||
if (resources.disposed) return;
|
||||
const data = part as AgentDataPart;
|
||||
const sessionId = getDataString(data.data, "session_id");
|
||||
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
|
||||
if (sessionId) runtime.sessionId = sessionId;
|
||||
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
|
||||
if (data.type === "data-credential_refresh_required") {
|
||||
void refreshRuntimeCredential(runtime, data.data);
|
||||
} else if (!active() && data.type === "data-frontend_action") {
|
||||
void rejectBackgroundAction(resources, runtime, data.data);
|
||||
} else if (active()) runtime.handlers?.onData(data);
|
||||
notify();
|
||||
},
|
||||
onError: (error) => { if (resources.disposed) return; runtime.error = error.message; runtime.runStatus = "error"; if (active()) runtime.handlers?.onError(error); notify(); },
|
||||
onFinish: ({ isAbort, isError }) => {
|
||||
if (resources.disposed) return;
|
||||
runtime.loadedAt = Date.now();
|
||||
runtime.updatedAt = Date.now();
|
||||
runtime.runStatus = isAbort ? "aborted" : isError ? "error" : "completed";
|
||||
if (active()) runtime.handlers?.onFinish(runtime.runStatus);
|
||||
else notifyBackgroundCompletion(runtime);
|
||||
notify();
|
||||
},
|
||||
});
|
||||
resources.agents.set(runtime.key, runtime);
|
||||
resources.disposers.add(() => { runtime.handlers = undefined; runtime.controller?.abort(); void runtime.chat.stop(); });
|
||||
return runtime;
|
||||
}
|
||||
|
||||
const credentialRequests = new WeakMap<AgentSessionRuntime, Set<string>>();
|
||||
async function refreshRuntimeCredential(runtime: AgentSessionRuntime, data: Record<string, unknown>) {
|
||||
const requestId = getDataString(data, "request_id");
|
||||
if (!requestId || !runtime.sessionId) return;
|
||||
const pending = credentialRequests.get(runtime) ?? new Set<string>();
|
||||
credentialRequests.set(runtime, pending);
|
||||
if (pending.has(requestId)) return;
|
||||
pending.add(requestId);
|
||||
try { await runtime.client.refreshCredential(runtime.sessionId, requestId); }
|
||||
catch { pending.delete(requestId); runtime.error = "会话凭据续期失败,请重新登录后重试"; }
|
||||
}
|
||||
|
||||
async function rejectBackgroundAction(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime, data: Record<string, unknown>) {
|
||||
const request = parseFrontendActionRequest(data);
|
||||
if (!request || request.sessionId !== runtime.sessionId) return;
|
||||
if (!resources.disposed) showMapNotice({ id: `agent-background-display-${runtime.key}`, tone: "info",
|
||||
title: "后台对话需要展示结果", message: `回到“${runtime.title}”后重新请求地图或工作区展示。` });
|
||||
await runtime.client.submitFrontendActionResult(request.sessionId, request.actionId, {
|
||||
version: "frontend-action-result@1", actionId: request.actionId, status: "failed",
|
||||
error: { code: "SESSION_NOT_ACTIVE", message: "返回对应会话后重新请求地图或工作区操作" }, completedAt: Date.now(),
|
||||
}).catch(() => { runtime.error = "后台展示操作回执失败"; });
|
||||
}
|
||||
|
||||
export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime) {
|
||||
if (!runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return;
|
||||
const controller = new AbortController();
|
||||
runtime.controller = controller;
|
||||
runtime.resumed = true;
|
||||
runtime.runStatus = "running";
|
||||
runtime.error = undefined;
|
||||
void runtime.client.streamSession(runtime.sessionId, { signal: controller.signal, messageLimit: 40,
|
||||
onEvent: (event) => {
|
||||
if (resources.disposed) return;
|
||||
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
|
||||
if (sessionId && sessionId !== runtime.sessionId) return;
|
||||
if (event.type === "state") {
|
||||
const tail = toAgentUiMessages(event.messages);
|
||||
const prior = runtime.chat.messages;
|
||||
// Start filling a disconnected interval before reconciling the live tail.
|
||||
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
|
||||
const boundary = event.messagePage?.beforeCursor;
|
||||
if (boundary) void fillHistoryGap(runtime, boundary, prior, controller.signal).then(() => resources.notify()).catch(() => {});
|
||||
}
|
||||
runtime.chat.messages = mergeHistoryMessages(prior, tail);
|
||||
if (!runtime.messagePage) runtime.messagePage = event.messagePage;
|
||||
runtime.resumed = event.isStreaming;
|
||||
runtime.runStatus = event.runStatus ?? (event.isStreaming ? "running" : "completed");
|
||||
} else {
|
||||
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
|
||||
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
|
||||
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
|
||||
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
|
||||
if (event.type === "done" || event.type === "error") {
|
||||
runtime.resumed = false;
|
||||
runtime.runStatus = event.type === "error" ? "error" : "completed";
|
||||
runtime.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
if (resources.activeAgent === runtime && event.type !== "credential_refresh_required") runtime.handlers?.onEvent(event);
|
||||
resources.notify();
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
runtime.error = error instanceof Error ? error.message : "会话连接中断";
|
||||
if (resources.activeAgent === runtime) runtime.handlers?.onError(new Error(runtime.error));
|
||||
}
|
||||
}).finally(() => {
|
||||
if (runtime.resumed && !controller.signal.aborted && !runtime.error) runtime.error = "会话连接已断开,返回会话后重新连接";
|
||||
if (runtime.controller === controller) { runtime.controller = undefined; runtime.resumed = false; }
|
||||
runtime.loadedAt = Date.now();
|
||||
if (!resources.disposed && !controller.signal.aborted && resources.activeAgent !== runtime) notifyBackgroundCompletion(runtime);
|
||||
resources.notify();
|
||||
});
|
||||
}
|
||||
|
||||
function notifyBackgroundCompletion(runtime: AgentSessionRuntime) {
|
||||
showMapNotice({ id: `agent-background-${runtime.key}`, tone: runtime.error || runtime.runStatus === "error" ? "error" : "info",
|
||||
title: runtime.error || runtime.runStatus === "error" ? "后台对话处理失败" : runtime.runStatus === "aborted" ? "后台对话已停止" : "后台对话已完成",
|
||||
message: runtime.error ?? runtime.title });
|
||||
}
|
||||
|
||||
async function fillHistoryGap(runtime: AgentSessionRuntime, cursor: string, prior: AgentUiMessage[], signal: AbortSignal) {
|
||||
let before: string | null = cursor;
|
||||
const middle: AgentUiMessage[] = [];
|
||||
while (before && !signal.aborted) {
|
||||
const page = await runtime.client.loadOlderMessages(runtime.sessionId!, before, signal);
|
||||
const messages = toAgentUiMessages(page.messages);
|
||||
middle.unshift(...messages);
|
||||
if (messages.some((message) => prior.some((old) => old.id === message.id))) break;
|
||||
before = page.messagePage.beforeCursor;
|
||||
}
|
||||
if (!signal.aborted) runtime.chat.messages = mergeHistoryMessages(mergeHistoryMessages(prior, middle), runtime.chat.messages);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SWRConfig } from "swr";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { AgentApiClient, AgentSessionPage } from "@/features/agent";
|
||||
import { useAgentHistory } from "./use-agent-history";
|
||||
import { WorkbenchSessionProvider } from "./workbench-session-provider";
|
||||
|
||||
it("keeps a late history refresh in its original project cache", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const page = (id: string): AgentSessionPage => ({ sessions: [{ id, title: id, createdAt: 1, updatedAt: 1, purpose: "ordinary_chat" }], hasMore: false, nextCursor: null });
|
||||
let finish!: (page: AgentSessionPage) => void;
|
||||
const first = vi.fn().mockResolvedValueOnce(page("project-a-original")).mockImplementationOnce(() => new Promise<AgentSessionPage>((resolve) => { finish = resolve; }));
|
||||
const second = vi.fn(async () => page("project-b-only"));
|
||||
const clients = { a: { listSessionPage: first }, b: { listSessionPage: second } } as unknown as Record<"a" | "b", AgentApiClient>;
|
||||
let history!: ReturnType<typeof useAgentHistory>;
|
||||
function Probe({ scope }: { scope: "a" | "b" }) { history = useAgentHistory(clients[scope], scope); return null; }
|
||||
const swrCache = new Map();
|
||||
const render = (scope: "a" | "b") => root.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(SWRConfig, { value: { provider: () => swrCache, dedupingInterval: 0 } }, createElement(Probe, { scope }))));
|
||||
try {
|
||||
await act(async () => render("a"));
|
||||
expect(history.recentSessions[0]?.id).toBe("project-a-original");
|
||||
clock.mockReturnValue(61_000);
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = history.ensure(); });
|
||||
await act(async () => render("b"));
|
||||
await act(async () => { finish(page("project-a-late-result")); await pending; });
|
||||
expect(history.sessions.map((session) => session.id)).toEqual(["project-b-only"]);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
} finally { await act(async () => root.unmount()); clock.mockRestore(); }
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import type { AgentApiClient, AgentChatSessionSummary, AgentSessionPage } from "@/features/agent";
|
||||
import { useWorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
const TTL = 60_000;
|
||||
|
||||
export function useAgentHistory(client: AgentApiClient, scope: string) {
|
||||
const resources = useWorkbenchSessionResources();
|
||||
const [topicFilter, setTopicFilter] = useState<{ scope: string; id?: string }>({ scope });
|
||||
const topicId = topicFilter.scope === scope ? topicFilter.id : undefined;
|
||||
const setTopicId = useCallback((id: string | undefined) => setTopicFilter({ scope, id }), [scope]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [pendingUpdateKey, setPendingUpdateKey] = useState<string | null>(null);
|
||||
const viewKey = JSON.stringify([scope, query, topicId]);
|
||||
const view = useRef({ scope, query, topicId });
|
||||
view.current = { scope, query, topicId };
|
||||
const staged = useRef<{ key: string; page: AgentSessionPage } | null>(null);
|
||||
const [orderVersion, setOrderVersion] = useState(0);
|
||||
const key = useCallback((q: string, cursor?: string | null, topic?: string) => JSON.stringify(["history", scope, q, cursor ?? null, topic ?? null]), [scope]);
|
||||
const fetchPage = useCallback((q: string, cursor?: string | null, force = false, topic?: string) => resources.cache.load(key(q, cursor, topic),
|
||||
() => client.listSessionPage({ q, topicId: topic, includeCollaboration: true, cursor: cursor ?? undefined, signal: AbortSignal.timeout(15_000) }), TTL, { force }), [client, key, resources]);
|
||||
const recent = useSWR(["agent-recent-sessions", scope], () => fetchPage(""), {
|
||||
revalidateOnFocus: false, shouldRetryOnError: false,
|
||||
});
|
||||
const pages = useSWRInfinite<AgentSessionPage>(
|
||||
(_index, previous: AgentSessionPage | null) => !enabled || (previous && !previous.hasMore) ? null
|
||||
: ["agent-history-page", scope, query, previous?.nextCursor ?? null, topicId ?? null],
|
||||
([, , q, cursor, topic]: [string, string, string, string | null, string | null]) => fetchPage(q, cursor, false, topic ?? undefined),
|
||||
{ revalidateOnFocus: false, revalidateFirstPage: false, shouldRetryOnError: false, persistSize: false },
|
||||
);
|
||||
const sessions = useMemo(() => [...new Map((pages.data ?? (query || topicId ? [] : recent.data ? [recent.data] : []))
|
||||
.flatMap((page) => page.sessions).map((session) => [session.id, session])).values()], [pages.data, query, recent.data, topicId]);
|
||||
const refresh = useCallback(async () => {
|
||||
const page = staged.current?.key === viewKey ? staged.current.page : await fetchPage(query, null, true, topicId);
|
||||
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
|
||||
staged.current = null;
|
||||
await pages.mutate([page], { revalidate: false });
|
||||
await pages.setSize(1);
|
||||
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
|
||||
setPendingUpdateKey(null);
|
||||
setOrderVersion((version) => version + 1);
|
||||
}, [fetchPage, pages, query, recent, scope, topicId, viewKey]);
|
||||
const ensure = useCallback(async () => {
|
||||
setOpen(true);
|
||||
setEnabled(true);
|
||||
const stale = (resources.cache.peek(key(query, null, topicId))?.expiresAt ?? 0) <= Date.now();
|
||||
const page = await fetchPage(query, null, false, topicId);
|
||||
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
|
||||
// Opening a window never invalidates a fresh page or hides existing content.
|
||||
if (!pages.data?.length) await pages.mutate([page], { revalidate: false });
|
||||
else if (stale) { staged.current = { key: viewKey, page }; setPendingUpdateKey(viewKey); }
|
||||
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
|
||||
}, [fetchPage, key, pages, query, recent, resources, scope, topicId, viewKey]);
|
||||
const update = useCallback(async (change: (sessions: AgentChatSessionSummary[]) => AgentChatSessionSummary[]) => {
|
||||
resources.cache.updateMatching<AgentSessionPage>((cacheKey) => {
|
||||
if (!cacheKey.startsWith('["history",')) return false;
|
||||
const value = JSON.parse(cacheKey) as unknown[];
|
||||
return value[0] === "history" && value[1] === scope;
|
||||
}, (page) => ({ ...page, sessions: change(page.sessions) }));
|
||||
const recentPage = resources.cache.peek<AgentSessionPage>(key(""))?.value;
|
||||
if (recentPage) {
|
||||
const next = { ...recentPage, sessions: change(recentPage.sessions) };
|
||||
resources.cache.set(key(""), next, TTL);
|
||||
if (view.current.scope === scope) await recent.mutate(next, { revalidate: false });
|
||||
}
|
||||
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
|
||||
await pages.mutate((current) => current?.map((page) => ({ ...page, sessions: change(page.sessions) })), { revalidate: false });
|
||||
// Search and later pages can now have different membership/order. Retain what is being read.
|
||||
setPendingUpdateKey(viewKey);
|
||||
}, [key, pages, query, recent, resources, scope, topicId, viewKey]);
|
||||
return {
|
||||
topicId, setTopicId, sessions, recentSessions: recent.data?.sessions ?? [], query, setQuery,
|
||||
loading: (open ? pages.isLoading : recent.isLoading), error: pages.error ?? recent.error,
|
||||
loadingMore: pages.isValidating, hasMore: pages.data?.at(-1)?.hasMore ?? recent.data?.hasMore ?? false,
|
||||
loadMore: () => pages.setSize(pages.size + 1), refresh, ensure, update,
|
||||
pendingUpdate: pendingUpdateKey === viewKey, isOpen: open, orderVersion, close: () => setOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent
|
||||
} from "react";
|
||||
import {
|
||||
clampAgentPanelWidth,
|
||||
getWorkbenchViewportLayout
|
||||
} from "../layout/workbench-layout";
|
||||
|
||||
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || !panelRef.current) return;
|
||||
|
||||
event.preventDefault();
|
||||
cleanupRef.current?.();
|
||||
const startX = event.clientX;
|
||||
const startWidth = panelRef.current.getBoundingClientRect().width;
|
||||
const handle = event.currentTarget;
|
||||
const pointerId = event.pointerId;
|
||||
handle.setPointerCapture(pointerId);
|
||||
setResizing(true);
|
||||
|
||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
pointerEvent.preventDefault();
|
||||
setWidth(clampAgentPanelWidth(
|
||||
startWidth + pointerEvent.clientX - startX,
|
||||
window.innerWidth,
|
||||
conditionExpanded
|
||||
));
|
||||
};
|
||||
const stopResizing = (pointerEvent?: PointerEvent) => {
|
||||
if (pointerEvent && pointerEvent.pointerId !== pointerId) return;
|
||||
cleanupRef.current?.();
|
||||
setResizing(false);
|
||||
};
|
||||
const handleBlur = () => stopResizing();
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopResizing);
|
||||
window.removeEventListener("pointercancel", stopResizing);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
|
||||
cleanupRef.current = cleanup;
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopResizing);
|
||||
window.addEventListener("pointercancel", stopResizing);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
},
|
||||
[conditionExpanded]
|
||||
);
|
||||
|
||||
return {
|
||||
handlePointerDown,
|
||||
panelRef,
|
||||
resizing,
|
||||
width: clampAgentPanelWidth(
|
||||
width ?? getWorkbenchViewportLayout(viewportWidth).agentWidth,
|
||||
viewportWidth,
|
||||
conditionExpanded
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectRecentAgentActivities } from "./use-agent-prompt-suggestions";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { collectRecentAgentActivities, useAgentPromptSuggestions } from "./use-agent-prompt-suggestions";
|
||||
import { WorkbenchSessionProvider } from "./workbench-session-provider";
|
||||
import type { ScheduledConditionItem } from "../types";
|
||||
import type { AgentChatSessionSummary } from "@/features/agent";
|
||||
|
||||
describe("recent activities for Agent suggestions", () => {
|
||||
@@ -21,3 +25,56 @@ describe("recent activities for Agent suggestions", () => {
|
||||
expect(activities.filter((item) => item.kind === "session").map((item) => item.title)).toEqual(["updated-old-conversation"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recommendation cache lifecycle", () => {
|
||||
let root: Root | undefined;
|
||||
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.restoreAllMocks(); });
|
||||
it("reuses recommendations across history refresh, panel reopen and a new empty conversation", async () => {
|
||||
const container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
const request = vi.fn(async () => [{ question: "核查当前压力", activityId: null }]);
|
||||
const empty: never[] = [];
|
||||
function Probe({ enabled, sessionId }: { enabled: boolean; sessionId: string | null }) {
|
||||
const result = useAgentPromptSuggestions({ enabled, model: "fixture/model", cacheScope: "project:a", sessionId,
|
||||
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return createElement("p", null, result.suggestions[0]?.question ?? "loading");
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: false, sessionId: null }))));
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: true, sessionId: `empty-${i}` }))));
|
||||
}
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toBe("核查当前压力");
|
||||
});
|
||||
it("ignores task heartbeat timestamps but regenerates when an activity changes status", async () => {
|
||||
root = createRoot(document.createElement("div"));
|
||||
const request = vi.fn(async () => [{ question: "复核任务证据", activityId: "task:task-a" }]);
|
||||
const empty: never[] = [];
|
||||
function Probe({ time, status }: { time: number; status: "running" | "completed" }) {
|
||||
const condition: ScheduledConditionItem = { id: "task-a", kind: "condition", taskId: "scada-diagnosis", sessionId: "native-a",
|
||||
title: "SCADA 证据复核", summary: "检查压力监测", riskLevel: "normal", scheduledAt: "2026-10-22T01:00:00Z", updatedAt: time, status };
|
||||
useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "project:a", sessionId: null, request,
|
||||
alerts: empty, alertDataSource: "backend", conditions: [condition], runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < 10; i++) await act(async () => root!.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(Probe, { time: 1000 + i * 5000, status: "running" }))));
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { time: 60000, status: "completed" }))));
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("isolates projects and caches generation errors instead of retrying each reopen", async () => {
|
||||
root = createRoot(document.createElement("div"));
|
||||
const request = vi.fn(async () => { throw new Error("unavailable"); });
|
||||
const empty: never[] = [];
|
||||
function Probe({ scope, enabled }: { scope: string; enabled: boolean }) {
|
||||
useAgentPromptSuggestions({ enabled, cacheScope: scope, model: "fixture/model", sessionId: null,
|
||||
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return null;
|
||||
}
|
||||
for (const [scope, enabled] of [["a", true], ["a", false], ["a", true], ["b", true]] as const) {
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { scope, enabled }))));
|
||||
}
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, Ag
|
||||
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
|
||||
import type { AnalysisArtifact } from "../workspace/workspace-model";
|
||||
|
||||
import { useWorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
type ActivityInputs = {
|
||||
alerts: WorkbenchAlert[];
|
||||
alertDataSource: WorkbenchAlertDataSource;
|
||||
@@ -44,38 +46,52 @@ export function collectRecentAgentActivities(input: ActivityInputs, now = Date.n
|
||||
return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()];
|
||||
}
|
||||
|
||||
export function useAgentPromptSuggestions({ enabled, model, sessionId, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
|
||||
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
|
||||
enabled: boolean;
|
||||
model: string;
|
||||
sessionId: string | null;
|
||||
cacheScope?: string;
|
||||
request: AgentApiClient["generatePromptSuggestions"];
|
||||
}) {
|
||||
const { cache } = useWorkbenchSessionResources();
|
||||
const activities = useMemo(() => collectRecentAgentActivities({ alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }),
|
||||
[alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions]);
|
||||
const snapshot = useRef(activities);
|
||||
useEffect(() => { snapshot.current = activities; }, [activities]);
|
||||
const key = JSON.stringify([enabled, model, sessionId]);
|
||||
const [state, setState] = useState<{ key: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
|
||||
key: "", suggestions: [], loading: false, unavailable: false,
|
||||
// Heartbeat timestamps change while the activity itself stays the same.
|
||||
const digest = JSON.stringify([...activities].sort((a, b) => a.id.localeCompare(b.id)).map((activity) => ({
|
||||
id: activity.id, kind: activity.kind, title: activity.title, summary: activity.summary,
|
||||
status: activity.status, origin: activity.origin,
|
||||
})));
|
||||
const activitySnapshot = useRef(activities);
|
||||
activitySnapshot.current = activities;
|
||||
const key = JSON.stringify(["suggestions", cacheScope, model, digest]);
|
||||
const [state, setState] = useState<{ scope: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
|
||||
scope: cacheScope, suggestions: [], loading: false, unavailable: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
const context = snapshot.current;
|
||||
setState({ key, suggestions: [], loading: true, unavailable: false });
|
||||
void request(context, model, controller.signal).then((suggestions) => {
|
||||
if (controller.signal.aborted) return;
|
||||
let disposed = false;
|
||||
const previous = cache.peek<DisplaySuggestion[]>(key);
|
||||
const apply = (suggestions: DisplaySuggestion[]) => {
|
||||
if (!disposed) setState({ scope: cacheScope, suggestions, loading: false, unavailable: false });
|
||||
};
|
||||
if (previous?.value) apply(previous.value);
|
||||
else setState((current) => ({ scope: cacheScope,
|
||||
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: true, unavailable: false }));
|
||||
void cache.load(key, async () => {
|
||||
const context = activitySnapshot.current;
|
||||
const suggestions = await request(context, model, AbortSignal.timeout(60_000));
|
||||
const sourceById = new Map(context.map((item) => [item.id, item]));
|
||||
setState({ key, loading: false, unavailable: false, suggestions: suggestions.map((item) => {
|
||||
return suggestions.map((item) => {
|
||||
const source = item.activityId ? sourceById.get(item.activityId) : undefined;
|
||||
return { ...item, sourceLabel: source
|
||||
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
|
||||
: "供水任务建议" };
|
||||
}) });
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setState({ key, suggestions: [], loading: false, unavailable: true });
|
||||
});
|
||||
}, 10 * 60_000, { errorTtl: 30_000 }).then(apply).catch(() => {
|
||||
if (!disposed) setState((current) => ({ scope: cacheScope,
|
||||
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: false, unavailable: true }));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [enabled, key, model, request]);
|
||||
return state.key === key ? state : { suggestions: [], loading: enabled, unavailable: false };
|
||||
return () => { disposed = true; };
|
||||
}, [cache, cacheScope, digest, enabled, key, model, request]);
|
||||
return state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SWRConfig } from "swr";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { AgentApiClient, AgentTopic } from "@/features/agent";
|
||||
import { useAgentTopics } from "./use-agent-topics";
|
||||
import { WorkbenchSessionProvider } from "./workbench-session-provider";
|
||||
|
||||
it("reuses theme reads and keeps a late project result out of the personal theme list", async () => {
|
||||
const topic = (id: string): AgentTopic => ({ id, name: id, createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" });
|
||||
let finish!: (topics: AgentTopic[]) => void;
|
||||
const projectRead = vi.fn(() => new Promise<AgentTopic[]>((resolve) => { finish = resolve; }));
|
||||
const personalRead = vi.fn(async () => [topic("personal-theme")]);
|
||||
const clients = { project: { listTopics: projectRead }, personal: { listTopics: personalRead } } as unknown as Record<"project" | "personal", AgentApiClient>;
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const cache = new Map();
|
||||
let topics!: ReturnType<typeof useAgentTopics>;
|
||||
function Probe({ scope }: { scope: "project" | "personal" }) { topics = useAgentTopics(clients[scope], scope); return null; }
|
||||
const render = (scope: "project" | "personal") => root.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(SWRConfig, { value: { provider: () => cache, dedupingInterval: 0 } }, createElement(Probe, { scope }))));
|
||||
try {
|
||||
await act(async () => render("project"));
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = topics.ensure(); });
|
||||
await act(async () => render("personal"));
|
||||
await act(async () => { finish([topic("project-theme")]); await pending; });
|
||||
expect(topics.topics.map((entry) => entry.id)).toEqual(["personal-theme"]);
|
||||
for (let i = 0; i < 10; i++) await act(async () => { await topics.ensure(); });
|
||||
expect(projectRead).toHaveBeenCalledTimes(1);
|
||||
expect(personalRead).toHaveBeenCalledTimes(1);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it("keeps a newly created topic selectable when the open menu's older read finishes later", async () => {
|
||||
const topic = (id: string): AgentTopic => ({ id, name: id, createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" });
|
||||
let finish!: (topics: AgentTopic[]) => void;
|
||||
const client = {
|
||||
listTopics: vi.fn(() => new Promise<AgentTopic[]>((resolve) => { finish = resolve; })),
|
||||
createTopic: vi.fn(async () => topic("new-topic")),
|
||||
} as unknown as AgentApiClient;
|
||||
let topics!: ReturnType<typeof useAgentTopics>;
|
||||
function Probe() { topics = useAgentTopics(client, "project:slow-read"); return null; }
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const cache = new Map();
|
||||
try {
|
||||
await act(async () => root.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(SWRConfig, { value: { provider: () => cache, dedupingInterval: 0 } }, createElement(Probe)))));
|
||||
let reading!: Promise<void>;
|
||||
let creating!: Promise<AgentTopic>;
|
||||
await act(async () => { reading = topics.ensure(); });
|
||||
await act(async () => { creating = topics.create("new-topic"); });
|
||||
await act(async () => { finish([topic("existing-topic")]); await reading; await creating; });
|
||||
expect(topics.topics.map((entry) => entry.id)).toEqual(["existing-topic", "new-topic"]);
|
||||
await act(async () => { await topics.ensure(); });
|
||||
expect(topics.topics.map((entry) => entry.id)).toEqual(["existing-topic", "new-topic"]);
|
||||
expect(client.listTopics).toHaveBeenCalledTimes(1);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import type { AgentApiClient, AgentTopic } from "@/features/agent";
|
||||
import { useWorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
const TTL = 5 * 60_000;
|
||||
|
||||
export function useAgentTopics(client: AgentApiClient, scope: string) {
|
||||
const resources = useWorkbenchSessionResources();
|
||||
const currentScope = useRef(scope);
|
||||
currentScope.current = scope;
|
||||
const key = JSON.stringify(["agent-topics", scope]);
|
||||
const fetchTopics = useCallback(() => resources.cache.load(key, () => client.listTopics(), TTL), [client, key, resources]);
|
||||
const state = useSWR(["agent-topics", scope], fetchTopics, { revalidateOnFocus: false, shouldRetryOnError: false });
|
||||
const save = async (change: (current: AgentTopic[]) => AgentTopic[]) => {
|
||||
const next = change(resources.cache.peek<AgentTopic[]>(key)?.value ?? state.data ?? []);
|
||||
if (resources.disposed) return;
|
||||
resources.cache.set(key, next, TTL);
|
||||
if (currentScope.current === scope) await state.mutate(next, { revalidate: false });
|
||||
};
|
||||
const ensure = async () => {
|
||||
const topics = await fetchTopics();
|
||||
if (!resources.disposed && currentScope.current === scope) await state.mutate(topics, { revalidate: false });
|
||||
};
|
||||
return {
|
||||
topics: state.data ?? [], loading: state.isLoading, error: state.error,
|
||||
ensure,
|
||||
// Finish the shared read before writing, so a late menu snapshot cannot replace the mutation.
|
||||
create: async (name: string) => { await ensure(); const topic = await client.createTopic(name); await save((current) => [...current, topic]); return topic; },
|
||||
rename: async (id: string, name: string) => { await ensure(); const topic = await client.renameTopic(id, name); await save((current) => current.map((entry) => entry.id === id ? topic : entry)); },
|
||||
remove: async (id: string) => { await ensure(); await client.deleteTopic(id); await save((current) => current.filter((topic) => topic.id !== id)); },
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
fetchElementGisProperties,
|
||||
fetchSourceGisFeatureProperties,
|
||||
@@ -18,7 +18,7 @@ const INITIAL_STATE: GisPropertiesState = { status: "idle", groups: [] };
|
||||
export function useElementGisProperties(
|
||||
feature: DetailFeature | null,
|
||||
projectId: string | null,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
getAccessToken?: WorkbenchAccess
|
||||
) {
|
||||
const [state, setState] = useState<GisPropertiesState>(INITIAL_STATE);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||
@@ -13,7 +13,7 @@ import { InvestigationPresentationFollower } from "./investigation-presentation-
|
||||
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
|
||||
type Options = {
|
||||
enabled: boolean; task: OperationalTask | null; projectId: string | null;
|
||||
sync: WorkbenchSyncState; getAccessToken?: AccessTokenProvider;
|
||||
sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
|
||||
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
|
||||
onClearLayers: () => void;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
useMapInteractions,
|
||||
clearMapFeatureInteractionState,
|
||||
isPointOccludedByCityBuilding,
|
||||
setMapFeatureInteractionState,
|
||||
@@ -71,3 +74,17 @@ describe("map feature interaction state", () => {
|
||||
expect(queryRenderedFeatures).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("suspends feature picking while the map is used to look up a place", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const map = { on: vi.fn(), off: vi.fn(), getLayer: vi.fn(() => ({})), getCanvas: () => canvas };
|
||||
const props = { enabled: false, mapRef: { current: map as unknown as MapLibreMap },
|
||||
availableSourceIds: ["pipes"] as const, mapReady: true, onSelectFeature: vi.fn(), selectedFeature: null };
|
||||
const { rerender } = renderHook(useMapInteractions, { initialProps: props });
|
||||
expect(map.on).not.toHaveBeenCalled();
|
||||
rerender({ ...props, enabled: true });
|
||||
expect(map.on).toHaveBeenCalledWith("click", expect.any(Array), expect.any(Function));
|
||||
rerender(props);
|
||||
expect(map.off).toHaveBeenCalledWith("click", expect.any(Array), expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ export function isPointOccludedByCityBuilding(map: CityOcclusionMap, point: Poin
|
||||
}
|
||||
|
||||
type UseMapInteractionsOptions = {
|
||||
enabled?: boolean;
|
||||
availableSourceIds: readonly WaterNetworkSourceId[];
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
@@ -66,6 +67,7 @@ type UseMapInteractionsOptions = {
|
||||
};
|
||||
|
||||
export function useMapInteractions({
|
||||
enabled = true,
|
||||
availableSourceIds,
|
||||
mapRef,
|
||||
mapReady,
|
||||
@@ -74,7 +76,7 @@ export function useMapInteractions({
|
||||
}: UseMapInteractionsOptions) {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) return;
|
||||
if (!mapReady || !map || !enabled) return;
|
||||
|
||||
let hoveredFeature: FeatureReference | null = null;
|
||||
|
||||
@@ -127,12 +129,15 @@ export function useMapInteractions({
|
||||
map.on("click", hitLayerIds, handleClick);
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
|
||||
if (mapRef.current !== map) return;
|
||||
clearHoveredFeature();
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.off("mousemove", hitLayerIds, handleMouseMove);
|
||||
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||
map.off("click", hitLayerIds, handleClick);
|
||||
};
|
||||
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@@ -142,7 +147,8 @@ export function useMapInteractions({
|
||||
setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||
}
|
||||
return () => {
|
||||
if (next && map.getSource(next.source)) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
|
||||
if (mapRef.current === map && next && map.getSource(next.source)) {
|
||||
clearMapFeatureInteractionState(map, next, "selected");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
acknowledgeOperationalTaskNotification,
|
||||
createOperationalRequestId,
|
||||
@@ -23,7 +23,7 @@ import type { WorkbenchEvent } from "../api/workbench-sync-client";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
|
||||
|
||||
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
|
||||
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report" | "scada_anomaly";
|
||||
export type CreateInvestigationInput = {
|
||||
kind: ManualInvestigationKind;
|
||||
title: string;
|
||||
@@ -66,7 +66,7 @@ export function selectHeaderNotifications(items: OperationalTaskNotification[])
|
||||
}
|
||||
|
||||
export function useOperationalTasks(
|
||||
getAccessToken: AccessTokenProvider | undefined,
|
||||
getAccessToken: WorkbenchAccess | undefined,
|
||||
sync: WorkbenchSyncState
|
||||
) {
|
||||
const syncEvents = sync.events;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ export type WorkbenchDrawingAnnotation = {
|
||||
};
|
||||
|
||||
type UseWorkbenchDrawingOptions = {
|
||||
selectionEnabled?: boolean;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
activeMode: WorkbenchDrawMode | null;
|
||||
@@ -47,6 +48,7 @@ type UseWorkbenchDrawingOptions = {
|
||||
};
|
||||
|
||||
export function useWorkbenchDrawing({
|
||||
selectionEnabled = true,
|
||||
mapRef,
|
||||
mapReady,
|
||||
activeMode,
|
||||
@@ -134,7 +136,7 @@ export function useWorkbenchDrawing({
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || activeMode) {
|
||||
if (!mapReady || !map || activeMode || !selectionEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,6 +169,9 @@ export function useWorkbenchDrawing({
|
||||
});
|
||||
|
||||
return () => {
|
||||
// The map owner may already have disposed this instance during project switching.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
|
||||
if (mapRef.current !== activeMap) return;
|
||||
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||
if (activeMap.getLayer(layerId)) {
|
||||
activeMap.off("click", layerId, handleDrawingClick);
|
||||
@@ -175,7 +180,7 @@ export function useWorkbenchDrawing({
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [activeMode, mapReady, mapRef]);
|
||||
}, [activeMode, mapReady, mapRef, selectionEnabled]);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(draft: DrawingDraftState | null) => {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import { reverseGeocodePoint, searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
|
||||
import { useWorkbenchGeocoding } from "./use-workbench-geocoding";
|
||||
|
||||
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn() }));
|
||||
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn(), reverseGeocodePoint: vi.fn() }));
|
||||
vi.mock("../map/geocoding-location", () => ({ locateGeocodingResult: vi.fn(), markGeocodingLocation: vi.fn() }));
|
||||
const location: PlaceSearchItem = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.44, latitude: 31.22, category: "道路名", district: "奉贤区" };
|
||||
const other = { ...location, id: "place-2", address: "奉贤区南桥路", longitude: 121.35, latitude: 30.77 };
|
||||
const results: PlaceSearchResults = { items: [location, other], total: 2, truncated: false, region: "奉贤区" };
|
||||
const remove = vi.fn();
|
||||
const options = {
|
||||
placeSearch: { enabled: true, provider: "tianditu" as const, admin_code: "310120", region: "奉贤区" },
|
||||
active: true, projectId: "project-1", mapRef: { current: {} as MapLibreMap }, mapReady: true,
|
||||
getPadding: () => ({ top: 48, right: 400, bottom: 48, left: 48 }), reducedMotion: true, onLocate: vi.fn()
|
||||
};
|
||||
@@ -22,6 +23,23 @@ beforeEach(() => {
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("place search selection lifecycle", () => {
|
||||
it("disables search without project configuration, even for the original workspace", async () => {
|
||||
const { result } = renderHook(useWorkbenchGeocoding, {
|
||||
initialProps: { ...options, placeSearch: null }
|
||||
});
|
||||
act(() => result.current.changeAddress("南桥路"));
|
||||
await act(() => result.current.search());
|
||||
expect(result.current.configured).toBe(false);
|
||||
expect(result.current.error).toBe("当前项目暂未配置地名搜索");
|
||||
expect(searchPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the selected project's configured region", async () => {
|
||||
const { result } = renderHook(useWorkbenchGeocoding, {
|
||||
initialProps: { ...options, placeSearch: { ...options.placeSearch, admin_code: "341126", region: "凤阳县" } }
|
||||
});
|
||||
expect(result.current.configured).toBe(true);
|
||||
expect(result.current.region).toBe("凤阳县");
|
||||
});
|
||||
it("does not move until selection and replaces the previous marker", async () => {
|
||||
vi.mocked(searchPlaces).mockResolvedValue(results);
|
||||
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
|
||||
@@ -84,3 +102,95 @@ describe("place search selection lifecycle", () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function pickingMap() {
|
||||
const canvas = document.createElement("canvas");
|
||||
const listeners = new Map<string, (event: MapMouseEvent) => void>();
|
||||
const map = {
|
||||
on: vi.fn((name: string, handler: (event: MapMouseEvent) => void) => listeners.set(name, handler)),
|
||||
off: vi.fn((name: string) => listeners.delete(name)),
|
||||
getCanvas: () => canvas
|
||||
};
|
||||
return {
|
||||
map: map as unknown as MapLibreMap,
|
||||
canvas,
|
||||
click: (longitude = location.longitude, latitude = location.latitude) => listeners.get("click")?.({
|
||||
lngLat: { lng: longitude, lat: latitude }, preventDefault: vi.fn()
|
||||
} as unknown as MapMouseEvent)
|
||||
};
|
||||
}
|
||||
|
||||
describe("map point place lookup", () => {
|
||||
it("ends picking after a successful lookup and keeps the marker and camera unchanged", async () => {
|
||||
const map = pickingMap();
|
||||
const onPickingChange = vi.fn();
|
||||
const props = { ...options, picking: true, onPickingChange, mapRef: { current: map.map } };
|
||||
vi.mocked(reverseGeocodePoint).mockResolvedValue(location);
|
||||
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
|
||||
onPickingChange.mockClear();
|
||||
expect(map.canvas.style.cursor).toBe("crosshair");
|
||||
await act(async () => { map.click(); });
|
||||
expect(result.current.result).toEqual(location);
|
||||
expect(result.current.origin).toBe("map");
|
||||
expect(result.current.choosing).toBe(false);
|
||||
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(markGeocodingLocation).toHaveBeenCalledWith(map.map, location);
|
||||
expect(locateGeocodingResult).not.toHaveBeenCalled();
|
||||
act(() => result.current.locate());
|
||||
expect(locateGeocodingResult).toHaveBeenCalledOnce();
|
||||
rerender({ ...props, picking: false });
|
||||
expect(map.canvas.style.cursor).toBe("");
|
||||
expect(map.map.off).toHaveBeenCalledWith("click", expect.any(Function));
|
||||
});
|
||||
|
||||
it("ignores an older point after a second click", async () => {
|
||||
const map = pickingMap();
|
||||
let first!: (value: PlaceSearchItem) => void;
|
||||
let second!: (value: PlaceSearchItem) => void;
|
||||
vi.mocked(reverseGeocodePoint)
|
||||
.mockReturnValueOnce(new Promise((resolve) => { first = resolve; }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { second = resolve; }));
|
||||
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, mapRef: { current: map.map } }));
|
||||
act(() => map.click());
|
||||
const firstSignal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
|
||||
act(() => map.click(other.longitude, other.latitude));
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
await act(async () => { second(other); });
|
||||
await act(async () => { first(location); });
|
||||
expect(result.current.result).toEqual(other);
|
||||
});
|
||||
|
||||
it.each(["close", "project", "escape"])("aborts an outstanding map lookup on %s", async (action) => {
|
||||
const map = pickingMap();
|
||||
let resolve!: (value: PlaceSearchItem) => void;
|
||||
vi.mocked(reverseGeocodePoint).mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
const props = { ...options, picking: true, onPickingChange: vi.fn(), mapRef: { current: map.map } };
|
||||
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
|
||||
act(() => map.click());
|
||||
const signal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
|
||||
if (action === "close") rerender({ ...props, active: false });
|
||||
if (action === "project") rerender({ ...props, projectId: "other-project" });
|
||||
if (action === "escape") act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => { resolve(location); });
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.pending).toBe(false);
|
||||
expect(props.onPickingChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows a lookup error and accepts another point", async () => {
|
||||
const map = pickingMap();
|
||||
const onPickingChange = vi.fn();
|
||||
vi.mocked(reverseGeocodePoint).mockRejectedValueOnce(new Error("取点超出项目范围")).mockResolvedValueOnce(location);
|
||||
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, onPickingChange, mapRef: { current: map.map } }));
|
||||
onPickingChange.mockClear();
|
||||
await act(async () => { map.click(); });
|
||||
expect(result.current.error).toBe("取点超出项目范围");
|
||||
expect(onPickingChange).not.toHaveBeenCalled();
|
||||
await act(async () => { map.click(); });
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.result).toEqual(location);
|
||||
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, MapMouseEvent, PaddingOptions } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { reverseGeocodePoint, searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
|
||||
import type { PlaceSearchConfiguration } from "../api/project-client";
|
||||
|
||||
type GeocodingOptions = {
|
||||
placeSearch?: PlaceSearchConfiguration | null;
|
||||
active: boolean;
|
||||
picking?: boolean;
|
||||
onPickingChange?: (picking: boolean) => void;
|
||||
projectId: string | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
getPadding: () => PaddingOptions;
|
||||
@@ -16,6 +20,10 @@ type GeocodingOptions = {
|
||||
};
|
||||
|
||||
export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
const configured = Boolean(options.placeSearch?.enabled);
|
||||
const region = options.placeSearch?.region ?? "";
|
||||
const picking = Boolean(options.active && configured && options.mapReady && options.picking);
|
||||
const [origin, setOrigin] = useState<"search" | "map">("search");
|
||||
const [address, setAddress] = useState("");
|
||||
const [result, setResult] = useState<PlaceSearchItem | null>(null);
|
||||
const [results, setResults] = useState<PlaceSearchResults | null>(null);
|
||||
@@ -37,15 +45,20 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
setResults(null);
|
||||
setChoosing(true);
|
||||
setError(null);
|
||||
setOrigin("search");
|
||||
}, [cancel]);
|
||||
useEffect(() => {
|
||||
clear();
|
||||
setAddress("");
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
return () => requestRef.current?.abort();
|
||||
}, [options.projectId, options.getAccessToken, clear]);
|
||||
useEffect(() => {
|
||||
if (!options.active) cancel();
|
||||
}, [options.active, cancel]);
|
||||
if (!options.active || !configured) {
|
||||
cancel();
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
}
|
||||
}, [options.active, configured, cancel]);
|
||||
|
||||
const locate = useCallback(() => {
|
||||
const current = optionsRef.current;
|
||||
@@ -59,18 +72,75 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
const map = options.mapRef.current;
|
||||
if (!map || !options.mapReady || !result) return;
|
||||
const marker = markGeocodingLocation(map, result);
|
||||
locate();
|
||||
if (origin === "search") locate();
|
||||
return () => { marker.remove(); };
|
||||
}, [options.mapRef, options.mapReady, result, locate]);
|
||||
}, [options.mapRef, options.mapReady, result, origin, locate]);
|
||||
|
||||
async function lookupPoint(longitude: number, latitude: number) {
|
||||
const current = optionsRef.current;
|
||||
if (!current.active || !current.picking || !current.placeSearch?.enabled) return;
|
||||
clear();
|
||||
setOrigin("map");
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setPending(true);
|
||||
try {
|
||||
const item = await reverseGeocodePoint(longitude, latitude, current.projectId,
|
||||
current.getAccessToken, controller.signal, current.placeSearch);
|
||||
if (controller.signal.aborted || requestRef.current !== controller) return;
|
||||
setResult(item);
|
||||
setResults({ items: [item], total: 1, truncated: false, region: current.placeSearch.region });
|
||||
setChoosing(false);
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
} catch (failure) {
|
||||
if (controller.signal.aborted || requestRef.current !== controller) return;
|
||||
setError(failure instanceof Error && failure.name === "TimeoutError"
|
||||
? "地图取点查询超时,请重新取点"
|
||||
: failure instanceof Error ? failure.message : "地图取点查询失败,请重新取点");
|
||||
} finally {
|
||||
if (requestRef.current === controller) { requestRef.current = null; setPending(false); }
|
||||
}
|
||||
}
|
||||
const lookupPointRef = useRef(lookupPoint);
|
||||
lookupPointRef.current = lookupPoint;
|
||||
|
||||
useEffect(() => {
|
||||
const map = options.mapRef.current;
|
||||
if (!picking || !map) return;
|
||||
const canvas = map.getCanvas();
|
||||
const previousCursor = canvas.style.cursor;
|
||||
canvas.style.cursor = "crosshair";
|
||||
const handleClick = (event: MapMouseEvent) => {
|
||||
event.preventDefault();
|
||||
const longitude = ((event.lngLat.lng + 180) % 360 + 360) % 360 - 180;
|
||||
void lookupPointRef.current(longitude, event.lngLat.lat);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancel();
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
};
|
||||
map.on("click", handleClick);
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
map.off("click", handleClick);
|
||||
canvas.style.cursor = previousCursor;
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [picking, options.mapRef, cancel]);
|
||||
|
||||
async function search() {
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
clear();
|
||||
if (!configured) { setError("当前项目暂未配置地名搜索"); return; }
|
||||
if (!address.trim()) { setError("请输入需要搜索的地址或地名"); return; }
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setPending(true);
|
||||
try {
|
||||
const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal);
|
||||
const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal, options.placeSearch);
|
||||
if (controller.signal.aborted || requestRef.current !== controller) return;
|
||||
setResults(value);
|
||||
} catch (failure) {
|
||||
@@ -84,7 +154,12 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
address, result, results, choosing, pending, error, locate, search,
|
||||
address, result, results, choosing, pending, error, locate, search, region, origin, picking,
|
||||
togglePicking: () => {
|
||||
cancel();
|
||||
if (!picking) { clear(); setOrigin("map"); optionsRef.current.onLocate(); }
|
||||
optionsRef.current.onPickingChange?.(!picking);
|
||||
},
|
||||
select: (id: string) => {
|
||||
if (pending) return;
|
||||
const item = results?.items.find((candidate) => candidate.id === id);
|
||||
@@ -95,9 +170,10 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
}
|
||||
},
|
||||
changeSelection: () => setChoosing(true),
|
||||
enabled: options.mapReady,
|
||||
changeAddress: (value: string) => { clear(); setAddress(value); },
|
||||
clear: () => { clear(); setAddress(""); }
|
||||
configured,
|
||||
enabled: configured && options.mapReady,
|
||||
changeAddress: (value: string) => { optionsRef.current.onPickingChange?.(false); clear(); setAddress(value); },
|
||||
clear: () => { optionsRef.current.onPickingChange?.(false); clear(); setAddress(""); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
|
||||
export function useWorkbenchMapController({
|
||||
mapRef,
|
||||
geoserverWorkspace,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
@@ -16,6 +17,7 @@ export function useWorkbenchMapController({
|
||||
getScadaFeatures,
|
||||
availableSourceIds
|
||||
}: {
|
||||
geoserverWorkspace?: string;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
leftPanelOpen: boolean;
|
||||
@@ -42,6 +44,7 @@ export function useWorkbenchMapController({
|
||||
const controllerRef = useRef<WorkbenchMapController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new WorkbenchMapController({
|
||||
geoserverWorkspace,
|
||||
getMap: () => mapRef.current,
|
||||
isReady: () => valuesRef.current.mapReady,
|
||||
getPadding: () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
StyleSpecification
|
||||
} from "maplibre-gl";
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { fetchScadaSnapshot, type ScadaDevice } from "../api/scada-client";
|
||||
import type { DetailFeature } from "../types";
|
||||
@@ -62,6 +62,7 @@ declare global {
|
||||
}
|
||||
|
||||
type UseWorkbenchMapOptions = {
|
||||
featureInteractionsEnabled?: boolean;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
impactVisible: boolean;
|
||||
layerVisibility: Record<string, boolean>;
|
||||
@@ -71,7 +72,8 @@ type UseWorkbenchMapOptions = {
|
||||
simulationOverlay: FeatureCollection<Geometry> | null;
|
||||
allowDemoSimulation: boolean;
|
||||
networkView?: NetworkViewParams | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
geoserverWorkspace?: string;
|
||||
};
|
||||
|
||||
type UseWorkbenchMapResult = {
|
||||
@@ -132,6 +134,7 @@ const EMPTY_SIMULATION_COLLECTION: FeatureCollection = {
|
||||
};
|
||||
|
||||
export function useWorkbenchMap({
|
||||
featureInteractionsEnabled = true,
|
||||
containerRef,
|
||||
impactVisible,
|
||||
layerVisibility,
|
||||
@@ -141,6 +144,7 @@ export function useWorkbenchMap({
|
||||
simulationOverlay,
|
||||
allowDemoSimulation,
|
||||
networkView,
|
||||
geoserverWorkspace,
|
||||
getAccessToken
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
@@ -199,7 +203,7 @@ export function useWorkbenchMap({
|
||||
);
|
||||
geoServerLayerProbeInFlightRef.current = true;
|
||||
|
||||
void resolveGeoServerLayerAvailability(requestAbortController.signal)
|
||||
void resolveGeoServerLayerAvailability(requestAbortController.signal, geoserverWorkspace)
|
||||
.then((availability) => {
|
||||
if (lifecycleAbortController.signal.aborted) return;
|
||||
setAvailableGeoServerSourceIds((current) =>
|
||||
@@ -237,7 +241,7 @@ export function useWorkbenchMap({
|
||||
geoServerLayerProbeInFlightRef.current = false;
|
||||
window.clearTimeout(probeTimeout);
|
||||
};
|
||||
}, [geoServerLayerRefreshRevision]);
|
||||
}, [geoServerLayerRefreshRevision, geoserverWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
@@ -306,7 +310,7 @@ export function useWorkbenchMap({
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
scadaCollectionRef.current
|
||||
);
|
||||
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS);
|
||||
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS, geoserverWorkspace);
|
||||
REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||
const source = sources[sourceId];
|
||||
if (source) map.addSource(sourceId, source);
|
||||
@@ -315,8 +319,10 @@ export function useWorkbenchMap({
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
|
||||
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
|
||||
await registerSupplyAssetImages(map);
|
||||
const scadaRegistration = await registerScadaImages(map);
|
||||
await registerSupplyAssetImages(map, scadaAbortController.signal);
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
const activeScadaBusinessLayers =
|
||||
scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
@@ -444,13 +450,13 @@ export function useWorkbenchMap({
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
};
|
||||
}, [containerRef, getAccessToken]);
|
||||
}, [containerRef, getAccessToken, geoserverWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) return;
|
||||
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
|
||||
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
|
||||
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility, geoserverWorkspace);
|
||||
}, [availableGeoServerSourceIds, layerVisibility, mapReady, geoserverWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@@ -462,6 +468,7 @@ export function useWorkbenchMap({
|
||||
}, [mapReady, networkView]);
|
||||
|
||||
useMapInteractions({
|
||||
enabled: featureInteractionsEnabled,
|
||||
availableSourceIds: availableGeoServerSourceIds,
|
||||
mapRef,
|
||||
mapReady,
|
||||
@@ -557,7 +564,8 @@ function syncSimulationSourceData(
|
||||
function syncDataDependentGeoServerLayers(
|
||||
map: MapLibreMap,
|
||||
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
|
||||
layerVisibility: Record<string, boolean>
|
||||
layerVisibility: Record<string, boolean>,
|
||||
workspace?: string
|
||||
) {
|
||||
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>(availableSourceIds);
|
||||
|
||||
@@ -574,7 +582,7 @@ function syncDataDependentGeoServerLayers(
|
||||
const requestedOptionalSourceIds = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) =>
|
||||
availableSourceIdSet.has(sourceId)
|
||||
);
|
||||
const sources = createWaterNetworkSources(requestedOptionalSourceIds);
|
||||
const sources = createWaterNetworkSources(requestedOptionalSourceIds, workspace);
|
||||
requestedOptionalSourceIds.forEach((sourceId) => {
|
||||
if (!map.getSource(sourceId) && sources[sourceId]) {
|
||||
map.addSource(sourceId, sources[sourceId]);
|
||||
@@ -620,7 +628,8 @@ function addMissingDataDependentLayers(
|
||||
layers: StyleSpecification["layers"],
|
||||
beforeLayerCandidates: readonly string[],
|
||||
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
|
||||
layerVisibility: Record<string, boolean>
|
||||
layerVisibility: Record<string, boolean>,
|
||||
workspace?: string
|
||||
) {
|
||||
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
|
||||
layers.forEach((layer) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ function presentation(
|
||||
systemRole: "user",
|
||||
isSystemAdmin: false,
|
||||
networkView: null,
|
||||
placeSearch: null,
|
||||
metadataAvailable: true,
|
||||
accessContextAvailable: true,
|
||||
...overrides
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
fetchWorkbenchProjectPresentation,
|
||||
type WorkbenchProjectPresentation
|
||||
@@ -14,11 +14,17 @@ const PROJECT_ROLE_LABELS: Record<string, string> = {
|
||||
|
||||
export function useWorkbenchProjectPresentation(
|
||||
fallbackUser: WorkbenchUser,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
getAccessToken?: WorkbenchAccess
|
||||
) {
|
||||
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(null);
|
||||
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(
|
||||
() => typeof getAccessToken === "object" ? getAccessToken.presentation ?? null : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof getAccessToken === "object" && getAccessToken.presentation) {
|
||||
setPresentation(getAccessToken.presentation);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void fetchWorkbenchProjectPresentation(getAccessToken, controller.signal)
|
||||
.then((value) => {
|
||||
@@ -39,6 +45,7 @@ export function useWorkbenchProjectPresentation(
|
||||
user,
|
||||
projectId: presentation?.projectId ?? null,
|
||||
networkView: presentation?.networkView ?? null,
|
||||
placeSearch: presentation?.placeSearch ?? null,
|
||||
dataSource: presentation?.metadataAvailable || presentation?.accessContextAvailable
|
||||
? "backend" as const
|
||||
: "fallback" as const
|
||||
|
||||
@@ -81,5 +81,6 @@ describe("workbench responsive layout", () => {
|
||||
|
||||
act(() => result.current.openAgentPanelForViewport());
|
||||
expect(result.current.mobileSheet).toBe("agent");
|
||||
expect(result.current.mobileSheetSnap).toBe("full");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
setMobileSheet("agent");
|
||||
setMobileSheetSnap("half");
|
||||
setMobileSheetSnap("full");
|
||||
}
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkbenchRequestContext } from "../api/request-context";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
import { useWorkbenchRuntimeData } from "./use-workbench-runtime-data";
|
||||
|
||||
vi.mock("../api/realtime-flow-client", () => ({
|
||||
fetchRealtimePipeFlows: vi.fn().mockResolvedValue({ records: [], observedAt: null })
|
||||
}));
|
||||
|
||||
const fengyangAccess: WorkbenchRequestContext = {
|
||||
project: {
|
||||
project_id: "b962c44e-8433-4a10-81e9-a64b70ac1152",
|
||||
name: "凤阳(新版)",
|
||||
code: "fengyang_v2",
|
||||
gs_workspace: "fengyang_v2",
|
||||
status: "active",
|
||||
project_role: "member"
|
||||
},
|
||||
getAccessToken: async () => "test-token"
|
||||
};
|
||||
|
||||
const sync = {
|
||||
snapshot: null,
|
||||
events: [],
|
||||
status: "live",
|
||||
error: null,
|
||||
detailResetGeneration: 0,
|
||||
acknowledgeEventsThrough: vi.fn(),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
subscribePresentationEvents: vi.fn(() => () => undefined)
|
||||
} as unknown as WorkbenchSyncState;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("project-scoped workbench runtime data", () => {
|
||||
it("shows mock scheduled conditions outside the demo project", async () => {
|
||||
const { result } = renderHook(() => useWorkbenchRuntimeData(fengyangAccess, sync));
|
||||
|
||||
await waitFor(() => expect(result.current.scheduledConditionsLoading).toBe(false));
|
||||
|
||||
expect(result.current.scheduledConditions.length).toBeGreaterThan(0);
|
||||
expect(result.current.scheduledConditions.every((condition) => condition.kind === "condition"))
|
||||
.toBe(true);
|
||||
expect(result.current.scenarios).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { getWorkbenchProject, type WorkbenchAccess } from "../api/request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import {
|
||||
type WorkbenchRuntimeSnapshot
|
||||
@@ -128,9 +128,11 @@ function isSameLocalOperationalDate(value: string, operationalDate: Date) {
|
||||
}
|
||||
|
||||
export function useWorkbenchRuntimeData(
|
||||
getAccessToken: AccessTokenProvider | undefined,
|
||||
getAccessToken: WorkbenchAccess | undefined,
|
||||
sync: WorkbenchSyncState
|
||||
) {
|
||||
const scopedProject = getWorkbenchProject(getAccessToken);
|
||||
const allowDemoScenarios = !scopedProject || scopedProject.gs_workspace === "tjwater_next";
|
||||
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
|
||||
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
|
||||
const backendSnapshot: WorkbenchRuntimeSnapshot | null = sync.snapshot?.runtime ?? null;
|
||||
@@ -252,7 +254,8 @@ export function useWorkbenchRuntimeData(
|
||||
scheduledConditionsLoading,
|
||||
realtimePipeFlows,
|
||||
flowDataSource: realtimePipeFlows ? "realtime" as const : "mock" as const,
|
||||
...resolvedRuntimeData
|
||||
...resolvedRuntimeData,
|
||||
scenarios: !allowDemoScenarios && resolvedRuntimeData.scenarioDataSource === "mock" ? [] : resolvedRuntimeData.scenarios
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
consumeWorkbenchEvents,
|
||||
fetchWorkbenchSyncSnapshot,
|
||||
@@ -45,7 +45,7 @@ export function shouldApplyWorkbenchSnapshot(input: {
|
||||
&& (input.resetCursor || input.eventCursor >= input.currentCursor);
|
||||
}
|
||||
|
||||
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
|
||||
export function useWorkbenchSync(getAccessToken?: WorkbenchAccess): WorkbenchSyncState {
|
||||
const channelRef = useRef(new WorkbenchEventChannel());
|
||||
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
|
||||
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
import { WorkbenchSessionContext, WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
export function WorkbenchSessionProvider({ children, owner }: { children: ReactNode; owner?: unknown }) {
|
||||
const resources = useMemo(() => new WorkbenchSessionResources(owner), [owner]);
|
||||
useEffect(() => { resources.retain(); return () => resources.release(); }, [resources]);
|
||||
return <WorkbenchSessionContext.Provider key={resources.id} value={resources}>{children}</WorkbenchSessionContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import type { AgentSessionRuntime } from "./agent-session-runtime";
|
||||
import { ResourceCache } from "@/shared/cache/resource-cache";
|
||||
|
||||
export class WorkbenchSessionResources {
|
||||
constructor(readonly owner?: unknown) {}
|
||||
readonly id = crypto.randomUUID();
|
||||
disposed = false;
|
||||
readonly cache = new ResourceCache();
|
||||
readonly agents = new Map<string, AgentSessionRuntime>();
|
||||
readonly disposers = new Set<() => void>();
|
||||
activeAgent: AgentSessionRuntime | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private revision = 0;
|
||||
private retainCount = 0;
|
||||
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
|
||||
getSnapshot = () => this.revision;
|
||||
notify() { if (!this.disposed) { this.revision++; this.listeners.forEach((listener) => listener()); } }
|
||||
retain() { this.retainCount++; }
|
||||
release() {
|
||||
this.retainCount--;
|
||||
// StrictMode immediately reacquires its lease; a real unmount releases it.
|
||||
queueMicrotask(() => { if (this.retainCount === 0) this.dispose(); });
|
||||
}
|
||||
dispose() { this.disposed = true; this.activeAgent = null; this.disposers.forEach((dispose) => dispose()); this.disposers.clear(); this.cache.clear(); this.agents.clear(); }
|
||||
}
|
||||
|
||||
export const WorkbenchSessionContext = createContext<WorkbenchSessionResources | null>(null);
|
||||
|
||||
export function useWorkbenchSessionResources() {
|
||||
const provided = useContext(WorkbenchSessionContext);
|
||||
const [fallback] = useState(() => new WorkbenchSessionResources());
|
||||
return provided ?? fallback;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { MapWorkbenchPage } from "./map-workbench-page";
|
||||
export { ProjectWorkbench } from "./project-workbench";
|
||||
export { WorkbenchMapController } from "./map/workbench-map-controller";
|
||||
export type {
|
||||
FeatureTarget,
|
||||
|
||||
@@ -4,7 +4,7 @@ export const WORKBENCH_LAYOUT = {
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 0,
|
||||
collapsedAgentRailWidth: 72,
|
||||
collapsedAgentRailWidth: 64,
|
||||
minAgentWidth: 400,
|
||||
maxAgentWidth: 640,
|
||||
agentPanelLeftInset: 12,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { CalendarClock, Clock3 } from "lucide-react";
|
||||
import { ReadingWorkspaceProvider } from "@/shared/ui/reading-workspace";
|
||||
import {
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
@@ -18,7 +20,8 @@ import {
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
type AgentUiResult,
|
||||
type AgentWorkspaceContextItem
|
||||
type AgentWorkspaceContextItem,
|
||||
type AnswerUiSelection
|
||||
} from "@/features/agent";
|
||||
import {
|
||||
MapErrorNotice,
|
||||
@@ -33,11 +36,13 @@ import {
|
||||
} from "@/features/map/core";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { waitForAgentPresentation } from "./map/agent-presentation";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { getWorkbenchProject, type WorkbenchAccess } from "./api/request-context";
|
||||
import type { WorkbenchProjectSelection } from "./components/workbench-top-bar-menus";
|
||||
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 type { PeripheralTestDataRecord, PeripheralWorkOrder } from "./peripheral-sample/client";
|
||||
import { buildScadaAnomalyInvestigation } from "./peripheral-sample/scada-anomaly-agent-trigger";
|
||||
import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel";
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
@@ -125,15 +130,22 @@ const ALERT_TIME_FORMATTER = new Intl.DateTimeFormat("zh-CN", {
|
||||
hour12: false
|
||||
});
|
||||
|
||||
export function MapWorkbenchPage({
|
||||
user = WORKBENCH_USER,
|
||||
onLogout,
|
||||
getAccessToken
|
||||
}: {
|
||||
type MapWorkbenchPageProps = {
|
||||
user?: WorkbenchUser;
|
||||
onLogout?: () => Promise<void>;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
}) {
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
projectSelection?: WorkbenchProjectSelection;
|
||||
};
|
||||
export function MapWorkbenchPage(props: MapWorkbenchPageProps) {
|
||||
return <ReadingWorkspaceProvider><MapWorkbenchContent {...props} /></ReadingWorkspaceProvider>;
|
||||
}
|
||||
function MapWorkbenchContent({
|
||||
user = WORKBENCH_USER,
|
||||
onLogout,
|
||||
getAccessToken,
|
||||
projectSelection
|
||||
}: MapWorkbenchPageProps) {
|
||||
const readingWorkspace = useReadingWorkspace();
|
||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
|
||||
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
|
||||
@@ -145,6 +157,7 @@ export function MapWorkbenchPage({
|
||||
const [investigationFollow, setInvestigationFollow] = useState(true);
|
||||
const presentationLayerChangesRef = useRef(new Map<string, boolean>());
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [geocodingPickActive, setGeocodingPickActive] = useState(false);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
@@ -272,7 +285,8 @@ export function MapWorkbenchPage({
|
||||
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
|
||||
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
|
||||
const allowDemoSimulation =
|
||||
artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo";
|
||||
(!getWorkbenchProject(getAccessToken) || getWorkbenchProject(getAccessToken)?.gs_workspace === "tjwater_next")
|
||||
&& (artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo");
|
||||
const latestAnalysisAvailable = Boolean(
|
||||
latestAnalysisArtifact?.blocks.some(
|
||||
(block) =>
|
||||
@@ -395,6 +409,44 @@ export function MapWorkbenchPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScadaAnomalyAgentTrigger(record: PeripheralTestDataRecord) {
|
||||
showMapNotice({
|
||||
id: "scada-anomaly-agent-trigger",
|
||||
tone: "loading",
|
||||
title: "SCADA 模拟异常已接收",
|
||||
message: "正在建立结构化异常调查任务。",
|
||||
duration: Infinity
|
||||
});
|
||||
|
||||
try {
|
||||
const task = await operationalTasks.createInvestigation(
|
||||
buildScadaAnomalyInvestigation(record)
|
||||
);
|
||||
setPeripheralSamplePanelOpen(false);
|
||||
openConditionWorkspaceForViewport();
|
||||
const conditionId = operationalTaskConditionId(task.task_id);
|
||||
setSelectedConditionId(conditionId);
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
}));
|
||||
showMapNotice({
|
||||
id: "scada-anomaly-agent-trigger",
|
||||
tone: "success",
|
||||
title: "已建立 SCADA 异常调查",
|
||||
message: `关联模拟读数 ${record.record_id.slice(0, 8)},Agent 将按任务上下文核查。`
|
||||
});
|
||||
} catch (error) {
|
||||
showMapNotice({
|
||||
id: "scada-anomaly-agent-trigger",
|
||||
tone: "error",
|
||||
title: "异常调查任务创建失败",
|
||||
message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function openConditionWorkspaceForViewport() {
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
@@ -422,6 +474,7 @@ export function MapWorkbenchPage({
|
||||
scadaObservedAt,
|
||||
refreshGeoServerLayers
|
||||
} = useWorkbenchMap({
|
||||
featureInteractionsEnabled: !geocodingPickActive,
|
||||
containerRef: mapContainerRef,
|
||||
impactVisible,
|
||||
layerVisibility,
|
||||
@@ -431,6 +484,7 @@ export function MapWorkbenchPage({
|
||||
simulationOverlay: activeSpatialOverlay,
|
||||
allowDemoSimulation,
|
||||
networkView: projectPresentation.networkView,
|
||||
geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace,
|
||||
getAccessToken
|
||||
});
|
||||
const displayedDataTime =
|
||||
@@ -439,6 +493,7 @@ export function MapWorkbenchPage({
|
||||
: headerDataTime;
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace,
|
||||
mapReady,
|
||||
leftPanelOpen: false,
|
||||
rightPanelOpen,
|
||||
@@ -464,6 +519,12 @@ export function MapWorkbenchPage({
|
||||
getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
|
||||
});
|
||||
const geocoding = useWorkbenchGeocoding({
|
||||
picking: geocodingPickActive,
|
||||
onPickingChange: (picking) => {
|
||||
setGeocodingPickActive(picking);
|
||||
if (picking) { setActiveDrawToolId(null); measurement.stop(); }
|
||||
},
|
||||
placeSearch: projectPresentation.placeSearch,
|
||||
active: activeToolId === "geocoding",
|
||||
projectId: projectPresentation.projectId,
|
||||
getAccessToken, mapRef, mapReady,
|
||||
@@ -479,7 +540,11 @@ export function MapWorkbenchPage({
|
||||
}
|
||||
});
|
||||
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
|
||||
void presentation.locateTargets(targets).catch((error) => showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" }));
|
||||
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
|
||||
return presentation.locateTargets(targets).then(() => true).catch((error) => {
|
||||
showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
useEffect(() => () => {
|
||||
restorePresentationLayers();
|
||||
@@ -502,6 +567,7 @@ export function MapWorkbenchPage({
|
||||
[scheduledConditions]
|
||||
);
|
||||
const drawing = useWorkbenchDrawing({
|
||||
selectionEnabled: !geocodingPickActive,
|
||||
mapRef,
|
||||
mapReady,
|
||||
activeMode: activeDrawToolId,
|
||||
@@ -516,6 +582,7 @@ export function MapWorkbenchPage({
|
||||
});
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker =
|
||||
!readingWorkspace?.active &&
|
||||
!artifactActive &&
|
||||
mobileSheet === null &&
|
||||
!conditionFeedExpanded &&
|
||||
@@ -933,20 +1000,24 @@ export function MapWorkbenchPage({
|
||||
}
|
||||
|
||||
async function handleLoadAgentHistorySession(sessionId: string) {
|
||||
if (sessionId !== agent.sessionId) {
|
||||
mapController.clearScadaAnalysis();
|
||||
}
|
||||
await agent.loadHistorySession(sessionId);
|
||||
const changesSession = sessionId !== agent.sessionId;
|
||||
const loaded = await agent.loadHistorySession(sessionId);
|
||||
if (!loaded) throw new Error("会话打开已取消");
|
||||
if (changesSession) mapController.clearScadaAnalysis();
|
||||
}
|
||||
|
||||
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
|
||||
mapController.clearScadaAnalysis();
|
||||
await agent.openTaskCollaboration(taskId, discussion);
|
||||
const id = await agent.openTaskCollaboration(taskId, discussion);
|
||||
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
|
||||
readingWorkspace?.close();
|
||||
openAgentPanelForViewport();
|
||||
}
|
||||
|
||||
async function handleDeleteAgentHistorySession(sessionId: string) {
|
||||
const clearsActiveAnalysis = sessionId === agent.sessionId;
|
||||
const deleted = await agent.deleteHistorySession(sessionId);
|
||||
if (!deleted) throw new Error("会话删除失败,请重试");
|
||||
if (deleted && clearsActiveAnalysis) {
|
||||
mapController.clearScadaAnalysis();
|
||||
}
|
||||
@@ -1134,7 +1205,7 @@ export function MapWorkbenchPage({
|
||||
? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId)
|
||||
: undefined;
|
||||
const promptSuggestions = useAgentPromptSuggestions({
|
||||
enabled: agent.conversationScope === "project" && (isLargeScreen ? agent.panelOpen : mobileSheet === "agent") && agent.messages.length === 0 && !agent.streaming &&
|
||||
enabled: (isLargeScreen ? agent.panelOpen : mobileSheet === "agent") && agent.messages.length === 0 && !agent.streaming &&
|
||||
agent.sessionPurpose === "ordinary_chat" && agent.connectionStatus === "online" && !agent.sessionHistoryLoading &&
|
||||
!scheduledConditionsLoading && !operationalTasks.loading,
|
||||
model: agent.selectedModel,
|
||||
@@ -1145,7 +1216,8 @@ export function MapWorkbenchPage({
|
||||
conditions: scheduledConditions,
|
||||
runtimeDataSource,
|
||||
artifact: latestAnalysisArtifact ?? null,
|
||||
sessions: agent.sessionHistory,
|
||||
sessions: agent.recentSessions,
|
||||
cacheScope: agent.cacheScope,
|
||||
});
|
||||
const agentPanelProps: Omit<
|
||||
ComponentProps<typeof AgentCommandPanel>,
|
||||
@@ -1155,6 +1227,25 @@ export function MapWorkbenchPage({
|
||||
sessionTitle: agent.sessionTitle,
|
||||
sessionHistory: agent.sessionHistory,
|
||||
sessionHistoryLoading: agent.sessionHistoryLoading,
|
||||
topicControls: agent.topicControls,
|
||||
historyTopicId: agent.history.topicId,
|
||||
onHistoryTopicChange: agent.history.setTopicId,
|
||||
onMoveSessionToTopic: agent.moveSessionToTopic,
|
||||
historyQuery: agent.history.query,
|
||||
onHistoryQueryChange: agent.history.setQuery,
|
||||
historyHasMore: agent.history.hasMore,
|
||||
historyLoadingMore: agent.history.loadingMore,
|
||||
historyError: agent.history.error ? "历史记录加载失败,请刷新重试" : undefined,
|
||||
historyPendingUpdate: agent.history.pendingUpdate,
|
||||
onHistoryOpen: agent.history.ensure,
|
||||
onHistoryClose: agent.closeHistory,
|
||||
onLoadMoreHistory: agent.history.loadMore,
|
||||
hasOlderMessages: agent.hasOlderMessages,
|
||||
loadingOlderMessages: agent.loadingOlderMessages,
|
||||
onLoadOlderMessages: agent.loadOlderMessages,
|
||||
readingPosition: agent.readingPosition,
|
||||
onReadingPositionChange: agent.onReadingPositionChange,
|
||||
backgroundStatus: agent.backgroundStatus,
|
||||
promptSuggestions: promptSuggestions.suggestions,
|
||||
promptSuggestionsLoading: promptSuggestions.loading,
|
||||
promptSuggestionsUnavailable: promptSuggestions.unavailable,
|
||||
@@ -1167,12 +1258,19 @@ export function MapWorkbenchPage({
|
||||
onInputDeliveryChange: agent.setInputDelivery,
|
||||
pendingInputs: agent.pendingInputs,
|
||||
onPendingInputDeliveryChange: agent.updateInputDelivery,
|
||||
onPendingInputEdit: (id) => agent.withdrawInput(id, true),
|
||||
onPendingInputCancel: (id) => agent.withdrawInput(id, false),
|
||||
onDownloadFile: async (path) => {
|
||||
try { await agent.apiClient.downloadFile(path); }
|
||||
catch (error) {
|
||||
showMapNotice({ tone: "error", title: "文件下载失败", message: error instanceof Error ? error.message : "请稍后重试" });
|
||||
}
|
||||
},
|
||||
messages: agent.messages,
|
||||
streamRenderState: agent.streamRenderState,
|
||||
modelOptions: agent.modelOptions,
|
||||
selectedModel: agent.selectedModel,
|
||||
conversationScope: agent.conversationScope,
|
||||
onConversationScopeChange: agent.changeConversationScope,
|
||||
currentProjectName: getWorkbenchProject(getAccessToken)?.name,
|
||||
approvalMode: agent.approvalMode,
|
||||
uiResults: activeAgentUiResults,
|
||||
reviewPanel: collaborationReviewTask ? <TaskConversationReview task={collaborationReviewTask}
|
||||
@@ -1185,6 +1283,11 @@ export function MapWorkbenchPage({
|
||||
onRefresh={operationalTasks.refresh} /> : undefined,
|
||||
workspaceContext: agentWorkspaceContext,
|
||||
prompt: agent.promptDraft,
|
||||
contentReference: agent.contentReference,
|
||||
onContentReferenceChange: (value) => {
|
||||
agent.setContentReference(value);
|
||||
if (value) readingWorkspace?.close();
|
||||
},
|
||||
promptReference: collaborationReviewTask ? {
|
||||
title: collaborationReviewTask.title,
|
||||
placeholder: "可询问调查进展、讨论报告或补充信息",
|
||||
@@ -1211,7 +1314,10 @@ export function MapWorkbenchPage({
|
||||
onRefresh: agent.agentMessageInbox.refresh
|
||||
} : undefined,
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onLocateAnswerFeatures: (target) => mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal),
|
||||
onLocateAnswerFeatures: async (target) => {
|
||||
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
|
||||
return mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal);
|
||||
},
|
||||
onStopPrompt: agent.stopPrompt,
|
||||
onReplyPermission: agent.replyPermission,
|
||||
onReplyQuestion: agent.replyQuestion,
|
||||
@@ -1241,6 +1347,7 @@ export function MapWorkbenchPage({
|
||||
alerts={workbenchAlerts}
|
||||
alertDataSource={alertDataSource}
|
||||
user={projectPresentation.user}
|
||||
projectSelection={projectSelection}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
timelineVisible={timelineVisible}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
@@ -1298,113 +1405,7 @@ export function MapWorkbenchPage({
|
||||
onClose={() => setPeripheralSamplePanelOpen(false)}
|
||||
getAccessToken={getAccessToken}
|
||||
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
|
||||
/>
|
||||
|
||||
<WorkbenchMainFrame
|
||||
activeArtifact={artifactWorkspace.activeArtifact}
|
||||
pendingArtifact={artifactWorkspace.pendingArtifact}
|
||||
surfaceMode={artifactWorkspace.surfaceMode}
|
||||
mapSplitAvailable={artifactWorkspace.mapSplitAvailable}
|
||||
onOpenPendingArtifact={artifactWorkspace.openPreview}
|
||||
onDestroyPendingArtifact={artifactWorkspace.destroyPreview}
|
||||
onSetArtifactView={artifactWorkspace.setView}
|
||||
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
||||
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"
|
||||
>
|
||||
<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={
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-14">
|
||||
{investigationFollow && selectedInvestigationTask && !devPanelOpen ? <InvestigationPresentationPanel
|
||||
task={selectedInvestigationTask} charts={presentation.charts} overlay={presentation.overlay} cameraPaused={presentation.cameraPaused}
|
||||
status={presentation.status} error={presentation.error}
|
||||
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
|
||||
relatedLayers={presentationRelatedLayers} onToggleLayer={handleTogglePresentationLayer}
|
||||
onResumeCamera={presentation.resumeCamera} onCloseChart={presentation.closeChart} onToggleOverlay={presentation.toggleOverlay} onFitOverlay={presentation.fitOverlay}
|
||||
/> : null}
|
||||
{!devPanelOpen ? (
|
||||
<>
|
||||
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
|
||||
<MapToolbar items={toolbarItems} />
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute right-16 top-10 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
</>
|
||||
) : 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
|
||||
}
|
||||
>
|
||||
<div className="flex items-end gap-2 lg:pr-2"
|
||||
onPointerDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}
|
||||
onKeyDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}>
|
||||
<MapZoom
|
||||
mapRef={mapRef}
|
||||
mapReady={mapReady}
|
||||
onHome={fitNetworkBounds}
|
||||
reducedMotion={prefersReducedMotion ?? false}
|
||||
/>
|
||||
</div>
|
||||
<MapScaleLine className="hidden lg:inline-flex" mapRef={mapRef} mapReady={mapReady} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onScadaAnomalyTrigger={handleScadaAnomalyAgentTrigger}
|
||||
/>
|
||||
|
||||
<WorkbenchAgentPanels
|
||||
@@ -1418,10 +1419,119 @@ export function MapWorkbenchPage({
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onWidthCommit={setAgentPanelWidth}
|
||||
/>
|
||||
>
|
||||
<WorkbenchMainFrame
|
||||
activeArtifact={artifactWorkspace.activeArtifact}
|
||||
pendingArtifact={artifactWorkspace.pendingArtifact}
|
||||
surfaceMode={artifactWorkspace.surfaceMode}
|
||||
mapSplitAvailable={artifactWorkspace.mapSplitAvailable}
|
||||
readingSplitAvailable={businessWorkspaceWidth >= 960}
|
||||
onOpenPendingArtifact={artifactWorkspace.openPreview}
|
||||
onDestroyPendingArtifact={artifactWorkspace.destroyPreview}
|
||||
onSetArtifactView={artifactWorkspace.setView}
|
||||
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
||||
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"
|
||||
>
|
||||
<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={
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-14">
|
||||
{investigationFollow && selectedInvestigationTask && !devPanelOpen ? <InvestigationPresentationPanel
|
||||
task={selectedInvestigationTask} charts={presentation.charts} overlay={presentation.overlay} cameraPaused={presentation.cameraPaused}
|
||||
status={presentation.status} error={presentation.error}
|
||||
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
|
||||
relatedLayers={presentationRelatedLayers} onToggleLayer={handleTogglePresentationLayer}
|
||||
onResumeCamera={presentation.resumeCamera} onCloseChart={presentation.closeChart} onToggleOverlay={presentation.toggleOverlay} onFitOverlay={presentation.fitOverlay}
|
||||
/> : null}
|
||||
{!devPanelOpen ? (
|
||||
<>
|
||||
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
|
||||
<MapToolbar items={toolbarItems} />
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute right-16 top-10 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
</>
|
||||
) : 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
|
||||
}
|
||||
>
|
||||
<div className="flex items-end gap-2 lg:pr-2"
|
||||
onPointerDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}
|
||||
onKeyDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}>
|
||||
<MapZoom
|
||||
mapRef={mapRef}
|
||||
mapReady={mapReady}
|
||||
onHome={fitNetworkBounds}
|
||||
reducedMotion={prefersReducedMotion ?? false}
|
||||
/>
|
||||
</div>
|
||||
<MapScaleLine className="hidden lg:inline-flex" mapRef={mapRef} mapReady={mapReady} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
</WorkbenchAgentPanels>
|
||||
|
||||
{conditionFeedMounted ? (
|
||||
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={devPanelOpen ? { display: "none" } : undefined}>
|
||||
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={devPanelOpen || readingWorkspace?.active ? { display: "none" } : undefined}>
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
conditions={scheduledConditions}
|
||||
@@ -1469,7 +1579,7 @@ export function MapWorkbenchPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<div
|
||||
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
|
||||
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
|
||||
@@ -1485,7 +1595,7 @@ export function MapWorkbenchPage({
|
||||
{shouldShowTaskTicker ? (
|
||||
<motion.div
|
||||
key="agent-task-ticker-shell"
|
||||
className="fixed left-1/2 right-auto top-24 z-50 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
||||
className="fixed left-1/2 right-auto top-24 z-50 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] lg:left-[calc((100%+var(--workbench-agent-current-width))/2)] lg:max-w-[calc(100%-var(--workbench-agent-current-width)-1.5rem)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
||||
style={{ transformOrigin: "top center" }}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
@@ -1521,7 +1631,7 @@ export function MapWorkbenchPage({
|
||||
onClick={openAgentPanelForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||
<AgentPersona className="pointer-events-none h-10 w-10" state={agent.personaState} />
|
||||
</button>
|
||||
{artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<MapToolbar
|
||||
@@ -1545,6 +1655,8 @@ export function MapWorkbenchPage({
|
||||
|
||||
{mobileSheet ? (
|
||||
<MobileWorkbenchSheet
|
||||
className={readingWorkspace?.active ? "hidden" : undefined}
|
||||
suspended={Boolean(readingWorkspace?.active)}
|
||||
label={
|
||||
mobileSheet === "agent"
|
||||
? "Agent 工作台抽屉"
|
||||
|
||||
@@ -33,23 +33,24 @@ export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SU
|
||||
layer.sourceLayer !== undefined && layer.availability === "required"
|
||||
).map((layer) => layer.id);
|
||||
|
||||
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId) {
|
||||
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId, workspace = GEOSERVER_WORKSPACE) {
|
||||
const params = new URLSearchParams({
|
||||
service: "WFS",
|
||||
version: "2.0.0",
|
||||
request: "GetFeature",
|
||||
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[sourceId]}`,
|
||||
typeNames: `${workspace}:${SOURCE_LAYERS[sourceId]}`,
|
||||
resultType: "hits"
|
||||
});
|
||||
|
||||
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||
return new URL(`${MAP_URL}/${workspace}/ows?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchGeoServerLayerFeatureCount(
|
||||
sourceId: DataDependentGeoServerSourceId,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
workspace = GEOSERVER_WORKSPACE
|
||||
) {
|
||||
const response = await fetch(createGeoServerLayerHitsUrl(sourceId), {
|
||||
const response = await fetch(createGeoServerLayerHitsUrl(sourceId, workspace), {
|
||||
headers: { Accept: "application/xml, text/xml" },
|
||||
signal
|
||||
});
|
||||
@@ -74,12 +75,13 @@ export async function fetchGeoServerLayerFeatureCount(
|
||||
}
|
||||
|
||||
export async function resolveGeoServerLayerAvailability(
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
workspace = GEOSERVER_WORKSPACE
|
||||
): Promise<GeoServerLayerAvailability> {
|
||||
const results = await Promise.allSettled(
|
||||
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map(async (sourceId) => ({
|
||||
sourceId,
|
||||
count: await fetchGeoServerLayerFeatureCount(sourceId, signal)
|
||||
count: await fetchGeoServerLayerFeatureCount(sourceId, signal, workspace)
|
||||
}))
|
||||
);
|
||||
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
|
||||
|
||||
@@ -63,13 +63,13 @@ export function escapeCqlLiteral(value: string) {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
|
||||
export function createMapFeatureWfsUrl(query: MapFeatureQuery, workspace = GEOSERVER_WORKSPACE) {
|
||||
const idField = MAP_FEATURE_ID_FIELDS[query.sourceId];
|
||||
const params = new URLSearchParams({
|
||||
service: "WFS",
|
||||
version: "2.0.0",
|
||||
request: "GetFeature",
|
||||
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
|
||||
typeNames: `${workspace}:${SOURCE_LAYERS[query.sourceId]}`,
|
||||
outputFormat: "application/json",
|
||||
srsName: "EPSG:4326",
|
||||
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES)
|
||||
@@ -79,7 +79,7 @@ export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
|
||||
params.set("cql_filter", `"${idField}" IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
|
||||
}
|
||||
|
||||
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||
return new URL(`${MAP_URL}/${workspace}/ows?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function normalizeMapFeatureCollection(value: unknown, maxFeatures: number): FeatureCollection | null {
|
||||
|
||||
@@ -131,3 +131,16 @@ function createImageMap(loadImage: (path: string) => Promise<{ data: { width: nu
|
||||
addImage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
it("does not access a disposed map after SCADA image loading completes", async () => {
|
||||
const controller = new AbortController();
|
||||
let resolve!: (value: { data: { width: number; height: number } }) => void;
|
||||
const map = createImageMap(() => new Promise((done) => { resolve = done; }));
|
||||
const pending = registerScadaImages(map, controller.signal);
|
||||
controller.abort();
|
||||
resolve({ data: { width: 64, height: 64 } });
|
||||
await expect(pending).resolves.toMatchObject({ status: "cancelled" });
|
||||
expect(map.addImage).not.toHaveBeenCalled();
|
||||
expect(map.loadImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
|
||||
type Layer = NonNullable<StyleSpecification["layers"]>[number];
|
||||
|
||||
export type ScadaImageRegistrationResult = {
|
||||
status: "ready" | "degraded" | "unavailable";
|
||||
status: "ready" | "degraded" | "unavailable" | "cancelled";
|
||||
failedImageIds: string[];
|
||||
};
|
||||
|
||||
@@ -226,12 +226,15 @@ export const scadaFallbackLayers = [
|
||||
];
|
||||
|
||||
export async function registerScadaImages(
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">,
|
||||
signal?: AbortSignal
|
||||
): Promise<ScadaImageRegistrationResult> {
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds: [] };
|
||||
let pressure: Awaited<ReturnType<typeof loadImage>>;
|
||||
|
||||
try {
|
||||
pressure = await loadImage(map, SCADA_MAP_ICON_PATHS.pressure);
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds: [] };
|
||||
addImageIfMissing(map, SCADA_IMAGE_IDS.pressure, pressure);
|
||||
} catch {
|
||||
return { status: "unavailable", failedImageIds: Object.values(SCADA_IMAGE_IDS) };
|
||||
@@ -245,8 +248,10 @@ export async function registerScadaImages(
|
||||
|
||||
try {
|
||||
const image = await loadImage(map, path);
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
addImageIfMissing(map, imageId, image);
|
||||
} catch {
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
failedImageIds.push(imageId);
|
||||
try {
|
||||
addImageIfMissing(map, imageId, pressure);
|
||||
|
||||
@@ -282,30 +282,33 @@ export function createWaterNetworkSources(): Record<
|
||||
VectorSourceSpecification
|
||||
>;
|
||||
export function createWaterNetworkSources(
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[]
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[],
|
||||
workspace?: string
|
||||
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||
export function createWaterNetworkSources(
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||
workspace = GEOSERVER_WORKSPACE
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
|
||||
layer.available &&
|
||||
layer.sourceLayer !== undefined &&
|
||||
sourceIds.includes(layer.id as GeoServerWaterNetworkSourceId)
|
||||
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
|
||||
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer, workspace)] as const]
|
||||
: []
|
||||
)
|
||||
) as Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||
}
|
||||
|
||||
function createGeoServerVectorSource(
|
||||
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS]
|
||||
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
|
||||
workspace: string
|
||||
): VectorSourceSpecification {
|
||||
return {
|
||||
type: "vector",
|
||||
promoteId: "id",
|
||||
tiles: [
|
||||
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
`${MAP_URL}/gwc/service/wmts/rest/${workspace}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
],
|
||||
minzoom: 0,
|
||||
maxzoom: 24
|
||||
|
||||
@@ -51,3 +51,17 @@ describe("supply facility map icons", () => {
|
||||
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("does not register images after the owning map is cancelled during loading", async () => {
|
||||
const controller = new AbortController();
|
||||
let resolve!: (value: { data: { width: number; height: number } }) => void;
|
||||
const map = { hasImage: vi.fn(() => false), addImage: vi.fn(),
|
||||
loadImage: vi.fn(() => new Promise<{ data: { width: number; height: number } }>((done) => { resolve = done; })) };
|
||||
const pending = registerSupplyAssetImages(map as unknown as Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">, controller.signal);
|
||||
controller.abort();
|
||||
resolve({ data: { width: 64, height: 64 } });
|
||||
await expect(pending).resolves.toMatchObject({ status: "cancelled" });
|
||||
expect(map.addImage).not.toHaveBeenCalled();
|
||||
expect(map.loadImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ export const SUPPLY_ASSET_MAP_ICON_PATHS = {
|
||||
} as const;
|
||||
|
||||
export type SupplyAssetImageRegistrationResult = {
|
||||
status: "ready" | "degraded";
|
||||
status: "ready" | "degraded" | "cancelled";
|
||||
failedImageIds: string[];
|
||||
};
|
||||
|
||||
@@ -63,16 +63,19 @@ const ACTIVE_SUPPLY_ASSET_ICON_KEYS = [
|
||||
] as const;
|
||||
|
||||
export async function registerSupplyAssetImages(
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">,
|
||||
signal?: AbortSignal
|
||||
): Promise<SupplyAssetImageRegistrationResult> {
|
||||
const failedImageIds: string[] = [];
|
||||
|
||||
for (const key of ACTIVE_SUPPLY_ASSET_ICON_KEYS) {
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
const imageId = SUPPLY_ASSET_IMAGE_IDS[key];
|
||||
if (map.hasImage(imageId)) continue;
|
||||
|
||||
try {
|
||||
const image = await loadImage(map, SUPPLY_ASSET_MAP_ICON_PATHS[key]);
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
addImageIfMissing(map, imageId, image);
|
||||
} catch {
|
||||
failedImageIds.push(imageId);
|
||||
|
||||
@@ -86,6 +86,7 @@ export type WorkbenchMapCommands = {
|
||||
};
|
||||
|
||||
export type WorkbenchMapControllerOptions = {
|
||||
geoserverWorkspace?: string;
|
||||
getMap: () => MapLibreMap | null;
|
||||
isReady: () => boolean;
|
||||
getPadding: () => PaddingOptions;
|
||||
@@ -619,7 +620,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
||||
if (!parsed.ok) throw new Error(parsed.code);
|
||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value, this.options.geoserverWorkspace), {
|
||||
headers: { Accept: "application/geo+json, application/json" },
|
||||
signal
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { getWorkbenchAccessToken } from "../api/request-context";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject } from "../api/project-client";
|
||||
import type { components } from "./generated/schema";
|
||||
@@ -32,7 +33,7 @@ export class PeripheralSampleClient {
|
||||
private readonly pendingCommands = new Map<string, { requestId: string; payload: string }>();
|
||||
private scenarioProjection: PeripheralScenarioState | null = null;
|
||||
|
||||
constructor(private readonly getAccessToken?: AccessTokenProvider) {}
|
||||
constructor(private readonly getAccessToken?: WorkbenchAccess) {}
|
||||
|
||||
get configured() {
|
||||
return Boolean(this.baseUrl);
|
||||
@@ -216,7 +217,7 @@ export class PeripheralSampleClient {
|
||||
private async prepareHeaders(signal: AbortSignal | undefined, json: boolean) {
|
||||
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
|
||||
const projectId = await this.resolveProjectId(signal);
|
||||
const accessToken = await this.getAccessToken?.();
|
||||
const accessToken = await getWorkbenchAccessToken(this.getAccessToken);
|
||||
return createWorkbenchHeaders(accessToken, projectId, json);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ClipboardList, ReceiptText, RefreshCw, X } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { Activity, ClipboardList, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { ManagedTestResources } from "./managed-test-resources";
|
||||
import { ScadaSystemSection } from "./scada-system-section";
|
||||
import { WorkbenchNotice } from "../components/workbench-panel-ui";
|
||||
import {
|
||||
PeripheralSampleClient,
|
||||
type PeripheralContext,
|
||||
type PeripheralTestDataRecord,
|
||||
type PeripheralWorkOrder
|
||||
} from "./client";
|
||||
|
||||
const DISCLAIMER = "仅用于验证外围工单接口与同步过程,不代表真实现场处置";
|
||||
|
||||
export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTrigger }: {
|
||||
export function PeripheralSamplePanel({
|
||||
open,
|
||||
onClose,
|
||||
getAccessToken,
|
||||
onAgentTrigger,
|
||||
onScadaAnomalyTrigger
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||||
onScadaAnomalyTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
|
||||
}) {
|
||||
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
|
||||
const client = clientRef.current;
|
||||
@@ -28,6 +38,8 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [receiptRequestId, setReceiptRequestId] = useState("");
|
||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||
const [scadaRefreshGeneration, setScadaRefreshGeneration] = useState(0);
|
||||
const [activeSystem, setActiveSystem] = useState<"work-orders" | "scada">("work-orders");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client.configured) return;
|
||||
@@ -51,84 +63,119 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="外围工单测试"
|
||||
aria-label="外围系统"
|
||||
className="acrylic-panel absolute bottom-3 right-3 top-16 z-[61] flex w-[min(680px,calc(100%-1.5rem))] flex-col overflow-hidden overscroll-contain rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)] max-sm:bg-[var(--surface-reading)]"
|
||||
>
|
||||
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b border-slate-200/80 px-4 sm:px-5">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-cyan-700 text-white">
|
||||
<ClipboardList size={17} />
|
||||
<RadioTower size={17} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold">外围工单测试</h2>
|
||||
<p className="text-xs text-slate-500">创建、更新过程、回执核对与事件上报</p>
|
||||
<h2 className="text-sm font-semibold">外围系统</h2>
|
||||
<p className="text-xs text-slate-500">工单协同与 SCADA 最新数据</p>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" aria-label="刷新外围工单" onClick={() => void refresh()} disabled={busy}>
|
||||
<RefreshCw size={16} className={cn(busy && "animate-spin")} />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="刷新当前外围系统"
|
||||
onClick={() => {
|
||||
if (activeSystem === "work-orders") void refresh();
|
||||
else setScadaRefreshGeneration((current) => current + 1);
|
||||
}}
|
||||
disabled={activeSystem === "work-orders" && busy}
|
||||
>
|
||||
<RefreshCw size={16} className={cn(activeSystem === "work-orders" && busy && "animate-spin")} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭外围工单测试" onClick={onClose}>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭外围系统" onClick={onClose}>
|
||||
<X size={17} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
|
||||
<div className="mb-3 grid gap-2">
|
||||
<WorkbenchNotice tone="info" title="合成工单环境">{DISCLAIMER}</WorkbenchNotice>
|
||||
{!client.configured ? (
|
||||
<WorkbenchNotice tone="warning">
|
||||
未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接外围工单服务。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||||
{client.configured && !context ? (
|
||||
<WorkbenchNotice role="status" tone="neutral">正在核验项目授权与工单接口。</WorkbenchNotice>
|
||||
) : null}
|
||||
<Tabs
|
||||
value={activeSystem}
|
||||
onValueChange={(value) => setActiveSystem(value as "work-orders" | "scada")}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="shrink-0 border-b border-[var(--surface-divider)] px-3 py-2 sm:px-4">
|
||||
<TabsList aria-label="外围系统类型" className="grid w-full grid-cols-2 bg-[var(--surface-control)]">
|
||||
<TabsTrigger value="work-orders"><ClipboardList size={14} />工单系统</TabsTrigger>
|
||||
<TabsTrigger value="scada"><Activity size={14} />SCADA 系统</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{context ? (
|
||||
<div className="space-y-3">
|
||||
<ManagedTestResources
|
||||
client={client}
|
||||
canWrite={context.can_write}
|
||||
capabilities={context.managed_resources}
|
||||
refreshGeneration={refreshGeneration}
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
<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-[var(--surface-divider)] bg-[var(--surface-reading)] font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!receiptRequestId || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.getReceipt(receiptRequestId);
|
||||
setError("回执已确认,请刷新工单列表查看最新状态。");
|
||||
} catch (nextError) {
|
||||
setError(errorMessage(nextError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>查询</Button>
|
||||
</div>
|
||||
</Disclosure>
|
||||
<TabsContent value="work-orders" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
|
||||
<div className="mb-3 grid gap-2">
|
||||
<WorkbenchNotice tone="info" title="合成工单环境">{DISCLAIMER}</WorkbenchNotice>
|
||||
{!client.configured ? (
|
||||
<WorkbenchNotice tone="warning">
|
||||
未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接外围工单服务。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||||
{client.configured && !context ? (
|
||||
<WorkbenchNotice role="status" tone="neutral">正在核验项目授权与工单接口。</WorkbenchNotice>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{context ? (
|
||||
<div className="space-y-3">
|
||||
<ManagedTestResources
|
||||
client={client}
|
||||
canWrite={context.can_write}
|
||||
capabilities={context.managed_resources}
|
||||
refreshGeneration={refreshGeneration}
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
<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-[var(--surface-divider)] bg-[var(--surface-reading)] font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!receiptRequestId || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.getReceipt(receiptRequestId);
|
||||
setError("回执已确认,请刷新工单列表查看最新状态。");
|
||||
} catch (nextError) {
|
||||
setError(errorMessage(nextError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>查询</Button>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scada" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
|
||||
<ScadaSystemSection
|
||||
active={activeSystem === "scada"}
|
||||
getAccessToken={getAccessToken}
|
||||
refreshGeneration={scadaRefreshGeneration}
|
||||
peripheralClient={client}
|
||||
canSimulate={Boolean(
|
||||
context?.can_write && context.managed_resources.test_data === "available"
|
||||
)}
|
||||
onAgentTrigger={onScadaAnomalyTrigger}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PeripheralTestDataRecord } from "./client";
|
||||
import { buildScadaAnomalyInvestigation } from "./scada-anomaly-agent-trigger";
|
||||
|
||||
describe("SCADA anomaly Agent trigger", () => {
|
||||
it("keeps the persisted synthetic reading as the external task source", () => {
|
||||
const record = {
|
||||
record_id: "50000000-0000-4000-8000-000000000017",
|
||||
revision: 3,
|
||||
point_id: "PRESSURE-17",
|
||||
observed_at: "2026-10-22T02:03:00Z",
|
||||
value: 0.08,
|
||||
unit: "MPa",
|
||||
notes: "[SCADA_ANOMALY] 压力突降"
|
||||
} as PeripheralTestDataRecord;
|
||||
|
||||
expect(buildScadaAnomalyInvestigation(record)).toMatchObject({
|
||||
kind: "scada_anomaly",
|
||||
title: "SCADA 异常调查 · PRESSURE-17",
|
||||
summary: expect.stringContaining("0.08 MPa"),
|
||||
occurredAt: "2026-10-22T02:03:00Z",
|
||||
source: {
|
||||
kind: "external_system",
|
||||
external_id: record.record_id,
|
||||
external_revision: 3,
|
||||
event_kind: "scada_anomaly"
|
||||
},
|
||||
triggerKind: "event"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CreateInvestigationInput } from "../hooks/use-operational-tasks";
|
||||
import type { PeripheralTestDataRecord } from "./client";
|
||||
|
||||
export function buildScadaAnomalyInvestigation(
|
||||
record: PeripheralTestDataRecord
|
||||
): CreateInvestigationInput {
|
||||
const valueLabel = record.value === null
|
||||
? "无有效读数"
|
||||
: `${record.value}${record.unit ? ` ${record.unit}` : ""}`;
|
||||
const anomalyDetail = record.notes.replace("[SCADA_ANOMALY]", "").trim();
|
||||
|
||||
return {
|
||||
kind: "scada_anomaly",
|
||||
title: `SCADA 异常调查 · ${record.point_id}`,
|
||||
summary: `${record.point_id} 在 ${record.observed_at} 记录模拟异常值 ${valueLabel}。${anomalyDetail}`,
|
||||
detail: `由外围 SCADA 合成读数触发,点位 ${record.point_id},读数 ${valueLabel},发生时间 ${record.observed_at}。`,
|
||||
occurredAt: record.observed_at,
|
||||
priority: "normal",
|
||||
source: {
|
||||
kind: "external_system",
|
||||
external_id: record.record_id,
|
||||
external_revision: record.revision,
|
||||
event_kind: "scada_anomaly"
|
||||
},
|
||||
triggerKind: "event"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
|
||||
import { ScadaSystemSection } from "./scada-system-section";
|
||||
|
||||
describe("SCADA peripheral system", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("persists a synthetic abnormal reading before triggering Agent handling", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [{
|
||||
device_id: "PRESSURE-17",
|
||||
device_type: "pressure",
|
||||
measurement_unit: "MPa",
|
||||
transmission_mode: "realtime",
|
||||
transmission_frequency: "15min",
|
||||
observed_at: "2026-10-22T01:02:03Z",
|
||||
monitored_value: 0.31,
|
||||
cleaned_value: 0.305
|
||||
}],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
observed_count: 1,
|
||||
latest_observed_at: "2026-10-22T01:02:03Z"
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const record: PeripheralTestDataRecord = {
|
||||
record_id: "50000000-0000-4000-8000-000000000017",
|
||||
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
|
||||
revision: 1,
|
||||
label: "SCADA 异常模拟 · PRESSURE-17",
|
||||
point_id: "PRESSURE-17",
|
||||
observed_at: "2026-10-22T02:03:00Z",
|
||||
value: 0.08,
|
||||
unit: "MPa",
|
||||
quality: "valid",
|
||||
notes: "[SCADA_ANOMALY] 压力突降",
|
||||
origin: "synthetic",
|
||||
created_at: "2026-10-22T02:03:01Z",
|
||||
updated_at: "2026-10-22T02:03:01Z",
|
||||
created_by: "tester",
|
||||
updated_by: "tester"
|
||||
};
|
||||
const createTestData = vi.fn().mockResolvedValue(record);
|
||||
const peripheralClient = {
|
||||
listTestData: vi.fn().mockResolvedValue({ items: [], next_cursor: null }),
|
||||
createTestData
|
||||
} as unknown as PeripheralSampleClient;
|
||||
const onAgentTrigger = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScadaSystemSection
|
||||
active
|
||||
refreshGeneration={0}
|
||||
peripheralClient={peripheralClient}
|
||||
canSimulate
|
||||
onAgentTrigger={onAgentTrigger}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("PRESSURE-17", { exact: true });
|
||||
await user.click(screen.getByRole("button", { name: "设定读数" }));
|
||||
await user.type(screen.getByPlaceholderText("输入需要演练的异常值"), "0.08");
|
||||
await user.type(screen.getByPlaceholderText("例如:压力突降,偏离近一小时稳定区间"), "压力突降");
|
||||
await user.click(screen.getByRole("button", { name: "保存并触发 Agent" }));
|
||||
|
||||
await waitFor(() => expect(createTestData).toHaveBeenCalledOnce());
|
||||
expect(createTestData).toHaveBeenCalledWith(expect.objectContaining({
|
||||
point_id: "PRESSURE-17",
|
||||
value: 0.08,
|
||||
unit: "MPa",
|
||||
quality: "valid",
|
||||
notes: "[SCADA_ANOMALY] 压力突降"
|
||||
}));
|
||||
expect(onAgentTrigger).toHaveBeenCalledWith(record);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Activity, Database, LoaderCircle, Plus, Search, Sparkles, X } from "lucide-react";
|
||||
|
||||
import { ScadaSystemClient, type LatestScadaReadings } from "../api/scada-client";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { StatusBadge } from "@/shared/ui/status";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 15_000;
|
||||
const SCADA_ANOMALY_MARKER = "[SCADA_ANOMALY]";
|
||||
|
||||
export function ScadaSystemSection({
|
||||
active,
|
||||
getAccessToken,
|
||||
refreshGeneration,
|
||||
peripheralClient,
|
||||
canSimulate,
|
||||
onAgentTrigger
|
||||
}: {
|
||||
active: boolean;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
refreshGeneration: number;
|
||||
peripheralClient: PeripheralSampleClient;
|
||||
canSimulate: boolean;
|
||||
onAgentTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
|
||||
}) {
|
||||
const clientRef = useRef(new ScadaSystemClient(getAccessToken));
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const handledRefreshRef = useRef(refreshGeneration);
|
||||
const [snapshot, setSnapshot] = useState<LatestScadaReadings | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null);
|
||||
const [simulationEditorOpen, setSimulationEditorOpen] = useState(false);
|
||||
const [simulationPointId, setSimulationPointId] = useState("");
|
||||
const [simulationValue, setSimulationValue] = useState("");
|
||||
const [simulationObservedAt, setSimulationObservedAt] = useState(() => toLocalInput(new Date()));
|
||||
const [simulationReason, setSimulationReason] = useState("");
|
||||
const [simulationBusy, setSimulationBusy] = useState(false);
|
||||
const [simulationError, setSimulationError] = useState<string | null>(null);
|
||||
const [simulations, setSimulations] = useState<PeripheralTestDataRecord[]>([]);
|
||||
const [triggerRetry, setTriggerRetry] = useState<PeripheralTestDataRecord | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!active) return;
|
||||
requestRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
const sequence = ++requestSequenceRef.current;
|
||||
requestRef.current = controller;
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await clientRef.current.getLatestReadings(controller.signal);
|
||||
if (sequence !== requestSequenceRef.current) return;
|
||||
setSnapshot(next);
|
||||
setError(null);
|
||||
setRefreshedAt(new Date());
|
||||
} catch (nextError) {
|
||||
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
|
||||
setError(nextError instanceof Error ? nextError.message : "SCADA 最新数据读取失败");
|
||||
} finally {
|
||||
if (sequence === requestSequenceRef.current) setBusy(false);
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
const refreshSimulations = useCallback(async () => {
|
||||
if (!active || !canSimulate) return;
|
||||
try {
|
||||
const page = await peripheralClient.listTestData();
|
||||
setSimulations(page.items.filter((item) => item.notes.startsWith(SCADA_ANOMALY_MARKER)));
|
||||
} catch (nextError) {
|
||||
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数读取失败");
|
||||
}
|
||||
}, [active, canSimulate, peripheralClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void refresh();
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
requestRef.current?.abort();
|
||||
};
|
||||
}, [active, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || handledRefreshRef.current === refreshGeneration) return;
|
||||
handledRefreshRef.current = refreshGeneration;
|
||||
void refresh();
|
||||
void refreshSimulations();
|
||||
}, [active, refresh, refreshGeneration, refreshSimulations]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSimulations();
|
||||
}, [refreshSimulations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!simulationPointId && snapshot?.items[0]) {
|
||||
setSimulationPointId(snapshot.items[0].device_id);
|
||||
}
|
||||
}, [simulationPointId, snapshot]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const query = filter.trim().toLocaleLowerCase("zh-CN");
|
||||
if (!query) return snapshot?.items ?? [];
|
||||
return (snapshot?.items ?? []).filter((item) =>
|
||||
[item.device_id, item.device_type, item.measurement_unit, item.transmission_mode]
|
||||
.some((value) => value.toLocaleLowerCase("zh-CN").includes(query))
|
||||
);
|
||||
}, [filter, snapshot]);
|
||||
|
||||
const selectedSimulationDevice = snapshot?.items.find(
|
||||
(item) => item.device_id === simulationPointId
|
||||
) ?? null;
|
||||
const parsedSimulationValue = Number(simulationValue);
|
||||
const canSaveSimulation = canSimulate && selectedSimulationDevice !== null
|
||||
&& simulationValue.trim() !== "" && Number.isFinite(parsedSimulationValue)
|
||||
&& Boolean(simulationObservedAt) && Boolean(simulationReason.trim()) && !simulationBusy;
|
||||
|
||||
const saveSimulation = async (triggerAgent: boolean) => {
|
||||
if (!canSaveSimulation || !selectedSimulationDevice) return;
|
||||
setSimulationBusy(true);
|
||||
setSimulationError(null);
|
||||
setTriggerRetry(null);
|
||||
try {
|
||||
const record = await peripheralClient.createTestData({
|
||||
label: `SCADA 异常模拟 · ${selectedSimulationDevice.device_id}`,
|
||||
point_id: selectedSimulationDevice.device_id,
|
||||
observed_at: new Date(simulationObservedAt).toISOString(),
|
||||
value: parsedSimulationValue,
|
||||
unit: selectedSimulationDevice.measurement_unit || "未配置",
|
||||
quality: "valid",
|
||||
notes: `${SCADA_ANOMALY_MARKER} ${simulationReason.trim()}`
|
||||
});
|
||||
setSimulations((current) => [record, ...current.filter((item) => item.record_id !== record.record_id)]);
|
||||
setSimulationEditorOpen(false);
|
||||
setSimulationValue("");
|
||||
setSimulationReason("");
|
||||
setSimulationObservedAt(toLocalInput(new Date()));
|
||||
if (triggerAgent) {
|
||||
if (!onAgentTrigger) throw new Error("Agent 异常处理入口未配置");
|
||||
try {
|
||||
await onAgentTrigger(record);
|
||||
} catch (triggerError) {
|
||||
setTriggerRetry(record);
|
||||
setSimulationError(
|
||||
`模拟读数已保存,但 Agent 任务未建立:${triggerError instanceof Error ? triggerError.message : "触发失败"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (nextError) {
|
||||
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数保存失败");
|
||||
} finally {
|
||||
setSimulationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retryAgentTrigger = async () => {
|
||||
if (!triggerRetry || !onAgentTrigger) return;
|
||||
setSimulationBusy(true);
|
||||
setSimulationError(null);
|
||||
try {
|
||||
await onAgentTrigger(triggerRetry);
|
||||
setTriggerRetry(null);
|
||||
} catch (nextError) {
|
||||
setSimulationError(nextError instanceof Error ? nextError.message : "Agent 任务触发失败");
|
||||
} finally {
|
||||
setSimulationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3" lang="zh-CN">
|
||||
<WorkbenchNotice tone="info" title="项目 SCADA 数据源">
|
||||
展示当前项目中每个设备最后一条已持久化记录。页面可见时每 15 秒刷新,不据此推断设备在线状态。
|
||||
</WorkbenchNotice>
|
||||
{error ? (
|
||||
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新">
|
||||
{error}。已加载的数据会继续保留。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
{simulationError ? (
|
||||
<WorkbenchNotice role="alert" tone={triggerRetry ? "warning" : "danger"} title="异常演练未完成">
|
||||
<p>{simulationError}</p>
|
||||
{triggerRetry ? (
|
||||
<Button
|
||||
className="mt-2"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={simulationBusy}
|
||||
onClick={() => void retryAgentTrigger()}
|
||||
>
|
||||
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
|
||||
重试触发 Agent
|
||||
</Button>
|
||||
) : null}
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
aria-label="SCADA 最新数据概览"
|
||||
className="surface-control grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-[var(--surface-divider)] px-1 py-3"
|
||||
>
|
||||
<div className="px-3">
|
||||
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">已取得最新记录</p>
|
||||
<p className="mt-1 text-xl font-semibold leading-7 text-slate-900 tabular-nums">
|
||||
{snapshot ? snapshot.observed_count : "—"}
|
||||
<span className="ml-1 text-xs font-medium text-[var(--ink-muted)]">
|
||||
/ {snapshot ? snapshot.total : "—"} 个设备
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">最近记录时间</p>
|
||||
<p className="mt-1 text-sm font-semibold leading-7 text-slate-900 tabular-nums">
|
||||
{formatObservedTime(snapshot?.latest_observed_at ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<WorkbenchSection
|
||||
title="异常读数演练"
|
||||
description="读数写入独立合成数据区,可据此建立 Agent 调查任务,不改写正式 SCADA 时序表。"
|
||||
icon={<Sparkles size={15} />}
|
||||
tone="warning"
|
||||
actions={canSimulate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-expanded={simulationEditorOpen}
|
||||
onClick={() => setSimulationEditorOpen((current) => !current)}
|
||||
disabled={simulationBusy || !snapshot?.items.length}
|
||||
>
|
||||
{simulationEditorOpen ? <X size={13} /> : <Plus size={13} />}
|
||||
{simulationEditorOpen ? "收起" : "设定读数"}
|
||||
</Button>
|
||||
) : null}
|
||||
bodyClassName="space-y-3"
|
||||
>
|
||||
{!canSimulate ? (
|
||||
<WorkbenchNotice tone="warning" title="合成读数存储不可用">
|
||||
需要配置外围样板服务、当前项目写权限以及 test_data 存储,正式 SCADA 数据仍保持只读。
|
||||
</WorkbenchNotice>
|
||||
) : null}
|
||||
|
||||
{simulationEditorOpen && canSimulate ? (
|
||||
<div className="rounded-xl bg-[var(--surface-control)] p-3 ring-1 ring-inset ring-[var(--surface-divider)]">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FieldSelect
|
||||
label="SCADA 设备"
|
||||
value={simulationPointId}
|
||||
onValueChange={setSimulationPointId}
|
||||
disabled={simulationBusy}
|
||||
options={(snapshot?.items ?? []).map((item) => ({
|
||||
value: item.device_id,
|
||||
label: `${item.device_id} · ${deviceTypeLabel(item.device_type)}`
|
||||
}))}
|
||||
/>
|
||||
<DateTimeField
|
||||
label="模拟发生时间"
|
||||
value={simulationObservedAt}
|
||||
onChange={setSimulationObservedAt}
|
||||
disabled={simulationBusy}
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs font-medium text-slate-600">
|
||||
模拟读数{selectedSimulationDevice?.measurement_unit
|
||||
? `(${selectedSimulationDevice.measurement_unit})`
|
||||
: ""}
|
||||
<Input
|
||||
className="mt-1.5 h-10 bg-[var(--surface-reading)] text-sm tabular-nums"
|
||||
inputMode="decimal"
|
||||
value={simulationValue}
|
||||
onChange={(event) => setSimulationValue(event.target.value)}
|
||||
disabled={simulationBusy}
|
||||
placeholder="输入需要演练的异常值"
|
||||
aria-invalid={simulationValue.trim() !== "" && !Number.isFinite(parsedSimulationValue)}
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-xs font-medium text-slate-600">
|
||||
异常说明
|
||||
<Textarea
|
||||
className="mt-1.5 min-h-20 bg-[var(--surface-reading)]"
|
||||
value={simulationReason}
|
||||
onChange={(event) => setSimulationReason(event.target.value)}
|
||||
disabled={simulationBusy}
|
||||
placeholder="例如:压力突降,偏离近一小时稳定区间"
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
|
||||
读数按“有效但业务异常”保存。保存并触发后,Agent 会收到点位、数值、单位、发生时间和异常说明。
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canSaveSimulation}
|
||||
onClick={() => void saveSimulation(true)}
|
||||
>
|
||||
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
|
||||
保存并触发 Agent
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canSaveSimulation}
|
||||
onClick={() => void saveSimulation(false)}
|
||||
>
|
||||
保存模拟读数
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{simulations.length ? (
|
||||
<ol aria-label="SCADA 模拟异常列表" className="space-y-2">
|
||||
{simulations.slice(0, 5).map((item) => (
|
||||
<li
|
||||
key={item.record_id}
|
||||
className="flex min-w-0 items-start gap-3 rounded-xl bg-[var(--surface-control)] px-3 py-2.5 ring-1 ring-inset ring-[var(--surface-divider)]"
|
||||
>
|
||||
<StatusBadge tone="warning">模拟异常</StatusBadge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-xs font-semibold text-slate-900">{item.point_id}</p>
|
||||
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
{formatValue(item.value, item.unit)} · {formatObservedTime(item.observed_at)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-[11px] leading-4 text-slate-700">
|
||||
{item.notes.replace(SCADA_ANOMALY_MARKER, "").trim()}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : canSimulate && !simulationEditorOpen ? (
|
||||
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">尚未设定 SCADA 模拟异常。</p>
|
||||
) : null}
|
||||
</WorkbenchSection>
|
||||
|
||||
<WorkbenchSection
|
||||
title="最新测点值"
|
||||
description={refreshedAt
|
||||
? `本页读取于 ${formatClock(refreshedAt)},按设备编号排序`
|
||||
: busy ? "正在读取设备目录与最后一条记录" : "尚未读取"}
|
||||
icon={<Activity size={15} />}
|
||||
tone="teal"
|
||||
meta={busy ? <StatusBadge tone="info" activity="loading">更新中</StatusBadge> : null}
|
||||
bodyClassName="p-0"
|
||||
>
|
||||
<div className="border-b border-[var(--surface-divider)] p-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--ink-muted)]"
|
||||
/>
|
||||
<Input
|
||||
aria-label="筛选 SCADA 设备"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="筛选设备编号、类型或单位"
|
||||
className="h-10 bg-[var(--surface-reading)] pl-9 text-xs md:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!snapshot && busy ? (
|
||||
<p role="status" className="px-4 py-8 text-center text-xs text-[var(--ink-muted)]">
|
||||
正在读取 SCADA 最新数据
|
||||
</p>
|
||||
) : visibleItems.length === 0 ? (
|
||||
<WorkbenchEmptyState
|
||||
icon={<Database size={16} />}
|
||||
title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"}
|
||||
description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"}
|
||||
className="m-3"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[calc(100dvh-390px)] min-h-48 overflow-auto overscroll-contain">
|
||||
<table className="w-full min-w-[620px] border-collapse text-xs" aria-label="SCADA 最新数据表">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--surface-control)] text-[11px] text-[var(--ink-muted)]">
|
||||
<tr className="border-b border-[var(--surface-divider)]">
|
||||
<th scope="col" className="px-3 py-2.5 text-left font-medium">设备</th>
|
||||
<th scope="col" className="px-3 py-2.5 text-right font-medium">原始值</th>
|
||||
<th scope="col" className="px-3 py-2.5 text-right font-medium">清洗值</th>
|
||||
<th scope="col" className="px-3 py-2.5 text-right font-medium">记录时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleItems.map((item) => (
|
||||
<tr key={item.device_id} className="border-b border-[var(--surface-divider)]/70 last:border-b-0">
|
||||
<th scope="row" className="px-3 py-2.5 text-left font-medium text-slate-800">
|
||||
<span className="block font-mono text-[12px]">{item.device_id}</span>
|
||||
<span className="mt-0.5 block text-[11px] font-normal text-[var(--ink-muted)]">
|
||||
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
|
||||
</span>
|
||||
</th>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-slate-900 tabular-nums">
|
||||
{formatValue(item.monitored_value, item.measurement_unit)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-[var(--ink-muted)] tabular-nums">
|
||||
{formatValue(item.cleaned_value, item.measurement_unit)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{item.observed_at ? (
|
||||
<time dateTime={item.observed_at} className="text-slate-700">
|
||||
{formatObservedTime(item.observed_at)}
|
||||
</time>
|
||||
) : (
|
||||
<StatusBadge tone="neutral">无记录</StatusBadge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot && snapshot.total > snapshot.items.length ? (
|
||||
<p className="border-t border-[var(--surface-divider)] px-3 py-2 text-[11px] text-[var(--ink-muted)]">
|
||||
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
|
||||
</p>
|
||||
) : null}
|
||||
</WorkbenchSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: number | null, unit: string) {
|
||||
if (value === null) return "—";
|
||||
const formatted = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 4 }).format(value);
|
||||
return `${formatted}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
function formatObservedTime(value: string | null) {
|
||||
if (!value) return "暂无记录";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "时间无效";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatClock(value: Date) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function toLocalInput(value: Date) {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function deviceTypeLabel(value: string) {
|
||||
return ({
|
||||
pressure: "压力",
|
||||
pipe_flow: "流量",
|
||||
quality: "水质",
|
||||
demand: "需水量",
|
||||
reservoir_liquid_level: "水库液位",
|
||||
tank_liquid_level: "水箱液位",
|
||||
fixed_pump: "定速泵",
|
||||
variable_pump: "变频泵"
|
||||
} as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { SWRConfig } from "swr";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { clearWorkbenchProjectCache, fetchWorkbenchProjects, fetchWorkbenchProjectPresentation, type WorkbenchProject, type WorkbenchProjectPresentation } from "./api/project-client";
|
||||
import type { WorkbenchRequestContext } from "./api/request-context";
|
||||
import { GEOSERVER_WORKSPACE } from "./map/geoserver-config";
|
||||
import { MapWorkbenchPage } from "./map-workbench-page";
|
||||
import { ProjectStartupScreen, type ProjectStartupState } from "./components/project-startup-screen";
|
||||
|
||||
import { useWorkbenchSessionResources } from "./hooks/workbench-session-resources";
|
||||
import { WorkbenchSessionProvider } from "./hooks/workbench-session-provider";
|
||||
|
||||
const PROJECT_STORAGE_KEY = "tjwater:active-project";
|
||||
|
||||
type ProjectWorkbenchProps = Omit<ComponentProps<typeof MapWorkbenchPage>, "getAccessToken" | "projectSelection"> & {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
export function ProjectWorkbench(props: ProjectWorkbenchProps) {
|
||||
return <WorkbenchSessionProvider owner={props.getAccessToken}>
|
||||
<ProjectWorkbenchContent {...props} />
|
||||
</WorkbenchSessionProvider>;
|
||||
}
|
||||
|
||||
function ProjectWorkbenchContent(props: ProjectWorkbenchProps) {
|
||||
const resources = useWorkbenchSessionResources();
|
||||
const [refreshingProjects, setRefreshingProjects] = useState(false);
|
||||
const [projects, setProjects] = useState<WorkbenchProject[]>([]);
|
||||
const [selected, setSelected] = useState<WorkbenchProject | null>(null);
|
||||
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | undefined>();
|
||||
const [pendingProjectId, setPendingProjectId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [startupState, setStartupState] = useState<ProjectStartupState>({ phase: "projects" });
|
||||
const switchController = useRef<AbortController | null>(null);
|
||||
const accessTokenProvider = props.getAccessToken;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchWorkbenchProjects(accessTokenProvider, controller.signal, reload > 0)
|
||||
.then(async (items) => {
|
||||
if (controller.signal.aborted) return;
|
||||
let remembered: string | null = null;
|
||||
try { remembered = localStorage.getItem(PROJECT_STORAGE_KEY); } catch { /* Browser storage is optional. */ }
|
||||
const initial = items.find((item) => item.project_id === remembered)
|
||||
?? items.find((item) => item.gs_workspace === GEOSERVER_WORKSPACE)
|
||||
?? items[0];
|
||||
if (!initial) throw new Error("当前账号没有可访问的项目");
|
||||
setStartupState({ phase: "context", projectName: initial.name ?? initial.code ?? initial.gs_workspace });
|
||||
const metadata = await fetchWorkbenchProjectPresentation({ project: initial,
|
||||
getAccessToken: accessTokenProvider
|
||||
}, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setProjects(items);
|
||||
setPresentation(metadata);
|
||||
setSelected(initial);
|
||||
setError(null);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "项目加载失败");
|
||||
});
|
||||
return () => { controller.abort(); switchController.current?.abort(); };
|
||||
}, [accessTokenProvider, reload]);
|
||||
|
||||
const requestContext = useMemo<WorkbenchRequestContext | null>(() => selected ? {
|
||||
project: selected,
|
||||
presentation,
|
||||
getAccessToken: accessTokenProvider
|
||||
} : null, [selected, presentation, accessTokenProvider]);
|
||||
|
||||
const refreshProjects = useCallback(async (force = false) => {
|
||||
setRefreshingProjects(true);
|
||||
try {
|
||||
const items = await fetchWorkbenchProjects(accessTokenProvider, undefined, force);
|
||||
setProjects(items);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "项目列表刷新失败");
|
||||
} finally { setRefreshingProjects(false); }
|
||||
}, [accessTokenProvider]);
|
||||
|
||||
const selectProject = async (projectId: string) => {
|
||||
const target = projects.find((item) => item.project_id === projectId);
|
||||
if (!target || target.project_id === selected?.project_id) return;
|
||||
switchController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
switchController.current = controller;
|
||||
setPendingProjectId(projectId);
|
||||
setError(null);
|
||||
try {
|
||||
const presentation = await fetchWorkbenchProjectPresentation({
|
||||
project: target, getAccessToken: requestContext?.getAccessToken
|
||||
}, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
if (!presentation.metadataAvailable || !presentation.accessContextAvailable) {
|
||||
clearWorkbenchProjectCache(accessTokenProvider);
|
||||
throw new Error("目标项目暂时无法访问,请稍后重试");
|
||||
}
|
||||
setPresentation(presentation);
|
||||
setSelected(target);
|
||||
try { localStorage.setItem(PROJECT_STORAGE_KEY, target.project_id); } catch { /* Browser storage is optional. */ }
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted) {
|
||||
const message = reason instanceof Error ? reason.message : "项目切换失败";
|
||||
setError(message);
|
||||
showMapNotice({ tone: "error", title: "项目切换失败", message });
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setPendingProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!requestContext) return (
|
||||
<ProjectStartupScreen state={startupState} error={error} onRetry={() => {
|
||||
setStartupState({ phase: "projects" });
|
||||
setError(null);
|
||||
setReload((value) => value + 1);
|
||||
}} />
|
||||
);
|
||||
|
||||
return (
|
||||
<SWRConfig key={requestContext.project.project_id} value={{ provider: () => new Map() }}>
|
||||
<MapWorkbenchPage {...props} getAccessToken={requestContext}
|
||||
onLogout={props.onLogout ? async () => {
|
||||
resources.dispose();
|
||||
clearWorkbenchProjectCache(accessTokenProvider);
|
||||
await props.onLogout?.();
|
||||
} : undefined}
|
||||
projectSelection={{ projects, selectedProjectId: requestContext.project.project_id,
|
||||
pendingProjectId, error, refreshing: refreshingProjects,
|
||||
onOpen: () => void refreshProjects(), onRefresh: () => void refreshProjects(true),
|
||||
onSelectProject: (id) => void selectProject(id) }} />
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
@@ -64,12 +64,12 @@ export function AnalysisArtifactWorkspace({
|
||||
return (
|
||||
<article
|
||||
aria-label={`${artifact.title}分析成果`}
|
||||
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
|
||||
className="surface-dock relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<ArtifactHeader artifact={artifact} />
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
|
||||
<div className="surface-reading min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
|
||||
<section aria-labelledby="artifact-summary-heading">
|
||||
<div className="mb-3 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
@@ -130,7 +130,7 @@ function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
|
||||
const demoArtifact = artifact.origin === "demo";
|
||||
const OriginIcon = backendArtifact ? Database : Sparkles;
|
||||
return (
|
||||
<header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
|
||||
<header className="surface-dock relative z-10 border-b px-4 py-4 xl:px-5">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
|
||||
@@ -437,7 +437,7 @@ function ArtifactActionBar({
|
||||
onDestroy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
|
||||
<footer className="surface-dock relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
|
||||
<span />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useGroupRef, type Layout } from "react-resizable-panels";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/shared/ui/resizable";
|
||||
|
||||
/** Keep both hosts mounted while switching between split and single-pane views. */
|
||||
export function WorkbenchContentSplit({
|
||||
map,
|
||||
document,
|
||||
split,
|
||||
showMap,
|
||||
minPaneWidth,
|
||||
onMapSizeChange
|
||||
}: {
|
||||
map: ReactNode;
|
||||
document: ReactNode;
|
||||
split: boolean;
|
||||
showMap: boolean;
|
||||
minPaneWidth: number;
|
||||
onMapSizeChange: (percentage: number) => void;
|
||||
}) {
|
||||
const group = useGroupRef();
|
||||
const splitLayout = useRef<Layout>({ map: 50, document: 50 });
|
||||
useLayoutEffect(() => {
|
||||
group.current?.setLayout(
|
||||
split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 }
|
||||
);
|
||||
}, [group, split, showMap, minPaneWidth]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id="workspace-content-columns"
|
||||
groupRef={group}
|
||||
orientation="horizontal"
|
||||
disabled={!split}
|
||||
resizeTargetMinimumSize={{ fine: 12, coarse: 24 }}
|
||||
onLayoutChange={(layout) => onMapSizeChange(layout.map ?? 50)}
|
||||
onLayoutChanged={(layout, { isUserInteraction }) => {
|
||||
if (split && isUserInteraction) splitLayout.current = layout;
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="map"
|
||||
defaultSize={showMap ? (split ? "50%" : "100%") : "0%"}
|
||||
minSize={split ? minPaneWidth : showMap ? "100%" : 0}
|
||||
maxSize={!showMap ? 0 : "100%"}
|
||||
className="relative h-full min-h-0 min-w-0"
|
||||
>
|
||||
{map}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
withHandle
|
||||
aria-label="调整地图与报告宽度"
|
||||
disabled={!split}
|
||||
className={`z-40 h-[calc(100%-3.5rem)] self-end ${split ? "" : "hidden"}`}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="document"
|
||||
defaultSize={showMap ? (split ? "50%" : "0%") : "100%"}
|
||||
minSize={split ? minPaneWidth : showMap ? 0 : "100%"}
|
||||
maxSize={!split && showMap ? 0 : "100%"}
|
||||
className="relative h-full min-h-0 min-w-0"
|
||||
>
|
||||
{document}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
|
||||
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
|
||||
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ReadingWorkspaceContent } from "@/shared/ui/reading-workspace";
|
||||
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||
import { WorkbenchContentSplit } from "./workbench-content-split";
|
||||
import { ARTIFACT_PANE_MIN_WIDTH } from "./workspace-model";
|
||||
import {
|
||||
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
@@ -25,6 +30,7 @@ type WorkbenchMainFrameProps = {
|
||||
pendingArtifact: AnalysisArtifact | null;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
readingSplitAvailable?: boolean;
|
||||
onOpenPendingArtifact: () => void;
|
||||
onDestroyPendingArtifact: () => void;
|
||||
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||
@@ -40,20 +46,28 @@ export function WorkbenchMainFrame({
|
||||
timelineBottom,
|
||||
activeArtifact,
|
||||
pendingArtifact,
|
||||
surfaceMode,
|
||||
surfaceMode: artifactSurfaceMode,
|
||||
mapSplitAvailable,
|
||||
readingSplitAvailable = false,
|
||||
onOpenPendingArtifact,
|
||||
onDestroyPendingArtifact,
|
||||
onSetArtifactView,
|
||||
onCollapseArtifact,
|
||||
onDestroyArtifact
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const reading = useReadingWorkspace();
|
||||
const surfaceMode = reading?.active
|
||||
? reading.view === "map_split" && !readingSplitAvailable
|
||||
? "focus"
|
||||
: reading.view
|
||||
: artifactSurfaceMode;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const showMap = surfaceMode !== "focus";
|
||||
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean(activeArtifact);
|
||||
const showArtifact = Boolean(!reading?.active && activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean(activeArtifact && !reading?.active);
|
||||
const mapOnly = surfaceMode === "map_only";
|
||||
const mapSplit = surfaceMode === "map_split";
|
||||
const [mapPercentage, setMapPercentage] = useState(50);
|
||||
const viewControlLabel = mapOnly
|
||||
? "返回分析成果"
|
||||
: mapSplit
|
||||
@@ -61,7 +75,7 @@ export function WorkbenchMainFrame({
|
||||
: mapSplitAvailable
|
||||
? "半屏显示"
|
||||
: "显示地图";
|
||||
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
|
||||
const viewControlLeft = mapSplit ? `calc(${mapPercentage}% + 24px)` : "24px";
|
||||
const timelineTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
|
||||
@@ -75,68 +89,136 @@ export function WorkbenchMainFrame({
|
||||
// Map imagery fills the header backdrop; documents and controls reserve the header inset.
|
||||
return (
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0 lg:left-[var(--workbench-agent-current-width)]"
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0"
|
||||
data-testid="workbench-main-frame"
|
||||
data-timeline-mode={timelineMode}
|
||||
>
|
||||
<div
|
||||
id="main-workspace"
|
||||
data-layout-mode={surfaceMode}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
|
||||
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
|
||||
surfaceMode !== "map_split" && "grid-cols-1"
|
||||
)}
|
||||
className="pointer-events-auto absolute inset-0 min-h-0 overflow-hidden bg-[#dbe3eb]"
|
||||
>
|
||||
<section
|
||||
id="workspace-map"
|
||||
aria-label="供水管网地图工作区"
|
||||
aria-hidden={!showMap}
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||
!showMap && "hidden"
|
||||
)}
|
||||
>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</section>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
key={activeArtifact.id}
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
<WorkbenchContentSplit
|
||||
split={mapSplit}
|
||||
showMap={showMap}
|
||||
minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH}
|
||||
onMapSizeChange={setMapPercentage}
|
||||
map={
|
||||
<section
|
||||
id="workspace-map"
|
||||
aria-label="供水管网地图工作区"
|
||||
aria-hidden={!showMap}
|
||||
className={cn(
|
||||
"z-30 mt-14 min-h-0 min-w-0 overflow-hidden",
|
||||
surfaceMode === "focus" && "lg:pl-[84px]",
|
||||
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
|
||||
"relative h-full min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||
!showMap && "hidden"
|
||||
)}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
|
||||
animate={
|
||||
showArtifact
|
||||
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.994,
|
||||
transitionEnd: { visibility: "hidden" }
|
||||
}
|
||||
}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.992 }}
|
||||
transition={artifactTransition}
|
||||
>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</motion.section>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</section>
|
||||
}
|
||||
document={
|
||||
<>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
key={activeArtifact.id}
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 top-14 z-30 min-h-0 min-w-0 overflow-hidden",
|
||||
surfaceMode === "focus" && "lg:pl-[84px]",
|
||||
!showArtifact && "pointer-events-none"
|
||||
)}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
|
||||
animate={
|
||||
showArtifact
|
||||
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.994,
|
||||
transitionEnd: { visibility: "hidden" }
|
||||
}
|
||||
}
|
||||
exit={
|
||||
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.992 }
|
||||
}
|
||||
transition={artifactTransition}
|
||||
>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</motion.section>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{pendingArtifact ? (
|
||||
{reading?.active ? (
|
||||
<section
|
||||
aria-label="展开报告工作区"
|
||||
className={cn(
|
||||
"agent-report-workspace surface-dock absolute inset-x-0 bottom-0 top-14 z-30 flex min-h-0 min-w-0 flex-col",
|
||||
reading.view === "map_only" && "hidden"
|
||||
)}
|
||||
>
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] text-muted-foreground">报告与分析</p>
|
||||
<h2 className="break-words text-sm font-semibold">{reading.active.title}</h2>
|
||||
</div>
|
||||
{readingSplitAvailable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
reading.setView(surfaceMode === "map_split" ? "focus" : "map_split")
|
||||
}
|
||||
>
|
||||
{surfaceMode === "map_split" ? <Focus size={14} /> : <MapIcon size={14} />}
|
||||
{surfaceMode === "map_split" ? "专注阅读" : "地图对照"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={() => reading.setView("map_only")}>
|
||||
<MapIcon size={14} />
|
||||
查看地图
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="收起展开报告"
|
||||
onClick={() => reading.close()}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</header>
|
||||
<div
|
||||
data-reading-scroll
|
||||
className="surface-reading min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-24 md:px-6 md:pb-6"
|
||||
>
|
||||
<div className="mx-auto max-w-5xl pt-4 md:pt-6">
|
||||
<ReadingWorkspaceContent />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{reading?.active && reading.view === "map_only" ? (
|
||||
<Button
|
||||
className="absolute bottom-24 left-4 z-40 gap-2"
|
||||
onClick={() => reading.setView("focus")}
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
返回报告
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{pendingArtifact && !reading?.active ? (
|
||||
<ArtifactPeekBar
|
||||
artifact={pendingArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
@@ -152,12 +234,9 @@ export function WorkbenchMainFrame({
|
||||
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
)}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
? false
|
||||
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
|
||||
style={{ left: viewControlLeft }}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
|
||||
transition={viewControlTransition}
|
||||
>
|
||||
@@ -213,7 +292,7 @@ export function WorkbenchMainFrame({
|
||||
transition={timelineTransition}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{timelineContent ? (
|
||||
{timelineContent && !reading?.active ? (
|
||||
<motion.div
|
||||
key="operational-timeline"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
|
||||
|
||||
Reference in New Issue
Block a user