Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28287a1447 | ||
|
|
abef9ffabf | ||
|
|
db696eb4f1 | ||
|
|
229cb0def2 | ||
|
|
1fb950806a | ||
|
|
073832034a | ||
|
|
57c494e8bd | ||
|
|
4a589d7a43 | ||
|
|
d39e58196f | ||
|
|
b93999646c | ||
|
|
b91be09f5c | ||
|
|
602b39d60a | ||
|
|
5e7101bfee | ||
|
|
21dbfd82b6 | ||
|
|
760370c93f | ||
|
|
3db95c27b5 | ||
|
|
45f1ebee7e | ||
|
|
d2f2895c36 | ||
|
|
bfadc95f93 | ||
|
|
be7b4149f2 | ||
|
|
1068304da5 | ||
|
|
f58e0a70f4 | ||
|
|
d08cf2abc1 | ||
|
|
bdd5eff776 |
@@ -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_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
||||
|
||||
@@ -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_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
|
||||
@@ -43,6 +46,10 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
||||
|
||||
`.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` 用于覆盖默认中文语音。
|
||||
|
||||
## 常用命令
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
set -eu
|
||||
|
||||
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 mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
||||
@@ -9,6 +12,9 @@ runtime_config=$(jq -cn \
|
||||
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-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_MAP_URL: $mapUrl,
|
||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"edge-tts-ts": "^1.0.0",
|
||||
"katex": "^0.17.0",
|
||||
"keycloak-js": "26.2.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"motion": "^12.40.0",
|
||||
|
||||
@@ -2,12 +2,16 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
|
||||
const runningInCi = Boolean(process.env.CI);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
|
||||
fullyParallel: true,
|
||||
reporter: "html",
|
||||
forbidOnly: runningInCi,
|
||||
retries: runningInCi ? 1 : 0,
|
||||
workers: runningInCi ? 2 : undefined,
|
||||
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
|
||||
use: {
|
||||
baseURL,
|
||||
locale: "zh-CN",
|
||||
@@ -16,7 +20,7 @@ export default defineConfig({
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
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,
|
||||
reuseExistingServer: false
|
||||
},
|
||||
|
||||
Generated
+8
@@ -83,6 +83,9 @@ importers:
|
||||
katex:
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0
|
||||
keycloak-js:
|
||||
specifier: 26.2.4
|
||||
version: 26.2.4
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(react@19.2.7)
|
||||
@@ -2859,6 +2862,9 @@ packages:
|
||||
kdbush@4.1.0:
|
||||
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
||||
|
||||
keycloak-js@26.2.4:
|
||||
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -6645,6 +6651,8 @@ snapshots:
|
||||
|
||||
kdbush@4.1.0: {}
|
||||
|
||||
keycloak-js@26.2.4: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
globalThis.__TJWATER_CONFIG__ = {};
|
||||
globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||
};
|
||||
|
||||
+14
-6
@@ -100,7 +100,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
|
||||
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.goto("/");
|
||||
|
||||
@@ -112,10 +112,18 @@ test("keeps menu origin and integrated composer styling through Tailwind v4", as
|
||||
expect(menuTransformOrigin).not.toBe("50% 50%");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
|
||||
const commandShadow = await agentCommandControl.evaluate(
|
||||
(element) => window.getComputedStyle(element).boxShadow
|
||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||
const navigationMaterial = await navigation.evaluate(
|
||||
(element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor
|
||||
};
|
||||
}
|
||||
);
|
||||
expect(commandShadow).toBe("none");
|
||||
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px");
|
||||
expect(navigationMaterial.backdropFilter).toBe("none");
|
||||
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
@@ -1,11 +1,16 @@
|
||||
import { MapToaster } from "@/features/map/core";
|
||||
import { MapWorkbenchPage } from "@/features/workbench";
|
||||
import type { Authentication } from "@/shared/auth/keycloak-auth";
|
||||
import { AppProviders } from "./providers";
|
||||
|
||||
export function App() {
|
||||
export function App({ authentication }: { authentication: Authentication }) {
|
||||
return (
|
||||
<AppProviders>
|
||||
<MapWorkbenchPage />
|
||||
<MapWorkbenchPage
|
||||
user={authentication.user ?? undefined}
|
||||
onLogout={authentication.logout}
|
||||
getAccessToken={authentication.getAccessToken}
|
||||
/>
|
||||
<MapToaster />
|
||||
</AppProviders>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
|
||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||
|
||||
@@ -112,19 +113,47 @@ export type AgentApiClient = {
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type AgentApiClientOptions = {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
|
||||
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(/\/$/, ""));
|
||||
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 {
|
||||
async createSession() {
|
||||
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/session", {
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
"/sessions"
|
||||
);
|
||||
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
|
||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||
},
|
||||
|
||||
async getFrontendActionRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
|
||||
return requestJson<unknown>("/frontend-action-registry");
|
||||
},
|
||||
|
||||
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",
|
||||
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
||||
body: JSON.stringify(result)
|
||||
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async loadSession(sessionId) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/session/${encodeURIComponent(sessionId)}`);
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async streamSession(sessionId, options) {
|
||||
const response = await fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
const response = await request(
|
||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
return;
|
||||
}
|
||||
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||
{
|
||||
method: "PATCH",
|
||||
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async deleteSession(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
},
|
||||
|
||||
async getModels() {
|
||||
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/models");
|
||||
const payload = await requestJson<unknown>("/models");
|
||||
return toModelsResponse(payload);
|
||||
},
|
||||
|
||||
async getUiRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/ui-registry");
|
||||
return requestJson<unknown>("/ui-registry");
|
||||
},
|
||||
|
||||
async resolveRenderRef(renderRef, sessionId) {
|
||||
const params = new URLSearchParams({ session_id: sessionId });
|
||||
return requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
return requestJson<unknown>(
|
||||
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||
);
|
||||
},
|
||||
|
||||
async replyPermission(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async replyQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async rejectQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async abort(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/abort", {
|
||||
await requestJson<unknown>("/abort", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
let lastResponse: Response | null = null;
|
||||
const accessToken = await getAccessToken?.();
|
||||
const requestInit = accessToken
|
||||
? {
|
||||
...init,
|
||||
headers: withBearerToken(init?.headers, accessToken)
|
||||
}
|
||||
: init;
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
@@ -359,6 +337,12 @@ async function fetchWithFallback(
|
||||
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) {
|
||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AgentCollapsedRail({
|
||||
<aside
|
||||
aria-label="Agent 折叠栏"
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
ChevronsUpDown,
|
||||
ChevronLeft,
|
||||
DatabaseZap,
|
||||
@@ -82,6 +83,13 @@ import type {
|
||||
|
||||
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
|
||||
|
||||
export type AgentWorkspaceContextItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "info" | "warning";
|
||||
};
|
||||
|
||||
type AgentCommandPanelProps = {
|
||||
presentation?: AgentCommandPanelPresentation;
|
||||
collapsing?: boolean;
|
||||
@@ -99,6 +107,7 @@ type AgentCommandPanelProps = {
|
||||
selectedModel?: string;
|
||||
approvalMode?: AgentApprovalMode;
|
||||
uiResults?: AgentUiResult[];
|
||||
workspaceContext?: AgentWorkspaceContextItem[];
|
||||
onCollapse: () => void;
|
||||
onRefreshHistory?: () => Promise<void> | void;
|
||||
onStartNewSession?: () => Promise<void> | void;
|
||||
@@ -150,6 +159,7 @@ export function AgentCommandPanel({
|
||||
selectedModel = "",
|
||||
approvalMode = "request",
|
||||
uiResults = [],
|
||||
workspaceContext = [],
|
||||
onCollapse,
|
||||
onRefreshHistory,
|
||||
onStartNewSession,
|
||||
@@ -302,7 +312,7 @@ export function AgentCommandPanel({
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
|
||||
"agent-panel-shell",
|
||||
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
|
||||
? "acrylic-panel overflow-visible rounded-2xl border"
|
||||
: "overflow-hidden rounded-none border-0 bg-transparent",
|
||||
@@ -423,6 +433,27 @@ export function AgentCommandPanel({
|
||||
</header>
|
||||
</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 ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{hasConversation && !historyOpen ? (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||
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 { createAgentApiClient } from "./api/client";
|
||||
export type {
|
||||
AgentApiClient,
|
||||
AgentApiClientOptions,
|
||||
AgentChatSessionSummary,
|
||||
AgentLoadedChatSession,
|
||||
AgentSessionStreamEvent
|
||||
|
||||
@@ -1141,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
||||
{index + 1}
|
||||
</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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -98,6 +98,8 @@ export function ScheduledConditionFeed({
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const mobileSheet = presentation === "mobile-sheet";
|
||||
const desktopDock = presentation === "desktop-dock";
|
||||
const compactPresentation = mobileSheet || desktopDock;
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
@@ -298,7 +300,7 @@ export function ScheduledConditionFeed({
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded
|
||||
expanded && !desktopDock
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
: "w-[var(--workbench-condition-width)]"
|
||||
)
|
||||
@@ -354,7 +356,7 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
mobileSheet
|
||||
compactPresentation
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
@@ -364,7 +366,7 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet
|
||||
compactPresentation
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
@@ -386,7 +388,7 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
||||
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { useEffect, type ComponentProps } from "react";
|
||||
import { getAgentPanelDefaultWidth, getAgentPanelMaxWidth } from "../layout/workbench-layout";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
import { getWorkbenchViewportLayout } from "../layout/workbench-layout";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
|
||||
@@ -10,7 +8,6 @@ type WorkbenchAgentPanelsProps = {
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
conditionExpanded: boolean;
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
statusLabel: string;
|
||||
viewportWidth: number;
|
||||
@@ -23,7 +20,6 @@ export function WorkbenchAgentPanels({
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
conditionExpanded,
|
||||
personaState,
|
||||
statusLabel,
|
||||
viewportWidth,
|
||||
@@ -31,9 +27,7 @@ export function WorkbenchAgentPanels({
|
||||
onExpandPanel,
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const resize = useAgentPanelResize(viewportWidth, conditionExpanded);
|
||||
const defaultWidth = getAgentPanelDefaultWidth(viewportWidth, conditionExpanded);
|
||||
const committedWidth = resize.committedWidth ?? defaultWidth;
|
||||
const committedWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
|
||||
useEffect(() => {
|
||||
onWidthCommit?.(committedWidth);
|
||||
@@ -41,43 +35,24 @@ export function WorkbenchAgentPanels({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block"
|
||||
style={{ width: resize.width ?? defaultWidth }}
|
||||
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
|
||||
style={{ width: panelOpen ? committedWidth : 0 }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-floating"
|
||||
presentation="desktop-dock"
|
||||
collapsing={panelCollapsing}
|
||||
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
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -283,7 +283,8 @@ export function UserMenu({
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -292,6 +293,7 @@ export function UserMenu({
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
@@ -320,7 +322,15 @@ export function UserMenu({
|
||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||
<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" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
@@ -27,6 +28,7 @@ export type WorkbenchTopBarProps = {
|
||||
alerts: WorkbenchAlert[];
|
||||
user: WorkbenchUser;
|
||||
conditionFeedVisible: boolean;
|
||||
timelineVisible: boolean;
|
||||
taskTickerAvailable: boolean;
|
||||
taskTickerVisible: boolean;
|
||||
devPanelEnabled: boolean;
|
||||
@@ -34,16 +36,19 @@ export type WorkbenchTopBarProps = {
|
||||
onSelectScenario: (scenarioId: string) => void;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
onToggleConditionFeed: () => void;
|
||||
onToggleTimeline: () => void;
|
||||
onToggleTaskTicker: () => void;
|
||||
onToggleDevPanel: () => void;
|
||||
onPreviewScenario: () => void;
|
||||
onCompareScenario: () => void;
|
||||
onExportScenarioReport: () => void;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
onCreateTestAnalysis?: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||
@@ -56,6 +61,7 @@ export function WorkbenchTopBar({
|
||||
alerts,
|
||||
user,
|
||||
conditionFeedVisible,
|
||||
timelineVisible,
|
||||
taskTickerAvailable,
|
||||
taskTickerVisible,
|
||||
devPanelEnabled,
|
||||
@@ -63,16 +69,19 @@ export function WorkbenchTopBar({
|
||||
onSelectScenario,
|
||||
onSelectAlert,
|
||||
onToggleConditionFeed,
|
||||
onToggleTimeline,
|
||||
onToggleTaskTicker,
|
||||
onToggleDevPanel,
|
||||
onPreviewScenario,
|
||||
onCompareScenario,
|
||||
onExportScenarioReport,
|
||||
onAnalyzeAlertsWithAgent,
|
||||
onCreateTestAnalysis,
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
onExportConfig
|
||||
onExportConfig,
|
||||
onLogout
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const activeScenario =
|
||||
@@ -139,6 +148,29 @@ export function WorkbenchTopBar({
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
@@ -176,6 +208,7 @@ export function WorkbenchTopBar({
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
onExportConfig={onExportConfig}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
</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({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
|
||||
import useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import {
|
||||
@@ -50,20 +51,25 @@ const AGENT_PANEL_COLLAPSE_MS = 180;
|
||||
type UseWorkbenchAgentOptions = {
|
||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||
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 mobileCollapseTimerRef = useRef<number | 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 approvalModeRef = useRef<AgentApprovalMode>("request");
|
||||
const registryRef = useRef<UIRegistry | null>(null);
|
||||
const frontendActionEnabledRef = useRef(false);
|
||||
const onFrontendActionRef = useRef(onFrontendAction);
|
||||
const processedEnvelopeIdsRef = useRef(new Set<string>());
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||
@@ -205,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
() =>
|
||||
new DefaultChatTransport<AgentUiMessage>({
|
||||
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 }) {
|
||||
return {
|
||||
body: {
|
||||
@@ -220,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
};
|
||||
}
|
||||
}),
|
||||
[]
|
||||
[getAccessToken]
|
||||
);
|
||||
|
||||
const chat = useChat<AgentUiMessage>({
|
||||
|
||||
@@ -166,11 +166,19 @@ export function useWorkbenchMap({
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
});
|
||||
|
||||
let resizeFrame: number | null = null;
|
||||
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.setTimeout(resizeMap, 0);
|
||||
window.setTimeout(resizeMap, 300);
|
||||
window.setTimeout(resizeMap, 1000);
|
||||
|
||||
map.on("sourcedata", (event) => {
|
||||
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
||||
@@ -201,6 +209,10 @@ export function useWorkbenchMap({
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeMap);
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -17,3 +17,22 @@ export type {
|
||||
LineLayerGroupStylePatch,
|
||||
PointLayerGroupStylePatch
|
||||
} 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,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchCameraPadding,
|
||||
getWorkbenchViewportLayout
|
||||
getWorkbenchViewportLayout,
|
||||
resolveMobileWorkbenchOverlayLayout
|
||||
} from "./workbench-layout";
|
||||
|
||||
describe("workbench basemap surface tone", () => {
|
||||
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||
agentWidth: 500,
|
||||
@@ -51,6 +65,15 @@ describe("workbench floating panels", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
getWorkbenchCameraPadding(1920, {
|
||||
|
||||
@@ -3,7 +3,7 @@ export const WORKBENCH_LAYOUT = {
|
||||
desktopMinWidth: 1024,
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 72,
|
||||
collapsedAgentWidth: 0,
|
||||
maxAgentWidth: 720,
|
||||
agentPanelLeftInset: 12,
|
||||
conditionPanelRightInset: 64,
|
||||
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
|
||||
mobileSheet: {
|
||||
halfViewportRatio: 0.52,
|
||||
fullTopGap: 68
|
||||
},
|
||||
mobileOverlay: {
|
||||
edgeInset: 12,
|
||||
gap: 8,
|
||||
dockHeight: 44,
|
||||
timelineSummaryHeight: 48
|
||||
}
|
||||
} 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(
|
||||
width: number,
|
||||
viewportWidth: number,
|
||||
|
||||
@@ -2,20 +2,22 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type CSSProperties
|
||||
} from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { CalendarClock, Clock3 } from "lucide-react";
|
||||
import {
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
toTrustedMapAction,
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
type AgentUiResult
|
||||
type AgentUiResult,
|
||||
type AgentWorkspaceContextItem
|
||||
} from "@/features/agent";
|
||||
import {
|
||||
MapErrorNotice,
|
||||
@@ -29,15 +31,22 @@ import {
|
||||
type MapSourceStatus
|
||||
} from "@/features/map/core";
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import {
|
||||
MobileWorkbenchSheet,
|
||||
type MobileWorkbenchSheetSnap
|
||||
} from "./components/mobile-workbench-sheet";
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
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 {
|
||||
ToolbarPanel,
|
||||
type ExportViewPreset,
|
||||
@@ -45,11 +54,20 @@ import {
|
||||
type ToolbarToolId
|
||||
} from "./components/toolbar-panel";
|
||||
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 { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||
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 { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||
import {
|
||||
@@ -60,7 +78,8 @@ import {
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchLayoutCssVariables
|
||||
getWorkbenchLayoutCssVariables,
|
||||
resolveMobileWorkbenchOverlayLayout
|
||||
} from "./layout/workbench-layout";
|
||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||
import { exportMapViewImage } from "./map/export-view";
|
||||
@@ -69,7 +88,6 @@ import {
|
||||
BASE_LAYER_OPTIONS,
|
||||
INITIAL_LAYER_VISIBILITY,
|
||||
MAP_LEGEND_ITEMS,
|
||||
WORKBENCH_LAYER_GROUPS,
|
||||
applyBaseLayerVisibility,
|
||||
createLayerControlItems,
|
||||
getWorkbenchLayerIds
|
||||
@@ -79,7 +97,8 @@ import type {
|
||||
DetailFeature,
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionRecord,
|
||||
WorkbenchAlert
|
||||
WorkbenchAlert,
|
||||
WorkbenchUser
|
||||
} from "./types";
|
||||
import {
|
||||
createScheduledConditionAlerts,
|
||||
@@ -89,25 +108,22 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
||||
|
||||
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 devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||
const [devPanelOpen, setDevPanelOpen] = 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 [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 [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||||
@@ -141,115 +157,108 @@ export function MapWorkbenchPage() {
|
||||
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({
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
onFrontendAction: handleFrontendAction,
|
||||
getAccessToken
|
||||
});
|
||||
function openAgentPanelForViewport() {
|
||||
if (isLargeScreen) {
|
||||
if (conditionFeedExpanded && viewportWidth < 1440) {
|
||||
setConditionFeedExpanded(false);
|
||||
setAgentCollapsedForCondition(false);
|
||||
}
|
||||
agent.expandPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileSheet("agent");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
|
||||
const closeMobileSheet = useCallback(() => {
|
||||
setMobileSheet(null);
|
||||
const collapseAgentPanel = agent.collapsePanel;
|
||||
const expandAgentPanel = agent.expandPanel;
|
||||
const clearActiveTool = useCallback(() => {
|
||||
setActiveToolId(null);
|
||||
}, []);
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
|
||||
setAgentCollapsedForCondition(agent.panelOpen);
|
||||
agent.collapsePanel();
|
||||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
agent.expandPanel();
|
||||
}
|
||||
setConditionFeedExpanded(nextExpanded);
|
||||
}
|
||||
const {
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
isLargeScreen,
|
||||
leftPanelOpen,
|
||||
mobileSheet,
|
||||
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(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||
return;
|
||||
setTimelineMode((current) => {
|
||||
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);
|
||||
if (agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
agent.expandPanel();
|
||||
}
|
||||
}, [
|
||||
agent,
|
||||
agentCollapsedForCondition,
|
||||
conditionFeedExpanded,
|
||||
isLargeScreen,
|
||||
shouldShowConditionFeed
|
||||
]);
|
||||
|
||||
function toggleConditionFeedForViewport() {
|
||||
setActiveToolId(null);
|
||||
if (isLargeScreen) {
|
||||
setConditionFeedVisible((current) => !current);
|
||||
return;
|
||||
function openConditionWorkspaceForViewport() {
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
|
||||
if (mobileSheet === "condition") {
|
||||
closeMobileSheet();
|
||||
return;
|
||||
function toggleConditionWorkspaceForViewport() {
|
||||
toggleConditionFeedForViewport();
|
||||
}
|
||||
|
||||
setConditionFeedExpanded(false);
|
||||
setMobileSheetSnap("half");
|
||||
setMobileSheet("condition");
|
||||
function toggleTimelineVisibility() {
|
||||
setTimelineVisible((current) => {
|
||||
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({
|
||||
containerRef: mapContainerRef,
|
||||
impactVisible,
|
||||
@@ -259,7 +268,7 @@ export function MapWorkbenchPage() {
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
leftPanelOpen: false,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
@@ -298,7 +307,11 @@ export function MapWorkbenchPage() {
|
||||
});
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker =
|
||||
mobileSheet === null && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
!artifactActive
|
||||
&& mobileSheet === null
|
||||
&& !conditionFeedExpanded
|
||||
&& taskTickerVisible
|
||||
&& taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
@@ -351,6 +364,54 @@ export function MapWorkbenchPage() {
|
||||
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
|
||||
}, []);
|
||||
|
||||
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
|
||||
dispatchWorkspaceContext({
|
||||
type: "select-operational-event",
|
||||
eventId: event.id,
|
||||
cursorTime: event.cursorTime
|
||||
});
|
||||
|
||||
const mapTarget = event.mapTargets[0];
|
||||
if (mapTarget) {
|
||||
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[]>(
|
||||
() =>
|
||||
waterNetworkToolbarItems.map((item) => ({
|
||||
@@ -379,9 +440,7 @@ export function MapWorkbenchPage() {
|
||||
}
|
||||
|
||||
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -476,11 +535,10 @@ export function MapWorkbenchPage() {
|
||||
setImpactVisible(true);
|
||||
setLayerVisibility((current) => ({ ...current, simulation: true }));
|
||||
|
||||
if (mapRef.current && mapReady) {
|
||||
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => {
|
||||
if (mapRef.current?.getLayer(layerId)) {
|
||||
mapRef.current.setLayoutProperty(layerId, "visibility", "visible");
|
||||
}
|
||||
const map = mapRef.current;
|
||||
if (map && mapReady) {
|
||||
getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
|
||||
map.setLayoutProperty(layerId, "visibility", "visible");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -522,7 +580,7 @@ export function MapWorkbenchPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
openAgentPanelForViewport();
|
||||
openAgentWorkspaceForViewport();
|
||||
|
||||
try {
|
||||
await agent.submitPrompt(
|
||||
@@ -559,12 +617,7 @@ export function MapWorkbenchPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionFeedVisible(true);
|
||||
setActiveToolId(null);
|
||||
if (!isLargeScreen) {
|
||||
setMobileSheet("condition");
|
||||
setMobileSheetSnap("half");
|
||||
}
|
||||
openConditionWorkspaceForViewport();
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
@@ -686,7 +739,7 @@ export function MapWorkbenchPage() {
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
leftPanelOpen,
|
||||
false,
|
||||
rightPanelOpen,
|
||||
rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
@@ -804,6 +857,35 @@ export function MapWorkbenchPage() {
|
||||
onShowShortcuts: handleShowShortcuts,
|
||||
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<
|
||||
ComponentProps<typeof AgentCommandPanel>,
|
||||
"collapsing" | "onCollapse"
|
||||
@@ -822,6 +904,7 @@ export function MapWorkbenchPage() {
|
||||
selectedModel: agent.selectedModel,
|
||||
approvalMode: agent.approvalMode,
|
||||
uiResults: activeAgentUiResults,
|
||||
workspaceContext: agentWorkspaceContext,
|
||||
onRefreshHistory: agent.refreshSessionHistory,
|
||||
onStartNewSession: handleStartNewAgentSession,
|
||||
onLoadHistorySession: handleLoadAgentHistorySession,
|
||||
@@ -843,51 +926,135 @@ export function MapWorkbenchPage() {
|
||||
style={
|
||||
{
|
||||
...WORKBENCH_LAYOUT_CSS_VARIABLES,
|
||||
"--workbench-agent-current-width": `${
|
||||
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
||||
}px`
|
||||
"--workbench-agent-current-width": `${currentAgentWidth}px`,
|
||||
"--workbench-timeline-height": `${OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="供水管网地图"
|
||||
/>
|
||||
|
||||
<WorkbenchTopBar
|
||||
dataTime={headerDataTime}
|
||||
modelName="AquaDispatch v1.0"
|
||||
scenarios={WORKBENCH_SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={WORKBENCH_USER}
|
||||
user={user}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
timelineVisible={timelineVisible}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
devPanelEnabled={devPanelEnabled}
|
||||
devPanelOpen={devPanelOpen}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
onSelectAlert={handleSelectAlert}
|
||||
onToggleConditionFeed={toggleConditionFeedForViewport}
|
||||
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
||||
onToggleTimeline={toggleTimelineVisibility}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||
onPreviewScenario={handlePreviewScenario}
|
||||
onCompareScenario={handleCompareScenario}
|
||||
onExportScenarioReport={handleExportScenarioReport}
|
||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
|
||||
onShowDataStatus={handleShowDataStatus}
|
||||
onRefreshTiles={handleRefreshTiles}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
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
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
viewportWidth={viewportWidth}
|
||||
@@ -896,14 +1063,8 @@ export function MapWorkbenchPage() {
|
||||
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 ? (
|
||||
<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
|
||||
presentation="desktop-floating"
|
||||
conditions={scheduledConditions}
|
||||
@@ -915,19 +1076,13 @@ export function MapWorkbenchPage() {
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
onExpandAgent={openAgentWorkspaceForViewport}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{devPanelEnabled && devPanelOpen ? (
|
||||
<MapDevPanel
|
||||
commands={mapController}
|
||||
@@ -937,8 +1092,11 @@ export function MapWorkbenchPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mobileSheet === null ? (
|
||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||||
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<div
|
||||
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
|
||||
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
|
||||
>
|
||||
<ToolbarPanel
|
||||
{...toolbarPanelProps}
|
||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||
@@ -946,18 +1104,11 @@ export function MapWorkbenchPage() {
|
||||
</div>
|
||||
) : 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}>
|
||||
{shouldShowTaskTicker ? (
|
||||
<motion.div
|
||||
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" }}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
@@ -976,7 +1127,10 @@ export function MapWorkbenchPage() {
|
||||
) : null}
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
@@ -988,11 +1142,13 @@ export function MapWorkbenchPage() {
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-8 w-8" state={agent.personaState} />
|
||||
</button>
|
||||
{artifactWorkspace.surfaceMode !== "focus" ? (
|
||||
<MapToolbar
|
||||
items={toolbarItems}
|
||||
orientation="horizontal"
|
||||
className="h-11 justify-center"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开工况任务"
|
||||
@@ -1008,7 +1164,13 @@ export function MapWorkbenchPage() {
|
||||
|
||||
{mobileSheet ? (
|
||||
<MobileWorkbenchSheet
|
||||
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
|
||||
label={
|
||||
mobileSheet === "agent"
|
||||
? "Agent 工作台抽屉"
|
||||
: mobileSheet === "timeline"
|
||||
? "运行时间轴抽屉"
|
||||
: "工况任务抽屉"
|
||||
}
|
||||
snap={mobileSheetSnap}
|
||||
onSnapChange={setMobileSheetSnap}
|
||||
onClose={closeMobileSheet}
|
||||
@@ -1019,6 +1181,19 @@ export function MapWorkbenchPage() {
|
||||
presentation="mobile-sheet"
|
||||
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
|
||||
presentation="mobile-sheet"
|
||||
@@ -1091,3 +1266,11 @@ function getExportPresetLabel(preset: ExportViewPreset) {
|
||||
|
||||
return "当前分辨率";
|
||||
}
|
||||
|
||||
function formatContextClock(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 96 }
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 24 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
@@ -28,7 +28,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 96 }
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 24 }
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
+21
-11
@@ -1,23 +1,33 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "@/app/app";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { AuthStartupScreen } from "@/app/auth-startup-screen";
|
||||
import { initializeAuthentication } from "@/shared/auth/keycloak-auth";
|
||||
import "@/styles.css";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "streamdown/styles.css";
|
||||
|
||||
async function prepareMocks() {
|
||||
if (!env.TJWATER_ENABLE_MSW) return;
|
||||
const root = ReactDOM.createRoot(document.getElementById("root")!);
|
||||
|
||||
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");
|
||||
await worker.start({ onUnhandledRequest: "bypass" });
|
||||
}
|
||||
|
||||
await prepareMocks();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
}
|
||||
const authentication = await initializeAuthentication(env);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<App authentication={authentication} />
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize authentication", error);
|
||||
root.render(<AuthStartupScreen error />);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ describe("runtime frontend configuration", () => {
|
||||
it("uses container-provided values without relying on Vite build variables", () => {
|
||||
expect(
|
||||
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_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
@@ -14,6 +17,9 @@ describe("runtime frontend configuration", () => {
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
})
|
||||
).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_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
@@ -24,12 +30,18 @@ describe("runtime frontend configuration", () => {
|
||||
});
|
||||
|
||||
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_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", () => {
|
||||
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://agent.example.test/#secret"]
|
||||
])("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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,14 +22,53 @@ 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_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_ENABLE_DEV_PANEL: 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 {
|
||||
var __TJWATER_CONFIG__: unknown;
|
||||
|
||||
@@ -899,6 +899,15 @@ button.agent-history-session-trigger:not(:disabled):active {
|
||||
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-core {
|
||||
transform-box: fill-box;
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "disabled" };
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
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 = {
|
||||
x: number;
|
||||
@@ -12,41 +27,36 @@ async function waitForAnimations(locator: Locator) {
|
||||
const animations = element
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
|
||||
await Promise.all(
|
||||
animations.map((animation) => animation.finished.catch(() => undefined))
|
||||
);
|
||||
await Promise.all(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" });
|
||||
|
||||
const topBar = page.locator("header.acrylic-navigation");
|
||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||
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 agentHeader = agentPanel.locator(".agent-panel-header");
|
||||
const agentConversation = agentPanel.getByRole("log");
|
||||
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(navigation).toBeVisible();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect(agentHeader).toBeVisible();
|
||||
await expect(agentComposer).toBeVisible();
|
||||
await expect(agentControls).toBeVisible();
|
||||
await waitForAnimations(agentPanel);
|
||||
|
||||
await expectAcrylicSurface(topBar);
|
||||
await expectAcrylicSurface(agentPanel, ".agent-panel-composer");
|
||||
await expectIntegratedHeader(agentHeader);
|
||||
await expectContextualAcrylicSurface(agentComposer, {
|
||||
backdropFilter: "blur(",
|
||||
maximumAlpha: 0.9,
|
||||
minimumAlpha: 0.84
|
||||
});
|
||||
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(navigation).toHaveCSS("background-color", "rgb(237, 241, 245)");
|
||||
await expect(navigation).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(agentPanel).toHaveCSS("background-color", "rgb(247, 249, 251)");
|
||||
await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||
await expect(agentPanel).toHaveCSS("box-shadow", "none");
|
||||
await expect(agentControls).toHaveCSS("border-top-width", "1px");
|
||||
await expect(
|
||||
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||
).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 conversationBox = await agentConversation.boundingBox();
|
||||
const scrollBox = await agentConversationScroll.boundingBox();
|
||||
const composerBox = await agentComposer.boundingBox();
|
||||
expect(panelBox).not.toBeNull();
|
||||
expect(conversationBox).not.toBeNull();
|
||||
expect(scrollBox).not.toBeNull();
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(panelBox!.x).toBe(48);
|
||||
expect(panelBox!.width).toBe(319);
|
||||
expect(
|
||||
Math.abs(conversationBox!.y + conversationBox!.height - (panelBox!.y + panelBox!.height - 4))
|
||||
).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)
|
||||
)
|
||||
Math.abs(conversationBox!.x - panelBox!.x)
|
||||
).toBeLessThanOrEqual(1);
|
||||
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 expect
|
||||
.poll(() => getFloatingSurfaceStyles(agentHeader, agentComposer))
|
||||
.poll(() => getSurfacePresentation(agentPanel))
|
||||
.toEqual(beforeFocus);
|
||||
});
|
||||
|
||||
async function expectIntegratedHeader(header: Locator) {
|
||||
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
|
||||
}) => {
|
||||
test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
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", {
|
||||
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();
|
||||
|
||||
@@ -125,51 +97,7 @@ test("desktop Agent header expands into one attached acrylic history surface", a
|
||||
await expect(history).toBeVisible();
|
||||
await waitForAnimations(history);
|
||||
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
||||
await page.mouse.move(720, 320);
|
||||
await expectAttachedHistoryExtension(history);
|
||||
await expectTransparentExpandedHeader(agentHeader, agentPanel);
|
||||
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();
|
||||
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(historyButton).toBeFocused();
|
||||
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 ({
|
||||
page
|
||||
}) => {
|
||||
test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
|
||||
const agentPanel = page
|
||||
.locator('aside[aria-label="Agent 命令面板"]')
|
||||
.filter({ visible: true });
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await waitForAnimations(agentPanel);
|
||||
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();
|
||||
});
|
||||
|
||||
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.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const composer = page.locator(".agent-panel-composer");
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(composer).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(composer).toHaveCSS("background-image", "none");
|
||||
await expect(composer).toHaveCSS("box-shadow", "none");
|
||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||
const workspace = page.locator("#main-workspace");
|
||||
await expect(navigation).toBeVisible();
|
||||
await expect(navigation).toHaveCSS("backdrop-filter", "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) {
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
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" });
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(resizeHandle).toBeVisible();
|
||||
|
||||
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" });
|
||||
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const expandedWidth = Math.round((await agentPanel.boundingBox())?.width ?? 0);
|
||||
expect(expandedWidth).toBeGreaterThan(400);
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
|
||||
|
||||
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 折叠栏"]');
|
||||
const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ });
|
||||
await expect(rail).toBeVisible();
|
||||
await expect(rail).toHaveCSS("width", "72px");
|
||||
await expect(expandButton).toHaveAttribute("title", /当前|正在/);
|
||||
await page.getByLabel(/展开 Agent 助手面板/).click();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
|
||||
});
|
||||
|
||||
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();
|
||||
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 }) => {
|
||||
@@ -71,19 +71,18 @@ test.describe("mobile touch layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Temporarily disabled: this regression flow would start a real Agent run.
|
||||
// Agent analysis must be explicitly triggered by a user, not by automated tests.
|
||||
test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||
await mockAgentApi(page);
|
||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
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(
|
||||
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
|
||||
await expect(page.getByText("已发起工况汇总", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ test("submits a custom answer with the actionable id after a later tool placehol
|
||||
await mockQuestionApi(page, replies);
|
||||
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 page
|
||||
.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.getByRole("button", { name: "提交回答" }).click();
|
||||
|
||||
await expect.poll(() => replies).toEqual([
|
||||
await expect
|
||||
.poll(() => replies)
|
||||
.toEqual([
|
||||
{
|
||||
pathname: "/api/v1/agent/chat/question/question-1/reply",
|
||||
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);
|
||||
});
|
||||
|
||||
async function mockQuestionApi(
|
||||
page: Page,
|
||||
replies: Array<{ pathname: string; body: unknown }>
|
||||
) {
|
||||
async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; body: unknown }>) {
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
@@ -119,8 +121,8 @@ function createQuestionStream() {
|
||||
question: "你想分析哪一部分?",
|
||||
options: [
|
||||
{
|
||||
label: "雨污混接分析",
|
||||
description: "检查混接问题"
|
||||
label: "供水分区压力分析",
|
||||
description: "检查分区压力问题"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ test.beforeAll(async () => {
|
||||
phase: "planning",
|
||||
status: "running",
|
||||
title: "正在分析运行上下文",
|
||||
detail: "核对泵站、液位与降雨过程。",
|
||||
detail: "核对泵站、液位与用水过程。",
|
||||
started_at: Date.now()
|
||||
}
|
||||
});
|
||||
@@ -47,8 +47,8 @@ test.beforeAll(async () => {
|
||||
id: "inspect-rainfall",
|
||||
phase: "evidence",
|
||||
status: "completed",
|
||||
title: "正在核对降雨过程与汇水区响应关系",
|
||||
detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。",
|
||||
title: "正在核对用水过程与供水分区响应关系",
|
||||
detail: "读取最近十二小时分钟级流量序列,并检查每个供水分区的响应延迟是否处于合理范围。",
|
||||
started_at: Date.now()
|
||||
}
|
||||
});
|
||||
@@ -74,7 +74,7 @@ test.beforeAll(async () => {
|
||||
todos: [
|
||||
{ id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" },
|
||||
{ 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",
|
||||
phase: "analysis",
|
||||
status: "completed",
|
||||
title: "溢流风险判断完成",
|
||||
title: "供水风险判断完成",
|
||||
detail: "已完成风险点位排序。",
|
||||
ended_at: Date.now()
|
||||
}
|
||||
@@ -131,7 +131,7 @@ test.beforeAll(async () => {
|
||||
id: "assess-risk",
|
||||
phase: "analysis",
|
||||
status: "running",
|
||||
title: "正在判断溢流风险",
|
||||
title: "正在判断供水风险",
|
||||
detail: "结合流量突变与液位趋势形成判断。",
|
||||
started_at: Date.now()
|
||||
}
|
||||
@@ -382,7 +382,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
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.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0);
|
||||
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 })
|
||||
expandedLiveProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true })
|
||||
).toHaveCSS("white-space", "normal");
|
||||
await progressToggle.click();
|
||||
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(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })
|
||||
page.getByText("供水风险判断完成", { exact: true }).filter({ visible: true })
|
||||
).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
@@ -541,7 +541,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
await expect(
|
||||
page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()
|
||||
).toBeVisible();
|
||||
const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", {
|
||||
const expandedLongTitle = expandedProgress.getByText("正在核对用水过程与供水分区响应关系", {
|
||||
exact: true
|
||||
});
|
||||
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");
|
||||
triggerProgressAppend();
|
||||
await expect(
|
||||
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true })
|
||||
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断供水风险", { exact: true })
|
||||
).toBeVisible();
|
||||
await latestProgressToggle.click();
|
||||
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(latestExpandedProgress).toBeVisible();
|
||||
await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible();
|
||||
await expect(latestExpandedProgress.getByText("供水风险判断完成", { exact: true })).toBeVisible();
|
||||
|
||||
const conversationScroll = page.locator(".agent-conversation-scroll");
|
||||
await conversationScroll.hover();
|
||||
@@ -673,7 +673,7 @@ function createLongMarkdownResponse() {
|
||||
"当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n";
|
||||
content += "- 检查泵站运行状态与实时流量。\n";
|
||||
content += "- 核对液位计连续性和异常峰值。\n";
|
||||
content += "- 结合降雨过程判断溢流风险。\n\n";
|
||||
content += "- 结合用水过程判断供水风险。\n\n";
|
||||
}
|
||||
return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
@@ -151,6 +210,7 @@ async function prepareWorkbench(page: Page) {
|
||||
await route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
||||
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 }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
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 }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
@@ -168,6 +170,7 @@ test("keeps execution spinners without breathing badges after selecting timeline
|
||||
page
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
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 }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
test("native scroll regions share the workbench scrollbar presentation", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
|
||||
const reference = page
|
||||
.getByRole("region", { name: "工况任务", exact: true })
|
||||
|
||||
@@ -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 }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(window.__waterNetworkMap?.getLayer("scada-hit"))
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) &&
|
||||
!window.__waterNetworkMap?.isMoving()
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => {
|
||||
const map = window.__waterNetworkMap;
|
||||
document.documentElement.dataset.panelCameraMoveCount = "0";
|
||||
map?.on("movestart", () => {
|
||||
const currentCount = Number(
|
||||
document.documentElement.dataset.panelCameraMoveCount ?? "0"
|
||||
);
|
||||
const currentCount = Number(document.documentElement.dataset.panelCameraMoveCount ?? "0");
|
||||
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
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.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 });
|
||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
await expect(conditionPanel).toHaveCSS("width", "880px");
|
||||
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.dataset.panelCameraMoveCount
|
||||
)
|
||||
).toBe("0");
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.panelCameraMoveCount)).toBe(
|
||||
"0"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64"
|
||||
);
|
||||
|
||||
test.describe("neutral blue mist workbench", () => {
|
||||
test.describe("Main Frame workbench", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
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 openWorkbench(page);
|
||||
|
||||
await expectDesktopFloatingGeometry(page);
|
||||
await expectAcrylicAlphas(page, {
|
||||
navigation: 0.64,
|
||||
agentPanel: 0.82,
|
||||
conditionPanel: 0.56,
|
||||
control: 0.74
|
||||
});
|
||||
await expectDesktopMainFrameGeometry(page);
|
||||
await expectSolidMainFrameMaterials(page);
|
||||
await expectLoadedChineseFont(page);
|
||||
await expectBodyContrast(page);
|
||||
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 openWorkbench(page);
|
||||
await selectBasemap(page, "影像");
|
||||
|
||||
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
||||
await expectDesktopFloatingGeometry(page);
|
||||
await expectAcrylicAlphas(page, {
|
||||
navigation: 0.8,
|
||||
agentPanel: 0.88,
|
||||
conditionPanel: 0.72,
|
||||
control: 0.86
|
||||
});
|
||||
await expectDesktopMainFrameGeometry(page);
|
||||
await expectSolidMainFrameMaterials(page);
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
||||
animations: "disabled",
|
||||
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 openWorkbench(page);
|
||||
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
const navigation = page.getByRole("complementary", { name: "工作台导航" });
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const expandedWorkspace = await workspace.boundingBox();
|
||||
expect(expandedWorkspace).not.toBeNull();
|
||||
await expect(navigation).toHaveCSS("width", "368px");
|
||||
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect(agentPanel).toHaveCSS("width", "720px");
|
||||
await expectMapCorridor(agentPanel, conditionPanel, 680);
|
||||
|
||||
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(conditionPanel).toHaveCSS("width", "960px");
|
||||
await expect(agentPanel).toHaveCSS("width", "628px");
|
||||
await expectMapCorridor(agentPanel, conditionPanel, 256);
|
||||
await page.getByRole("button", { name: "折叠工作台导航" }).click();
|
||||
await expect(navigation).toHaveCSS("width", "48px");
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48);
|
||||
await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1872);
|
||||
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
@@ -116,10 +105,19 @@ test.describe("neutral blue mist workbench", () => {
|
||||
});
|
||||
|
||||
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 route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
||||
@@ -148,7 +146,9 @@ async function prepareDeterministicWorkbench(page: Page) {
|
||||
|
||||
async function openWorkbench(page: Page) {
|
||||
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");
|
||||
});
|
||||
await page.addStyleTag({
|
||||
@@ -176,51 +176,67 @@ async function openWorkbench(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
await page.waitForTimeout(350);
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-hit")));
|
||||
}
|
||||
|
||||
async function selectBasemap(page: Page, label: "浅色" | "影像") {
|
||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||
await page.getByRole("button", { name: label, exact: true }).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 agentHeader = agentPanel.locator(".agent-panel-integrated-header");
|
||||
const agentComposer = agentPanel.locator(".agent-panel-composer");
|
||||
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
|
||||
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");
|
||||
await expect(navigation).toHaveCSS("width", "368px");
|
||||
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||
await expect(mapWindow).toHaveCSS("border-radius", "0px");
|
||||
|
||||
const navigationBox = await navigation.boundingBox();
|
||||
const workspaceBox = await workspace.boundingBox();
|
||||
const mapBox = await mapWindow.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(conditionBox).not.toBeNull();
|
||||
expect(agentBox!.x).toBe(12);
|
||||
expect(agentBox!.y).toBe(96);
|
||||
expect(agentBox!.y + agentBox!.height).toBe(884);
|
||||
expect(conditionBox!.x).toBe(944);
|
||||
expect(conditionBox!.y).toBe(96);
|
||||
expect(navigationBox!.x).toBe(0);
|
||||
expect(navigationBox!.y).toBe(56);
|
||||
expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
|
||||
expect(workspaceBox!.y).toBe(navigationBox!.y);
|
||||
expect(mapBox).toEqual(workspaceBox);
|
||||
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(
|
||||
agentPanel: Locator,
|
||||
conditionPanel: Locator,
|
||||
minimumWidth: number
|
||||
) {
|
||||
const agentBox = await agentPanel.boundingBox();
|
||||
const conditionBox = await conditionPanel.boundingBox();
|
||||
expect(agentBox).not.toBeNull();
|
||||
expect(conditionBox).not.toBeNull();
|
||||
expect(conditionBox!.x - (agentBox!.x + agentBox!.width)).toBeGreaterThanOrEqual(minimumWidth);
|
||||
async function expectSolidMainFrameMaterials(page: Page) {
|
||||
const materials = await page.evaluate(() => {
|
||||
const navigation = getComputedStyle(document.querySelector('[aria-label="工作台导航"]')!);
|
||||
const agent = getComputedStyle(document.querySelector('aside[aria-label="Agent 命令面板"]')!);
|
||||
return {
|
||||
navigationBackdrop: navigation.backdropFilter,
|
||||
navigationBackground: navigation.backgroundColor,
|
||||
agentBackdrop: agent.backdropFilter,
|
||||
agentBackground: agent.backgroundColor,
|
||||
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) {
|
||||
@@ -231,41 +247,6 @@ async function expectLoadedChineseFont(page: Page) {
|
||||
.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) {
|
||||
const contrast = await page
|
||||
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 199 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 219 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 174 KiB |
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,9 @@ const RUNTIME_CONFIG_PATH = "/runtime-config.js";
|
||||
|
||||
function renderRuntimeConfig(values: Record<string, string>) {
|
||||
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_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater",
|
||||
|
||||
Reference in New Issue
Block a user