Author SHA1 Message Date
jiang 28287a1447 fix: coordinate mobile workbench overlays
Generic Container CI/CD / test-build-publish (push) Successful in 1m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m40s
Independent fixed offsets caused the timeline, map dock, tool panel, and zoom controls to overlap. Derive them from one layout function and scope collapse to the expanded timeline state.
2026-08-19 18:55:43 +08:00
jiang abef9ffabf fix: bypass blocked Keycloak session iframe
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m31s
2026-08-19 18:12:21 +08:00
jiang db696eb4f1 test: align collapsed agent camera padding
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-19 18:00:40 +08:00
jiang 229cb0def2 fix: animate analysis workspace transitions 2026-08-19 17:54:37 +08:00
jiang 1fb950806a fix: unify analysis workspace controls 2026-08-19 17:43:14 +08:00
jiang 073832034a fix: restyle analysis return control 2026-08-19 17:33:19 +08:00
jiang 57c494e8bd refactor: simplify evidence workspace presentation 2026-08-19 17:31:21 +08:00
jiang 4a589d7a43 fix: refine evidence detail presentation 2026-08-19 17:28:21 +08:00
jiang d39e58196f fix: tighten timeline lane spacing 2026-08-19 17:21:41 +08:00
jiang b93999646c fix: simplify timeline legend controls 2026-08-19 17:15:48 +08:00
jiang b91be09f5c fix: distinguish timeline attention events 2026-08-19 17:12:13 +08:00
jiang 602b39d60a feat: add interactive timeline legend 2026-08-19 17:07:22 +08:00
jiang 5e7101bfee fix: default Agent panel to collapsed 2026-08-19 17:00:43 +08:00
jiang 21dbfd82b6 fix: decouple timeline from workspace layout
The regression recurred because a floating timeline height was still exported as shared layout space and its z-index exceeded artifacts and notices. Reserve zero layout height and define the timeline below artifact and notification overlays.
2026-08-19 16:58:55 +08:00
jiang 760370c93f fix: refine floating workspace controls
Keep ScaleInfo pinned to the map corner and move the centered timeline above collisions, preventing the previous conditional offset from lifting the scale. Remove collapsed Agent rail width reservation and add timeline state transitions.
2026-08-19 16:53:21 +08:00
jiang 3db95c27b5 fix: align scale info with floating timeline 2026-08-19 16:45:35 +08:00
jiang 45f1ebee7e feat: add operational timeline workspace 2026-08-19 16:42:41 +08:00
jiang d2f2895c36 feat: refactor artifact analysis workspace 2026-08-19 16:09:51 +08:00
jiang bfadc95f93 feat: add workspace task view 2026-08-19 14:34:03 +08:00
jiang be7b4149f2 feat: redesign floating workspace interactions 2026-08-19 14:12:08 +08:00
jiang 1068304da5 feat: add workspace layout and window swapping 2026-08-19 13:52:44 +08:00
jiang f58e0a70f4 feat: add adaptive agent workspace windows 2026-08-19 12:57:37 +08:00
jiang d08cf2abc1 feat: add Keycloak authentication 2026-08-19 12:11:18 +08:00
jiang bdd5eff776 refactor: simplify workbench layout and browser tests 2026-08-18 17:06:42 +08:00
64 changed files with 4374 additions and 663 deletions
+3
View File
@@ -1,3 +1,6 @@
TJWATER_AUTH_MODE=required
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
TJWATER_MAPBOX_ACCESS_TOKEN= TJWATER_MAPBOX_ACCESS_TOKEN=
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
TJWATER_GEOSERVER_WORKSPACE=tjwater TJWATER_GEOSERVER_WORKSPACE=tjwater
+7
View File
@@ -34,6 +34,9 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | | `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | | `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 | | `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
@@ -43,6 +46,10 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。 `.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。 Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
## 常用命令 ## 常用命令
+6
View File
@@ -2,6 +2,9 @@
set -eu set -eu
runtime_config=$(jq -cn \ runtime_config=$(jq -cn \
--arg authMode "${TJWATER_AUTH_MODE:-required}" \
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \ --arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \ --arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \ --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
@@ -9,6 +12,9 @@ runtime_config=$(jq -cn \
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \ --arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \ --arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
'{ '{
TJWATER_AUTH_MODE: $authMode,
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken, TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
TJWATER_MAP_URL: $mapUrl, TJWATER_MAP_URL: $mapUrl,
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
+1
View File
@@ -43,6 +43,7 @@
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"edge-tts-ts": "^1.0.0", "edge-tts-ts": "^1.0.0",
"katex": "^0.17.0", "katex": "^0.17.0",
"keycloak-js": "26.2.4",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",
"motion": "^12.40.0", "motion": "^12.40.0",
+6 -2
View File
@@ -2,12 +2,16 @@ import { defineConfig, devices } from "@playwright/test";
const testPort = process.env.PLAYWRIGHT_PORT || "5191"; const testPort = process.env.PLAYWRIGHT_PORT || "5191";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`; const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
const runningInCi = Boolean(process.env.CI);
export default defineConfig({ export default defineConfig({
testDir: ".", testDir: ".",
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"], testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
fullyParallel: true, fullyParallel: true,
reporter: "html", forbidOnly: runningInCi,
retries: runningInCi ? 1 : 0,
workers: runningInCi ? 2 : undefined,
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
use: { use: {
baseURL, baseURL,
locale: "zh-CN", locale: "zh-CN",
@@ -16,7 +20,7 @@ export default defineConfig({
trace: "on-first-retry" trace: "on-first-retry"
}, },
webServer: { webServer: {
command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`, command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
url: baseURL, url: baseURL,
reuseExistingServer: false reuseExistingServer: false
}, },
+8
View File
@@ -83,6 +83,9 @@ importers:
katex: katex:
specifier: ^0.17.0 specifier: ^0.17.0
version: 0.17.0 version: 0.17.0
keycloak-js:
specifier: 26.2.4
version: 26.2.4
lucide-react: lucide-react:
specifier: ^0.468.0 specifier: ^0.468.0
version: 0.468.0(react@19.2.7) version: 0.468.0(react@19.2.7)
@@ -2859,6 +2862,9 @@ packages:
kdbush@4.1.0: kdbush@4.1.0:
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==} resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
keycloak-js@26.2.4:
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -6645,6 +6651,8 @@ snapshots:
kdbush@4.1.0: {} kdbush@4.1.0: {}
keycloak-js@26.2.4: {}
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
+5 -1
View File
@@ -1 +1,5 @@
globalThis.__TJWATER_CONFIG__ = {}; globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
};
+14 -6
View File
@@ -100,7 +100,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
expect(hoverBackground).not.toBe(restingBackground); expect(hoverBackground).not.toBe(restingBackground);
}); });
test("keeps menu origin and integrated composer styling through Tailwind v4", async ({ page }) => { test("keeps menu origin and the solid Main Frame styling through Tailwind v4", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/"); await page.goto("/");
@@ -112,10 +112,18 @@ test("keeps menu origin and integrated composer styling through Tailwind v4", as
expect(menuTransformOrigin).not.toBe("50% 50%"); expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first(); const navigation = page.getByRole("complementary", { name: "工作台导航" });
const commandShadow = await agentCommandControl.evaluate( const navigationMaterial = await navigation.evaluate(
(element) => window.getComputedStyle(element).boxShadow (element) => {
const style = window.getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
}
); );
expect(commandShadow).toBe("none"); expect(navigationMaterial.backdropFilter).toBe("none");
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px"); expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)");
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
}); });
+7 -2
View File
@@ -1,11 +1,16 @@
import { MapToaster } from "@/features/map/core"; import { MapToaster } from "@/features/map/core";
import { MapWorkbenchPage } from "@/features/workbench"; import { MapWorkbenchPage } from "@/features/workbench";
import type { Authentication } from "@/shared/auth/keycloak-auth";
import { AppProviders } from "./providers"; import { AppProviders } from "./providers";
export function App() { export function App({ authentication }: { authentication: Authentication }) {
return ( return (
<AppProviders> <AppProviders>
<MapWorkbenchPage /> <MapWorkbenchPage
user={authentication.user ?? undefined}
onLogout={authentication.logout}
getAccessToken={authentication.getAccessToken}
/>
<MapToaster /> <MapToaster />
</AppProviders> </AppProviders>
); );
+26
View File
@@ -0,0 +1,26 @@
import { Droplets, RefreshCw } from "lucide-react";
import { Button } from "@/shared/ui/button";
export function AuthStartupScreen({ error }: { error?: boolean }) {
return (
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
<Droplets size={22} aria-hidden="true" />
</span>
<h1 className="mt-4 text-lg font-semibold">
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
</h1>
<p className="mt-2 text-sm leading-6 text-slate-500">
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
</p>
{error ? (
<Button className="mt-5" onClick={() => window.location.reload()}>
<RefreshCw size={15} aria-hidden="true" />
</Button>
) : null}
</section>
</main>
);
}
+17
View File
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
); );
}); });
it("adds the current Keycloak access token without dropping request headers", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
);
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
expect(getAccessToken).toHaveBeenCalledOnce();
const init = fetchMock.mock.calls[0]?.[1];
if (!init) throw new Error("Expected Agent request init");
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("streams session events from the backend SSE endpoint", async () => { it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
+63 -79
View File
@@ -1,4 +1,5 @@
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted"; export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
@@ -112,19 +113,47 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>; abort: (sessionId: string) => Promise<void>;
}; };
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")]; const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat"; const CHAT_PATH = "/api/v1/agent/chat";
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient { export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
options: AgentApiClientOptions = {}
): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, "")); const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? ""; let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
path,
init,
options.getAccessToken
);
const requestJson = async <T,>(path: string, init?: RequestInit) => {
const response = await request(path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
};
return { return {
async createSession() { async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => { return requestJson<AgentChatSession>("/session", {
activeBaseUrl = nextBaseUrl;
}, "/session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) body: JSON.stringify({})
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}, },
async listSessions() { async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>( const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries); return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
}, },
async getFrontendActionRegistry() { async getFrontendActionRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry"); return requestJson<unknown>("/frontend-action-registry");
}, },
async submitFrontendActionResult(sessionId, actionId, result) { async submitFrontendActionResult(sessionId, actionId, result) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, { await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId }, headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
body: JSON.stringify(result) body: JSON.stringify(result)
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}, },
async loadSession(sessionId) { async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { const response = await request(`/session/${encodeURIComponent(sessionId)}`);
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const text = await response.text(); const text = await response.text();
const data = text ? JSON.parse(text) : null; const data = text ? JSON.parse(text) : null;
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}, },
async streamSession(sessionId, options) { async streamSession(sessionId, options) {
const response = await fetchWithFallback( const response = await request(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/stream`, `/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal } { signal: options.signal }
); );
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
return; return;
} }
await requestJsonWithFallback<unknown>( await requestJson<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/title`, `/session/${encodeURIComponent(sessionId)}/title`,
{ {
method: "PATCH", method: "PATCH",
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}, },
async deleteSession(sessionId) { async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>( await requestJson<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}`, `/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" } { method: "DELETE" }
); );
}, },
async getModels() { async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { const payload = await requestJson<unknown>("/models");
activeBaseUrl = nextBaseUrl;
}, "/models");
return toModelsResponse(payload); return toModelsResponse(payload);
}, },
async getUiRegistry() { async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { return requestJson<unknown>("/ui-registry");
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
}, },
async resolveRenderRef(renderRef, sessionId) { async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId }); const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>( return requestJson<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}` `/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
); );
}, },
async replyPermission(requestId, options) { async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}, },
async replyQuestion(requestId, options) { async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}, },
async rejectQuestion(requestId, options) { async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}, },
async abort(sessionId) { async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { await requestJson<unknown>("/abort", {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }) body: JSON.stringify({ session_id: sessionId })
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
}; };
} }
async function requestJsonWithFallback<T>(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
}
async function fetchWithFallback( async function fetchWithFallback(
baseUrls: string[], baseUrls: string[],
activeBaseUrl: string, activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void, setActiveBaseUrl: (baseUrl: string) => void,
path: string, path: string,
init?: RequestInit init?: RequestInit,
getAccessToken?: AccessTokenProvider
) { ) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown; let lastError: unknown;
let lastResponse: Response | null = null; let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const requestInit = accessToken
? {
...init,
headers: withBearerToken(init?.headers, accessToken)
}
: init;
for (const baseUrl of orderedBaseUrls) { for (const baseUrl of orderedBaseUrls) {
try { try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init); const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
if (response.ok) { if (response.ok) {
setActiveBaseUrl(baseUrl); setActiveBaseUrl(baseUrl);
return response; return response;
@@ -359,6 +337,12 @@ async function fetchWithFallback(
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable"); throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
} }
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
const headers = new Headers(headersInit);
headers.set("Authorization", `Bearer ${accessToken}`);
return headers;
}
function shouldFallbackOnHttpStatus(status: number) { function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504; return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
} }
@@ -20,7 +20,7 @@ export function AgentCollapsedRail({
<aside <aside
aria-label="Agent 折叠栏" aria-label="Agent 折叠栏"
className={cn( className={cn(
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] rounded-2xl border p-1.5" "agent-rail-enter surface-dock pointer-events-auto w-[72px] rounded-2xl border p-1.5 shadow-[0_8px_24px_rgba(15,23,42,0.1)]"
)} )}
> >
<button <button
@@ -1,6 +1,7 @@
import { import {
Activity, Activity,
CheckCircle2, CheckCircle2,
Clock3,
ChevronsUpDown, ChevronsUpDown,
ChevronLeft, ChevronLeft,
DatabaseZap, DatabaseZap,
@@ -82,6 +83,13 @@ import type {
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet"; export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
export type AgentWorkspaceContextItem = {
id: string;
label: string;
value: string;
tone?: "neutral" | "info" | "warning";
};
type AgentCommandPanelProps = { type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation; presentation?: AgentCommandPanelPresentation;
collapsing?: boolean; collapsing?: boolean;
@@ -99,6 +107,7 @@ type AgentCommandPanelProps = {
selectedModel?: string; selectedModel?: string;
approvalMode?: AgentApprovalMode; approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[]; uiResults?: AgentUiResult[];
workspaceContext?: AgentWorkspaceContextItem[];
onCollapse: () => void; onCollapse: () => void;
onRefreshHistory?: () => Promise<void> | void; onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void; onStartNewSession?: () => Promise<void> | void;
@@ -150,6 +159,7 @@ export function AgentCommandPanel({
selectedModel = "", selectedModel = "",
approvalMode = "request", approvalMode = "request",
uiResults = [], uiResults = [],
workspaceContext = [],
onCollapse, onCollapse,
onRefreshHistory, onRefreshHistory,
onStartNewSession, onStartNewSession,
@@ -302,7 +312,7 @@ export function AgentCommandPanel({
"pointer-events-auto flex h-full min-h-0 w-full flex-col", "pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell", "agent-panel-shell",
presentation === "desktop-dock" presentation === "desktop-dock"
? "acrylic-panel overflow-hidden rounded-r-2xl border-y border-r" ? "surface-dock overflow-hidden rounded-r-2xl border-y border-r shadow-[0_12px_32px_rgba(15,23,42,0.12)]"
: desktopFloating : desktopFloating
? "acrylic-panel overflow-visible rounded-2xl border" ? "acrylic-panel overflow-visible rounded-2xl border"
: "overflow-hidden rounded-none border-0 bg-transparent", : "overflow-hidden rounded-none border-0 bg-transparent",
@@ -423,6 +433,27 @@ export function AgentCommandPanel({
</header> </header>
</div> </div>
{workspaceContext.length > 0 ? (
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500"></span>
{workspaceContext.map((item) => (
<span
key={item.id}
className={cn(
"shrink-0 rounded-md px-2 py-1",
item.tone === "info" && "bg-blue-50 text-blue-700",
item.tone === "warning" && "bg-amber-50 text-amber-700",
(!item.tone || item.tone === "neutral") && "bg-white/70 text-slate-600"
)}
title={`${item.label}${item.value}`}
>
{item.label} · <strong className="font-semibold">{item.value}</strong>
</span>
))}
</div>
) : null}
{floatingPresentation ? ( {floatingPresentation ? (
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{hasConversation && !historyOpen ? ( {hasConversation && !historyOpen ? (
+5 -1
View File
@@ -1,11 +1,15 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail"; export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel"; export { AgentCommandPanel } from "./components/agent-command-panel";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state"; export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel"; export type {
AgentCommandPanelPresentation,
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona"; export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client"; export { createAgentApiClient } from "./api/client";
export type { export type {
AgentApiClient, AgentApiClient,
AgentApiClientOptions,
AgentChatSessionSummary, AgentChatSessionSummary,
AgentLoadedChatSession, AgentLoadedChatSession,
AgentSessionStreamEvent AgentSessionStreamEvent
@@ -1141,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
{index + 1} {index + 1}
</span> </span>
<span className="line-clamp-1">{item}</span> <span className="line-clamp-1">{item}</span>
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700"></span> <StatusBadge tone="info" activity="loading" className="mt-0.5">
</StatusBadge>
</li> </li>
)) ))
) : ( ) : (
@@ -98,6 +98,8 @@ export function ScheduledConditionFeed({
const [statusFilter, setStatusFilter] = const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL); useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const mobileSheet = presentation === "mobile-sheet"; const mobileSheet = presentation === "mobile-sheet";
const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating"; const desktopFloating = presentation === "desktop-floating";
const expanded = controlledExpanded ?? uncontrolledExpanded; const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId; const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
@@ -298,7 +300,7 @@ export function ScheduledConditionFeed({
: "max-h-[calc(100dvh-8rem)]" : "max-h-[calc(100dvh-8rem)]"
) )
: "h-full rounded-l-2xl rounded-r-none", : "h-full rounded-l-2xl rounded-r-none",
expanded expanded && !desktopDock
? "scheduled-feed-panel-expanded flex flex-col" ? "scheduled-feed-panel-expanded flex flex-col"
: "w-[var(--workbench-condition-width)]" : "w-[var(--workbench-condition-width)]"
) )
@@ -354,7 +356,7 @@ export function ScheduledConditionFeed({
<div <div
className={cn( className={cn(
"scheduled-feed-layout mt-3 grid min-h-0", "scheduled-feed-layout mt-3 grid min-h-0",
mobileSheet compactPresentation
? "flex flex-1" ? "flex flex-1"
: expanded : expanded
? "scheduled-feed-layout-expanded flex-1" ? "scheduled-feed-layout-expanded flex-1"
@@ -364,7 +366,7 @@ export function ScheduledConditionFeed({
<div <div
className={cn( className={cn(
"relative min-h-0 min-w-0 overflow-hidden", "relative min-h-0 min-w-0 overflow-hidden",
mobileSheet compactPresentation
? expanded ? expanded
? "scheduled-feed-detail-enter h-full w-full" ? "scheduled-feed-detail-enter h-full w-full"
: "hidden" : "hidden"
@@ -386,7 +388,7 @@ export function ScheduledConditionFeed({
<div <div
className={cn( className={cn(
"min-h-0 min-w-0 overflow-hidden", "min-h-0 min-w-0 overflow-hidden",
mobileSheet && (expanded ? "hidden" : "h-full w-full") compactPresentation && (expanded ? "hidden" : "h-full w-full")
)} )}
> >
<ConditionTimelinePanel <ConditionTimelinePanel
@@ -1,8 +1,6 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent"; import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import { cn } from "@/shared/ui/cn";
import { useEffect, type ComponentProps } from "react"; import { useEffect, type ComponentProps } from "react";
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout"; import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">; type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
@@ -10,7 +8,6 @@ type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps; panelProps: AgentPanelProps;
panelOpen: boolean; panelOpen: boolean;
panelCollapsing: boolean; panelCollapsing: boolean;
conditionExpanded: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"]; personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string; statusLabel: string;
viewportWidth: number; viewportWidth: number;
@@ -23,7 +20,6 @@ export function WorkbenchAgentPanels({
panelProps, panelProps,
panelOpen, panelOpen,
panelCollapsing, panelCollapsing,
conditionExpanded,
personaState, personaState,
statusLabel, statusLabel,
viewportWidth, viewportWidth,
@@ -31,9 +27,7 @@ export function WorkbenchAgentPanels({
onExpandPanel, onExpandPanel,
onWidthCommit onWidthCommit
}: WorkbenchAgentPanelsProps) { }: WorkbenchAgentPanelsProps) {
const resize = useAgentPanelResize(viewportWidth, conditionExpanded); const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
const committedWidth = resize.committedWidth ?? defaultWidth;
useEffect(() => { useEffect(() => {
onWidthCommit?.(committedWidth); onWidthCommit?.(committedWidth);
@@ -41,43 +35,24 @@ export function WorkbenchAgentPanels({
return ( return (
<div <div
ref={resize.panelRef} className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block" style={{ width: panelOpen ? committedWidth : 0 }}
style={{ width: resize.width ?? defaultWidth }}
> >
{panelOpen ? ( {panelOpen ? (
<>
<AgentCommandPanel <AgentCommandPanel
{...panelProps} {...panelProps}
presentation="desktop-floating" presentation="desktop-dock"
collapsing={panelCollapsing} collapsing={panelCollapsing}
onCollapse={onCollapsePanel} onCollapse={onCollapsePanel}
/> />
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={getAgentPanelMaxWidth(viewportWidth, conditionExpanded)}
aria-valuemin={defaultWidth}
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
className={cn(
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
)}
role="separator"
tabIndex={0}
title="拖拽调整 Agent 面板宽度"
onKeyDown={resize.handleKeyDown}
onPointerDown={resize.handlePointerDown}
>
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
</div>
</>
) : ( ) : (
<div className="absolute left-3 top-5">
<AgentCollapsedRail <AgentCollapsedRail
personaState={personaState} personaState={personaState}
statusLabel={statusLabel} statusLabel={statusLabel}
onExpand={onExpandPanel} onExpand={onExpandPanel}
/> />
</div>
)} )}
</div> </div>
); );
@@ -283,7 +283,8 @@ export function UserMenu({
onRefreshTiles, onRefreshTiles,
onShowDataStatus, onShowDataStatus,
onShowShortcuts, onShowShortcuts,
onExportConfig onExportConfig,
onLogout
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -292,6 +293,7 @@ export function UserMenu({
onShowDataStatus: () => void; onShowDataStatus: () => void;
onShowShortcuts: () => void; onShowShortcuts: () => void;
onExportConfig: () => void; onExportConfig: () => void;
onLogout?: () => Promise<void>;
}) { }) {
return ( return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}> <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
@@ -320,7 +322,15 @@ export function UserMenu({
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} /> <MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} /> <MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<MenuSeparator /> <MenuSeparator />
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}> <DropdownMenuItem
disabled={!onLogout}
onSelect={onLogout ? () => void onLogout() : undefined}
className={cn(
"px-2 py-2",
!onLogout && "text-slate-400",
MAP_COMPACT_RADIUS_CLASS_NAME
)}
>
<LogOut size={15} aria-hidden="true" /> <LogOut size={15} aria-hidden="true" />
退 退
</DropdownMenuItem> </DropdownMenuItem>
@@ -7,6 +7,7 @@ import {
Eye, Eye,
EyeOff, EyeOff,
FlaskConical, FlaskConical,
Sparkles,
type LucideIcon type LucideIcon
} from "lucide-react"; } from "lucide-react";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
@@ -27,6 +28,7 @@ export type WorkbenchTopBarProps = {
alerts: WorkbenchAlert[]; alerts: WorkbenchAlert[];
user: WorkbenchUser; user: WorkbenchUser;
conditionFeedVisible: boolean; conditionFeedVisible: boolean;
timelineVisible: boolean;
taskTickerAvailable: boolean; taskTickerAvailable: boolean;
taskTickerVisible: boolean; taskTickerVisible: boolean;
devPanelEnabled: boolean; devPanelEnabled: boolean;
@@ -34,16 +36,19 @@ export type WorkbenchTopBarProps = {
onSelectScenario: (scenarioId: string) => void; onSelectScenario: (scenarioId: string) => void;
onSelectAlert: (alert: WorkbenchAlert) => void; onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void; onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void; onToggleTaskTicker: () => void;
onToggleDevPanel: () => void; onToggleDevPanel: () => void;
onPreviewScenario: () => void; onPreviewScenario: () => void;
onCompareScenario: () => void; onCompareScenario: () => void;
onExportScenarioReport: () => void; onExportScenarioReport: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>; onAnalyzeAlertsWithAgent: () => void | Promise<void>;
onCreateTestAnalysis?: () => void;
onShowDataStatus: () => void; onShowDataStatus: () => void;
onRefreshTiles: () => void; onRefreshTiles: () => void;
onShowShortcuts: () => void; onShowShortcuts: () => void;
onExportConfig: () => void; onExportConfig: () => void;
onLogout?: () => Promise<void>;
}; };
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user"; type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
@@ -56,6 +61,7 @@ export function WorkbenchTopBar({
alerts, alerts,
user, user,
conditionFeedVisible, conditionFeedVisible,
timelineVisible,
taskTickerAvailable, taskTickerAvailable,
taskTickerVisible, taskTickerVisible,
devPanelEnabled, devPanelEnabled,
@@ -63,16 +69,19 @@ export function WorkbenchTopBar({
onSelectScenario, onSelectScenario,
onSelectAlert, onSelectAlert,
onToggleConditionFeed, onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker, onToggleTaskTicker,
onToggleDevPanel, onToggleDevPanel,
onPreviewScenario, onPreviewScenario,
onCompareScenario, onCompareScenario,
onExportScenarioReport, onExportScenarioReport,
onAnalyzeAlertsWithAgent, onAnalyzeAlertsWithAgent,
onCreateTestAnalysis,
onShowDataStatus, onShowDataStatus,
onRefreshTiles, onRefreshTiles,
onShowShortcuts, onShowShortcuts,
onExportConfig onExportConfig,
onLogout
}: WorkbenchTopBarProps) { }: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null); const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const activeScenario = const activeScenario =
@@ -139,6 +148,29 @@ export function WorkbenchTopBar({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<div className="lg:hidden">
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
<div className="hidden lg:block">
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
{onCreateTestAnalysis ? (
<div className="hidden lg:block">
<button
type="button"
aria-label="生成 Agent 模拟分析成果"
title="生成 Agent 模拟分析成果"
data-selection-tone="soft"
onClick={onCreateTestAnalysis}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Sparkles size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}></span>
</button>
</div>
) : null}
{devPanelEnabled ? ( {devPanelEnabled ? (
<div className="hidden lg:block"> <div className="hidden lg:block">
<button <button
@@ -176,6 +208,7 @@ export function WorkbenchTopBar({
onShowDataStatus={onShowDataStatus} onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts} onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig} onExportConfig={onExportConfig}
onLogout={onLogout}
/> />
</div> </div>
</header> </header>
@@ -234,6 +267,33 @@ function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle
); );
} }
function TimelineToggle({
visible,
compact = false,
onToggle
}: {
visible: boolean;
compact?: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
aria-label={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
aria-pressed={visible}
data-selection-tone="soft"
title={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
onClick={onToggle}
className={cn("group", compact ? "px-1.5" : "px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Clock3 size={14} aria-hidden="true" />
</span>
{!compact ? <span className={compactHeaderTextClassName}></span> : null}
</button>
);
}
function HeaderReadout({ function HeaderReadout({
icon: Icon, icon: Icon,
label, label,
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
import useSWR from "swr"; import useSWR from "swr";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona"; import type { PersonaState } from "@/shared/ai-elements/persona";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core"; import { showMapNotice } from "@/features/map/core";
import { import {
@@ -50,20 +51,25 @@ const AGENT_PANEL_COLLAPSE_MS = 180;
type UseWorkbenchAgentOptions = { type UseWorkbenchAgentOptions = {
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void; onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>; onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
getAccessToken?: AccessTokenProvider;
}; };
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) { export function useWorkbenchAgent({
onUiEnvelope,
onFrontendAction,
getAccessToken
}: UseWorkbenchAgentOptions) {
const collapseTimerRef = useRef<number | null>(null); const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null); const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null); const sessionStreamAbortRef = useRef<AbortController | null>(null);
const clientRef = useRef(createAgentApiClient()); const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
const sessionIdRef = useRef<string | null>(null); const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request"); const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null); const registryRef = useRef<UIRegistry | null>(null);
const frontendActionEnabledRef = useRef(false); const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction); const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>()); const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(true); const [panelOpen, setPanelOpen] = useState(false);
const [panelCollapsing, setPanelCollapsing] = useState(false); const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false); const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
@@ -205,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
() => () =>
new DefaultChatTransport<AgentUiMessage>({ new DefaultChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`, api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
headers: async (): Promise<Record<string, string>> => {
const accessToken = await getAccessToken?.();
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
},
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) { prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
return { return {
body: { body: {
@@ -220,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
}; };
} }
}), }),
[] [getAccessToken]
); );
const chat = useChat<AgentUiMessage>({ const chat = useChat<AgentUiMessage>({
@@ -166,11 +166,19 @@ export function useWorkbenchMap({
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW); fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
}); });
let resizeFrame: number | null = null;
const resizeMap = () => map.resize(); const resizeMap = () => map.resize();
const resizeObserver = new ResizeObserver(() => {
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeFrame = window.requestAnimationFrame(() => {
resizeFrame = null;
resizeMap();
});
});
resizeObserver.observe(containerRef.current);
window.addEventListener("resize", resizeMap); window.addEventListener("resize", resizeMap);
window.setTimeout(resizeMap, 0);
window.setTimeout(resizeMap, 300);
window.setTimeout(resizeMap, 1000);
map.on("sourcedata", (event) => { map.on("sourcedata", (event) => {
updateStatusFromSourceEvent(event, "online", setSourceStatuses); updateStatusFromSourceEvent(event, "online", setSourceStatuses);
@@ -201,6 +209,10 @@ export function useWorkbenchMap({
return () => { return () => {
window.removeEventListener("resize", resizeMap); window.removeEventListener("resize", resizeMap);
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeObserver.disconnect();
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
delete window.__waterNetworkMap; delete window.__waterNetworkMap;
@@ -0,0 +1,85 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchResponsiveLayout } from "./use-workbench-responsive-layout";
function mockViewport(width: number, largeScreen: boolean) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width
});
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: largeScreen,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("workbench responsive layout", () => {
it("coordinates the expanded condition feed with the desktop Agent panel", () => {
mockViewport(1366, true);
const collapseAgentPanel = vi.fn();
const expandAgentPanel = vi.fn();
const { result } = renderHook(() =>
useWorkbenchResponsiveLayout({
activeToolOpen: false,
agentPanelOpen: true,
devPanelOpen: false,
collapseAgentPanel,
expandAgentPanel,
onClearActiveTool: vi.fn()
})
);
expect(result.current.isLargeScreen).toBe(true);
expect(result.current.shouldShowConditionFeed).toBe(false);
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.shouldShowConditionFeed).toBe(true);
act(() => result.current.handleConditionExpandedChange(true));
expect(result.current.conditionFeedExpanded).toBe(true);
expect(collapseAgentPanel).toHaveBeenCalledOnce();
act(() => result.current.handleConditionExpandedChange(false));
expect(result.current.conditionFeedExpanded).toBe(false);
expect(expandAgentPanel).toHaveBeenCalledOnce();
});
it("toggles the condition sheet without leaking desktop panel state on mobile", () => {
mockViewport(390, false);
const onClearActiveTool = vi.fn();
const { result } = renderHook(() =>
useWorkbenchResponsiveLayout({
activeToolOpen: true,
agentPanelOpen: true,
devPanelOpen: false,
collapseAgentPanel: vi.fn(),
expandAgentPanel: vi.fn(),
onClearActiveTool
})
);
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBe("condition");
expect(result.current.mobileSheetSnap).toBe("half");
expect(onClearActiveTool).toHaveBeenCalledOnce();
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBeNull();
act(() => result.current.openAgentPanelForViewport());
expect(result.current.mobileSheet).toBe("agent");
});
});
@@ -0,0 +1,181 @@
import { useEffect, useState } from "react";
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
const CONDITION_FEED_EXIT_MS = 170;
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
type WorkbenchMobileSheet = "agent" | "condition" | "timeline" | null;
type UseWorkbenchResponsiveLayoutOptions = {
activeToolOpen: boolean;
agentPanelOpen: boolean;
devPanelOpen: boolean;
collapseAgentPanel: () => void;
expandAgentPanel: () => void;
onClearActiveTool: () => void;
};
export function useWorkbenchResponsiveLayout({
activeToolOpen,
agentPanelOpen,
devPanelOpen,
collapseAgentPanel,
expandAgentPanel,
onClearActiveTool
}: UseWorkbenchResponsiveLayoutOptions) {
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
WORKBENCH_LAYOUT.desktop.agentWidth
);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(LARGE_SCREEN_QUERY);
const handleViewportChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleViewportChange();
setConditionFeedVisible(false);
mediaQuery.addEventListener("change", handleViewportChange);
window.addEventListener("resize", handleViewportChange);
return () => {
mediaQuery.removeEventListener("change", handleViewportChange);
window.removeEventListener("resize", handleViewportChange);
};
}, []);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
useEffect(() => {
if (shouldShowConditionFeed) {
setConditionFeedMounted(true);
return;
}
const timeoutId = window.setTimeout(() => {
setConditionFeedMounted(false);
}, CONDITION_FEED_EXIT_MS);
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
function closeMobileSheet() {
setMobileSheet(null);
}
function openAgentPanelForViewport() {
if (isLargeScreen) {
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
setConditionFeedExpanded(false);
setAgentCollapsedForCondition(false);
}
expandAgentPanel();
return;
}
setMobileSheet("agent");
setMobileSheetSnap("half");
}
function handleConditionExpandedChange(nextExpanded: boolean) {
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
setAgentCollapsedForCondition(agentPanelOpen);
collapseAgentPanel();
} else if (!nextExpanded && agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
expandAgentPanel();
}
setConditionFeedExpanded(nextExpanded);
}
useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
return;
}
setConditionFeedExpanded(false);
if (agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
expandAgentPanel();
}
}, [
agentCollapsedForCondition,
conditionFeedExpanded,
expandAgentPanel,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() {
onClearActiveTool();
if (isLargeScreen) {
setConditionFeedVisible((current) => !current);
return;
}
if (mobileSheet === "condition") {
closeMobileSheet();
return;
}
setConditionFeedExpanded(false);
setMobileSheetSnap("half");
setMobileSheet("condition");
}
function openConditionFeedForViewport() {
setConditionFeedVisible(true);
onClearActiveTool();
if (!isLargeScreen) {
setMobileSheet("condition");
setMobileSheetSnap("half");
}
}
function openTimelineForViewport() {
if (isLargeScreen) return;
setMobileSheet("timeline");
setMobileSheetSnap("half");
}
const leftPanelOpen = isLargeScreen && agentPanelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
const rightPanelExpanded =
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
return {
agentPanelWidth,
closeMobileSheet,
conditionFeedExpanded,
conditionFeedMounted,
handleConditionExpandedChange,
isLargeScreen,
leftPanelOpen,
mobileSheet,
mobileSheetSnap,
openAgentPanelForViewport,
openConditionFeedForViewport,
openTimelineForViewport,
rightPanelExpanded,
rightPanelOpen,
setAgentPanelWidth,
setMobileSheetSnap,
shouldShowConditionFeed,
toggleConditionFeedForViewport,
viewportWidth
};
}
+19
View File
@@ -17,3 +17,22 @@ export type {
LineLayerGroupStylePatch, LineLayerGroupStylePatch,
PointLayerGroupStylePatch PointLayerGroupStylePatch
} from "./map/layer-group-style"; } from "./map/layer-group-style";
export type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock,
ArtifactLifecycle,
ArtifactMapRelation,
ArtifactRequestedView,
ArtifactType,
ArtifactViewMode,
WorkbenchSurfaceMode
} from "./workspace/workspace-model";
export type {
OperationalEvent,
OperationalEventImportance,
OperationalEventLane,
OperationalEventStatus,
OperationalTimelineMode
} from "./operational-timeline/operational-timeline-model";
export type { WorkspaceContext, WorkspaceTimeRange } from "./workspace/workspace-context";
@@ -5,7 +5,8 @@ import {
getAgentPanelMaxWidth, getAgentPanelMaxWidth,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchCameraPadding, getWorkbenchCameraPadding,
getWorkbenchViewportLayout getWorkbenchViewportLayout,
resolveMobileWorkbenchOverlayLayout
} from "./workbench-layout"; } from "./workbench-layout";
describe("workbench basemap surface tone", () => { describe("workbench basemap surface tone", () => {
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
}); });
describe("workbench floating panels", () => { describe("workbench floating panels", () => {
it("derives mobile dock and map control offsets from one overlay stack", () => {
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
timelineBottom: 12,
dockBottom: 68,
mapControlBottom: 120
});
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
timelineBottom: 12,
dockBottom: 12,
mapControlBottom: 64
});
});
it("uses bounded desktop and wide panel widths", () => { it("uses bounded desktop and wide panel widths", () => {
expect(getWorkbenchViewportLayout(1440)).toMatchObject({ expect(getWorkbenchViewportLayout(1440)).toMatchObject({
agentWidth: 500, agentWidth: 500,
@@ -51,6 +65,15 @@ describe("workbench floating panels", () => {
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 }); ).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
}); });
it("does not reserve map width for the collapsed Agent launcher", () => {
expect(
getWorkbenchCameraPadding(1440, {
agentOpen: false,
conditionOpen: false
}).left
).toBe(24);
});
it("uses the committed Agent width without exceeding the hard limit", () => { it("uses the committed Agent width without exceeding the hard limit", () => {
expect( expect(
getWorkbenchCameraPadding(1920, { getWorkbenchCameraPadding(1920, {
@@ -3,7 +3,7 @@ export const WORKBENCH_LAYOUT = {
desktopMinWidth: 1024, desktopMinWidth: 1024,
persistentConditionMinWidth: 1280, persistentConditionMinWidth: 1280,
wideMinWidth: 1536, wideMinWidth: 1536,
collapsedAgentWidth: 72, collapsedAgentWidth: 0,
maxAgentWidth: 720, maxAgentWidth: 720,
agentPanelLeftInset: 12, agentPanelLeftInset: 12,
conditionPanelRightInset: 64, conditionPanelRightInset: 64,
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
mobileSheet: { mobileSheet: {
halfViewportRatio: 0.52, halfViewportRatio: 0.52,
fullTopGap: 68 fullTopGap: 68
},
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
timelineSummaryHeight: 48
} }
} as const; } as const;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
return {
timelineBottom: edgeInset,
dockBottom,
mapControlBottom: dockBottom + dockHeight + gap
};
}
export function clampAgentPanelWidth( export function clampAgentPanelWidth(
width: number, width: number,
viewportWidth: number, viewportWidth: number,
+355 -172
View File
@@ -2,20 +2,22 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useReducer,
useRef, useRef,
useState, useState,
type ComponentProps, type ComponentProps,
type CSSProperties type CSSProperties
} from "react"; } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarClock } from "lucide-react"; import { CalendarClock, Clock3 } from "lucide-react";
import { import {
AgentCommandPanel, AgentCommandPanel,
AgentPersona, AgentPersona,
toTrustedMapAction, toTrustedMapAction,
type FrontendActionRequest, type FrontendActionRequest,
type UIEnvelopePayload, type UIEnvelopePayload,
type AgentUiResult type AgentUiResult,
type AgentWorkspaceContextItem
} from "@/features/agent"; } from "@/features/agent";
import { import {
MapErrorNotice, MapErrorNotice,
@@ -29,15 +31,22 @@ import {
type MapSourceStatus type MapSourceStatus
} from "@/features/map/core"; } from "@/features/map/core";
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker"; import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel"; import { MapDevPanel } from "./components/map-dev-panel";
import { import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
MobileWorkbenchSheet,
type MobileWorkbenchSheetSnap
} from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover"; import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
import {
createOperationalEvents,
getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout,
type OperationalEvent,
type OperationalTimelineMode
} from "./operational-timeline/operational-timeline-model";
import { import {
ToolbarPanel, ToolbarPanel,
type ExportViewPreset, type ExportViewPreset,
@@ -45,11 +54,20 @@ import {
type ToolbarToolId type ToolbarToolId
} from "./components/toolbar-panel"; } from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar"; import { WorkbenchTopBar } from "./components/workbench-top-bar";
import {
WorkbenchMainFrame
} from "./workspace/workbench-main-frame";
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
import {
createInitialWorkspaceContext,
reduceWorkspaceContext
} from "./workspace/workspace-context";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session"; import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map"; import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller"; import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data"; import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions"; import { toMapFeatureReference } from "./hooks/use-map-interactions";
import { import {
@@ -60,7 +78,8 @@ import {
import { import {
WORKBENCH_LAYOUT, WORKBENCH_LAYOUT,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout"; } from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera"; import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view"; import { exportMapViewImage } from "./map/export-view";
@@ -69,7 +88,6 @@ import {
BASE_LAYER_OPTIONS, BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY, INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS, MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
applyBaseLayerVisibility, applyBaseLayerVisibility,
createLayerControlItems, createLayerControlItems,
getWorkbenchLayerIds getWorkbenchLayerIds
@@ -79,7 +97,8 @@ import type {
DetailFeature, DetailFeature,
ScheduledConditionItem, ScheduledConditionItem,
ScheduledConditionRecord, ScheduledConditionRecord,
WorkbenchAlert WorkbenchAlert,
WorkbenchUser
} from "./types"; } from "./types";
import { import {
createScheduledConditionAlerts, createScheduledConditionAlerts,
@@ -89,25 +108,22 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables(); const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
export function MapWorkbenchPage() { export function MapWorkbenchPage({
user = WORKBENCH_USER,
onLogout,
getAccessToken
}: {
user?: WorkbenchUser;
onLogout?: () => Promise<void>;
getAccessToken?: AccessTokenProvider;
}) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null); const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false); const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false); const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
WORKBENCH_LAYOUT.desktop.agentWidth
);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null); const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const [taskTickerVisible, setTaskTickerVisible] = useState(true); const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null); const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
@@ -141,115 +157,108 @@ export function MapWorkbenchPage() {
setDetailFeature(feature); setDetailFeature(feature);
}, []); }, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleChange();
setConditionFeedVisible(window.innerWidth >= 1280);
mediaQuery.addEventListener("change", handleChange);
window.addEventListener("resize", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("resize", handleChange);
};
}, []);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
useEffect(() => {
if (shouldShowConditionFeed) {
setConditionFeedMounted(true);
return;
}
const timeoutId = window.setTimeout(() => {
setConditionFeedMounted(false);
}, 170);
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
const agent = useWorkbenchAgent({ const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope, onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction onFrontendAction: handleFrontendAction,
getAccessToken
}); });
function openAgentPanelForViewport() { const collapseAgentPanel = agent.collapsePanel;
if (isLargeScreen) { const expandAgentPanel = agent.expandPanel;
if (conditionFeedExpanded && viewportWidth < 1440) { const clearActiveTool = useCallback(() => {
setConditionFeedExpanded(false); setActiveToolId(null);
setAgentCollapsedForCondition(false);
}
agent.expandPanel();
return;
}
setMobileSheet("agent");
setMobileSheetSnap("half");
}
const closeMobileSheet = useCallback(() => {
setMobileSheet(null);
}, []); }, []);
const {
function handleConditionExpandedChange(nextExpanded: boolean) { agentPanelWidth,
if (nextExpanded && isLargeScreen && viewportWidth < 1440) { closeMobileSheet,
setAgentCollapsedForCondition(agent.panelOpen); conditionFeedExpanded,
agent.collapsePanel(); conditionFeedMounted,
} else if (!nextExpanded && agentCollapsedForCondition) { handleConditionExpandedChange,
setAgentCollapsedForCondition(false); isLargeScreen,
agent.expandPanel(); leftPanelOpen,
} mobileSheet,
setConditionFeedExpanded(nextExpanded); mobileSheetSnap,
} openAgentPanelForViewport,
openConditionFeedForViewport,
openTimelineForViewport,
rightPanelExpanded,
rightPanelOpen,
setAgentPanelWidth,
setMobileSheetSnap,
shouldShowConditionFeed,
toggleConditionFeedForViewport,
viewportWidth
} = useWorkbenchResponsiveLayout({
activeToolOpen: activeToolId !== null,
agentPanelOpen: agent.panelOpen,
devPanelOpen,
collapseAgentPanel,
expandAgentPanel,
onClearActiveTool: clearActiveTool
});
const currentAgentWidth = leftPanelOpen
? agentPanelWidth
: WORKBENCH_LAYOUT.collapsedAgentWidth;
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null;
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
const [timelineVisible, setTimelineVisible] = useState(true);
const [workspaceContext, dispatchWorkspaceContext] = useReducer(
reduceWorkspaceContext,
undefined,
() => createInitialWorkspaceContext()
);
const operationalEvents = useMemo(
() => createOperationalEvents(scheduledConditions),
[scheduledConditions]
);
const operationalSummary = useMemo(
() => getOperationalEventSummary(operationalEvents),
[operationalEvents]
);
const timelineScaleLayout = resolveTimelineScaleLayout(
businessWorkspaceWidth,
timelineMode,
timelineVisible
);
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
const timelineBottom = isLargeScreen
? timelineScaleLayout.timelineBottom
: mobileOverlayLayout.timelineBottom;
useEffect(() => { useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) { setTimelineMode((current) => {
return; if (artifactWorkspace.surfaceMode === "focus") return "summary";
return current === "summary" ? "compact" : current;
});
}, [artifactWorkspace.surfaceMode]);
useEffect(() => {
dispatchWorkspaceContext({
type: "sync-artifact",
artifact: artifactWorkspace.activeArtifact
});
}, [artifactWorkspace.activeArtifact]);
function openAgentWorkspaceForViewport() {
openAgentPanelForViewport();
} }
setConditionFeedExpanded(false); function openConditionWorkspaceForViewport() {
if (agentCollapsedForCondition) { openConditionFeedForViewport();
setAgentCollapsedForCondition(false);
agent.expandPanel();
}
}, [
agent,
agentCollapsedForCondition,
conditionFeedExpanded,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() {
setActiveToolId(null);
if (isLargeScreen) {
setConditionFeedVisible((current) => !current);
return;
} }
if (mobileSheet === "condition") { function toggleConditionWorkspaceForViewport() {
closeMobileSheet(); toggleConditionFeedForViewport();
return;
} }
setConditionFeedExpanded(false); function toggleTimelineVisibility() {
setMobileSheetSnap("half"); setTimelineVisible((current) => {
setMobileSheet("condition"); if (current && mobileSheet === "timeline") closeMobileSheet();
return !current;
});
} }
const leftPanelOpen = isLargeScreen && agent.panelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
const rightPanelExpanded =
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({ const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef, containerRef: mapContainerRef,
impactVisible, impactVisible,
@@ -259,7 +268,7 @@ export function MapWorkbenchPage() {
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef, mapRef,
mapReady, mapReady,
leftPanelOpen, leftPanelOpen: false,
rightPanelOpen, rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded, conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth agentPanelWidth
@@ -298,7 +307,11 @@ export function MapWorkbenchPage() {
}); });
const taskTickerAvailable = runningTickerConditions.length > 0; const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker = const shouldShowTaskTicker =
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable; !artifactActive
&& mobileSheet === null
&& !conditionFeedExpanded
&& taskTickerVisible
&& taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion const taskTickerEnterState = prefersReducedMotion
? { ? {
opacity: 1, opacity: 1,
@@ -351,6 +364,54 @@ export function MapWorkbenchPage() {
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current)); setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
}, []); }, []);
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
dispatchWorkspaceContext({
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
const mapTarget = event.mapTargets[0];
if (mapTarget) {
dispatchWorkspaceContext({
type: "select-spatial-feature",
featureId: mapTarget.featureId,
sourceId: mapTarget.sourceId
});
void mapController.locateAndHighlight(mapTarget).then(() => {
if (mapController.getSnapshot().errorCode) {
showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) {
setSelectedConditionId(event.conditionId);
setConditionFocusRequest((current) => ({
conditionId: event.conditionId!,
requestId: (current?.requestId ?? 0) + 1
}));
openConditionFeedForViewport();
handleConditionExpandedChange(true);
}
}, [handleConditionExpandedChange, mapController, openConditionFeedForViewport]);
const handleClearOperationalSelection = useCallback(() => {
dispatchWorkspaceContext({ type: "clear-operational-event" });
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
mapController.clearHighlight();
}, [mapController]);
const handleReturnOperationalNow = useCallback(() => {
dispatchWorkspaceContext({ type: "return-operational-now", now: new Date().toISOString() });
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
mapController.clearHighlight();
}, [mapController]);
const toolbarItems = useMemo<MapToolbarItem[]>( const toolbarItems = useMemo<MapToolbarItem[]>(
() => () =>
waterNetworkToolbarItems.map((item) => ({ waterNetworkToolbarItems.map((item) => ({
@@ -379,9 +440,7 @@ export function MapWorkbenchPage() {
} }
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => { getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none"); map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
}
}); });
} }
@@ -476,11 +535,10 @@ export function MapWorkbenchPage() {
setImpactVisible(true); setImpactVisible(true);
setLayerVisibility((current) => ({ ...current, simulation: true })); setLayerVisibility((current) => ({ ...current, simulation: true }));
if (mapRef.current && mapReady) { const map = mapRef.current;
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => { if (map && mapReady) {
if (mapRef.current?.getLayer(layerId)) { getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
mapRef.current.setLayoutProperty(layerId, "visibility", "visible"); map.setLayoutProperty(layerId, "visibility", "visible");
}
}); });
} }
@@ -522,7 +580,7 @@ export function MapWorkbenchPage() {
return; return;
} }
openAgentPanelForViewport(); openAgentWorkspaceForViewport();
try { try {
await agent.submitPrompt( await agent.submitPrompt(
@@ -559,12 +617,7 @@ export function MapWorkbenchPage() {
return; return;
} }
setConditionFeedVisible(true); openConditionWorkspaceForViewport();
setActiveToolId(null);
if (!isLargeScreen) {
setMobileSheet("condition");
setMobileSheetSnap("half");
}
setConditionFocusRequest((current) => ({ setConditionFocusRequest((current) => ({
conditionId, conditionId,
requestId: (current?.requestId ?? 0) + 1 requestId: (current?.requestId ?? 0) + 1
@@ -686,7 +739,7 @@ export function MapWorkbenchPage() {
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14), zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
padding: getResponsiveWorkbenchPadding( padding: getResponsiveWorkbenchPadding(
map, map,
leftPanelOpen, false,
rightPanelOpen, rightPanelOpen,
rightPanelExpanded, rightPanelExpanded,
agentPanelWidth agentPanelWidth
@@ -804,6 +857,35 @@ export function MapWorkbenchPage() {
onShowShortcuts: handleShowShortcuts, onShowShortcuts: handleShowShortcuts,
onExportConfig: handleExportConfig onExportConfig: handleExportConfig
}; };
const selectedOperationalEvent = operationalEvents.find(
(event) => event.id === workspaceContext.operational.selectedEventId
) ?? null;
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
const items: AgentWorkspaceContextItem[] = [{
id: "operational-time",
label: "运行时点",
value: formatContextClock(workspaceContext.operational.cursorTime),
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
}];
if (workspaceContext.spatial.featureId) {
items.push({
id: "spatial-feature",
label: "地图要素",
value: workspaceContext.spatial.featureId,
tone: "info"
});
}
if (artifactWorkspace.activeArtifact) {
items.push({
id: "analytical-artifact",
label: "分析成果",
value: artifactWorkspace.activeArtifact.title,
tone: "info"
});
}
return items;
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
const agentPanelProps: Omit< const agentPanelProps: Omit<
ComponentProps<typeof AgentCommandPanel>, ComponentProps<typeof AgentCommandPanel>,
"collapsing" | "onCollapse" "collapsing" | "onCollapse"
@@ -822,6 +904,7 @@ export function MapWorkbenchPage() {
selectedModel: agent.selectedModel, selectedModel: agent.selectedModel,
approvalMode: agent.approvalMode, approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults, uiResults: activeAgentUiResults,
workspaceContext: agentWorkspaceContext,
onRefreshHistory: agent.refreshSessionHistory, onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession, onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession, onLoadHistorySession: handleLoadAgentHistorySession,
@@ -843,51 +926,135 @@ export function MapWorkbenchPage() {
style={ style={
{ {
...WORKBENCH_LAYOUT_CSS_VARIABLES, ...WORKBENCH_LAYOUT_CSS_VARIABLES,
"--workbench-agent-current-width": `${ "--workbench-agent-current-width": `${currentAgentWidth}px`,
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth "--workbench-timeline-height": `${OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight}px`
}px`
} as CSSProperties } as CSSProperties
} }
> >
<div
ref={mapContainerRef}
className="map-grid"
style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图"
/>
<WorkbenchTopBar <WorkbenchTopBar
dataTime={headerDataTime} dataTime={headerDataTime}
modelName="AquaDispatch v1.0" modelName="AquaDispatch v1.0"
scenarios={WORKBENCH_SCENARIOS} scenarios={WORKBENCH_SCENARIOS}
activeScenarioId={activeScenarioId} activeScenarioId={activeScenarioId}
alerts={workbenchAlerts} alerts={workbenchAlerts}
user={WORKBENCH_USER} user={user}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"} conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
timelineVisible={timelineVisible}
taskTickerAvailable={taskTickerAvailable} taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible} taskTickerVisible={taskTickerVisible}
devPanelEnabled={devPanelEnabled} devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen} devPanelOpen={devPanelOpen}
onSelectScenario={handleSelectScenario} onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert} onSelectAlert={handleSelectAlert}
onToggleConditionFeed={toggleConditionFeedForViewport} onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)} onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)} onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onPreviewScenario={handlePreviewScenario} onPreviewScenario={handlePreviewScenario}
onCompareScenario={handleCompareScenario} onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport} onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent} onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
onShowDataStatus={handleShowDataStatus} onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles} onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts} onShowShortcuts={handleShowShortcuts}
onExportConfig={handleExportConfig} onExportConfig={handleExportConfig}
onLogout={onLogout}
/>
<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-0">
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
<MapToolbar items={toolbarItems} />
</div>
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
<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">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div>
<MapScaleLine
className="hidden lg:inline-flex"
mapRef={mapRef}
mapReady={mapReady}
/>
</div>
</div>
)}
/> />
<WorkbenchAgentPanels <WorkbenchAgentPanels
panelProps={agentPanelProps} panelProps={agentPanelProps}
panelOpen={agent.panelOpen} panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing} panelCollapsing={agent.panelCollapsing}
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
personaState={agent.personaState} personaState={agent.personaState}
statusLabel={agent.statusLabel} statusLabel={agent.statusLabel}
viewportWidth={viewportWidth} viewportWidth={viewportWidth}
@@ -896,14 +1063,8 @@ export function MapWorkbenchPage() {
onWidthCommit={setAgentPanelWidth} onWidthCommit={setAgentPanelWidth}
/> />
{!devPanelOpen ? (
<div className="absolute right-2 top-24 z-20 hidden lg:block">
<MapToolbar items={toolbarItems} className="hidden lg:block" />
</div>
) : null}
{conditionFeedMounted && !devPanelOpen ? ( {conditionFeedMounted && !devPanelOpen ? (
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]"> <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)]">
<ScheduledConditionFeed <ScheduledConditionFeed
presentation="desktop-floating" presentation="desktop-floating"
conditions={scheduledConditions} conditions={scheduledConditions}
@@ -915,19 +1076,13 @@ export function MapWorkbenchPage() {
onExpandedChange={handleConditionExpandedChange} onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled} onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId} onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={openAgentPanelForViewport} onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession} onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt} onSubmitPrompt={agent.submitPrompt}
/> />
</div> </div>
) : null} ) : null}
{!devPanelOpen ? (
<div className="absolute right-16 top-24 z-20 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
) : null}
{devPanelEnabled && devPanelOpen ? ( {devPanelEnabled && devPanelOpen ? (
<MapDevPanel <MapDevPanel
commands={mapController} commands={mapController}
@@ -937,8 +1092,11 @@ export function MapWorkbenchPage() {
/> />
) : null} ) : null}
{mobileSheet === null ? ( {mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden"> <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 }}
>
<ToolbarPanel <ToolbarPanel
{...toolbarPanelProps} {...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto" panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
@@ -946,18 +1104,11 @@ export function MapWorkbenchPage() {
</div> </div>
) : null} ) : null}
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
<div className="flex items-end gap-2 pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div>
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
</div>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{shouldShowTaskTicker ? ( {shouldShowTaskTicker ? (
<motion.div <motion.div
key="agent-task-ticker-shell" key="agent-task-ticker-shell"
className="fixed left-1/2 right-auto top-24 z-20 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)] 2xl:w-[var(--workbench-ticker-width-wide)]"
style={{ transformOrigin: "top center" }} style={{ transformOrigin: "top center" }}
initial={ initial={
prefersReducedMotion prefersReducedMotion
@@ -976,7 +1127,10 @@ export function MapWorkbenchPage() {
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden"> <div
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
style={{ bottom: mobileOverlayLayout.dockBottom }}
>
{mobileSheet === null ? ( {mobileSheet === null ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
@@ -988,11 +1142,13 @@ export function MapWorkbenchPage() {
> >
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} /> <AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
</button> </button>
{artifactWorkspace.surfaceMode !== "focus" ? (
<MapToolbar <MapToolbar
items={toolbarItems} items={toolbarItems}
orientation="horizontal" orientation="horizontal"
className="h-11 justify-center" className="h-11 justify-center"
/> />
) : null}
<button <button
type="button" type="button"
aria-label="打开工况任务" aria-label="打开工况任务"
@@ -1008,7 +1164,13 @@ export function MapWorkbenchPage() {
{mobileSheet ? ( {mobileSheet ? (
<MobileWorkbenchSheet <MobileWorkbenchSheet
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"} label={
mobileSheet === "agent"
? "Agent 工作台抽屉"
: mobileSheet === "timeline"
? "运行时间轴抽屉"
: "工况任务抽屉"
}
snap={mobileSheetSnap} snap={mobileSheetSnap}
onSnapChange={setMobileSheetSnap} onSnapChange={setMobileSheetSnap}
onClose={closeMobileSheet} onClose={closeMobileSheet}
@@ -1019,6 +1181,19 @@ export function MapWorkbenchPage() {
presentation="mobile-sheet" presentation="mobile-sheet"
onCollapse={closeMobileSheet} onCollapse={closeMobileSheet}
/> />
) : mobileSheet === "timeline" ? (
<OperationalTimeline
mobile
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={closeMobileSheet}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
) : ( ) : (
<ScheduledConditionFeed <ScheduledConditionFeed
presentation="mobile-sheet" presentation="mobile-sheet"
@@ -1091,3 +1266,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
return "当前分辨率"; return "当前分辨率";
} }
function formatContextClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
+2 -2
View File
@@ -18,7 +18,7 @@ describe("workbench camera padding", () => {
{ {
agentOpen: false, agentOpen: false,
conditionOpen: false, conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 96 } expected: { top: 72, right: 72, bottom: 32, left: 24 }
}, },
{ {
agentOpen: true, agentOpen: true,
@@ -28,7 +28,7 @@ describe("workbench camera padding", () => {
{ {
agentOpen: false, agentOpen: false,
conditionOpen: true, conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 96 } expected: { top: 72, right: 504, bottom: 32, left: 24 }
}, },
{ {
agentOpen: true, agentOpen: true,
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { ScheduledConditionItem } from "../types";
import {
canCenterTimelineBesideMapScale,
clusterOperationalEvents,
createOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventCategory,
OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout
} from "./operational-timeline-model";
import type { OperationalEvent } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = {
id: "work-1",
kind: "work_order",
code: "WO-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整",
summary: "调整阀门",
status: "running",
riskLevel: "attention",
updatedAt: 1,
durationMinutes: 40,
source: "调度方案",
location: "边界阀门 BV-07",
dispatcher: "调度",
assignee: "班组",
priority: "urgent",
replyWindowMinutes: 20,
stages: [],
replyRequirements: []
};
describe("operational timeline model", () => {
it("does not reserve workspace layout space for the floating timeline", () => {
expect(OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight).toBe(0);
});
it("creates linked plan and actual events for active work orders", () => {
const events = createOperationalEvents([workOrder]);
expect(events.map((event) => event.lane)).toEqual(["plan", "actual"]);
expect(events[0].correlationId).toBe(events[1].correlationId);
});
it("filters event lanes from the interactive legend", () => {
const events = createOperationalEvents([workOrder]);
const filtered = filterOperationalEvents(events, {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
actual: false
});
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
});
it("separates attention events from failed exceptions", () => {
const [planEvent] = createOperationalEvents([workOrder]);
const attentionEvent = {
...planEvent,
id: "attention-1",
lane: "exception",
status: "delayed"
} satisfies OperationalEvent;
const exceptionEvent = {
...attentionEvent,
id: "exception-1",
status: "failed"
} satisfies OperationalEvent;
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
attention: false
})).toEqual([exceptionEvent]);
});
it("only promotes abnormal condition runs into exception events", () => {
const normal = {
id: "condition-normal",
kind: "condition",
taskId: "scada-diagnosis",
sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "诊断",
summary: "正常",
status: "completed",
riskLevel: "normal",
updatedAt: 1
} satisfies ScheduledConditionItem;
const abnormal = {
...normal,
id: "condition-error",
status: "error",
riskLevel: "critical"
} satisfies ScheduledConditionItem;
expect(createOperationalEvents([normal])).toHaveLength(0);
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
});
it("aggregates nearby events into ten minute lane buckets", () => {
const events = createOperationalEvents([workOrder]);
const nearbyPlan = {
...events[0],
id: "plan-nearby",
cursorTime: "2026-08-19T10:05:00.000Z"
};
const clusters = clusterOperationalEvents([...events, nearbyPlan], 10);
expect(clusters).toHaveLength(2);
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
});
it("keeps a centered timeline beside scale info only when both side margins are wide enough", () => {
expect(canCenterTimelineBesideMapScale(2560, "expanded", true)).toBe(true);
expect(canCenterTimelineBesideMapScale(2048, "expanded", true)).toBe(false);
expect(canCenterTimelineBesideMapScale(1380, "expanded", false)).toBe(true);
});
it("keeps scale info in the map corner and moves the timeline when horizontal space is tight", () => {
expect(resolveTimelineScaleLayout(2560, "expanded", true)).toEqual({
scaleBottom: 0,
timelineBottom: 24
});
expect(resolveTimelineScaleLayout(2048, "expanded", true)).toEqual({
scaleBottom: 0,
timelineBottom: 56
});
});
});
@@ -0,0 +1,242 @@
import type { FeatureTarget } from "../map/workbench-map-controller";
import type {
ScheduledConditionItem,
ScheduledConditionRecord,
ScheduledWorkOrderItem
} from "../types";
export type OperationalTimelineMode = "compact" | "expanded" | "summary";
export type OperationalEventLane = "plan" | "actual" | "exception";
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
export type OperationalEventImportance = "normal" | "important" | "critical";
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
export type OperationalEvent = {
id: string;
title: string;
description: string;
lane: OperationalEventLane;
status: OperationalEventStatus;
importance: OperationalEventImportance;
plannedTime: string | null;
actualTime: string | null;
cursorTime: string;
correlationId: string | null;
scheduleId: string;
conditionId: string | null;
sourceLabel: string;
mapTargets: FeatureTarget[];
};
export type OperationalEventCluster = {
id: string;
lane: OperationalEventLane;
bucketStart: number;
events: OperationalEvent[];
};
export const OPERATIONAL_TIMELINE_LAYOUT = {
compact: { height: 88, maxWidth: 980 },
expanded: { height: 240, maxWidth: 1180 },
summary: { height: 52, maxWidth: 760 },
mobileSummaryHeight: 48,
mapScaleSafeWidth: 520,
floatingGap: 48,
edgeInset: 24,
scaleCollisionOffset: 56,
overlayReservedHeight: 0
} as const;
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
plan: true,
actual: true,
attention: true,
exception: true
};
export type TimelineScaleLayout = {
scaleBottom: number;
timelineBottom: number;
};
export function canCenterTimelineBesideMapScale(
businessWorkspaceWidth: number,
mode: OperationalTimelineMode,
visible: boolean
) {
if (!visible) return true;
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
return (
businessWorkspaceWidth >=
layout.maxWidth +
2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
);
}
export function resolveTimelineScaleLayout(
businessWorkspaceWidth: number,
mode: OperationalTimelineMode,
visible: boolean
): TimelineScaleLayout {
const scaleClearsCenteredTimeline = canCenterTimelineBesideMapScale(
businessWorkspaceWidth,
mode,
visible
);
return {
scaleBottom: 0,
timelineBottom:
visible && !scaleClearsCenteredTimeline
? OPERATIONAL_TIMELINE_LAYOUT.scaleCollisionOffset
: OPERATIONAL_TIMELINE_LAYOUT.edgeInset
};
}
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items
.flatMap((item) =>
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
)
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
}
export function clusterOperationalEvents(
events: OperationalEvent[],
bucketMinutes = 10
): OperationalEventCluster[] {
const bucketMs = bucketMinutes * 60_000;
const clusters = new Map<string, OperationalEventCluster>();
events.forEach((event) => {
const timestamp = Date.parse(event.cursorTime);
const bucketStart = Math.floor(timestamp / bucketMs) * bucketMs;
const key = `${event.lane}-${bucketStart}`;
const cluster = clusters.get(key) ?? {
id: key,
lane: event.lane,
bucketStart,
events: []
};
cluster.events.push(event);
clusters.set(key, cluster);
});
return [...clusters.values()].sort((left, right) => left.bucketStart - right.bucketStart);
}
export function getOperationalEventSummary(events: OperationalEvent[]) {
return {
planned: events.filter((event) => event.lane === "plan").length,
active: events.filter((event) => event.lane === "actual").length,
attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
critical: events.filter((event) => event.importance === "critical").length
};
}
export function getOperationalEventCategory(
event: OperationalEvent
): OperationalEventCategory {
if (event.lane === "plan" || event.lane === "actual") return event.lane;
return event.status === "failed" ? "exception" : "attention";
}
export function filterOperationalEvents(
events: OperationalEvent[],
visibility: OperationalEventVisibility
) {
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.status !== "warning" && item.status !== "error") {
return [];
}
return [
{
id: `exception-${item.id}`,
title: item.title,
description: item.report?.conclusion ?? item.summary,
lane: "exception",
status: item.status === "error" ? "failed" : "delayed",
importance: item.status === "error" ? "critical" : "important",
plannedTime: item.scheduledAt,
actualTime: item.scheduledAt,
cursorTime: item.scheduledAt,
correlationId: item.sessionId,
scheduleId: item.taskId,
conditionId: item.id,
sourceLabel: "工况诊断",
mapTargets: getConditionMapTargets(item)
}
];
}
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
const planEvent: OperationalEvent = {
id: `plan-${item.id}`,
title: item.title,
description: `${item.location} · ${item.assignee}`,
lane: "plan",
status: "planned",
importance: item.priority === "urgent" ? "important" : "normal",
plannedTime: item.scheduledAt,
actualTime: null,
cursorTime: item.scheduledAt,
correlationId: item.code,
scheduleId: item.id,
conditionId: item.id,
sourceLabel: item.source,
mapTargets: getWorkOrderMapTargets(item)
};
if (item.status === "pending") {
return [planEvent];
}
const start = Date.parse(item.scheduledAt);
const actualTime = new Date(
item.status === "running" ? start + 3 * 60_000 : start + (item.durationMinutes ?? 0) * 60_000
).toISOString();
const actualEvent: OperationalEvent = {
...planEvent,
id: `actual-${item.id}`,
lane: "actual",
status: "executed",
actualTime,
cursorTime: actualTime,
description:
item.status === "running"
? `${item.assignee}正在执行,位置:${item.location}`
: `${item.assignee}已完成现场执行,等待调度复令`
};
return [planEvent, actualEvent];
}
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
if (item.taskId === "scada-diagnosis") {
return [{ sourceId: "scada", featureId: "SCADA-07" }];
}
if (item.taskId === "pump-energy") {
return [{ sourceId: "junctions", featureId: "PUMP-02" }];
}
return [];
}
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
if (item.location.includes("阀")) {
return [{ sourceId: "valves", featureId: extractAssetId(item.location) }];
}
if (item.location.includes("流量计")) {
return [{ sourceId: "scada", featureId: extractAssetId(item.location) }];
}
return [];
}
function extractAssetId(location: string) {
return location.match(/[A-Z]{1,3}-\d{1,3}/)?.[0] ?? location;
}
@@ -0,0 +1,575 @@
import {
CalendarClock,
Check,
Clock3,
OctagonAlert,
PanelBottomClose,
PanelBottomOpen,
RotateCcw,
TriangleAlert
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import {
clusterOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent,
type OperationalEventCategory,
type OperationalEventCluster,
type OperationalEventLane,
type OperationalEventVisibility,
type OperationalTimelineMode
} from "./operational-timeline-model";
type OperationalTimelineProps = {
date: string;
cursorTime: string;
events: OperationalEvent[];
mode: OperationalTimelineMode;
selectedEventId: string | null;
mobile?: boolean;
onModeChange?: (mode: OperationalTimelineMode) => void;
onSelectEvent: (event: OperationalEvent) => void;
onClearSelection: () => void;
onReturnNow: () => void;
};
const LANES: Array<{ id: OperationalEventLane; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "exception", label: "异常" }
];
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "attention", label: "关注" },
{ id: "exception", label: "异常" }
];
export function OperationalTimeline({
date,
cursorTime,
events,
mode,
selectedEventId,
mobile = false,
onModeChange,
onSelectEvent,
onClearSelection,
onReturnNow
}: OperationalTimelineProps) {
const prefersReducedMotion = useReducedMotion();
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
}));
const summary = getOperationalEventSummary(events);
const visibleEvents = filterOperationalEvents(events, eventVisibility);
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
const cursorPosition = getDayPosition(cursorTime);
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
const sizeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };
if (mode === "summary" && !mobile) {
return (
<motion.section
aria-label="运行时间轴摘要"
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
initial={false}
animate={{ height }}
transition={sizeTransition}
>
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
<span className="text-xs font-semibold">{formatDate(date)}</span>
<span className="h-4 w-px bg-slate-300" />
<TimelineSummary summary={summary} />
{selectedEvent ? (
<button
type="button"
onClick={onClearSelection}
className="min-w-0 truncate text-left text-xs text-slate-600 hover:text-blue-700"
>
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : (
<span className="min-w-0 truncate text-xs text-slate-500"></span>
)}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</motion.section>
);
}
return (
<TooltipProvider delayDuration={180}>
<motion.section
aria-label="运行时间轴"
className={cn(
"flex min-h-0 flex-col overflow-hidden text-slate-900",
mobile
? "surface-reading h-full rounded-t-xl border-t"
: "acrylic-panel rounded-2xl border"
)}
initial={false}
animate={{ height }}
transition={sizeTransition}
>
<div
className={cn(
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
mode === "compact" && !mobile ? "h-10" : "h-12"
)}
>
<div className="flex min-w-0 items-center gap-2">
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<div className="flex items-center gap-2">
<p
className={cn(
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
((mode === "compact" && !mobile) || mobile) && "hidden"
)}
>
</p>
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
</div>
</div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
{!mobile ? <TimelineSummary summary={summary} /> : null}
{selectedEvent && !mobile ? (
<button
type="button"
onClick={onClearSelection}
title="清除当前事件选择"
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
>
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : null}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div>
<AnimatePresence initial={false} mode="wait">
{mode === "expanded" || mobile ? (
<motion.div
key="expanded-timeline"
className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
>
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
<span />
{LANES.map((lane) => (
<span key={lane.id} className="flex items-center justify-center font-semibold">
{lane.label}
</span>
))}
</div>
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
<TimeTicks />
{LANES.map((lane) => (
<TimelineLane
key={lane.id}
lane={lane.id}
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
style={{ left: `${cursorPosition}%` }}
>
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
</div>
</div>
</motion.div>
) : (
<motion.div
key="compact-timeline"
className="relative min-h-0 flex-1 px-3"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
>
<CompactTimeTicks />
<div className="absolute bottom-2 left-3 right-3 top-4">
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
{clusters.map((cluster, index) => (
<TimelineMarker
key={cluster.id}
cluster={cluster}
compact
markerIndex={index}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
style={{ left: `${cursorPosition}%` }}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.section>
</TooltipProvider>
);
}
function TimelineLane({
lane,
clusters,
selectedEventId,
onSelectEvent
}: {
lane: OperationalEventLane;
clusters: OperationalEventCluster[];
selectedEventId: string | null;
onSelectEvent: (event: OperationalEvent) => void;
}) {
return (
<div className="relative border-t border-slate-200" data-lane={lane}>
{clusters.map((cluster, index) => (
<TimelineMarker
key={cluster.id}
cluster={cluster}
markerIndex={index}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
</div>
);
}
function TimelineMarker({
cluster,
compact = false,
markerIndex,
selectedEventId,
onSelectEvent
}: {
cluster: OperationalEventCluster;
compact?: boolean;
markerIndex: number;
selectedEventId: string | null;
onSelectEvent: (event: OperationalEvent) => void;
}) {
const event = pickClusterEvent(cluster.events);
const selected = cluster.events.some((item) => item.id === selectedEventId);
const position = getDayPosition(event.cursorTime);
const exception = event.lane === "exception";
const verticalPosition = compact ? 56 : [24, 50, 76][markerIndex % 3];
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
onClick={() => onSelectEvent(event)}
className={cn(
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600",
compact ? "h-2 w-2" : "h-5 w-5",
event.lane === "plan" &&
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
event.lane === "actual" &&
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
exception &&
event.status !== "failed" &&
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
event.status === "failed" &&
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
event.importance === "critical" && !compact && "h-6 w-6",
selected && "ring-2 ring-blue-600 ring-offset-2 ring-offset-white"
)}
style={{ left: `${position}%`, top: `${verticalPosition}%` }}
>
{!compact ? <OperationalEventGlyph event={event} /> : null}
{cluster.events.length > 1 && !compact ? (
<span className="absolute -right-2 -top-2 min-w-3.5 rounded-full bg-white px-1 text-[8px] font-bold text-slate-950">
{cluster.events.length}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={10}
className={cn(
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
)}
>
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
<div className="flex items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
event.lane === "exception" &&
event.status !== "failed" &&
"border-amber-200 bg-amber-50 text-amber-800",
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
)}
>
<OperationalEventGlyph event={event} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"text-[10px] font-bold tracking-[0.08em]",
event.lane === "plan" && "text-blue-700",
event.lane === "actual" && "text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
event.status === "failed" && "text-rose-700"
)}
>
{operationalEventNatureLabel(event)}
</span>
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
{formatClock(event.cursorTime)}
</time>
</div>
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
</div>
</div>
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
{event.title}
</p>
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
{event.description}
</p>
{cluster.events.length > 1 ? (
<p className="mt-2 text-[10px] text-slate-500"> {cluster.events.length} </p>
) : null}
</div>
</TooltipContent>
</Tooltip>
);
}
function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
if (event.status === "failed") {
return <OctagonAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
}
if (event.lane === "exception") {
return <TriangleAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
}
if (event.lane === "actual") {
return <Check size={12} strokeWidth={2.8} aria-hidden="true" />;
}
return <CalendarClock size={11} strokeWidth={2.2} aria-hidden="true" />;
}
function operationalEventNatureLabel(event: OperationalEvent) {
if (event.status === "failed") return "异常";
if (event.lane === "exception") return "关注";
if (event.lane === "actual") return "实际执行";
return "计划任务";
}
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
return (
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
<span>
<strong className="text-blue-700">{summary.planned}</strong>
</span>
<span>
<strong className="text-emerald-700">{summary.active}</strong>
</span>
<span>
<strong className="text-amber-700">{summary.attention}</strong>
</span>
<span>
<strong className="text-rose-700">{summary.exceptions}</strong>
</span>
</div>
);
}
function TimelineActions({
mode,
onModeChange,
onReturnNow
}: {
mode: OperationalTimelineMode;
onModeChange?: (mode: OperationalTimelineMode) => void;
onReturnNow: () => void;
}) {
return (
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<button
type="button"
title="返回现在"
aria-label="返回现在"
onClick={onReturnNow}
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
>
<RotateCcw size={13} aria-hidden="true" />
<span className="hidden sm:inline"></span>
</button>
{onModeChange ? (
<>
<span className="mx-0.5 h-4 w-px bg-slate-300/80" aria-hidden="true" />
<button
type="button"
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
>
{mode === "expanded" ? (
<PanelBottomClose size={14} aria-hidden="true" />
) : (
<PanelBottomOpen size={14} aria-hidden="true" />
)}
<span>{mode === "expanded" ? "收起" : "展开"}</span>
</button>
</>
) : null}
</div>
);
}
function TimelineLegend({
visibility,
onVisibilityChange
}: {
visibility: OperationalEventVisibility;
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
}) {
return (
<div
aria-label="时间轴图例"
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
>
{LEGEND_ITEMS.map((item) => {
const visible = visibility[item.id];
return (
<button
key={item.id}
type="button"
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-pressed={visible}
onClick={() =>
onVisibilityChange({
...visibility,
[item.id]: !visible
})
}
className={cn(
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
visible ? "opacity-100" : "grayscale opacity-25"
)}
>
<LegendSwatch category={item.id} />
</button>
);
})}
</div>
);
}
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
return (
<span
className={cn(
"block h-3.5 w-3.5 shrink-0 border",
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
)}
/>
);
}
function TimeTicks() {
return (
<div className="relative text-[9px] text-slate-600">
{[0, 6, 12, 18, 24].map((hour) => (
<span
key={hour}
className={cn(
"absolute",
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
)}
style={{ left: `${(hour / 24) * 100}%` }}
>
{String(hour).padStart(2, "0")}:00
</span>
))}
</div>
);
}
function CompactTimeTicks() {
return (
<div className="absolute inset-x-3 top-0 text-[8px] text-slate-500">
{[0, 6, 12, 18, 24].map((hour) => (
<span
key={hour}
className={cn(
"absolute",
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
)}
style={{ left: `${(hour / 24) * 100}%` }}
>
{String(hour).padStart(2, "0")}:00
</span>
))}
</div>
);
}
function pickClusterEvent(events: OperationalEvent[]) {
return [...events].sort(
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
)[0];
}
function importanceRank(value: OperationalEvent["importance"]) {
return value === "critical" ? 2 : value === "important" ? 1 : 0;
}
function getDayPosition(value: string) {
const date = new Date(value);
const minutes = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
return Math.min(100, Math.max(0, (minutes / (24 * 60)) * 100));
}
function formatClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function formatDate(value: string) {
const [year, month, day] = value.split("-");
return `${year}.${month}.${day}`;
}
@@ -0,0 +1,272 @@
import type { AnalysisArtifact } from "./workspace-model";
type FixtureFactory = (id: string, generatedAt: string) => AnalysisArtifact;
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
id,
type: "diagnosis",
lifecycle: "reviewed",
revision: 3,
title: "北辰分区压力异常诊断",
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
summary: "北辰低压由上游泵组扰动与局部供水缺口共同触发,7 个测点需要持续观察。",
scope: "北辰供水分区 · 最近 6 小时",
confidence: "92.6%",
generatedAt,
analyticalTimeRange: {
start: new Date(resolveFixtureTimestamp(generatedAt) - 6 * 60 * 60 * 1000).toISOString(),
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
},
preferredView: "map_split",
mapRelation: "required",
blocks: [
{
id: "pressure-metrics",
kind: "metric-grid",
title: "诊断摘要",
span: 12,
metrics: [
{ label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" },
{ label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" },
{ label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" },
{ label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" }
]
},
{
id: "pressure-trend",
kind: "chart",
title: "关键节点压力曲线",
description: "BC-P-014 在 09:40 后持续低于调度下限",
span: 7,
chartType: "line",
categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"],
unit: "MPa",
series: [
{ name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" },
{ name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" },
{ name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" }
]
},
{
id: "pressure-ranking",
kind: "chart",
title: "异常偏差排名",
description: "按相对调度下限的压力偏差排序",
span: 5,
chartType: "bar",
categories: ["P-014", "P-008", "P-026", "P-031", "P-017"],
unit: "%",
series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }]
},
{
id: "pressure-evidence",
kind: "data-table",
title: "证据链",
description: "对诊断结论贡献最高的观测证据",
span: 12,
columns: [
{ key: "time", label: "时间" },
{ key: "source", label: "数据源" },
{ key: "finding", label: "观测" },
{ key: "impact", label: "影响" },
{ key: "confidence", label: "置信度", numeric: true }
],
rows: [
{ time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" },
{ time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" },
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
]
}
],
sources: ["SCADA 压力遥测", "泵站运行记录", "供水分区拓扑"],
limitations: ["BC-P-026 在 09:12 至 09:24 存在数据缺口", "尚未纳入现场阀门开度复核结果"],
actions: [
{ id: "locate", label: "定位异常测点", description: "在地图中定位 7 个异常测点", tone: "primary" },
{ id: "review", label: "标记为已复核", description: "将当前版本进入复核完成状态", tone: "neutral" }
]
});
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
id,
type: "metric_analysis",
lifecycle: "draft",
revision: 2,
title: "供水服务分区平衡分析",
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
summary: "北辰与东丽接近能力上限,津南仍有可调配余量,建议优先验证三条联络线。",
scope: "六个服务分区 · 今日峰值预测",
confidence: "88.4%",
generatedAt,
analyticalTimeRange: {
start: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(0, 0, 0, 0)).toISOString(),
end: new Date(new Date(resolveFixtureTimestamp(generatedAt)).setHours(23, 59, 59, 999)).toISOString()
},
preferredView: "focus",
mapRelation: "none",
blocks: [
{
id: "zone-metrics",
kind: "metric-grid",
title: "分区态势",
span: 12,
metrics: [
{ label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" },
{ label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" },
{ label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" },
{ label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" }
]
},
{
id: "zone-balance",
kind: "chart",
title: "分区供需对比",
description: "北辰与东丽当前需求已接近可用供水能力",
span: 7,
chartType: "bar",
categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"],
unit: "万m³/d",
series: [
{ name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" },
{ name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" }
]
},
{
id: "zone-risk",
kind: "data-table",
title: "调配优先级",
description: "按负荷率、连续供水和通道可用性综合排序",
span: 5,
columns: [
{ key: "zone", label: "分区" },
{ key: "load", label: "负荷率", numeric: true },
{ key: "risk", label: "风险" },
{ key: "action", label: "建议" }
],
rows: [
{ zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" },
{ zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" },
{ zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" },
{ zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" }
]
},
{
id: "zone-note",
kind: "narrative",
title: "Agent 结论",
span: 12,
paragraphs: [
"优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。",
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
]
}
],
sources: ["分区日供水量", "短期需求预测", "连通能力台账"],
limitations: ["需求预测未包含临时大型活动增量", "联络线能力采用最近一次校核值"],
actions: [
{ id: "compare", label: "生成方案对比", description: "基于当前结论创建调配模拟", tone: "primary" },
{ id: "export", label: "导出摘要", description: "导出当前分析摘要", tone: "neutral" }
]
});
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
id,
type: "simulation",
lifecycle: "published",
revision: 5,
title: "低压事件调度处置流程",
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
summary: "建议方案预计在 20 分钟内恢复目标压力,执行前仍有 3 项权限与现场状态待确认。",
scope: "北辰低压事件 · 30 分钟模拟",
confidence: "90.8%",
generatedAt,
analyticalTimeRange: {
start: new Date(resolveFixtureTimestamp(generatedAt) - 30 * 60 * 1000).toISOString(),
end: new Date(resolveFixtureTimestamp(generatedAt)).toISOString()
},
preferredView: "map_split",
mapRelation: "required",
blocks: [
{
id: "flow-metrics",
kind: "metric-grid",
title: "执行状态",
span: 12,
metrics: [
{ label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" },
{ label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" },
{ label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" },
{ label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" }
]
},
{
id: "dispatch-process",
kind: "process-flow",
title: "处置路径",
description: "所有调度动作均需在执行前完成权限和现场状态确认",
span: 7,
nodes: [
{ id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" },
{ id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" },
{ id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" },
{ id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" },
{ id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" }
]
},
{
id: "dispatch-checklist",
kind: "data-table",
title: "执行清单",
description: "当前阶段需要完成的人员与系统确认",
span: 5,
columns: [
{ key: "owner", label: "责任方" },
{ key: "item", label: "确认项" },
{ key: "state", label: "状态" }
],
rows: [
{ owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" },
{ owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" },
{ owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" },
{ owner: "系统管理员", item: "确认远控操作权限", state: "待处理" }
]
},
{
id: "dispatch-progress",
kind: "chart",
title: "处置前后压力预测",
description: "模拟结果显示执行后 20 分钟内压力回到调度区间",
span: 12,
chartType: "line",
categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"],
unit: "MPa",
series: [
{ name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" },
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
]
}
],
sources: ["低压事件诊断 R3", "调度规则库", "水力模拟结果"],
limitations: ["模拟未计入突发大用户需求变化", "远控动作必须由值班调度二次确认"],
actions: [
{ id: "prepare", label: "准备受控执行", description: "生成执行前确认清单", tone: "primary" },
{ id: "archive", label: "归档方案", description: "保留当前模拟版本", tone: "neutral" }
]
});
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
export function createAnalysisArtifactFixture(index: number, instance: number): AnalysisArtifact {
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
const now = new Date();
return factory(
`analysis-${now.getTime().toString(36)}-${instance}`,
now.toLocaleTimeString("zh-CN", { hour12: false })
);
}
function resolveFixtureTimestamp(clock: string) {
const [hours = 0, minutes = 0, seconds = 0] = clock.split(":").map(Number);
const value = new Date();
value.setHours(hours, minutes, seconds, 0);
return value.getTime();
}
@@ -0,0 +1,517 @@
import ReactECharts from "echarts-for-react";
import {
Activity,
AlertTriangle,
Check,
ChevronRight,
Clock3,
Database,
FileSearch,
Minimize2,
ShieldCheck,
Sparkles,
Trash2,
X
} from "lucide-react";
import { useEffect, useState } from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock,
AnalysisMetric
} from "./workspace-model";
type AnalysisArtifactWorkspaceProps = {
artifact: AnalysisArtifact;
onCollapse: () => void;
onDestroy: () => void;
};
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
export function AnalysisArtifactWorkspace({
artifact,
onCollapse,
onDestroy
}: AnalysisArtifactWorkspaceProps) {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
const evidenceBlocks = artifact.blocks.filter(
(block): block is EvidenceBlock => block.kind !== "metric-grid"
);
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
useEffect(() => {
setSelectedBlockId(null);
}, [artifact.id]);
useEffect(() => {
if (!selectedBlock) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSelectedBlockId(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedBlock]);
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"
lang="zh-CN"
>
<ArtifactHeader artifact={artifact} />
<div className="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>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Summary</p>
<h3 id="artifact-summary-heading" className="mt-1 text-sm font-semibold text-slate-950"></h3>
</div>
<p className="max-w-[34rem] text-right text-xs leading-5 text-slate-500">{artifact.summary}</p>
</div>
{metricBlock?.kind === "metric-grid" ? (
<div className="grid grid-cols-2 gap-2.5 2xl:grid-cols-4">
{metricBlock.metrics.map((metric) => <MetricTile key={metric.label} metric={metric} />)}
</div>
) : null}
</section>
<section className="mt-5" aria-labelledby="artifact-evidence-heading">
<div className="mb-3 flex items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Evidence canvas</p>
<h3 id="artifact-evidence-heading" className="mt-1 text-sm font-semibold text-slate-950"></h3>
</div>
<span className="text-xs tabular-nums text-slate-500">{evidenceBlocks.length} </span>
</div>
<div className="grid grid-cols-12 gap-3.5">
{evidenceBlocks.map((block) => (
<AnalysisBlockView
key={block.id}
block={block}
selected={block.id === selectedBlockId}
onSelect={() => setSelectedBlockId(block.id)}
/>
))}
</div>
</section>
<ArtifactProvenance artifact={artifact} />
</div>
<ArtifactActionBar
artifact={artifact}
onCollapse={onCollapse}
onDestroy={onDestroy}
/>
{selectedBlock ? (
<ArtifactDetailDrawer
artifact={artifact}
block={selectedBlock}
onClose={() => setSelectedBlockId(null)}
/>
) : null}
</article>
);
}
function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
return (
<header className="surface-control 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">
<span className="inline-flex items-center gap-1.5 font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
Agent Artifact
</span>
<span aria-hidden="true">·</span>
<span>{artifactTypeLabel(artifact.type)}</span>
<span aria-hidden="true">·</span>
<span>R{artifact.revision}</span>
</div>
<h2 className="mt-1.5 text-balance text-xl font-semibold leading-7 text-slate-950">{artifact.title}</h2>
<p className="mt-1 max-w-[68ch] text-sm leading-6 text-slate-600">{artifact.subtitle}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<LifecycleBadge lifecycle={artifact.lifecycle} />
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
<Clock3 size={13} aria-hidden="true" />
{artifact.generatedAt}
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
<span><strong className="font-medium text-slate-700"></strong> {artifact.scope}</span>
<span><strong className="font-medium text-slate-700"></strong> {artifact.confidence}</span>
<span><strong className="font-medium text-slate-700"></strong> {mapRelationLabel(artifact.mapRelation)}</span>
</div>
</header>
);
}
function AnalysisBlockView({
block,
selected,
onSelect
}: {
block: EvidenceBlock;
selected: boolean;
onSelect: () => void;
}) {
const spanClass = block.span === 5
? "col-span-12 2xl:col-span-5"
: block.span === 7
? "col-span-12 2xl:col-span-7"
: "col-span-12";
return (
<section
aria-labelledby={`${block.id}-title`}
className={cn(
"surface-reading group min-w-0 overflow-hidden rounded-xl border shadow-[0_1px_4px_rgba(15,23,42,0.08)]",
"focus-within:shadow-[0_0_0_2px_rgba(37,99,235,0.35),0_8px_24px_rgba(15,23,42,0.12)]",
spanClass,
selected && "shadow-[0_0_0_2px_rgba(37,99,235,0.28),0_8px_24px_rgba(15,23,42,0.12)]"
)}
>
<button
type="button"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60"
aria-label={`查看${block.title}详情`}
onClick={onSelect}
>
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<FileSearch size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span id={`${block.id}-title`} className="block text-sm font-semibold text-slate-950">{block.title}</span>
{"description" in block ? <span className="mt-1 block text-xs leading-5 text-slate-500">{block.description}</span> : null}
</span>
<ChevronRight size={16} className="mt-2 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
</button>
<div className="border-t border-slate-100 p-4">
<AnalysisBlockBody block={block} />
</div>
</section>
);
}
function AnalysisBlockBody({ block }: { block: EvidenceBlock }) {
if (block.kind === "chart") {
return (
<div className="h-[258px] min-h-0">
<ReactECharts notMerge lazyUpdate style={{ width: "100%", height: "100%" }} option={createChartOption(block)} />
</div>
);
}
if (block.kind === "process-flow") {
return (
<ol className="space-y-0" aria-label={block.title}>
{block.nodes.map((node, index) => (
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
{index < block.nodes.length - 1 ? <span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" /> : null}
<span className={cn(
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
node.status === "complete" && "bg-emerald-100 text-emerald-700",
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
node.status === "pending" && "bg-slate-100 text-slate-500"
)}>
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
</span>
<div className="min-w-0 pt-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700"></span> : null}
</div>
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
</div>
</li>
))}
</ol>
);
}
if (block.kind === "data-table") {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] border-collapse text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
{block.columns.map((column) => <th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>)}
</tr>
</thead>
<tbody>
{block.rows.map((row, index) => (
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
{block.columns.map((column) => <td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}
return <div className="space-y-3 text-sm leading-7 text-slate-700">{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}</div>;
}
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
return (
<section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
<div className="flex min-w-0 items-start gap-3 px-4 py-3">
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
<Database size={14} aria-hidden="true" />
</h3>
<ul className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 pt-1 text-xs text-slate-600">
{artifact.sources.map((source) => (
<li key={source} className="after:ml-2 after:text-slate-300 after:content-['/'] last:after:hidden">{source}</li>
))}
</ul>
</div>
<div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
<h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
<AlertTriangle size={14} aria-hidden="true" />
</h3>
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
{artifact.limitations.map((item) => (
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
))}
</ul>
</div>
</section>
);
}
function ArtifactDetailDrawer({
artifact,
block,
onClose
}: {
artifact: AnalysisArtifact;
block: EvidenceBlock;
onClose: () => void;
}) {
const blockFacts = getEvidenceBlockFacts(block);
return (
<aside
aria-label="证据详情"
className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
>
<div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<FileSearch size={17} className="mt-0.5 shrink-0 text-blue-700" aria-hidden="true" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
<span className="uppercase tracking-[0.12em]"></span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
</div>
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
</div>
</div>
<button
type="button"
aria-label="关闭证据详情"
onClick={onClose}
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
>
<X size={15} aria-hidden="true" />
</button>
</div>
<div className="space-y-5 px-5 py-5">
<section aria-labelledby="evidence-purpose-heading">
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-6 text-slate-700">
{evidenceBlockDescription(block)}
</p>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-data-heading">
<div className="flex items-center justify-between gap-3">
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500"></h4>
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
</div>
<dl className="mt-2 divide-y divide-slate-200">
{blockFacts.map((fact) => (
<div key={fact.label} className="flex items-center justify-between gap-4 py-2 text-xs">
<dt className="text-slate-500">{fact.label}</dt>
<dd className="font-medium text-slate-800 tabular-nums">{fact.value}</dd>
</div>
))}
</dl>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500"></h4>
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
</dl>
</section>
</div>
</aside>
);
}
function evidenceBlockDescription(block: EvidenceBlock) {
return "description" in block
? block.description
: block.paragraphs[0] ?? "该模块汇总了当前成果中的关键判断依据。";
}
function evidenceBlockKindLabel(block: EvidenceBlock) {
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
if (block.kind === "process-flow") return "流程证据";
if (block.kind === "data-table") return "明细数据";
return "分析说明";
}
function getEvidenceBlockFacts(block: EvidenceBlock): Array<{ label: string; value: string }> {
if (block.kind === "chart") {
return [
{ label: "数据序列", value: `${block.series.length}` },
{ label: "时间 / 类别点", value: `${block.categories.length}` },
{ label: "计量单位", value: block.unit }
];
}
if (block.kind === "data-table") {
return [
{ label: "证据记录", value: `${block.rows.length}` },
{ label: "数据字段", value: `${block.columns.length}` }
];
}
if (block.kind === "process-flow") {
return [
{ label: "流程步骤", value: `${block.nodes.length}` },
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length}` },
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length}` }
];
}
return [{ label: "分析段落", value: `${block.paragraphs.length}` }];
}
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
const formatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
return `${formatter.format(new Date(range.start))}${formatter.format(new Date(range.end))}`;
}
function ArtifactActionBar({
artifact,
onCollapse,
onDestroy
}: {
artifact: AnalysisArtifact;
onCollapse: () => void;
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">
<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} />)}
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className={ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME}><Trash2 size={15} aria-hidden="true" /></button>
</div>
</footer>
);
}
function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactAction; artifact: AnalysisArtifact }) {
return (
<button
type="button"
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}${action.description}` })}
className={cn("hidden sm:inline-flex", ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME)}
>
<ShieldCheck size={14} aria-hidden="true" />
{action.label}
</button>
);
}
function MetricTile({ metric }: { metric: AnalysisMetric }) {
return (
<div className="surface-reading min-w-0 rounded-xl border px-3.5 py-3 shadow-[0_1px_4px_rgba(15,23,42,0.08)]">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
</div>
<p className="mt-2 text-lg font-semibold text-slate-950 tabular-nums">{metric.value}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{metric.detail}</p>
</div>
);
}
function LifecycleBadge({ lifecycle }: { lifecycle: AnalysisArtifact["lifecycle"] }) {
const labels = { draft: "草稿", reviewed: "已复核", published: "已发布", archived: "已归档" } as const;
return <span className={cn("inline-flex h-7 items-center rounded-full px-2.5 text-xs font-semibold", lifecycle === "published" ? "bg-emerald-100 text-emerald-800" : lifecycle === "reviewed" ? "bg-blue-100 text-blue-800" : lifecycle === "archived" ? "bg-slate-200 text-slate-700" : "bg-amber-100 text-amber-800")}>{labels[lifecycle]}</span>;
}
function artifactTypeLabel(type: AnalysisArtifact["type"]) {
if (type === "diagnosis") return "异常诊断";
if (type === "simulation") return "方案模拟";
return "指标分析";
}
function mapRelationLabel(relation: AnalysisArtifact["mapRelation"]) {
if (relation === "required") return "强关联";
if (relation === "optional") return "可选";
return "无空间依赖";
}
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
return {
animationDuration: 260,
animationEasing: "cubicOut",
color: block.series.map((series) => series.color),
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
series: block.series.map((series) => ({
name: series.name,
type: series.type ?? block.chartType,
data: series.values,
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
symbol: "circle",
symbolSize: 6,
barMaxWidth: 24,
lineStyle: { width: 2.5 },
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
}))
};
}
function metricToneClass(tone: AnalysisMetric["tone"]) {
if (tone === "danger") return "bg-rose-500";
if (tone === "warning") return "bg-amber-500";
if (tone === "success") return "bg-emerald-500";
if (tone === "info") return "bg-blue-600";
return "bg-slate-400";
}
@@ -0,0 +1,59 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
export function ArtifactPeekBar({
artifact,
surfaceMode,
onOpen,
onDestroy
}: {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
onOpen: () => void;
onDestroy: () => void;
}) {
return (
<aside
aria-label="Agent 成果预览"
className={cn(
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2",
surfaceMode === "map_split" && "2xl:left-[25%]"
)}
>
<span className="material-tone-agent hidden h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700 sm:grid">
<FileChartColumnIncreasing size={21} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] font-semibold text-violet-700">
<Sparkles size={12} aria-hidden="true" />
· R{artifact.revision}
</div>
<p className="mt-1 truncate text-sm font-semibold text-slate-950">{artifact.title}</p>
<p className="mt-0.5 line-clamp-1 text-xs text-slate-600">{artifact.summary}</p>
</div>
<button
type="button"
onClick={onOpen}
className={ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME}
>
<ChevronUp size={14} aria-hidden="true" />
</button>
<button
type="button"
aria-label="销毁预览成果"
title="销毁预览成果"
onClick={onDestroy}
className={cn("surface-control border", ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME)}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</aside>
);
}
@@ -0,0 +1,14 @@
export const ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME =
"surface-control inline-flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]";
export const ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME =
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME =
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 text-[11px] font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.2)] transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2";
export const ARTIFACT_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 active:bg-slate-200/70 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 active:bg-rose-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-rose-500/60";
@@ -0,0 +1,67 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import {
collapseActiveArtifact,
createInitialArtifactWorkspaceState,
destroyActiveArtifact,
destroyPendingArtifact,
openPendingArtifact,
previewAnalysisArtifact,
requestArtifactView,
resolveWorkbenchSurfaceMode,
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
type ArtifactRequestedView
} from "./workspace-model";
export function useArtifactWorkspace(businessWorkspaceWidth: number) {
const [state, setState] = useState(createInitialArtifactWorkspaceState);
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
const createTestArtifactPreview = useCallback(() => {
const artifact = createAnalysisArtifactFixture(
fixtureIndexRef.current,
fixtureInstanceRef.current
);
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
fixtureInstanceRef.current += 1;
setState((current) => previewAnalysisArtifact(current, artifact));
}, []);
const openPreview = useCallback(() => {
setState(openPendingArtifact);
}, []);
const collapseArtifact = useCallback(() => {
setState(collapseActiveArtifact);
}, []);
const destroyPreview = useCallback(() => {
setState(destroyPendingArtifact);
}, []);
const destroyArtifact = useCallback(() => {
setState(destroyActiveArtifact);
}, []);
const setView = useCallback((view: ArtifactRequestedView) => {
setState((current) => requestArtifactView(current, view));
}, []);
const surfaceMode = useMemo(
() => resolveWorkbenchSurfaceMode(state, businessWorkspaceWidth),
[businessWorkspaceWidth, state]
);
return {
...state,
surfaceMode,
mapSplitAvailable: businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH,
createTestArtifactPreview,
openPreview,
collapseArtifact,
destroyPreview,
destroyArtifact,
setView
};
}
@@ -0,0 +1,222 @@
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar";
import {
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
ArtifactRequestedView,
WorkbenchSurfaceMode
} from "./workspace-model";
type WorkbenchMainFrameProps = {
mapContent: ReactNode;
mapOverlay?: ReactNode;
timelineContent: ReactNode;
timelineMode: "compact" | "expanded" | "summary";
timelineBottom: number;
activeArtifact: AnalysisArtifact | null;
pendingArtifact: AnalysisArtifact | null;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
onOpenPendingArtifact: () => void;
onDestroyPendingArtifact: () => void;
onSetArtifactView: (view: ArtifactRequestedView) => void;
onCollapseArtifact: () => void;
onDestroyArtifact: () => void;
};
export function WorkbenchMainFrame({
mapContent,
mapOverlay,
timelineContent,
timelineMode,
timelineBottom,
activeArtifact,
pendingArtifact,
surfaceMode,
mapSplitAvailable,
onOpenPendingArtifact,
onDestroyPendingArtifact,
onSetArtifactView,
onCollapseArtifact,
onDestroyArtifact
}: WorkbenchMainFrameProps) {
const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus";
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(
activeArtifact &&
activeArtifact.mapRelation !== "none"
);
const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split";
const viewControlLabel = mapOnly
? "返回分析成果"
: mapSplit
? "专注分析"
: "显示地图";
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
const timelineTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
const viewControlTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
const artifactTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 min-h-0 lg:left-[var(--workbench-agent-current-width)]"
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"
)}
>
<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}
className={cn(
"z-30 min-h-0 min-w-0 overflow-hidden",
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
)}
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 ? (
<ArtifactPeekBar
artifact={pendingArtifact}
surfaceMode={surfaceMode}
onOpen={onOpenPendingArtifact}
onDestroy={onDestroyPendingArtifact}
/>
) : null}
<AnimatePresence initial={false}>
{showArtifactViewControl ? (
<motion.div
className={cn(
"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 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition}
>
<button
type="button"
aria-label={viewControlLabel}
onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={surfaceMode}
className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
transition={viewControlTransition}
>
{mapOnly ? (
<ArrowLeft size={13} aria-hidden="true" />
) : mapSplit ? (
<Focus size={13} aria-hidden="true" />
) : (
<MapIcon size={13} aria-hidden="true" />
)}
{viewControlLabel}
</motion.span>
</AnimatePresence>
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
<motion.div
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0"
initial={false}
animate={{
bottom: timelineBottom,
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
right: 12
}}
transition={timelineTransition}
>
<AnimatePresence initial={false}>
{timelineContent ? (
<motion.div
key="operational-timeline"
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
transition={timelineTransition}
>
{timelineContent}
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
</div>
);
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import { createInitialWorkspaceContext, reduceWorkspaceContext } from "./workspace-context";
describe("workspace context", () => {
it("keeps operational time independent from artifact analytical time", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const withArtifact = reduceWorkspaceContext(createInitialWorkspaceContext(), {
type: "sync-artifact",
artifact
});
const selectedEvent = reduceWorkspaceContext(withArtifact, {
type: "select-operational-event",
eventId: "event-1",
cursorTime: "2026-08-19T10:30:00.000Z"
});
expect(selectedEvent.analytical.timeRange).toEqual(artifact.analyticalTimeRange);
expect(selectedEvent.operational.cursorTime).toBe("2026-08-19T10:30:00.000Z");
});
it("updates analytical context without moving the operational cursor", () => {
const initial = createInitialWorkspaceContext(new Date("2026-08-19T08:00:00.000Z"));
const cursorTime = initial.operational.cursorTime;
const withArtifact = reduceWorkspaceContext(initial, {
type: "sync-artifact",
artifact: createAnalysisArtifactFixture(1, 1)
});
expect(withArtifact.operational.cursorTime).toBe(cursorTime);
expect(withArtifact.analytical.artifactId).toBeTruthy();
});
it("clears selection while retaining cursor until returning to now", () => {
const initial = createInitialWorkspaceContext();
const selected = reduceWorkspaceContext(initial, {
type: "select-operational-event",
eventId: "event-2",
cursorTime: "2026-08-19T13:10:00.000Z"
});
const cleared = reduceWorkspaceContext(selected, { type: "clear-operational-event" });
expect(cleared.operational.selectedEventId).toBeNull();
expect(cleared.operational.cursorTime).toBe("2026-08-19T13:10:00.000Z");
});
});
@@ -0,0 +1,102 @@
import type { AnalysisArtifact } from "./workspace-model";
export type WorkspaceTimeRange = { start: string; end: string };
export type WorkspaceContext = {
spatial: {
featureId: string | null;
sourceId: string | null;
};
operational: {
date: string;
cursorTime: string;
selectedEventId: string | null;
};
analytical: {
artifactId: string | null;
timeRange: WorkspaceTimeRange | null;
evidenceId: string | null;
};
};
export type WorkspaceContextAction =
| { type: "select-operational-event"; eventId: string; cursorTime: string }
| { type: "clear-operational-event" }
| { type: "return-operational-now"; now: string }
| { type: "select-spatial-feature"; featureId: string; sourceId: string }
| { type: "clear-spatial-feature" }
| { type: "sync-artifact"; artifact: AnalysisArtifact | null }
| { type: "select-evidence"; evidenceId: string | null };
export function createInitialWorkspaceContext(now = new Date()): WorkspaceContext {
return {
spatial: { featureId: null, sourceId: null },
operational: {
date: toLocalDateKey(now),
cursorTime: now.toISOString(),
selectedEventId: null
},
analytical: { artifactId: null, timeRange: null, evidenceId: null }
};
}
export function reduceWorkspaceContext(
state: WorkspaceContext,
action: WorkspaceContextAction
): WorkspaceContext {
switch (action.type) {
case "select-operational-event":
return {
...state,
operational: {
...state.operational,
cursorTime: action.cursorTime,
selectedEventId: action.eventId
}
};
case "clear-operational-event":
return {
...state,
operational: { ...state.operational, selectedEventId: null }
};
case "return-operational-now":
return {
...state,
operational: {
date: toLocalDateKey(new Date(action.now)),
cursorTime: action.now,
selectedEventId: null
}
};
case "select-spatial-feature":
return {
...state,
spatial: { featureId: action.featureId, sourceId: action.sourceId }
};
case "clear-spatial-feature":
return { ...state, spatial: { featureId: null, sourceId: null } };
case "sync-artifact":
return {
...state,
analytical: action.artifact
? {
artifactId: action.artifact.id,
timeRange: action.artifact.analyticalTimeRange,
evidenceId: null
}
: { artifactId: null, timeRange: null, evidenceId: null }
};
case "select-evidence":
return {
...state,
analytical: { ...state.analytical, evidenceId: action.evidenceId }
};
}
}
function toLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { createAnalysisArtifactFixture } from "./analysis-document-fixtures";
import {
ARTIFACT_MAP_SPLIT_MIN_WIDTH,
collapseActiveArtifact,
createInitialArtifactWorkspaceState,
destroyActiveArtifact,
destroyPendingArtifact,
openPendingArtifact,
previewAnalysisArtifact,
requestArtifactView,
resolveWorkbenchSurfaceMode
} from "./workspace-model";
describe("artifact workspace state", () => {
it("previews generated artifacts without taking over the workspace", () => {
const initial = createInitialArtifactWorkspaceState();
const artifact = createAnalysisArtifactFixture(0, 0);
const previewed = previewAnalysisArtifact(initial, artifact);
expect(previewed.pendingArtifact).toBe(artifact);
expect(previewed.activeArtifact).toBeNull();
expect(resolveWorkbenchSurfaceMode(previewed, 1920)).toBe("default");
});
it("opens a map-related artifact in split mode only when enough width exists", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
expect(opened.activeArtifact).toBe(artifact);
expect(opened.pendingArtifact).toBeNull();
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH)).toBe("map_split");
expect(resolveWorkbenchSurfaceMode(opened, ARTIFACT_MAP_SPLIT_MIN_WIDTH - 1)).toBe("focus");
});
it("keeps non-spatial artifacts focused at every desktop width", () => {
const artifact = createAnalysisArtifactFixture(1, 0);
const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
expect(artifact.mapRelation).toBe("none");
expect(resolveWorkbenchSurfaceMode(opened, 1920)).toBe("focus");
expect(resolveWorkbenchSurfaceMode(requestArtifactView(opened, "map_split"), 1920)).toBe("focus");
});
it("supports explicit focus and map restoration for spatial artifacts", () => {
const artifact = createAnalysisArtifactFixture(2, 0);
const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
const focused = requestArtifactView(opened, "focus");
const restored = requestArtifactView(focused, "map_split");
expect(resolveWorkbenchSurfaceMode(focused, 1920)).toBe("focus");
expect(resolveWorkbenchSurfaceMode(restored, 1920)).toBe("map_split");
});
it("supports a map-only page while a narrow Artifact remains active", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
const mapOnly = requestArtifactView(opened, "map_only");
expect(resolveWorkbenchSurfaceMode(mapOnly, 390)).toBe("map_only");
expect(mapOnly.activeArtifact).toBe(artifact);
});
it("collapses the active artifact back into a peek", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact)
);
const collapsed = collapseActiveArtifact(opened);
expect(collapsed.activeArtifact).toBeNull();
expect(collapsed.pendingArtifact).toBe(artifact);
expect(resolveWorkbenchSurfaceMode(collapsed, 1920)).toBe("default");
});
it("replaces the active artifact only after the new preview is opened", () => {
const first = createAnalysisArtifactFixture(0, 0);
const second = createAnalysisArtifactFixture(2, 1);
const opened = openPendingArtifact(
previewAnalysisArtifact(createInitialArtifactWorkspaceState(), first)
);
const withNewPreview = previewAnalysisArtifact(opened, second);
expect(withNewPreview.activeArtifact).toBe(first);
expect(withNewPreview.pendingArtifact).toBe(second);
expect(openPendingArtifact(withNewPreview).activeArtifact).toBe(second);
});
it("destroys pending and active artifacts independently", () => {
const artifact = createAnalysisArtifactFixture(0, 0);
const previewed = previewAnalysisArtifact(createInitialArtifactWorkspaceState(), artifact);
const opened = openPendingArtifact(previewed);
expect(destroyPendingArtifact(previewed).pendingArtifact).toBeNull();
expect(destroyActiveArtifact(opened).activeArtifact).toBeNull();
});
});
@@ -0,0 +1,171 @@
export const ARTIFACT_PANE_MIN_WIDTH = 640;
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1;
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
export type ArtifactViewMode = "map_split" | "focus";
export type ArtifactRequestedView = ArtifactViewMode | "map_only";
export type ArtifactMapRelation = "required" | "optional" | "none";
export type WorkbenchSurfaceMode = "default" | ArtifactRequestedView;
export type AnalysisMetric = {
label: string;
value: string;
detail: string;
tone: "neutral" | "info" | "success" | "warning" | "danger";
};
export type AnalysisChartSeries = {
name: string;
values: number[];
color: string;
type?: "line" | "bar";
};
export type AnalysisBlock =
| {
id: string;
kind: "metric-grid";
title: string;
span: 12;
metrics: AnalysisMetric[];
}
| {
id: string;
kind: "chart";
title: string;
description: string;
span: 5 | 7 | 12;
chartType: "line" | "bar";
categories: string[];
unit: string;
series: AnalysisChartSeries[];
}
| {
id: string;
kind: "process-flow";
title: string;
description: string;
span: 5 | 7 | 12;
nodes: Array<{
id: string;
label: string;
detail: string;
status: "complete" | "active" | "pending";
}>;
}
| {
id: string;
kind: "data-table";
title: string;
description: string;
span: 5 | 7 | 12;
columns: Array<{ key: string; label: string; numeric?: boolean }>;
rows: Array<Record<string, string | number>>;
}
| {
id: string;
kind: "narrative";
title: string;
span: 5 | 7 | 12;
paragraphs: string[];
};
export type AnalysisArtifactAction = {
id: string;
label: string;
description: string;
tone: "primary" | "neutral" | "warning";
};
export type AnalysisArtifact = {
id: string;
type: ArtifactType;
lifecycle: ArtifactLifecycle;
revision: number;
title: string;
subtitle: string;
summary: string;
scope: string;
confidence: string;
generatedAt: string;
analyticalTimeRange: { start: string; end: string };
preferredView: ArtifactViewMode;
mapRelation: ArtifactMapRelation;
blocks: AnalysisBlock[];
sources: string[];
limitations: string[];
actions: AnalysisArtifactAction[];
};
export type ArtifactWorkspaceState = {
pendingArtifact: AnalysisArtifact | null;
activeArtifact: AnalysisArtifact | null;
requestedView: ArtifactRequestedView;
};
export function createInitialArtifactWorkspaceState(): ArtifactWorkspaceState {
return {
pendingArtifact: null,
activeArtifact: null,
requestedView: "map_split"
};
}
export function previewAnalysisArtifact(
state: ArtifactWorkspaceState,
artifact: AnalysisArtifact
): ArtifactWorkspaceState {
return { ...state, pendingArtifact: artifact };
}
export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
if (!state.pendingArtifact) return state;
return {
pendingArtifact: null,
activeArtifact: state.pendingArtifact,
requestedView: getPreferredArtifactView(state.pendingArtifact)
};
}
export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
if (!state.activeArtifact) return state;
return {
...state,
pendingArtifact: state.activeArtifact,
activeArtifact: null
};
}
export function destroyPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
return state.pendingArtifact ? { ...state, pendingArtifact: null } : state;
}
export function destroyActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
return state.activeArtifact
? { ...state, activeArtifact: null, requestedView: "map_split" }
: state;
}
export function requestArtifactView(
state: ArtifactWorkspaceState,
view: ArtifactRequestedView
): ArtifactWorkspaceState {
if (!state.activeArtifact) return state;
return { ...state, requestedView: view };
}
export function resolveWorkbenchSurfaceMode(
state: ArtifactWorkspaceState,
businessWorkspaceWidth: number
): WorkbenchSurfaceMode {
const artifact = state.activeArtifact;
if (!artifact) return "default";
if (state.requestedView === "map_only") return "map_only";
if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
return businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
}
function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {
return artifact.mapRelation === "none" ? "focus" : artifact.preferredView;
}
+19 -9
View File
@@ -1,23 +1,33 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "@/app/app"; import { AuthStartupScreen } from "@/app/auth-startup-screen";
import { env } from "@/shared/config/env"; import { initializeAuthentication } from "@/shared/auth/keycloak-auth";
import "@/styles.css"; import "@/styles.css";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "streamdown/styles.css"; import "streamdown/styles.css";
async function prepareMocks() { const root = ReactDOM.createRoot(document.getElementById("root")!);
if (!env.TJWATER_ENABLE_MSW) return;
root.render(<AuthStartupScreen />);
try {
const [{ App }, { env }] = await Promise.all([
import("@/app/app"),
import("@/shared/config/env")
]);
if (env.TJWATER_ENABLE_MSW) {
const { worker } = await import("@/mocks/browser"); const { worker } = await import("@/mocks/browser");
await worker.start({ onUnhandledRequest: "bypass" }); await worker.start({ onUnhandledRequest: "bypass" });
} }
const authentication = await initializeAuthentication(env);
await prepareMocks(); root.render(
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App authentication={authentication} />
</React.StrictMode> </React.StrictMode>
); );
} catch (error) {
console.error("Failed to initialize authentication", error);
root.render(<AuthStartupScreen error />);
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import { parseRuntimeConfig } from "@/shared/config/env";
import {
initializeAuthentication,
toAuthenticatedUser,
toKeycloakConfig
} from "./keycloak-auth";
const requiredConfig = parseRuntimeConfig({
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/auth/realms/tjwater",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
});
describe("Keycloak authentication", () => {
it("derives the Keycloak server and Realm from the issuer", () => {
expect(toKeycloakConfig(requiredConfig.TJWATER_KEYCLOAK_ISSUER, "next-tjwater")).toEqual({
url: "https://auth.example.test/auth",
realm: "tjwater",
clientId: "next-tjwater"
});
});
it("initializes the public SPA flow and exposes the refreshed token", async () => {
const client = {
token: "access-token",
idTokenParsed: {
sub: "user-1",
name: "张调度",
email: "operator@example.test"
},
init: vi.fn().mockResolvedValue(true),
login: vi.fn().mockResolvedValue(undefined),
logout: vi.fn().mockResolvedValue(undefined),
updateToken: vi.fn().mockResolvedValue(false)
};
const authentication = await initializeAuthentication(requiredConfig, () => client);
expect(client.init).toHaveBeenCalledWith({
onLoad: "login-required",
flow: "standard",
pkceMethod: "S256",
checkLoginIframe: false
});
expect(authentication.user).toEqual({
name: "张调度",
role: "operator@example.test"
});
await expect(authentication.getAccessToken()).resolves.toBe("access-token");
expect(client.updateToken).toHaveBeenCalledWith(30);
await authentication.logout?.();
expect(client.logout).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
});
it("uses stable user claim fallbacks", () => {
expect(toAuthenticatedUser({ sub: "user-2", preferred_username: "dispatcher" })).toEqual({
name: "dispatcher",
role: "统一认证用户"
});
});
it("returns to Keycloak instead of using a token after refresh failure", async () => {
const refreshError = new Error("refresh failed");
const client = {
token: "expired-token",
tokenParsed: { sub: "user-3" },
init: vi.fn().mockResolvedValue(true),
login: vi.fn().mockResolvedValue(undefined),
logout: vi.fn().mockResolvedValue(undefined),
updateToken: vi.fn().mockRejectedValue(refreshError)
};
const authentication = await initializeAuthentication(requiredConfig, () => client);
await expect(authentication.getAccessToken()).rejects.toBe(refreshError);
expect(client.login).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
});
it("keeps authentication inert when explicitly disabled", async () => {
const createClient = vi.fn();
const authentication = await initializeAuthentication(
parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" }),
createClient
);
expect(authentication.enabled).toBe(false);
await expect(authentication.getAccessToken()).resolves.toBeNull();
expect(createClient).not.toHaveBeenCalled();
});
});
+123
View File
@@ -0,0 +1,123 @@
import Keycloak, {
type KeycloakConfig,
type KeycloakInitOptions,
type KeycloakLoginOptions,
type KeycloakLogoutOptions,
type KeycloakTokenParsed
} from "keycloak-js";
import type { RuntimeConfig } from "@/shared/config/env";
const TOKEN_MIN_VALIDITY_SECONDS = 30;
export type AuthenticatedUser = {
name: string;
role: string;
};
export type AccessTokenProvider = () => Promise<string | null>;
export type Authentication = {
enabled: boolean;
user: AuthenticatedUser | null;
getAccessToken: AccessTokenProvider;
logout?: () => Promise<void>;
};
type KeycloakClient = {
token?: string;
tokenParsed?: KeycloakTokenParsed;
idTokenParsed?: KeycloakTokenParsed;
onTokenExpired?: () => void;
init: (options: KeycloakInitOptions) => Promise<boolean>;
login: (options?: KeycloakLoginOptions) => Promise<void>;
logout: (options?: KeycloakLogoutOptions) => Promise<void>;
updateToken: (minValidity: number) => Promise<boolean>;
};
type KeycloakClientFactory = (config: KeycloakConfig) => KeycloakClient;
export async function initializeAuthentication(
config: RuntimeConfig,
createClient: KeycloakClientFactory = (keycloakConfig) => new Keycloak(keycloakConfig)
): Promise<Authentication> {
if (config.TJWATER_AUTH_MODE === "disabled") {
return {
enabled: false,
user: null,
getAccessToken: async () => null
};
}
const keycloak = createClient(
toKeycloakConfig(
config.TJWATER_KEYCLOAK_ISSUER,
config.TJWATER_KEYCLOAK_CLIENT_ID
)
);
const redirectUri = `${window.location.origin}/`;
const login = () => keycloak.login({ redirectUri });
const authenticated = await keycloak.init({
onLoad: "login-required",
flow: "standard",
pkceMethod: "S256",
checkLoginIframe: false
});
if (!authenticated) {
await login();
throw new Error("Keycloak authentication was not completed");
}
const refreshAccessToken = async () => {
try {
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
} catch (error) {
await login();
throw error;
}
};
keycloak.onTokenExpired = () => {
void refreshAccessToken().catch(() => undefined);
};
return {
enabled: true,
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
getAccessToken: async () => {
await refreshAccessToken();
if (!keycloak.token) {
throw new Error("Keycloak access token is unavailable");
}
return keycloak.token;
},
logout: () => keycloak.logout({ redirectUri })
};
}
export function toKeycloakConfig(issuer: string, clientId: string): KeycloakConfig {
const url = new URL(issuer);
const realmMarker = "/realms/";
const markerIndex = url.pathname.lastIndexOf(realmMarker);
const realm = decodeURIComponent(url.pathname.slice(markerIndex + realmMarker.length));
const serverPath = url.pathname.slice(0, markerIndex).replace(/\/+$/, "");
return {
url: `${url.origin}${serverPath}`,
realm,
clientId
};
}
export function toAuthenticatedUser(token: KeycloakTokenParsed | undefined): AuthenticatedUser {
const name = readClaim(token, "name") ?? readClaim(token, "preferred_username") ?? readClaim(token, "sub") ?? "已认证用户";
return {
name,
role: readClaim(token, "email") ?? "统一认证用户"
};
}
function readClaim(token: KeycloakTokenParsed | undefined, key: string) {
const value = token?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
+24 -2
View File
@@ -6,6 +6,9 @@ describe("runtime frontend configuration", () => {
it("uses container-provided values without relying on Vite build variables", () => { it("uses container-provided values without relying on Vite build variables", () => {
expect( expect(
parseRuntimeConfig({ parseRuntimeConfig({
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a", TJWATER_GEOSERVER_WORKSPACE: "project-a",
@@ -14,6 +17,9 @@ describe("runtime frontend configuration", () => {
TJWATER_ENABLE_MSW: "false" TJWATER_ENABLE_MSW: "false"
}) })
).toEqual({ ).toEqual({
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a", TJWATER_GEOSERVER_WORKSPACE: "project-a",
@@ -24,12 +30,18 @@ describe("runtime frontend configuration", () => {
}); });
it("applies typed defaults for optional feature flags", () => { it("applies typed defaults for optional feature flags", () => {
expect(parseRuntimeConfig({})).toMatchObject({ expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({
TJWATER_AUTH_MODE: "disabled",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_ENABLE_DEV_PANEL: false, TJWATER_ENABLE_DEV_PANEL: false,
TJWATER_ENABLE_MSW: false TJWATER_ENABLE_MSW: false
}); });
}); });
it("requires a Realm issuer when authentication is enabled", () => {
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "required" })).toThrow();
});
it("rejects invalid runtime URLs before the application starts", () => { it("rejects invalid runtime URLs before the application starts", () => {
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow(); expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
}); });
@@ -39,6 +51,16 @@ describe("runtime frontend configuration", () => {
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"], ["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"] ["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
])("rejects unsafe browser runtime address %s", (key, value) => { ])("rejects unsafe browser runtime address %s", (key, value) => {
expect(() => parseRuntimeConfig({ [key]: value })).toThrow(); expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
});
it("rejects a Keycloak URL that does not identify a Realm", () => {
expect(() =>
parseRuntimeConfig({
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
})
).toThrow();
}); });
}); });
+40 -1
View File
@@ -22,13 +22,52 @@ const browserHttpUrl = z
} }
}); });
const runtimeConfigSchema = z.object({ const keycloakIssuer = z
.string()
.trim()
.default("")
.superRefine((value, context) => {
if (!value) return;
const parsed = browserHttpUrl.safeParse(value);
if (!parsed.success) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须是安全的 HTTP(S) 地址" });
return;
}
const url = new URL(value);
if (url.search || !/\/realms\/[^/]+\/?$/.test(url.pathname)) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须指向具体 Realm" });
}
})
.transform((value) => value.replace(/\/+$/, ""));
const runtimeConfigSchema = z
.object({
TJWATER_AUTH_MODE: z.enum(["required", "disabled"]).default("required"),
TJWATER_KEYCLOAK_ISSUER: keycloakIssuer,
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""), TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"), TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"), TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"), TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false), TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
TJWATER_ENABLE_MSW: runtimeBoolean(false) TJWATER_ENABLE_MSW: runtimeBoolean(false)
})
.superRefine((config, context) => {
if (config.TJWATER_AUTH_MODE !== "required") return;
if (!config.TJWATER_KEYCLOAK_ISSUER) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["TJWATER_KEYCLOAK_ISSUER"],
message: "启用认证时必须配置 Keycloak issuer"
});
}
if (!config.TJWATER_KEYCLOAK_CLIENT_ID) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["TJWATER_KEYCLOAK_CLIENT_ID"],
message: "启用认证时必须配置 Keycloak client ID"
});
}
}); });
declare global { declare global {
+9
View File
@@ -899,6 +899,15 @@ button.agent-history-session-trigger:not(:disabled):active {
background: var(--surface-reading); background: var(--surface-reading);
} }
.workbench-agent-dock .agent-panel-shell {
border: 0;
border-radius: 0;
background: #f7f9fb;
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.agent-ready-orbit, .agent-ready-orbit,
.agent-ready-core { .agent-ready-core {
transform-box: fill-box; transform-box: fill-box;
+2
View File
@@ -1 +1,3 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "disabled" };
+46 -126
View File
@@ -1,4 +1,19 @@
import { expect, test, type Locator } from "@playwright/test"; import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.beforeEach(async ({ page }) => {
await mockAgentApi(page, {
sessions: [
{
session_id: "browser-history-session",
title: "城南供水压力复核",
created_at: "2026-07-21T09:30:00+08:00",
updated_at: "2026-07-21T10:35:00+08:00",
run_status: "completed"
}
]
});
});
type BoundingBox = { type BoundingBox = {
x: number; x: number;
@@ -12,41 +27,36 @@ async function waitForAnimations(locator: Locator) {
const animations = element const animations = element
.getAnimations({ subtree: true }) .getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity); .filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
await Promise.all( await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
animations.map((animation) => animation.finished.catch(() => undefined))
);
}); });
} }
test("desktop Agent layers floating controls inside one acrylic panel", async ({ page }) => { test("desktop Agent lives in the solid Main Frame navigation pane", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const topBar = page.locator("header.acrylic-navigation"); const topBar = page.locator("header.acrylic-navigation");
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header"); const agentHeader = agentPanel.locator(".agent-panel-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas"); const agentConversation = agentPanel.getByRole("log");
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll"); const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
const agentComposer = agentPanel.locator(".agent-panel-composer"); const agentControls = agentPanel.locator(".agent-panel-band").last();
await expect(topBar).toBeVisible(); await expect(topBar).toBeVisible();
await expect(navigation).toBeVisible();
await expect(agentPanel).toBeVisible(); await expect(agentPanel).toBeVisible();
await expect(agentHeader).toBeVisible(); await expect(agentHeader).toBeVisible();
await expect(agentComposer).toBeVisible(); await expect(agentControls).toBeVisible();
await waitForAnimations(agentPanel); await waitForAnimations(agentPanel);
await expectAcrylicSurface(topBar); await expectAcrylicSurface(topBar);
await expectAcrylicSurface(agentPanel, ".agent-panel-composer"); await expect(navigation).toHaveCSS("background-color", "rgb(237, 241, 245)");
await expectIntegratedHeader(agentHeader); await expect(navigation).toHaveCSS("backdrop-filter", "none");
await expectContextualAcrylicSurface(agentComposer, { await expect(agentPanel).toHaveCSS("background-color", "rgb(247, 249, 251)");
backdropFilter: "blur(", await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
maximumAlpha: 0.9, await expect(agentPanel).toHaveCSS("border-radius", "0px");
minimumAlpha: 0.84 await expect(agentPanel).toHaveCSS("box-shadow", "none");
}); await expect(agentControls).toHaveCSS("border-top-width", "1px");
await expectLightweightMaterialContext(agentConversation);
await expectUnfilteredInternalSurfaces(agentComposer.locator(".agent-panel-control"));
await expect(agentComposer).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect( await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。") agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
).toHaveCSS("color", "rgb(51, 65, 85)"); ).toHaveCSS("color", "rgb(51, 65, 85)");
@@ -56,68 +66,30 @@ test("desktop Agent layers floating controls inside one acrylic panel", async ({
const panelBox = await agentPanel.boundingBox(); const panelBox = await agentPanel.boundingBox();
const conversationBox = await agentConversation.boundingBox(); const conversationBox = await agentConversation.boundingBox();
const scrollBox = await agentConversationScroll.boundingBox(); const scrollBox = await agentConversationScroll.boundingBox();
const composerBox = await agentComposer.boundingBox();
expect(panelBox).not.toBeNull(); expect(panelBox).not.toBeNull();
expect(conversationBox).not.toBeNull(); expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull(); expect(scrollBox).not.toBeNull();
expect(composerBox).not.toBeNull(); expect(panelBox!.x).toBe(48);
expect(panelBox!.width).toBe(319);
expect( expect(
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4)) Math.abs(conversationBox!.x - panelBox!.x)
).toBeLessThanOrEqual(1);
expect(
Math.abs(scrollBox!.y + scrollBox!.height - (conversationBox!.y + conversationBox!.height))
).toBeLessThanOrEqual(1);
expect(composerBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
expect(composerBox!.x - panelBox!.x).toBeGreaterThanOrEqual(21);
expect(
Math.abs(
composerBox!.x -
panelBox!.x -
(panelBox!.x + panelBox!.width - composerBox!.x - composerBox!.width)
)
).toBeLessThanOrEqual(1); ).toBeLessThanOrEqual(1);
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges"); await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
const beforeFocus = await getFloatingSurfaceStyles(agentHeader, agentComposer); const beforeFocus = await getSurfacePresentation(agentPanel);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus(); await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
await expect await expect
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer)) .poll(() => getSurfacePresentation(agentPanel))
.toEqual(beforeFocus); .toEqual(beforeFocus);
}); });
async function expectIntegratedHeader(header: Locator) { test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => {
const composition = await header.evaluate((element) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
borderRadius: style.borderRadius
};
});
expect(composition.backdropFilter).toBe("none");
expect(getAlpha(composition.backgroundColor)).toBe(0);
expect(composition.borderTopWidth).toBe("0px");
expect(composition.borderRadius).toBe("0px");
}
test("desktop Agent header expands into one attached acrylic history surface", async ({
page
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header");
const agentConversation = agentPanel.locator(".agent-panel-conversation-canvas");
const historyButton = agentPanel.getByRole("button", { const historyButton = agentPanel.getByRole("button", {
name: "打开 Agent 历史记录" name: "打开 Agent 历史记录"
}); });
const headerBefore = await agentHeader.boundingBox();
const conversationBefore = await agentConversation.boundingBox();
const historyButtonPresentationBefore = await getSurfacePresentation(historyButton);
const agentPanelFrameBefore = await expectAcrylicOutline(agentPanel);
await historyButton.click(); await historyButton.click();
@@ -125,51 +97,7 @@ test("desktop Agent header expands into one attached acrylic history surface", a
await expect(history).toBeVisible(); await expect(history).toBeVisible();
await waitForAnimations(history); await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true"); await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await page.mouse.move(720, 320);
await expectAttachedHistoryExtension(history);
await expectTransparentExpandedHeader(agentHeader, agentPanel);
await expectUnifiedHistoryStructure(history); await expectUnifiedHistoryStructure(history);
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
await expect
.poll(() => getSurfacePresentation(historyButton))
.toEqual(historyButtonPresentationBefore);
const sessionTrigger = history.locator(".agent-history-session-trigger").first();
const sessionTriggerBox = await sessionTrigger.boundingBox();
const historyHeadingBox = await history.getByText("历史记录", { exact: true }).boundingBox();
expect(sessionTriggerBox).not.toBeNull();
expect(historyHeadingBox).not.toBeNull();
await page.mouse.move(
sessionTriggerBox!.x + sessionTriggerBox!.width / 2,
sessionTriggerBox!.y + sessionTriggerBox!.height / 2
);
await page.mouse.down();
await expect(sessionTrigger).toHaveCSS("outline-style", "none");
await page.mouse.move(
historyHeadingBox!.x + historyHeadingBox!.width / 2,
historyHeadingBox!.y + historyHeadingBox!.height / 2
);
await page.mouse.up();
const headerBox = await agentHeader.boundingBox();
const agentPanelBox = await agentPanel.boundingBox();
const historyBox = await history.boundingBox();
const conversationAfter = await agentConversation.boundingBox();
expect(headerBefore).not.toBeNull();
expect(headerBox).not.toBeNull();
expect(agentPanelBox).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(conversationBefore).not.toBeNull();
expect(conversationAfter).not.toBeNull();
expectRoundedValue(headerBox!.y, headerBefore!.y);
expect(headerBox!.height).toBeGreaterThan(headerBefore!.height);
expectRoundedValue(historyBox!.y, headerBefore!.y + headerBefore!.height);
expectRoundedValue(headerBox!.x, agentPanelBox!.x + 1);
expectRoundedValue(headerBox!.y, agentPanelBox!.y + 1);
expectRoundedValue(headerBox!.width, agentPanelBox!.width - 2);
expectRoundedValue(historyBox!.x, headerBox!.x);
expectRoundedValue(historyBox!.width, headerBox!.width);
expectRoundedBox(conversationAfter!, conversationBefore!);
await history.getByRole("button", { name: "重命名对话" }).first().click(); await history.getByRole("button", { name: "重命名对话" }).first().click();
const renameInput = history.locator("input").first(); const renameInput = history.locator("input").first();
@@ -186,24 +114,14 @@ test("desktop Agent header expands into one attached acrylic history surface", a
await expect(history).toBeHidden(); await expect(history).toBeHidden();
await expect(historyButton).toBeFocused(); await expect(historyButton).toBeFocused();
await expect(historyButton).toHaveAttribute("aria-expanded", "false"); await expect(historyButton).toHaveAttribute("aria-expanded", "false");
await expect.poll(() => getAcrylicFrame(agentPanel)).toEqual(agentPanelFrameBefore);
await historyButton.click();
await expect(history).toBeVisible();
await page.mouse.click(720, 320);
await expect(history).toBeHidden();
}); });
test("mobile Agent mirrors the desktop floating history surface", async ({ test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
page
}) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click(); await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const agentPanel = page const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
.locator('aside[aria-label="Agent 命令面板"]')
.filter({ visible: true });
await expect(agentPanel).toBeVisible(); await expect(agentPanel).toBeVisible();
await waitForAnimations(agentPanel); await waitForAnimations(agentPanel);
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" }); const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
@@ -261,15 +179,17 @@ test("mobile Agent mirrors the desktop floating history surface", async ({
await expect(historyButton).toBeFocused(); await expect(historyButton).toBeFocused();
}); });
test("Agent floating acrylic respects forced color mode", async ({ page }) => { test("Main Frame navigation respects forced color mode", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" }); await page.emulateMedia({ forcedColors: "active" });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const composer = page.locator(".agent-panel-composer"); const navigation = page.getByRole("complementary", { name: "工作台导航" });
await expect(composer).toBeVisible(); const workspace = page.locator("#main-workspace");
await expect(composer).toHaveCSS("backdrop-filter", "none"); await expect(navigation).toBeVisible();
await expect(composer).toHaveCSS("background-image", "none"); await expect(navigation).toHaveCSS("backdrop-filter", "none");
await expect(composer).toHaveCSS("box-shadow", "none"); await expect(navigation).toHaveCSS("box-shadow", "none");
await expect(workspace).toBeVisible();
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
}); });
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) { async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
+37 -38
View File
@@ -1,45 +1,45 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => { test("the docked Agent collapses and restores the main workspace", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.locator('aside[aria-label="Agent 命令面板"]'); const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" }); const workspace = page.locator("#main-workspace");
await expect(panel).toBeVisible(); const expandedWidth = Math.round((await agentPanel.boundingBox())?.width ?? 0);
await expect(resizeHandle).toBeVisible(); expect(expandedWidth).toBeGreaterThan(400);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
const handleBox = await resizeHandle.boundingBox();
expect(handleBox).not.toBeNull();
await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2);
await page.mouse.down();
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 });
await page.mouse.up();
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
await resizeHandle.focus();
await page.keyboard.press("Home");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(500);
await page.keyboard.press("ArrowRight");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(516);
await page.keyboard.press("End");
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(676);
});
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "折叠 Agent 面板" }).click(); await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(72);
const rail = page.locator('aside[aria-label="Agent 折叠栏"]'); await page.getByLabel(/展开 Agent 助手面板/).click();
const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ }); await expect(agentPanel).toBeVisible();
await expect(rail).toBeVisible(); await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
await expect(rail).toHaveCSS("width", "72px"); });
await expect(expandButton).toHaveAttribute("title", /当前|正在/);
test("the floating condition feed coordinates with the docked Agent", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expandButton.click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
await prompt.fill("保留尚未发送的诊断问题");
const conditionToggle = page
.locator("header.acrylic-navigation")
.getByRole("button", { name: "工况任务", exact: true });
await conditionToggle.click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
await conditionPanel.getByRole("button", { name: "收起工况任务" }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
await conditionToggle.click();
await expect(conditionPanel).toBeHidden();
}); });
test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => { test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => {
@@ -71,19 +71,18 @@ test.describe("mobile touch layout", () => {
}); });
}); });
// Temporarily disabled: this regression flow would start a real Agent run. test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
// Agent analysis must be explicitly triggered by a user, not by automated tests. await mockAgentApi(page);
test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00")); await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 }); await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click(); await page.getByRole("button", { name: /查看异常处置面板/ }).click();
// Agent execution must remain an explicit user action. await page.getByRole("button", { name: "工况汇总" }).click();
// await page.getByRole("button", { name: "工况汇总" }).click();
await expect( await expect(
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true }) page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible(); await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
await expect(page.getByText("已发起工况汇总", { exact: true })).toBeVisible();
}); });
+10 -8
View File
@@ -7,7 +7,10 @@ test("submits a custom answer with the actionable id after a later tool placehol
await mockQuestionApi(page, replies); await mockQuestionApi(page, replies);
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first(); const prompt = page
.getByPlaceholder(/输入调度问题/)
.filter({ visible: true })
.first();
await prompt.fill("测试 question 自定义回答"); await prompt.fill("测试 question 自定义回答");
await page await page
.getByRole("button", { name: "发送 Agent 指令" }) .getByRole("button", { name: "发送 Agent 指令" })
@@ -20,7 +23,9 @@ test("submits a custom answer with the actionable id after a later tool placehol
await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力"); await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力");
await panel.getByRole("button", { name: "提交回答" }).click(); await panel.getByRole("button", { name: "提交回答" }).click();
await expect.poll(() => replies).toEqual([ await expect
.poll(() => replies)
.toEqual([
{ {
pathname: "/api/v1/agent/chat/question/question-1/reply", pathname: "/api/v1/agent/chat/question/question-1/reply",
body: { body: {
@@ -54,10 +59,7 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page
expect(box!.x + box!.width).toBeLessThanOrEqual(375); expect(box!.x + box!.width).toBeLessThanOrEqual(375);
}); });
async function mockQuestionApi( async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; body: unknown }>) {
page: Page,
replies: Array<{ pathname: string; body: unknown }>
) {
await page.route("**/api/v1/agent/chat/**", async (route) => { await page.route("**/api/v1/agent/chat/**", async (route) => {
const request = route.request(); const request = route.request();
const pathname = new URL(request.url()).pathname; const pathname = new URL(request.url()).pathname;
@@ -119,8 +121,8 @@ function createQuestionStream() {
question: "你想分析哪一部分?", question: "你想分析哪一部分?",
options: [ options: [
{ {
label: "雨污混接分析", label: "供水分区压力分析",
description: "检查混接问题" description: "检查分区压力问题"
} }
] ]
} }
+13 -13
View File
@@ -36,7 +36,7 @@ test.beforeAll(async () => {
phase: "planning", phase: "planning",
status: "running", status: "running",
title: "正在分析运行上下文", title: "正在分析运行上下文",
detail: "核对泵站、液位与降雨过程。", detail: "核对泵站、液位与用水过程。",
started_at: Date.now() started_at: Date.now()
} }
}); });
@@ -47,8 +47,8 @@ test.beforeAll(async () => {
id: "inspect-rainfall", id: "inspect-rainfall",
phase: "evidence", phase: "evidence",
status: "completed", status: "completed",
title: "正在核对降雨过程与汇水区响应关系", title: "正在核对用水过程与供水分区响应关系",
detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。", detail: "读取最近十二小时分钟级流量序列,并检查每个供水分区的响应延迟是否处于合理范围。",
started_at: Date.now() started_at: Date.now()
} }
}); });
@@ -74,7 +74,7 @@ test.beforeAll(async () => {
todos: [ todos: [
{ id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" }, { id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" },
{ id: "inspect-level", content: "核对液位计连续性", status: "pending" }, { id: "inspect-level", content: "核对液位计连续性", status: "pending" },
{ id: "assess-overflow", content: "判断溢流风险", status: "pending" } { id: "assess-supply-risk", content: "判断供水风险", status: "pending" }
] ]
} }
}); });
@@ -110,7 +110,7 @@ test.beforeAll(async () => {
id: "assess-risk", id: "assess-risk",
phase: "analysis", phase: "analysis",
status: "completed", status: "completed",
title: "溢流风险判断完成", title: "供水风险判断完成",
detail: "已完成风险点位排序。", detail: "已完成风险点位排序。",
ended_at: Date.now() ended_at: Date.now()
} }
@@ -131,7 +131,7 @@ test.beforeAll(async () => {
id: "assess-risk", id: "assess-risk",
phase: "analysis", phase: "analysis",
status: "running", status: "running",
title: "正在判断溢流风险", title: "正在判断供水风险",
detail: "结合流量突变与液位趋势形成判断。", detail: "结合流量突变与液位趋势形成判断。",
started_at: Date.now() started_at: Date.now()
} }
@@ -382,7 +382,7 @@ test("stream completion and the plan card remain positionally stable", async ({
requestAnimationFrame(sample); requestAnimationFrame(sample);
}); });
triggerProgressAppend(); triggerProgressAppend();
await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible(); await expect(liveProgress.getByText("正在判断供水风险", { exact: true })).toBeVisible();
await expect(liveProgress.locator("li")).toHaveCount(3); await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0); await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0);
await expect await expect
@@ -450,7 +450,7 @@ test("stream completion and the plan card remain positionally stable", async ({
); );
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible(); await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect( await expect(
expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true }) expandedLiveProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true })
).toHaveCSS("white-space", "normal"); ).toHaveCSS("white-space", "normal");
await progressToggle.click(); await progressToggle.click();
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3); await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
@@ -468,7 +468,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(progressToggle.locator("../..")).toContainText("步骤4/4"); await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0); await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect( await expect(
page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }) page.getByText("供水风险判断完成", { exact: true }).filter({ visible: true })
).toHaveCount(0); ).toHaveCount(0);
await expect await expect
.poll(async () => .poll(async () =>
@@ -541,7 +541,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect( await expect(
page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first() page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()
).toBeVisible(); ).toBeVisible();
const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", { const expandedLongTitle = expandedProgress.getByText("正在核对用水过程与供水分区响应关系", {
exact: true exact: true
}); });
await expect(expandedLongTitle).toHaveCSS("white-space", "normal"); await expect(expandedLongTitle).toHaveCSS("white-space", "normal");
@@ -562,7 +562,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(latestProgressRegion).toContainText("最近3/3"); await expect(latestProgressRegion).toContainText("最近3/3");
triggerProgressAppend(); triggerProgressAppend();
await expect( await expect(
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true }) page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断供水风险", { exact: true })
).toBeVisible(); ).toBeVisible();
await latestProgressToggle.click(); await latestProgressToggle.click();
await expect(latestProgressRegion).toContainText("全部4/4"); await expect(latestProgressRegion).toContainText("全部4/4");
@@ -570,7 +570,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 }); await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
await expect(latestExpandedProgress).toBeVisible(); await expect(latestExpandedProgress).toBeVisible();
await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible(); await expect(latestExpandedProgress.getByText("供水风险判断完成", { exact: true })).toBeVisible();
const conversationScroll = page.locator(".agent-conversation-scroll"); const conversationScroll = page.locator(".agent-conversation-scroll");
await conversationScroll.hover(); await conversationScroll.hover();
@@ -673,7 +673,7 @@ function createLongMarkdownResponse() {
"当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n"; "当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n";
content += "- 检查泵站运行状态与实时流量。\n"; content += "- 检查泵站运行状态与实时流量。\n";
content += "- 核对液位计连续性和异常峰值。\n"; content += "- 核对液位计连续性和异常峰值。\n";
content += "- 结合降雨过程判断溢流风险。\n\n"; content += "- 结合用水过程判断供水风险。\n\n";
} }
return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`; return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`;
} }
+60
View File
@@ -113,6 +113,65 @@ test("mobile launchers and map dock keep non-overlapping touch targets", async (
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x); expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
}); });
test("mobile timeline only offers collapse after opening its detail sheet", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const timelineSummary = page.getByRole("region", { name: "移动端运行时间轴摘要" });
await expect(timelineSummary).toBeVisible();
await expect(timelineSummary.getByRole("button", { name: "隐藏运行时间轴" })).toHaveCount(0);
await timelineSummary.getByRole("button", { name: "打开完整运行时间轴" }).click();
const timelineSheet = page.getByRole("region", { name: "运行时间轴抽屉" });
await expect(timelineSheet).toBeVisible();
await timelineSheet.getByRole("button", { name: "收起时间轴" }).click();
await expect(timelineSheet).toBeHidden();
await expect(timelineSummary).toBeVisible();
});
test("mobile map tool panel stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
const toolPanel = page.getByRole("region", { name: "更多" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(toolPanel).toBeVisible();
await expect
.poll(async () => {
const [panelBox, dockBox] = await Promise.all([
toolPanel.boundingBox(),
mapDock.boundingBox()
]);
if (!panelBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(panelBox.y + panelBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
});
test("mobile map zoom stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const mapZoom = page.getByRole("group", { name: "地图缩放" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(mapZoom).toBeVisible();
await expect
.poll(async () => {
const [zoomBox, dockBox] = await Promise.all([
mapZoom.boundingBox(),
mapDock.boundingBox()
]);
if (!zoomBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(zoomBox.y + zoomBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
const zoomBox = await mapZoom.boundingBox();
expect(Math.round(375 - zoomBox!.x - zoomBox!.width)).toBe(12);
});
test("Agent content buttons do not gain a border while pressed", async ({ page }) => { test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -151,6 +210,7 @@ async function prepareWorkbench(page: Page) {
await route.fulfill({ await route.fulfill({
contentType: "application/javascript", contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = { body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_MAP_URL: "https://button-system.invalid/geoserver", TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater", TJWATER_GEOSERVER_WORKSPACE: "tjwater",
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => { test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
@@ -111,6 +112,7 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => { test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
@@ -168,6 +170,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
page page
}) => { }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
@@ -199,6 +202,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => { test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
+1
View File
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => { test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const reference = page const reference = page
.getByRole("region", { name: "工况任务", exact: true }) .getByRole("region", { name: "工况任务", exact: true })
+83
View File
@@ -0,0 +1,83 @@
import type { Page } from "@playwright/test";
type MockAgentApiOptions = {
model?: {
id: string;
label: string;
description: string;
icon: "bolt" | "sparkle";
};
sessions?: Array<Record<string, unknown>>;
};
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
const model = options.model ?? {
id: "test/model",
label: "测试模型",
description: "Playwright 浏览器测试",
icon: "bolt" as const
};
await page.route("**/api/v1/agent/chat/**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname.endsWith("/stream")) {
await route.fulfill({
status: 200,
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
},
body: [
{ type: "start", messageId: "assistant-browser-test" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: "已收到工况汇总请求。" },
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
]
.map((part) => `data: ${JSON.stringify(part)}\n\n`)
.join("")
});
return;
}
if (pathname.endsWith("/sessions")) {
await route.fulfill({ json: { sessions: options.sessions ?? [] } });
return;
}
if (pathname.endsWith("/models")) {
await route.fulfill({
json: {
default_model: model.id,
models: [model]
}
});
return;
}
if (pathname.endsWith("/ui-registry")) {
await route.fulfill({
json: {
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
}
});
return;
}
if (pathname.endsWith("/frontend-action-registry")) {
await route.fulfill({
json: {
schema_version: "frontend-action-registry@1",
actions: []
}
});
return;
}
await route.fulfill({ json: {} });
});
}
@@ -2,33 +2,31 @@ import { expect, test } from "@playwright/test";
test("opening Agent and condition panels does not move the map camera", async ({ page }) => { test("opening Agent and condition panels does not move the map camera", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => await page.waitForFunction(
Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) () =>
Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) &&
!window.__waterNetworkMap?.isMoving()
); );
await page.waitForTimeout(500);
await page.evaluate(() => { await page.evaluate(() => {
const map = window.__waterNetworkMap; const map = window.__waterNetworkMap;
document.documentElement.dataset.panelCameraMoveCount = "0"; document.documentElement.dataset.panelCameraMoveCount = "0";
map?.on("movestart", () => { map?.on("movestart", () => {
const currentCount = Number( const currentCount = Number(document.documentElement.dataset.panelCameraMoveCount ?? "0");
document.documentElement.dataset.panelCameraMoveCount ?? "0"
);
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1); document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
}); });
}); });
await page.getByRole("button", { name: "折叠 Agent 面板" }).click(); await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await page.waitForTimeout(250); await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await page.waitForTimeout(250); await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click(); await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await page.waitForTimeout(250); await expect(conditionPanel).toHaveCSS("width", "880px");
expect( expect(await page.evaluate(() => document.documentElement.dataset.panelCameraMoveCount)).toBe(
await page.evaluate( "0"
() => document.documentElement.dataset.panelCameraMoveCount );
)
).toBe("0");
}); });
+79 -98
View File
@@ -1,28 +1,24 @@
import { expect, test, type Locator, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
const EMPTY_RASTER_TILE = Buffer.from( const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64" "base64"
); );
test.describe("neutral blue mist workbench", () => { test.describe("Main Frame workbench", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00")); await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
await prepareDeterministicWorkbench(page); await prepareDeterministicWorkbench(page);
}); });
test("desktop light basemap keeps both floating panels legible", async ({ page }) => { test("desktop light basemap keeps the Main Frame workspace legible", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page); await openWorkbench(page);
await expectDesktopFloatingGeometry(page); await expectDesktopMainFrameGeometry(page);
await expectAcrylicAlphas(page, { await expectSolidMainFrameMaterials(page);
navigation: 0.64,
agentPanel: 0.82,
conditionPanel: 0.56,
control: 0.74
});
await expectLoadedChineseFont(page); await expectLoadedChineseFont(page);
await expectBodyContrast(page); await expectBodyContrast(page);
await expect(page).toHaveScreenshot("workbench-desktop-light.png", { await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
@@ -31,42 +27,35 @@ test.describe("neutral blue mist workbench", () => {
}); });
}); });
test("desktop satellite basemap strengthens the same floating hierarchy", async ({ page }) => { test("desktop satellite basemap preserves the solid Main Frame hierarchy", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page); await openWorkbench(page);
await selectBasemap(page, "影像"); await selectBasemap(page, "影像");
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite"); await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
await expectDesktopFloatingGeometry(page); await expectDesktopMainFrameGeometry(page);
await expectAcrylicAlphas(page, { await expectSolidMainFrameMaterials(page);
navigation: 0.8,
agentPanel: 0.88,
conditionPanel: 0.72,
control: 0.86
});
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", { await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
}); });
}); });
test("wide desktop keeps a map corridor at maximum Agent width", async ({ page }) => { test("wide desktop expands the map document when navigation is collapsed", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 }); await page.setViewportSize({ width: 1920, height: 1080 });
await openWorkbench(page); await openWorkbench(page);
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); const navigation = page.getByRole("complementary", { name: "工作台导航" });
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); const workspace = page.locator("#main-workspace");
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" }); const expandedWorkspace = await workspace.boundingBox();
expect(expandedWorkspace).not.toBeNull();
await expect(navigation).toHaveCSS("width", "368px");
await resizeHandle.focus(); await page.getByRole("button", { name: "折叠工作台导航" }).click();
await page.keyboard.press("End"); await expect(navigation).toHaveCSS("width", "48px");
await expect(agentPanel).toHaveCSS("width", "720px"); await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48);
await expectMapCorridor(agentPanel, conditionPanel, 680); await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1872);
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(conditionPanel).toHaveCSS("width", "960px");
await expect(agentPanel).toHaveCSS("width", "628px");
await expectMapCorridor(agentPanel, conditionPanel, 256);
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", { await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
@@ -116,10 +105,19 @@ test.describe("neutral blue mist workbench", () => {
}); });
async function prepareDeterministicWorkbench(page: Page) { async function prepareDeterministicWorkbench(page: Page) {
await mockAgentApi(page, {
model: {
id: "deepseek/deepseek-v4-flash",
label: "快速",
description: "快速响应",
icon: "bolt"
}
});
await page.route("**/runtime-config.js", async (route) => { await page.route("**/runtime-config.js", async (route) => {
await route.fulfill({ await route.fulfill({
contentType: "application/javascript", contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = { body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token", TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver", TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater", TJWATER_GEOSERVER_WORKSPACE: "tjwater",
@@ -148,7 +146,9 @@ async function prepareDeterministicWorkbench(page: Page) {
async function openWorkbench(page: Page) { async function openWorkbench(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.locator("main").evaluate((element) => { const workbench = page.locator("main[data-basemap-tone]");
await workbench.waitFor();
await workbench.evaluate((element) => {
element.setAttribute("data-visual-test", "true"); element.setAttribute("data-visual-test", "true");
}); });
await page.addStyleTag({ await page.addStyleTag({
@@ -176,51 +176,67 @@ async function openWorkbench(page: Page) {
await page.evaluate(async () => { await page.evaluate(async () => {
await document.fonts.ready; await document.fonts.ready;
}); });
await page.waitForTimeout(350); await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-hit")));
} }
async function selectBasemap(page: Page, label: "浅色" | "影像") { async function selectBasemap(page: Page, label: "浅色" | "影像") {
await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.getByRole("button", { name: label, exact: true }).click(); await page.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.waitForTimeout(220); await expect(page.locator("main")).toHaveAttribute(
"data-basemap-tone",
label === "影像" ? "satellite" : "light"
);
} }
async function expectDesktopFloatingGeometry(page: Page) { async function expectDesktopMainFrameGeometry(page: Page) {
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const workspace = page.locator("#main-workspace");
const mapWindow = page.locator("#workspace-map");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-integrated-header"); await expect(navigation).toHaveCSS("width", "368px");
const agentComposer = agentPanel.locator(".agent-panel-composer"); await expect(agentPanel).toHaveCSS("border-radius", "0px");
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); await expect(mapWindow).toHaveCSS("border-radius", "0px");
await expect(agentPanel).toHaveCSS("width", "500px");
await expect(conditionPanel).toHaveCSS("width", "432px");
await expect(agentPanel).toHaveCSS("border-radius", "16px");
await expect(agentHeader).toHaveCSS("border-radius", "0px");
await expect(agentComposer).toHaveCSS("border-radius", "16px");
await expect(agentComposer).toHaveCSS("border-top-width", "0px");
await expect(conditionPanel).toHaveCSS("border-radius", "16px");
const navigationBox = await navigation.boundingBox();
const workspaceBox = await workspace.boundingBox();
const mapBox = await mapWindow.boundingBox();
const agentBox = await agentPanel.boundingBox(); const agentBox = await agentPanel.boundingBox();
const conditionBox = await conditionPanel.boundingBox(); expect(navigationBox).not.toBeNull();
expect(workspaceBox).not.toBeNull();
expect(mapBox).not.toBeNull();
expect(agentBox).not.toBeNull(); expect(agentBox).not.toBeNull();
expect(conditionBox).not.toBeNull(); expect(navigationBox!.x).toBe(0);
expect(agentBox!.x).toBe(12); expect(navigationBox!.y).toBe(56);
expect(agentBox!.y).toBe(96); expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
expect(agentBox!.y + agentBox!.height).toBe(884); expect(workspaceBox!.y).toBe(navigationBox!.y);
expect(conditionBox!.x).toBe(944); expect(mapBox).toEqual(workspaceBox);
expect(conditionBox!.y).toBe(96); expect(agentBox!.x).toBeGreaterThanOrEqual(48);
expect(agentBox!.x).toBeLessThanOrEqual(49);
expect(
Math.abs(agentBox!.x + agentBox!.width - (navigationBox!.x + navigationBox!.width))
).toBeLessThanOrEqual(1);
} }
async function expectMapCorridor( async function expectSolidMainFrameMaterials(page: Page) {
agentPanel: Locator, const materials = await page.evaluate(() => {
conditionPanel: Locator, const navigation = getComputedStyle(document.querySelector('[aria-label="工作台导航"]')!);
minimumWidth: number const agent = getComputedStyle(document.querySelector('aside[aria-label="Agent 命令面板"]')!);
) { return {
const agentBox = await agentPanel.boundingBox(); navigationBackdrop: navigation.backdropFilter,
const conditionBox = await conditionPanel.boundingBox(); navigationBackground: navigation.backgroundColor,
expect(agentBox).not.toBeNull(); agentBackdrop: agent.backdropFilter,
expect(conditionBox).not.toBeNull(); agentBackground: agent.backgroundColor,
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth); agentShadow: agent.boxShadow
};
});
expect(materials).toEqual({
navigationBackdrop: "none",
navigationBackground: "rgb(237, 241, 245)",
agentBackdrop: "none",
agentBackground: "rgb(247, 249, 251)",
agentShadow: "none"
});
} }
async function expectLoadedChineseFont(page: Page) { async function expectLoadedChineseFont(page: Page) {
@@ -231,41 +247,6 @@ async function expectLoadedChineseFont(page: Page) {
.toBe(true); .toBe(true);
} }
async function expectAcrylicAlphas(
page: Page,
expected: {
navigation: number;
agentPanel: number;
conditionPanel: number;
control: number;
}
) {
const alphas = await page.evaluate(() => {
return {
navigation: alphaOf(document.querySelector(".acrylic-navigation")),
agentPanel: alphaOf(
document.querySelector('aside[aria-label="Agent 命令面板"]')
),
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
control: alphaOf(document.querySelector(".acrylic-control"))
};
function alphaOf(element: Element | null) {
if (!element) {
return 1;
}
const color = getComputedStyle(element).backgroundColor;
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [];
return channels[3] ?? 1;
}
});
expect(alphas.navigation).toBeCloseTo(expected.navigation, 2);
expect(alphas.agentPanel).toBeCloseTo(expected.agentPanel, 2);
expect(alphas.conditionPanel).toBeCloseTo(expected.conditionPanel, 2);
expect(alphas.control).toBeCloseTo(expected.control, 2);
}
async function expectBodyContrast(page: Page) { async function expectBodyContrast(page: Page) {
const contrast = await page const contrast = await page
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。") .getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 KiB

After

Width:  |  Height:  |  Size: 174 KiB

+151
View File
@@ -0,0 +1,151 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test("the default workspace docks Agent beside the persistent map", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
const workspace = page.locator("#main-workspace");
const map = page.locator("#workspace-map");
await expect(workspace).toHaveAttribute("data-layout-mode", "default");
await expect(map).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await expect(map.getByRole("navigation", { name: "地图工具" })).toBeVisible();
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "任务视图" })).toHaveCount(0);
});
test("a generated Artifact stays in peek until explicitly opened", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
const peek = page.getByRole("complementary", { name: "Agent 成果预览" });
await expect(peek).toBeVisible();
await expect(peek).toContainText("北辰分区压力异常诊断");
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toHaveCount(0);
await peek.getByRole("button", { name: "打开成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("#artifact-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
});
test("Agent expands over an active Artifact without resizing the main workspace", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openFirstArtifact(page);
const workspace = page.locator("#main-workspace");
const before = await workspace.boundingBox();
expect(before).not.toBeNull();
await page.getByLabel(/展开 Agent 助手面板/).click();
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
const after = await workspace.boundingBox();
expect(after).not.toBeNull();
expect(after!.x).toBe(before!.x);
expect(after!.width).toBe(before!.width);
});
test("spatial Artifacts switch between split, focus and peek", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openFirstArtifact(page);
await page.getByRole("button", { name: "专注分析" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(page.locator("#workspace-map")).toBeHidden();
await page.getByRole("button", { name: "显示地图" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await page.getByRole("button", { name: "将成果收起为预览" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await page.getByRole("button", { name: "销毁预览成果" }).click();
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toHaveCount(0);
await expect(page.locator("#workspace-map")).toBeVisible();
});
test("non-spatial Artifacts open directly in focus mode", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "销毁预览成果" }).click();
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "打开成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析" })).toBeVisible();
await expect(page.locator("#workspace-map")).toBeHidden();
await expect(page.getByRole("button", { name: "显示地图" })).toHaveCount(0);
});
test("narrow workspaces switch between Artifact and map as pages", async ({ page }) => {
await page.setViewportSize({ width: 1180, height: 820 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openFirstArtifact(page);
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await page.getByRole("button", { name: "显示地图" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_only");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("#artifact-workspace")).toBeHidden();
await page.getByRole("button", { name: "返回分析成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(page.locator("#artifact-workspace")).toBeVisible();
});
test("the condition feed remains floating and starts closed", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
});
async function openFirstArtifact(page: Page) {
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "打开成果" }).click();
}
async function prepareMainFrame(page: Page) {
await mockAgentApi(page);
await page.route("**/runtime-config.js", async (route) => {
await route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false"
};`
});
});
await page.route("https://workspace-map.invalid/**", async (route) => {
await route.fulfill({ status: 204 });
});
}
+3
View File
@@ -8,6 +8,9 @@ const RUNTIME_CONFIG_PATH = "/runtime-config.js";
function renderRuntimeConfig(values: Record<string, string>) { function renderRuntimeConfig(values: Record<string, string>) {
const config = { const config = {
TJWATER_AUTH_MODE: values.TJWATER_AUTH_MODE || "required",
TJWATER_KEYCLOAK_ISSUER: values.TJWATER_KEYCLOAK_ISSUER || "",
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "", TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver", TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater", TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater",