fix: align drainage frontend configuration

This commit is contained in:
2026-07-22 15:01:35 +08:00
parent 72850761ce
commit 0995172828
45 changed files with 853 additions and 339 deletions
+3
View File
@@ -1,5 +1,8 @@
node_modules
.git
.next
dist
dist-server
coverage
playwright-report
test-results
+2 -4
View File
@@ -2,10 +2,8 @@ DRAINAGE_MAPBOX_ACCESS_TOKEN=
DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver
DRAINAGE_GEOSERVER_WORKSPACE=wenzhou
DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787
DRAINAGE_TTS_API_URL=/api/tts/edge
DRAINAGE_ENABLE_DEV_PANEL=false
DRAINAGE_ENABLE_MSW=false
# Optional development proxy target. This value is not exposed to browser code.
AGENT_API_INTERNAL_BASE_URL=http://127.0.0.1:8787
TTS_API_INTERNAL_BASE_URL=http://127.0.0.1:8790
# Optional server-only Edge TTS voice. Never exposed through runtime-config.js.
EDGE_TTS_VOICE=zh-CN-XiaoxiaoNeural
+1
View File
@@ -2,6 +2,7 @@
node_modules
.next
dist
dist-server
coverage
playwright-report
test-results
+7 -6
View File
@@ -16,10 +16,12 @@ drainage-network WebGIS. Runtime TypeScript lives under `src/`.
- `src/mocks/`: MSW handlers and fixtures.
- `tests/browser/`: product-specific Playwright regressions.
- `public/`: static drainage and SCADA assets plus the runtime-config placeholder.
- `server/`: same-origin Edge TTS adapter used by Vite and the production container.
Do not add Next.js APIs or route handlers. Backend and BFF behavior belongs in
the Agent service or another service. Browser-safe GeoServer reads may happen
directly when CORS is enabled.
Do not add Next.js APIs or general BFF routes. Agent behavior belongs in the
Agent service; the local `server/` exception is limited to the same-origin Edge
TTS network adapter. Browser-safe GeoServer reads may happen directly when CORS
is enabled.
## Commands
@@ -81,9 +83,8 @@ runtime configuration, or map interactions. Name tests after behavior.
## Configuration and Security
Browser configuration is injected through `/runtime-config.js` and validated in
`src/shared/config/env.ts`. Use `DRAINAGE_*` variables. Legacy
`NEXT_PUBLIC_*` values are accepted by the Vite development adapter only to ease
local migration.
`src/shared/config/env.ts`. Use `DRAINAGE_*` variables; legacy `NEXT_PUBLIC_*`
names are not supported.
Do not commit secrets, `.env.local`, generated `dist/`, dependency folders,
Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets
+7 -4
View File
@@ -8,9 +8,12 @@
@immutableAssets path /assets/*
header @immutableAssets Cache-Control "public, max-age=31536000, immutable"
@tts path /api/tts/edge
reverse_proxy @tts {$TTS_API_INTERNAL_BASE_URL:http://127.0.0.1:8790}
handle /api/tts/edge {
reverse_proxy 127.0.0.1:8790
}
try_files {path} /index.html
file_server
handle {
try_files {path} /index.html
file_server
}
}
+2 -1
View File
@@ -11,10 +11,11 @@ RUN pnpm build
FROM caddy:2.10.2-alpine
RUN apk add --no-cache jq
RUN apk add --no-cache jq nodejs
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/drainage-frontend-entrypoint
COPY --from=build /app/dist /srv
COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/drainage-frontend/edge-tts-server.cjs
RUN chmod 755 /usr/local/bin/drainage-frontend-entrypoint
EXPOSE 80
+11 -17
View File
@@ -26,23 +26,17 @@ pnpm dev
## 运行时配置
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 前缀。开发环境可在 `.env.local` 中设置:
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_``NEXT_PUBLIC_` 前缀。开发环境可在 `.env.local` 中设置:
| 变量 | 默认值 | 说明 |
| ------------------------------ | --------------------------------------------- | -------------------------------------- |
| `DRAINAGE_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `DRAINAGE_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `DRAINAGE_GEOSERVER_WORKSPACE` | `wenzhou` | 排水数据工作区 |
| `DRAINAGE_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent 服务地址 |
| `DRAINAGE_TTS_API_URL` | `/api/tts/edge` | 语音合成接口地址 |
| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
| `AGENT_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8787` | Vite 开发代理访问的 Agent 服务地址 |
| `TTS_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8790` | Vite/Caddy 代理访问的独立 TTS 服务地址 |
Vite 开发适配器会兼容现有 `.env.local` 中的 `NEXT_PUBLIC_*` 变量,生产容器应使用 `DRAINAGE_*` 变量。
排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。Agent 流式接口使用 `DRAINAGE_AGENT_API_BASE_URL`。语音合成依赖独立 TTS 服务;默认站内路径会由 Vite 或容器内 Caddy 转发到 `TTS_API_INTERNAL_BASE_URL`
| 变量 | 默认值 | 说明 |
| ------------------------------ | --------------------------------------------- | ------------------------------------ |
| `DRAINAGE_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `DRAINAGE_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `DRAINAGE_GEOSERVER_WORKSPACE` | `wenzhou` | 排水数据工作区 |
| `DRAINAGE_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent 服务地址 |
| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。所有 Agent 请求由浏览器直接访问 `DRAINAGE_AGENT_API_BASE_URL`。语音播放请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
## 常用命令
@@ -60,7 +54,7 @@ pnpm test:browser # 运行 Playwright 浏览器测试
## Docker
生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时根据环境变量生成 `/runtime-config.js`
生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时根据环境变量生成 `/runtime-config.js`,同时启动仅监听容器回环地址的 Edge TTS 适配器
```bash
docker build -t next-tjwater-drainage-frontend .
+17 -3
View File
@@ -6,7 +6,6 @@ runtime_config=$(jq -cn \
--arg mapUrl "${DRAINAGE_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${DRAINAGE_GEOSERVER_WORKSPACE:-wenzhou}" \
--arg agentApiBaseUrl "${DRAINAGE_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
--arg ttsApiUrl "${DRAINAGE_TTS_API_URL:-/api/tts/edge}" \
--arg enableDevPanel "${DRAINAGE_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${DRAINAGE_ENABLE_MSW:-false}" \
'{
@@ -14,11 +13,26 @@ runtime_config=$(jq -cn \
DRAINAGE_MAP_URL: $mapUrl,
DRAINAGE_GEOSERVER_WORKSPACE: $geoserverWorkspace,
DRAINAGE_AGENT_API_BASE_URL: $agentApiBaseUrl,
DRAINAGE_TTS_API_URL: $ttsApiUrl,
DRAINAGE_ENABLE_DEV_PANEL: $enableDevPanel,
DRAINAGE_ENABLE_MSW: $enableMsw
}')
printf 'globalThis.__DRAINAGE_CONFIG__ = %s;\n' "$runtime_config" > /srv/runtime-config.js
exec "$@"
node /usr/local/lib/drainage-frontend/edge-tts-server.cjs &
tts_pid=$!
"$@" &
app_pid=$!
terminate() {
kill "$app_pid" "$tts_pid" 2>/dev/null || true
}
trap terminate INT TERM
status=0
wait -n "$app_pid" "$tts_pid" || status=$?
terminate
wait "$app_pid" 2>/dev/null || true
wait "$tts_pid" 2>/dev/null || true
exit "$status"
+8 -1
View File
@@ -6,7 +6,14 @@ import globals from "globals";
export default [
{
ignores: [".next/**", "dist/**", "node_modules/**", "playwright-report/**", "test-results/**"]
ignores: [
".next/**",
"dist/**",
"dist-server/**",
"node_modules/**",
"playwright-report/**",
"test-results/**"
]
},
js.configs.recommended,
{
+5 -2
View File
@@ -6,8 +6,9 @@
"packageManager": "pnpm@11.8.0",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0",
"build": "tsc --noEmit && vite build && pnpm build:tts",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
@@ -41,6 +42,7 @@
"cmdk": "^1.1.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"edge-tts-ts": "^1.0.0",
"katex": "^0.17.0",
"lucide-react": "^0.468.0",
"maplibre-gl": "^4.7.1",
@@ -73,6 +75,7 @@
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"esbuild": "^0.28.0",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"msw": "^2.6.8",
+47
View File
@@ -83,6 +83,9 @@ importers:
echarts-for-react:
specifier: ^3.0.2
version: 3.0.6(echarts@6.1.0)(react@19.2.7)
edge-tts-ts:
specifier: ^1.0.0
version: 1.0.0
katex:
specifier: ^0.17.0
version: 0.17.0
@@ -165,6 +168,9 @@ importers:
'@vitejs/plugin-react-swc':
specifier: ^4.0.2
version: 4.3.1(@swc/helpers@0.5.23)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0))
esbuild:
specifier: ^0.28.0
version: 0.28.1
eslint:
specifier: ^9.17.0
version: 9.39.5(jiti@2.7.0)
@@ -2212,6 +2218,10 @@ packages:
resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==}
engines: {node: '>=12.20.0'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
@@ -2480,6 +2490,10 @@ packages:
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
edge-tts-ts@1.0.0:
resolution: {integrity: sha512-327gpuN0VjvMsuvbizqabzqMiYpdHb0Slt8/hyf+ridtSkOGN68oogLpFnm8KznunbCnoo3DWMTAn7j6sd3WrA==}
hasBin: true
electron-to-chromium@1.5.393:
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
@@ -2924,6 +2938,11 @@ packages:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
isomorphic-ws@5.0.0:
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
ws: '*'
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -3708,6 +3727,9 @@ packages:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
sound-play@1.1.0:
resolution: {integrity: sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3948,6 +3970,10 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
uuid@13.0.2:
resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==}
hasBin: true
uuid@14.0.1:
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
hasBin: true
@@ -6108,6 +6134,8 @@ snapshots:
table-layout: 4.1.1
typical: 7.3.0
commander@14.0.3: {}
commander@7.2.0: {}
commander@8.3.0: {}
@@ -6390,6 +6418,17 @@ snapshots:
tslib: 2.3.0
zrender: 6.1.0
edge-tts-ts@1.0.0:
dependencies:
commander: 14.0.3
isomorphic-ws: 5.0.0(ws@8.21.1)
sound-play: 1.1.0
uuid: 13.0.2
ws: 8.21.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
electron-to-chromium@1.5.393: {}
emoji-regex@8.0.0: {}
@@ -6892,6 +6931,10 @@ snapshots:
isexe@3.1.5: {}
isomorphic-ws@5.0.0(ws@8.21.1):
dependencies:
ws: 8.21.1
jiti@2.7.0: {}
js-tokens@4.0.0: {}
@@ -7964,6 +8007,8 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
sound-play@1.1.0: {}
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
@@ -8223,6 +8268,8 @@ snapshots:
dependencies:
react: 19.2.7
uuid@13.0.2: {}
uuid@14.0.1: {}
vfile-location@5.0.3:
+24
View File
@@ -0,0 +1,24 @@
import { createServer } from "node:http";
import { createEdgeTtsMiddleware } from "./edge-tts-service";
const host = "127.0.0.1";
const port = 8790;
const handleEdgeTts = createEdgeTtsMiddleware({ trustProxy: true });
const server = createServer((request, response) => {
void handleEdgeTts(request, response, () => {
response.statusCode = 404;
response.end("Not Found");
});
});
server.listen(port, host, () => {
console.log(`[Agent TTS] Edge TTS adapter listening on http://${host}:${port}`);
});
function closeServer() {
server.close(() => process.exit(0));
}
process.on("SIGINT", closeServer);
process.on("SIGTERM", closeServer);
+102
View File
@@ -0,0 +1,102 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createEdgeTtsMiddleware,
type EdgeTtsMiddlewareOptions,
type EdgeTtsSynthesizer
} from "./edge-tts-service";
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))
);
});
async function startServer(options: EdgeTtsMiddlewareOptions) {
const middleware = createEdgeTtsMiddleware(options);
const server = createServer((request, response) => {
void middleware(request, response, () => {
response.statusCode = 404;
response.end();
});
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}`;
}
function postSpeech(baseUrl: string, body: unknown, headers = true) {
return fetch(`${baseUrl}/api/tts/edge`, {
method: "POST",
...(headers ? { headers: { "Content-Type": "application/json" } } : {}),
body: typeof body === "string" ? body : JSON.stringify(body)
});
}
describe("Edge TTS adapter", () => {
it("returns synthesized MP3 audio with the server-owned voice", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([73, 68, 51]));
const baseUrl = await startServer({ synthesize, defaultVoice: "zh-CN-XiaoxiaoNeural" });
const response = await postSpeech(baseUrl, { text: " 朗读调度结果 " });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("audio/mpeg");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.from([73, 68, 51]));
expect(synthesize).toHaveBeenCalledWith("朗读调度结果", "zh-CN-XiaoxiaoNeural");
});
it("rejects invalid, unsafe and oversized requests before synthesis", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ synthesize });
expect((await postSpeech(baseUrl, "not-json")).status).toBe(400);
expect((await postSpeech(baseUrl, { text: "测试" }, false)).status).toBe(415);
expect((await postSpeech(baseUrl, { text: "测试", voice: "unsafe" })).status).toBe(400);
expect((await postSpeech(baseUrl, { text: "排".repeat(521) })).status).toBe(413);
expect(synthesize).not.toHaveBeenCalled();
});
it("limits requests by client and releases the window", async () => {
let currentTime = 1_000;
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({
synthesize,
maxRequestsPerWindow: 1,
rateWindowMs: 1_000,
now: () => currentTime
});
expect((await postSpeech(baseUrl, { text: "第一次" })).status).toBe(200);
expect((await postSpeech(baseUrl, { text: "第二次" })).status).toBe(429);
currentTime += 1_000;
expect((await postSpeech(baseUrl, { text: "新窗口" })).status).toBe(200);
});
it("caps concurrent synthesis work", async () => {
let releaseFirst: ((audio: Uint8Array) => void) | undefined;
const firstAudio = new Promise<Uint8Array>((resolve) => {
releaseFirst = resolve;
});
const synthesize = vi.fn<EdgeTtsSynthesizer>(() => firstAudio);
const baseUrl = await startServer({ synthesize, maxConcurrent: 1 });
const firstRequest = postSpeech(baseUrl, { text: "第一段" });
await vi.waitFor(() => expect(synthesize).toHaveBeenCalledTimes(1));
expect((await postSpeech(baseUrl, { text: "第二段" })).status).toBe(429);
releaseFirst?.(Uint8Array.from([1]));
expect((await firstRequest).status).toBe(200);
});
it("returns a gateway timeout without releasing the occupied slot early", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(() => new Promise(() => undefined));
const baseUrl = await startServer({ synthesize, timeoutMs: 5 });
expect((await postSpeech(baseUrl, { text: "超时测试" })).status).toBe(504);
});
});
+210
View File
@@ -0,0 +1,210 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { Communicate } from "edge-tts-ts";
const EDGE_TTS_PATH = "/api/tts/edge";
const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural";
const MAX_REQUEST_BYTES = 8 * 1024;
const MAX_TEXT_LENGTH = 520;
const MAX_AUDIO_BYTES = 5 * 1024 * 1024;
const DEFAULT_MAX_CONCURRENT = 8;
const DEFAULT_RATE_LIMIT = 60;
const DEFAULT_RATE_WINDOW_MS = 60_000;
const DEFAULT_TIMEOUT_MS = 30_000;
interface EdgeTtsPayload {
text?: unknown;
voice?: unknown;
}
interface RateBucket {
count: number;
startedAt: number;
}
export type EdgeTtsSynthesizer = (text: string, voice: string) => Promise<Uint8Array>;
export type EdgeTtsMiddlewareOptions = {
defaultVoice?: string;
maxConcurrent?: number;
maxRequestsPerWindow?: number;
now?: () => number;
rateWindowMs?: number;
synthesize?: EdgeTtsSynthesizer;
timeoutMs?: number;
trustProxy?: boolean;
};
function sendJson(response: ServerResponse, status: number, message: string) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify({ error: message }));
}
async function readJson(request: IncomingMessage): Promise<EdgeTtsPayload> {
const chunks: Buffer[] = [];
let byteLength = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
byteLength += buffer.byteLength;
if (byteLength > MAX_REQUEST_BYTES) {
throw new RangeError("请求内容过大");
}
chunks.push(buffer);
}
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new SyntaxError("请求内容必须是 JSON 对象");
}
return payload as EdgeTtsPayload;
}
function isLoopback(address: string | undefined) {
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
}
function getClientKey(request: IncomingMessage, trustProxy: boolean) {
const remoteAddress = request.socket.remoteAddress;
if (trustProxy && isLoopback(remoteAddress)) {
const forwardedFor = request.headers["x-forwarded-for"];
const firstAddress = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(",", 1)[0];
if (firstAddress?.trim()) return firstAddress.trim();
}
return remoteAddress || "unknown";
}
export async function synthesizeEdgeSpeech(text: string, voice: string): Promise<Uint8Array> {
const communicate = new Communicate(text, { voice });
const chunks: Uint8Array[] = [];
let byteLength = 0;
for await (const chunk of communicate.stream()) {
if (chunk.type !== "audio") continue;
chunks.push(chunk.data);
byteLength += chunk.data.byteLength;
}
if (!byteLength) throw new Error("语音服务返回了空音频");
const audio = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
audio.set(chunk, offset);
offset += chunk.byteLength;
}
return audio;
}
export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {}) {
const synthesize = options.synthesize ?? synthesizeEdgeSpeech;
const defaultVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE;
const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
const maxRequestsPerWindow = options.maxRequestsPerWindow ?? DEFAULT_RATE_LIMIT;
const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const trustProxy = options.trustProxy ?? false;
const now = options.now ?? Date.now;
const rateBuckets = new Map<string, RateBucket>();
let activeRequests = 0;
return async (request: IncomingMessage, response: ServerResponse, next?: () => void) => {
if (request.url?.split("?", 1)[0] !== EDGE_TTS_PATH) {
next?.();
return;
}
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
sendJson(response, 405, "仅支持 POST 请求");
return;
}
if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
sendJson(response, 415, "请求必须使用 application/json");
return;
}
const currentTime = now();
const clientKey = getClientKey(request, trustProxy);
const existingBucket = rateBuckets.get(clientKey);
const bucket = !existingBucket || currentTime - existingBucket.startedAt >= rateWindowMs
? { count: 0, startedAt: currentTime }
: existingBucket;
bucket.count += 1;
rateBuckets.set(clientKey, bucket);
if (rateBuckets.size > 1_024) {
for (const [key, value] of rateBuckets) {
if (currentTime - value.startedAt >= rateWindowMs) rateBuckets.delete(key);
}
}
if (bucket.count > maxRequestsPerWindow) {
response.setHeader("Retry-After", String(Math.max(1, Math.ceil(rateWindowMs / 1_000))));
sendJson(response, 429, "语音请求过于频繁,请稍后重试");
return;
}
let payload: EdgeTtsPayload;
try {
payload = await readJson(request);
} catch (error) {
sendJson(response, error instanceof RangeError ? 413 : 400, "请求内容不是有效 JSON");
return;
}
if (Object.prototype.hasOwnProperty.call(payload, "voice")) {
sendJson(response, 400, "语音角色只能由服务端配置");
return;
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
if (!text) {
sendJson(response, 400, "缺少待朗读文本");
return;
}
if (text.length > MAX_TEXT_LENGTH) {
sendJson(response, 413, `单次文本不能超过 ${MAX_TEXT_LENGTH}`);
return;
}
if (activeRequests >= maxConcurrent) {
response.setHeader("Retry-After", "1");
sendJson(response, 429, "语音服务繁忙,请稍后重试");
return;
}
activeRequests += 1;
const synthesisTask = Promise.resolve().then(() => synthesize(text, defaultVoice));
void synthesisTask.finally(() => {
activeRequests -= 1;
}).catch(() => undefined);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const audio = await Promise.race([
synthesisTask,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new DOMException("语音服务响应超时", "TimeoutError")), timeoutMs);
})
]);
if (!audio.byteLength) {
sendJson(response, 502, "语音服务返回了空音频");
return;
}
if (audio.byteLength > MAX_AUDIO_BYTES) {
sendJson(response, 502, "语音服务返回的音频过大");
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", "audio/mpeg");
response.setHeader("Cache-Control", "no-store");
response.end(Buffer.from(audio));
} catch (error) {
if (error instanceof DOMException && error.name === "TimeoutError") {
sendJson(response, 504, "语音服务响应超时");
return;
}
console.error("[Agent TTS] 语音生成失败", error);
sendJson(response, 502, "语音生成失败");
} finally {
if (timeout) clearTimeout(timeout);
}
};
}
+6 -3
View File
@@ -17,12 +17,13 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
expect(runtimeConfig).toMatchObject({
DRAINAGE_MAP_URL: expect.any(String),
DRAINAGE_GEOSERVER_WORKSPACE: expect.any(String),
DRAINAGE_AGENT_API_BASE_URL: expect.any(String),
DRAINAGE_TTS_API_URL: expect.any(String)
DRAINAGE_AGENT_API_BASE_URL: expect.any(String)
});
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
);
expect(runtimeConfig).not.toHaveProperty("DRAINAGE_TTS_API_URL");
expect(runtimeConfig).not.toHaveProperty("EDGE_TTS_VOICE");
const mapAsset = await page.request.get("/icons/scada-integrated-monitoring.png");
expect(mapAsset.ok()).toBe(true);
@@ -71,7 +72,9 @@ test("preserves compact typography in header and agent controls", async ({ page
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first();
const headerControl = page
.locator("header.acrylic-navigation button.font-semibold:visible")
.first();
const approvalControl = page.getByRole("button", { name: "权限批准模式" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
@@ -17,7 +17,7 @@ export function AgentCollapsedRail({
statusLabel = "Agent 已就绪",
onExpand
}: AgentCollapsedRailProps) {
const expandLabel = `展开排水助手面板,当前状态:${statusLabel}`;
const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`;
return (
<aside
@@ -29,7 +29,7 @@ export function AgentCollapsedRail({
>
<button
type="button"
aria-label="展开排水助手面板"
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
+1 -2
View File
@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { env } from "@/shared/config/env";
import {
splitSpeechTextIntoChunks,
type AgentSpeakOptions,
@@ -123,7 +122,7 @@ export function useAgentSpeechSynthesis() {
const controller = new AbortController();
fetchControllersRef.current.add(controller);
const promise = fetch(env.DRAINAGE_TTS_API_URL, {
const promise = fetch("/api/tts/edge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
@@ -20,6 +20,13 @@ describe("toTrustedMapAction", () => {
})
).toBeNull();
expect(toTrustedMapAction("apply_layer_style", {})).toBeNull();
expect(
toTrustedMapAction("apply_layer_style", {
layer_group_id: "conduits",
layer_id: "scada",
visible: true
})
).toBeNull();
});
it("rejects out-of-range coordinates and zoom", () => {
@@ -63,11 +70,11 @@ describe("toTrustedMapAction", () => {
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
});
it("normalizes legacy locate tool actions", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
it("normalizes drainage locate tool actions", () => {
expect(toTrustedMapAction("locate_conduits", { conduit_ids: "C1,C2" })).toEqual({
type: "locate_features",
featureIds: ["P1", "P2"],
layer: "geo_pipes_mat",
featureIds: ["C1", "C2"],
layer: "geo_conduits_mat",
fallbackText: undefined
});
});
@@ -79,4 +86,34 @@ describe("toTrustedMapAction", () => {
layer: "geo_junctions_mat"
});
});
it.each([
["locate_orifices", "orifice_id", "geo_orifices_mat"],
["locate_outfalls", "outfall_id", "geo_outfalls_mat"],
["locate_pumps", "pump_id", "geo_pumps_mat"],
["locate_scada", "scada_id", "geo_scadas_mat"]
])("accepts %s for a drainage source", (action, key, layer) => {
expect(toTrustedMapAction(action, { [key]: "asset-1" })).toMatchObject({
type: "locate_features",
featureIds: ["asset-1"],
layer
});
});
it("rejects stale supply layers and accepts drainage visibility controls", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: ["P1"] })).toBeNull();
expect(toTrustedMapAction("locate_features", { ids: ["P1"], layer: "geo_pipes_mat" })).toBeNull();
expect(toTrustedMapAction("apply_layer_style", { layer_id: "conduits", visible: true })).toMatchObject({
type: "apply_layer_style",
layerGroupId: "conduits",
layerId: "conduits",
visible: true
});
expect(toTrustedMapAction("apply_layer_style", { layer_group_id: "simulation", visible: false })).toMatchObject({
type: "apply_layer_style",
layerGroupId: "simulation",
visible: false
});
expect(toTrustedMapAction("apply_layer_style", { layer_id: "pipes", visible: true })).toBeNull();
});
});
+40 -20
View File
@@ -14,7 +14,7 @@ export type TrustedMapAction =
}
| {
type: "apply_layer_style";
layerGroupId?: string;
layerGroupId: string;
layerId?: string;
visible?: boolean;
fallbackText?: string;
@@ -33,12 +33,14 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
const featureIds = readLocateIds(params);
if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null;
const layer =
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
LEGACY_LOCATE_ACTION_LAYERS[action];
if (layer && !ALLOWED_LOCATE_LAYERS.has(layer)) return null;
return {
type: "locate_features",
featureIds,
layer:
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
LEGACY_LOCATE_ACTION_LAYERS[action],
layer,
fallbackText
};
}
@@ -66,11 +68,18 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
if (action === "apply_layer_style") {
const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId);
const layerId = stringValue(params.layer_id ?? params.layerId);
const resolvedLayerGroupId = layerGroupId ?? layerId;
const visible = booleanValue(params.visible);
if ((!layerGroupId && !layerId) || visible === undefined || (layerId && !ALLOWED_LAYER_IDS.has(layerId))) return null;
if (
!resolvedLayerGroupId ||
visible === undefined ||
(layerGroupId && !ALLOWED_LAYER_GROUP_IDS.has(layerGroupId)) ||
(layerId && !ALLOWED_LAYER_IDS.has(layerId)) ||
(layerGroupId && layerId && layerGroupId !== layerId)
) return null;
return {
type: "apply_layer_style",
layerGroupId,
layerGroupId: resolvedLayerGroupId,
layerId,
visible,
fallbackText
@@ -155,16 +164,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_conduits: "geo_conduits_mat",
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks"
locate_orifices: "geo_orifices_mat",
locate_outfalls: "geo_outfalls_mat",
locate_pumps: "geo_pumps_mat",
locate_scada: "geo_scadas_mat"
};
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes"]);
const DRAINAGE_SOURCE_IDS = ["conduits", "junctions", "orifices", "outfalls", "pumps", "scada"];
const ALLOWED_LAYER_IDS = new Set(DRAINAGE_SOURCE_IDS);
const ALLOWED_LAYER_GROUP_IDS = new Set([...DRAINAGE_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([
...DRAINAGE_SOURCE_IDS,
"geo_conduits_mat",
"geo_junctions_mat",
"geo_orifices_mat",
"geo_outfalls_mat",
"geo_pumps_mat",
"geo_scadas_mat"
]);
const WEB_MERCATOR_RADIUS = 6378137;
const LOCATE_ID_PARAM_KEYS = [
@@ -178,16 +198,16 @@ const LOCATE_ID_PARAM_KEYS = [
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"conduit_ids",
"conduit_id",
"orifice_ids",
"orifice_id",
"outfall_ids",
"outfall_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id"
"scada_ids",
"scada_id"
] as const;
function readLocateIds(params: Record<string, unknown>) {
@@ -483,7 +483,7 @@ function IncidentWorkOrderPanel({
const submitted = Boolean(submittedWorkOrder);
const steps: ExecutionStep[] = [
{ id: "analysis", title: "事件分析", status: "done", description: "汇总异常证据、影响分区和风险等级。" },
{ id: "solution", title: "形成方案", status: "done", description: "生成阀门、泵站和现场复核操作清单。" },
{ id: "solution", title: "形成方案", status: "done", description: "生成管渠、泵站和现场复核操作清单。" },
{ id: "confirm", title: "人工确认", status: submitted ? "done" : "confirmation", description: "调度员确认影响范围和操作边界。" },
{ id: "submit", title: "提交工单", status: submitted ? "done" : "pending", description: "写入系统工单并通知执行岗位。" }
];
@@ -497,7 +497,7 @@ function IncidentWorkOrderPanel({
summary={
submitted
? `${submittedWorkOrder?.code} 已进入系统工单流转,并通知相关班组执行管网要素操作。`
: "异常分析形成处置方案后,由调度员确认并提交系统工单,通知现场或调度岗位操作阀门、泵站和相关测点。"
: "异常分析形成处置方案后,由调度员确认并提交系统工单,通知现场或调度岗位操作管渠、泵站和相关测点。"
}
meta={
submittedWorkOrder
@@ -1202,22 +1202,22 @@ function createSubmittedIncidentWorkOrder(condition: ScheduledConditionItem): Su
function createIncidentOperations(condition: ScheduledConditionItem): IncidentOperation[] {
const baseVerification =
condition.status === "error" ? "执行后 5 分钟内复核压力、流量和影响用户反馈。" : "下一轮工况继续跟踪偏差是否回落。";
condition.status === "error" ? "执行后 5 分钟内复核液位、流量和影响道路与汇水区反馈。" : "下一轮工况继续跟踪偏差是否回落。";
if (condition.title.includes("智能调度")) {
return [
{
target: "华山路沿线阀门组 VG-11",
elementType: "阀门",
action: "阀门开度调整",
command: "按方案将边界阀门预置到 45% 开度,执行前确认上下游压力。",
verification: "确认高区末端压力不低于 0.22MPa。"
target: "高水位片区管渠组 VG-11",
elementType: "管渠",
action: "管渠过流状态调整",
command: "按方案将关键管渠预置到 45% 过流能力,执行前确认上下游液位。",
verification: "确认高水位风险区检查井液位回落至 2.50m 以下。"
},
{
target: "北辰二级泵站 P-02",
target: "2# 排水泵站 P-02",
elementType: "泵站",
action: "泵站出水复核",
command: "保持当前泵组组合,复核出水压力和频率波动。",
command: "保持当前泵组组合,复核集水池液位和频率波动。",
verification: baseVerification
}
];
@@ -1233,10 +1233,10 @@ function createIncidentOperations(condition: ScheduledConditionItem): IncidentOp
verification: "数据维护岗确认通信链路恢复后再解除隔离。"
},
{
target: "异常 DMA 现场巡检点",
target: "异常汇水分区现场巡检点",
elementType: "巡检点",
action: "现场复核",
command: "通知巡检班组核对压力表、流量计和井状态。",
command: "通知巡检班组核对液位计、流量计和检查井状态。",
verification: baseVerification
}
];
@@ -1244,17 +1244,17 @@ function createIncidentOperations(condition: ScheduledConditionItem): IncidentOp
return [
{
target: "疑似影响区上下游阀门",
elementType: "阀门",
action: "阀门边界确认",
command: "核对阀门当前开度,暂不执行大范围关断,保留应急隔离方案。",
verification: "确认影响区压力恢复趋势和用户侧反馈。"
target: "疑似影响区上下游检查井",
elementType: "管渠",
action: "管渠疏通范围确认",
command: "核对管渠当前过流状态,暂不执行大范围关断,保留应急隔离方案。",
verification: "确认影响区液位恢复趋势和现场积水反馈。"
},
{
target: "关联泵站与分区边界",
elementType: "泵站/边界",
action: "调度边界复核",
command: "使用最新泵站出水、阀门开度和 SCADA 流量刷新模拟边界。",
command: "使用最新泵站排水量、管渠过流状态和 SCADA 流量刷新模拟边界。",
verification: baseVerification
}
];
@@ -1270,7 +1270,7 @@ function getConditionAnalysisInsight(condition: ScheduledConditionItem): Schedul
summary: "Agent 判断这条工况未完整闭环,需要先确认异常来源,再决定是否进入调度处置。",
notes: [
"优先核对遥测源新鲜度和关键测点完整率。",
"结合历史基线复核压力/流量偏差是否持续。",
"结合历史基线复核液位/流量偏差是否持续。",
"处置前应先确认影响范围,避免误触发联动动作。"
],
solutionOptions: [
@@ -1284,15 +1284,15 @@ function getConditionAnalysisInsight(condition: ScheduledConditionItem): Schedul
{
id: "fallback-model",
title: "模型比对",
scenario: "压力或流量偏差集中在局部分区。",
scenario: "液位或流量偏差集中在局部分区。",
action: "对比基线工况并圈定疑似影响范围。",
tradeoff: "响应更快,但依赖模型边界条件质量。"
},
{
id: "fallback-manual",
title: "人工复核",
scenario: "异常持续或伴随用户侧反馈。",
action: "交由调度员确认阀门、泵站和巡检动作。",
scenario: "异常持续或伴随现场积水反馈。",
action: "交由调度员确认管渠、泵站和巡检动作。",
tradeoff: "闭环完整,但会占用人工资源。"
}
]
@@ -1309,7 +1309,7 @@ function getConditionAnalysisInsight(condition: ScheduledConditionItem): Schedul
escalationCriteria: [
"连续两轮工况仍高于关注阈值。",
"关键测点完整率继续下降。",
"同一分区出现用户投诉、低压或水质联动线索。"
"同一汇水分区出现现场积水、倒灌或溢流联动线索。"
]
};
}
@@ -126,7 +126,7 @@ export function ScenarioMenu({
<MenuSeparator />
<MenuSectionLabel></MenuSectionLabel>
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、管渠与输排风险差异" onSelect={onCompareScenario} />
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
</DropdownMenuContent>
</DropdownMenu>
@@ -26,7 +26,7 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
{
id: "ingest-telemetry",
title: "读取 SCADA 遥测",
detail: "拉取压力、流量和关键阀门测点,校验时间戳与回传频率。"
detail: "拉取液位、流量和关键排放口测点,校验时间戳与回传频率。"
},
{
id: "freshness-check",
@@ -36,7 +36,7 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
{
id: "consistency-check",
title: "压流一致性诊断",
detail: "比对压力变化、流量变化和相邻测点趋势是否符合水力逻辑。"
detail: "比对液位变化、流量变化和相邻测点趋势是否符合水力逻辑。"
},
{
id: "isolate-sources",
@@ -57,17 +57,17 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
{
id: "read-condition",
title: "读取实时工况",
detail: "汇总目标分区压力、流量、泵站出水和阀门开度。"
detail: "汇总目标汇水分区液位、流量、泵站排水量和管渠过流状态。"
},
{
id: "candidate-actions",
title: "生成候选动作",
detail: "枚举泵站目标压力、阀门边界和高频复核测点的可执行动作。"
detail: "枚举泵站启停组合、管渠疏通范围和高频复核测点的可执行动作。"
},
{
id: "simulate-impact",
title: "模拟影响范围",
detail: "预估指令执行后的压力改善、分区压差和关键用户影响。"
detail: "预估指令执行后的液位回落、溢流风险和周边道路影响。"
},
{
id: "rank-actions",
@@ -88,17 +88,17 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
{
id: "sync-boundary",
title: "同步模型边界",
detail: "读取最新泵站启停、阀门开度、需水量和 SCADA 边界条件。"
detail: "读取最新泵站启停、来水量、管渠过流能力和 SCADA 边界条件。"
},
{
id: "run-hydraulic-model",
title: "运行水力模型",
detail: "滚动计算全网节点压力、管流量和分区边界平衡。"
detail: "滚动计算全网检查井液位、管流量和排放口边界平衡。"
},
{
id: "compare-telemetry",
title: "比对实测偏差",
detail: "将模拟压力、流量与在线测点回传值进行偏差校验。"
detail: "将模拟液位、流量与在线测点回传值进行偏差校验。"
},
{
id: "locate-deviation",
@@ -119,7 +119,7 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
{
id: "read-pump-load",
title: "读取泵组负载",
detail: "采集泵站频率、电流、出水压力、出水量和启停记录。"
detail: "采集泵站频率、电流、集水池液位、出水量和启停记录。"
},
{
id: "calculate-energy",
@@ -134,12 +134,12 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
{
id: "pressure-guard",
title: "校验供压约束",
detail: "复核节能建议不会引发末端低压或分区边界异常。"
detail: "复核节能建议不会引发检查井高水位或分区边界异常。"
},
{
id: "energy-advice",
title: "形成节能建议",
detail: "输出启停优化、压力设定和继续观察条件。"
detail: "输出启停优化、液位设定和继续观察条件。"
}
]
}
@@ -71,28 +71,28 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
{
id: "scada-diagnosis",
title: "SCADA 数据诊断",
summary: "对压力、流量 SCADA 数据进行新鲜度、完整率、突变和一致性诊断,覆盖分区压力巡检范围。",
summary: "对液位、流量 SCADA 数据进行新鲜度、完整率、突变和一致性诊断,覆盖汇水分区监测范围。",
intervalMinutes: 5,
durationMinutes: 3
},
{
id: "smart-dispatch",
title: "管网智能调度",
summary: "结合实时工况生成管网要素调整指令,输出泵站、阀门或分区边界的建议动作。",
summary: "结合实时工况生成管网要素调整指令,输出泵站、管渠或排放口的建议动作。",
intervalMinutes: 15,
durationMinutes: 4
},
{
id: "network-simulation",
title: "管网定时模拟",
summary: "基于最新遥测、泵站启停和阀门开度滚动复核全网水力状态。",
summary: "基于最新遥测、泵站启停和管渠过流能力滚动复核全网水力状态。",
intervalMinutes: 15,
durationMinutes: 6
},
{
id: "pump-energy",
title: "泵站能耗检查",
summary: "检查泵组负载、单位排水电耗、启停频率和供压稳定性。",
summary: "检查泵组负载、单位排水电耗、启停频率和集水池液位稳定性。",
intervalMinutes: 60,
durationMinutes: 10
}
@@ -100,13 +100,13 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
{
id: "mock-incident-valve-dispatch",
title: "系统工单:华山路阀门边界确认",
summary: "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
id: "mock-high-water-dispatch",
title: "系统工单:高水位片区管渠疏通范围确认",
summary: "异常事件分析后生成管渠疏通范围确认任务,派发前需调度员复核影响范围。",
detail: "该工单由异常事件分析流程生成,用于演示人工确认后提交系统工单并通知管网要素操作。",
recommendation: "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
recommendation: "确认影响道路与汇水区、上下游液位和应急隔离边界后,再通知执行岗位到场。",
source: "异常事件分析",
location: "华山路沿线阀门组 VG-11",
location: "高水位风险区主干管渠 C-11",
dispatcher: "调度中心值班长",
assignee: "调度执行岗",
priority: "urgent",
@@ -115,34 +115,34 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "dispatch",
replyRequirements: ["确认阀门当前开度", "记录影响分区和用户范围", "回传派发确认截图"]
replyRequirements: ["确认管渠当前过流状态", "记录影响道路与汇水区", "回传派发确认截图"]
},
{
id: "mock-valve-operation-execution",
title: "系统工单:北辰分区阀门开度调整",
summary: "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
detail: "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
recommendation: "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
id: "mock-conduit-cleaning-execution",
title: "系统工单:重点汇水分区管渠疏通",
summary: "根据管网智能调度方案,现场疏通重点汇水分区主干管渠并复核液位。",
detail: "该工单模拟系统派发后的执行阶段,班组正在按方案开展管渠清淤疏通。",
recommendation: "执行过程中保持调度语音联络,调整后立即复核检查井液位和流量变化。",
source: "调度方案确认",
location: "北辰分区边界阀门 BV-07",
location: "重点汇水分区主干管渠 C-07",
dispatcher: "管网调度岗",
assignee: "北辰抢修二组",
assignee: "排水抢修二组",
priority: "urgent",
durationMinutes: 55,
dispatchLeadMinutes: 25,
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "execution",
replyRequirements: ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
replyRequirements: ["上传管渠处置前后照片", "记录清淤疏通结果", "复核检查井液位回落至 2.50m 以下"]
},
{
id: "mock-pump-boundary-reply",
title: "系统工单:泵站边界复核复令",
summary: "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
detail: "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
recommendation: "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
recommendation: "核对泵站集水池液位、频率曲线和分区边界模拟结果后完成复令。",
source: "模型复核建议",
location: "北辰二级泵站 P-02",
location: "2# 排水泵站 P-02",
dispatcher: "模型复核岗",
assignee: "泵站运行班组",
priority: "normal",
@@ -151,64 +151,64 @@ const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
replyWindowMinutes: 35,
riskLevel: "normal",
stage: "reply",
replyRequirements: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"]
replyRequirements: ["上传泵站运行截图", "填写集水池液位复核结果", "确认模型边界已同步更新"]
}
];
const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
{
id: "huashan-valve-well-service",
title: "人工工单:华山路阀井现场处置",
summary: "现场班组复核华山路阀井积水与井盖松动问题,完成照片和处置记录回传。",
id: "high-water-junction-service",
title: "人工工单:高水位检查井现场处置",
summary: "现场班组复核高水位检查井积水与井盖松动问题,完成照片和处置记录回传。",
detail: "该工单由调度员人工创建,来源为现场巡检反馈,不属于周期工况任务。",
recommendation: "到点后确认现场安全措施、处置照片和回填记录,必要时关联资产缺陷单。",
source: "现场巡检反馈",
location: "华山路阀井",
location: "高水位风险区检查井 J-11",
dispatcher: "调度中心值班长",
assignee: "北片区抢修一组",
assignee: "排水抢修一组",
priority: "urgent",
startMinute: 9 * 60 + 40,
durationMinutes: 35,
dispatchLeadMinutes: 20,
replyWindowMinutes: 15,
riskLevel: "attention",
replyRequirements: ["上传处置前后照片", "记录井积水处理结果", "复核井盖密封和周边安全状态"]
replyRequirements: ["上传处置前后照片", "记录检查井积水处理结果", "复核井盖密封和周边安全状态"]
},
{
id: "renmin-road-meter-change",
title: "人工工单:人民路流量计更换",
summary: "更换人民路 DN300 支线流量计通信模块,并复测回传质量。",
detail: "该工单由计量维护计划人工创建,执行前需确认旁通和作业窗口。",
id: "trunk-flow-meter-service",
title: "人工工单:主干管渠流量计维护",
summary: "更换主干管渠流量计通信模块,并复测回传质量。",
detail: "该工单由监测设备维护计划人工创建,执行前需确认作业安全窗口。",
recommendation: "作业完成后核对 15 分钟回传稳定性,并记录新模块编号。",
source: "计量维护计划",
location: "人民路 DN300 支线",
dispatcher: "计量调度岗",
assignee: "计量维护二组",
location: "主干管渠流量监测点 F-088",
dispatcher: "监测调度岗",
assignee: "监测维护二组",
priority: "normal",
startMinute: 10 * 60 + 30,
durationMinutes: 50,
dispatchLeadMinutes: 30,
replyWindowMinutes: 20,
riskLevel: "normal",
replyRequirements: ["登记新通信模块编号", "回传 15 分钟稳定性截图", "确认旁通恢复和计量数据连续"]
replyRequirements: ["登记新通信模块编号", "回传 15 分钟稳定性截图", "确认量数据连续"]
},
{
id: "beichen-fire-hydrant-repair",
title: "人工工单:北辰消防栓维修",
summary: "处理北辰片区消防栓渗漏缺陷,现场确认关停影响和恢复时间。",
detail: "该工单来自客服缺陷转派,由调度员人工排入当日维修窗口。",
recommendation: "维修前通知影响点位,完成后复核周边压力和栓体密封状态。",
source: "客服缺陷转派",
location: "北辰片区消防栓",
dispatcher: "客服联动调度",
assignee: "北辰维修班组",
id: "outfall-backflow-inspection",
title: "人工工单:排放口倒灌巡检",
summary: "复核排放口倒灌和淤积风险,现场确认潮位、出流状态和恢复时间。",
detail: "该工单来自异常监测转派,由调度员人工排入当日巡检窗口。",
recommendation: "巡检前确认现场安全条件,完成后复核排放口水位和出流状态。",
source: "异常监测转派",
location: "重点排放口 O-03",
dispatcher: "排水联动调度",
assignee: "排放口巡检班组",
priority: "urgent",
startMinute: 14 * 60,
durationMinutes: 60,
dispatchLeadMinutes: 45,
replyWindowMinutes: 20,
riskLevel: "attention",
replyRequirements: ["回填关停影响范围", "上传维修完成照片", "复测周边压力和消防栓密封状态"]
replyRequirements: ["记录潮位和倒灌范围", "上传巡检完成照片", "复测排放口水位和出流状态"]
}
];
@@ -580,12 +580,12 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
return [
{
id: "pressure-scada-freshness",
label: "压力 SCADA 新鲜度",
label: "液位 SCADA 新鲜度",
value: pressureFreshness.toString(),
unit: "%",
threshold: "关注 < 94%,异常 < 90%",
status: getInverseMetricRisk(pressureFreshness, 94, 90),
description: "压力监测点最近 5 分钟内有效回传占比,覆盖 DMA 分区压力巡检范围。"
description: "液位监测点最近 5 分钟内有效回传占比,覆盖重点汇水分区监测范围。"
},
{
id: "flow-scada-freshness",
@@ -603,16 +603,16 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
unit: "个",
threshold: "关注 >= 5,异常 >= 8",
status: getMetricRisk(abnormalSamples, 5, 8),
description: "压力、流量样本中出现突变、越界或跨源不一致的数量。"
description: "液位、流量样本中出现突变、越界或跨源不一致的数量。"
},
{
id: "pressure-flow-consistency",
label: "流一致性评分",
label: "流一致性评分",
value: consistencyScore.toString(),
unit: "分",
threshold: "关注 < 86,异常 < 80",
status: getInverseMetricRisk(consistencyScore, 86, 80),
description: "压力变化与流量变化是否符合水力逻辑的综合评分。"
description: "液位变化与流量变化是否符合水力逻辑的综合评分。"
}
];
}
@@ -642,13 +642,13 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
description: "基于实时工况、SCADA 数据质量和模拟结果的调度指令可信度。"
},
{
id: "expected-pressure-gain",
label: "预计压力改善",
id: "expected-level-drop",
label: "预计液位改善",
value: expectedPressureGain.toString(),
unit: "m",
threshold: "常规 0.5-3.5m",
status: "normal",
description: "执行调整指令后,目标片区末端压力的预计改善幅度。"
description: "执行调整指令后,目标片区检查井液位的预计回落幅度。"
}
];
}
@@ -662,13 +662,13 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
return [
{
id: "pressure-deviation",
label: "模拟-实测压力偏差",
label: "模拟-实测液位偏差",
value: pressureDeviation.toString(),
unit: "%",
baseline: "近 7 日同窗模型偏差",
threshold: "关注 > 8%,异常 > 12%",
status: getMetricRisk(pressureDeviation, 8, 12),
description: "对比在线水力模拟结果与 SCADA 压力监测值的平均相对偏差。"
description: "对比在线水力模拟结果与 SCADA 液位监测值的平均相对偏差。"
},
{
id: "flow-deviation",
@@ -682,12 +682,12 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
},
{
id: "max-pressure-delta",
label: "最大节点压差",
label: "最大液位差",
value: maxPressureDelta.toString(),
unit: "m",
threshold: "关注 > 5m,异常 > 8m",
status: getMetricRisk(maxPressureDelta, 5, 8),
description: "模拟节点压力与实测折算压力之间的最大差值。"
description: "模拟检查井液位与实测折算液位之间的最大差值。"
},
{
id: "hydraulic-balance-score",
@@ -696,7 +696,7 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
unit: "分",
threshold: "关注 < 84,异常 < 78",
status: getInverseMetricRisk(balanceScore, 84, 78),
description: "综合压力、流量、边界条件一致性后的模型可信评分。"
description: "综合液位、流量、边界条件一致性后的模型可信评分。"
}
];
}
@@ -757,22 +757,22 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
const templates: DispatchInstruction[][] = [
[
{
action: "泵站目标压力微调",
target: "2# 泵站出水压力",
command: "将目标压力0.42MPa 调整至 0.44MPa,保持变频泵优先运行",
verification: "15 分钟后复核 DMA-03 末端压力是否提升 1.5m 以上"
action: "泵站启停液位优化",
target: "2# 泵站集水池液位",
command: "将高液位启动值2.40m 调整至 2.20m,保持变频泵优先运行",
verification: "15 分钟后复核汇水分区 C-03 检查井液位是否回落 0.15m 以上"
},
{
action: "边界阀开度校正",
target: "DMA-03 / DMA-05 边界阀 BV-031",
command: "开度由 38% 调整至 42%,限制单次调整幅度不超过 5%",
verification: "观察相邻分区压差是否回落至 3m 内"
action: "主干管渠疏通复核",
target: "汇水分区 C-03 / C-05 联络管渠",
command: "安排现场核查淤积和异物阻塞,确认后执行分段清淤",
verification: "观察上下游检查井液位差是否回落至 0.30m 内"
},
{
action: "末端压力观察",
target: "华山路末端压力点 P-117",
action: "检查井液位观察",
target: "高水位片区检查井液位点 P-117",
command: "将该点加入 15 分钟高频复核队列",
verification: "若压力低于 0.18MPa 持续两轮,转人工调度确认"
verification: "若液位高于 2.80m 持续两轮,转人工调度确认"
}
],
[
@@ -783,50 +783,50 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
verification: "复核单位排水电耗是否低于 0.34kWh/m3"
},
{
action: "高区排水边界收敛",
target: "高区联络阀 HV-204",
command: "开度下调 3%,减少高区向中区回流",
verification: "确认高区末端压力不低于 0.22MPa"
action: "排放口倒灌复核",
target: "重点排放口 O-03",
command: "核对外河潮位与排放口出流状态,必要时启动关联泵组",
verification: "确认上游检查井液位回落至 2.50m 以下"
},
{
action: "流量计复核",
target: "人民路 DN300 流量计 F-088",
target: "主干管渠流量计 F-088",
command: "将该流量计设为本轮调度结果校验点",
verification: "15 分钟内流量波动需低于 6%"
}
],
[
{
action: "DMA 边界切换准备",
target: "DMA-07 北侧边界阀 BV-072",
command: "保持现状,预置 45% 开度为备选边界方案",
verification: "若下一轮 SCADA 诊断正常,可进入调度员确认"
action: "疑似淤堵管渠巡检",
target: "汇水分区 C-07 北侧主干管渠",
command: "保持泵站现状,预派巡检班组核查管渠过流断面",
verification: "若下一轮 SCADA 诊断仍异常,转入清淤工单确认"
},
{
action: "低压片区补压",
target: "北辰片区支线 PRV-12",
command: "出口压力设定值上调 0.01MPa,禁止联动超过 0.03MPa",
verification: "复核北辰消防栓维修工单影响范围内压力恢复"
action: "高水位片区排涝",
target: "汇水分区 C-07 关联泵站 P-02",
command: "提前启用备用泵组,单次调整后观察 10 分钟",
verification: "复核高水位风险区检查井液位持续回落"
},
{
action: "模型边界刷新",
target: "全网模型边界条件",
command: "使用最新泵站出水、阀门开度和 SCADA 流量刷新模拟边界",
verification: "模拟-实测压力偏差需回落至 8% 内"
command: "使用最新泵站排水量、管渠过流状态和 SCADA 流量刷新模拟边界",
verification: "模拟-实测液位偏差需回落至 8% 内"
}
],
[
{
action: "阀门开度回退",
target: "华山路沿线阀门组 VG-11",
command: "将昨日临时开度回退 2%,恢复常规排水边界",
verification: "观察华山路和人民路支线压差不超过 2.5m"
action: "管渠疏通效果复核",
target: "高水位风险区主干管渠 C-11",
command: "复核昨日清淤断面和残余淤积,恢复常规巡检频率",
verification: "观察上下游检查井液位差不超过 0.25m"
},
{
action: "泵站压力保持",
target: "3# 泵站出口压力",
command: "维持 0.40MPa 不变,禁止自动升压策略介入",
verification: "若末端压力连续两轮0.19MPa 再触发升压建议"
action: "泵站液位保持",
target: "3# 泵站集水池液位",
command: "维持 2.10m 高液位启动值不变,禁止频繁启停",
verification: "若检查井液位连续两轮2.80m,再触发增开泵组建议"
},
{
action: "异常测点隔离",
@@ -887,8 +887,8 @@ function createGuidanceItems(
) {
if (task.id === "scada-diagnosis") {
return [
"优先处理新鲜度或一致性异常的压力、流量测点,避免后续模拟和调度引用失真数据。",
"异常样本集中在同一 DMA 时,可直接替代分区压力巡检形成关注对象。",
"优先处理新鲜度或一致性异常的液位、流量测点,避免后续模拟和调度引用失真数据。",
"异常样本集中在同一汇水分区时,可直接形成重点巡检对象。",
"若连续两轮 SCADA 诊断异常,应通知数据维护或现场巡检确认通信链路。"
];
}
@@ -903,9 +903,9 @@ function createGuidanceItems(
if (task.id === "network-simulation") {
return [
"优先核对压力和流量偏差最大的 SCADA 测点,确认模型边界是否滞后。",
"若连续两轮模拟-实测偏差扩大,应重新校准泵站出水边界和关键阀门开度。",
"偏差回落前,不建议直接依据模型结果执行大范围阀门或泵站动作。"
"优先核对液位和流量偏差最大的 SCADA 测点,确认模型边界是否滞后。",
"若连续两轮模拟-实测偏差扩大,应重新校准泵站出水边界和关键管渠过流状态。",
"偏差回落前,不建议直接依据模型结果执行大范围管渠或泵站动作。"
];
}
@@ -913,7 +913,7 @@ function createGuidanceItems(
return [
"关注单位排水电耗和启停频率,判断是否存在泵组组合不经济。",
"若电耗偏差持续升高,建议比较相邻泵组效率曲线并优化启停策略。",
"调整前需确认供压稳定性,避免节能动作引发末端低压。"
"调整前需确认集水池液位稳定性,避免节能动作引发检查井高水位。"
];
}
@@ -1003,15 +1003,15 @@ function createAnalysisInsight(
{
id: "hydraulic-compare",
title: "水力模型比对",
scenario: "压力/流量同时偏离基线,疑似局部水力异常。",
scenario: "液位/流量同时偏离基线,疑似局部水力异常。",
action: "调用最近基线工况对比异常分区,输出可能影响范围。",
tradeoff: "可快速缩小范围,但依赖模型参数和边界条件质量。"
},
{
id: "manual-dispatch",
title: "人工调度复核",
scenario: "异常持续超过一个调度窗口或伴随用户侧反馈。",
action: "转入人工复核,准备阀门、泵站和现场巡检联动清单。",
scenario: "异常持续超过一个调度窗口或伴随现场积水反馈。",
action: "转入人工复核,准备管渠、泵站和现场巡检联动清单。",
tradeoff: "处置闭环更完整,但会占用调度员和巡检资源。"
}
]
@@ -1023,13 +1023,13 @@ function createAnalysisInsight(
summary: `Agent 将本轮${task.title}标记为关注项,当前偏差不足以触发自动处置。`,
notes: [
`${signal.target} 偏离运行基线 ${(signal.deviation * 100).toFixed(0)}%,建议保留本轮证据。`,
"重点观察压力/流量偏差是否持续扩大,避免单点噪声误判。",
"重点观察液位/流量偏差是否持续扩大,避免单点噪声误判。",
"可在 Agent 面板继续追问偏差来源和相关历史会话。"
],
escalationCriteria: [
"连续两轮工况仍高于关注阈值。",
"关键测点完整率继续下降或出现多源超时。",
"同一分区出现用户投诉、低压或水质联动线索。"
"同一汇水分区出现现场积水、倒灌或溢流联动线索。"
]
};
}
@@ -3,20 +3,20 @@ import type { WorkbenchScenario, WorkbenchUser } from "../types";
export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [
{
id: "scenario-a",
name: "方案 A",
description: "关闭事故点上下游阀门,优先保障核心片区压力。",
name: "泵站能力调整",
description: "提前启用关联泵组,降低高水位片区溢流风险。",
status: "active"
},
{
id: "scenario-b",
name: "方案 B",
description: "扩大隔离范围,降低抢修期间二次爆管风险。",
name: "管渠巡检疏通",
description: "分段核查疑似淤堵管渠,安排清淤并复核上下游液位。",
status: "review"
},
{
id: "scenario-c",
name: "夜间保供",
description: "按夜间低峰工况调整泵站与分区调度策略。",
name: "夜间泵组优化",
description: "按夜间低流量工况优化泵组启停和汇水分区调度策略。",
status: "draft"
}
];
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { WATER_NETWORK_BOUNDS } from "../map/sources";
import { impactAreaPolygon, mapAnnotations } from "./workbench-simulation";
function expectInsideDrainageBounds([longitude, latitude]: readonly number[]) {
const [west, south, east, north] = WATER_NETWORK_BOUNDS;
expect(longitude).toBeGreaterThanOrEqual(west);
expect(longitude).toBeLessThanOrEqual(east);
expect(latitude).toBeGreaterThanOrEqual(south);
expect(latitude).toBeLessThanOrEqual(north);
}
describe("drainage workbench simulation", () => {
it("keeps every simulated point and polygon vertex inside the drainage extent", () => {
mapAnnotations.forEach((annotation) => expectInsideDrainageBounds(annotation.coordinate));
impactAreaPolygon.features[0].geometry.coordinates[0].forEach(expectInsideDrainageBounds);
});
it("uses drainage-domain annotations", () => {
expect(mapAnnotations.map((annotation) => annotation.kind)).toEqual([
"high-water",
"conduit-label",
"risk-zone",
"tooltip"
]);
});
});
@@ -2,27 +2,27 @@ import type { MapAnnotation } from "../types";
export const mapAnnotations: MapAnnotation[] = [
{
id: "burst",
label: "爆管位置",
coordinate: [121.5215, 30.9084],
kind: "burst"
id: "high-water",
label: "高水位点",
coordinate: [120.699, 27.991],
kind: "high-water"
},
{
id: "dn600",
label: "DN600",
coordinate: [121.512, 30.9102],
kind: "pipe-label"
id: "trunk-conduit",
label: "主干管渠",
coordinate: [120.69, 27.996],
kind: "conduit-label"
},
{
id: "east-gate",
label: "东直门小区",
coordinate: [121.545, 30.951],
kind: "district"
id: "risk-zone",
label: "高水位风险片区",
coordinate: [120.712, 28.004],
kind: "risk-zone"
},
{
id: "impact",
label: "影响范围 约 1.82 km²",
coordinate: [121.567, 30.896],
label: "影响范围 约 0.86 km²",
coordinate: [120.721, 27.982],
kind: "tooltip"
}
];
@@ -35,20 +35,19 @@ export const impactAreaPolygon = {
properties: {
id: "impact-area",
label: "影响范围",
area: "1.82 km²"
area: "0.86 km²"
},
geometry: {
type: "Polygon" as const,
coordinates: [
[
[121.5205, 30.9084],
[121.548, 30.928],
[121.604, 30.918],
[121.626, 30.886],
[121.584, 30.862],
[121.538, 30.873],
[121.506, 30.895],
[121.5205, 30.9084]
[120.687, 27.984],
[120.7, 28.003],
[120.721, 28.006],
[120.733, 27.991],
[120.72, 27.976],
[120.697, 27.975],
[120.687, 27.984]
]
]
}
@@ -499,7 +499,7 @@ export function MapWorkbenchPage() {
showMapNotice({
tone: "info",
title: "方案比较",
message: "候选方案比较将汇总影响范围、阀门操作与保供风险。"
message: "候选方案比较将汇总影响范围、管渠处置与输排风险。"
});
}
+11 -11
View File
@@ -40,10 +40,10 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
}
},
{
id: "simulation-burst-halo",
id: "simulation-high-water-halo",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
filter: ["==", ["get", "kind"], "high-water"],
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": 18,
@@ -53,10 +53,10 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
}
},
{
id: "simulation-burst-point",
id: "simulation-high-water-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
filter: ["==", ["get", "kind"], "high-water"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.incident,
"circle-radius": 7,
@@ -65,10 +65,10 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
}
},
{
id: "simulation-pipe-label",
id: "simulation-conduit-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "pipe-label"],
filter: ["==", ["get", "kind"], "conduit-label"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
@@ -100,10 +100,10 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
}
},
{
id: "simulation-district-label",
id: "simulation-risk-zone-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "district"],
filter: ["==", ["get", "kind"], "risk-zone"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
@@ -117,12 +117,12 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
}
},
{
id: "simulation-burst-label",
id: "simulation-high-water-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
filter: ["==", ["get", "kind"], "high-water"],
layout: {
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-field": "检查井高水位\n主干管渠输排受限\n液位:2.36 m\n时间:09:00",
"text-size": 12,
"text-font": ["Open Sans Regular"],
"text-offset": [5.2, -3.4],
@@ -17,7 +17,8 @@ describe("workbench source layer controls", () => {
"orifices",
"outfalls",
"pumps",
"scada"
"scada",
"simulation"
]);
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"wenzhou:geo_conduits_mat",
@@ -26,10 +27,15 @@ describe("workbench source layer controls", () => {
"wenzhou:geo_outfalls_mat",
"wenzhou:geo_pumps_mat"
]);
expect(items.at(-1)).toMatchObject({
expect(items.at(-2)).toMatchObject({
label: "综合监测点",
description: "wenzhou:geo_scadas_mat"
});
expect(items.at(-1)).toMatchObject({
id: "simulation",
label: "模拟结果",
visible: false
});
});
it("finds every rendered layer that uses a source", () => {
@@ -14,12 +14,12 @@ export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
simulation: [
"simulation-impact-fill",
"simulation-impact-outline",
"simulation-burst-halo",
"simulation-burst-point",
"simulation-pipe-label",
"simulation-high-water-halo",
"simulation-high-water-point",
"simulation-conduit-label",
"simulation-impact-label",
"simulation-district-label",
"simulation-burst-label"
"simulation-risk-zone-label",
"simulation-high-water-label"
]
};
@@ -51,7 +51,7 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "scada-integrated", label: "综合监测点", color: "#0E7490", imageSrc: "/icons/scada-integrated-monitoring.svg" },
{ id: "inactive", label: "停用 / 关闭", color: MAP_STYLE_TOKENS.state.inactive, shape: "line" },
{ id: "impact-area", label: "风险影响范围", color: MAP_STYLE_TOKENS.state.risk, shape: "square" },
{ id: "incident", label: "事故 / 爆管", color: MAP_STYLE_TOKENS.state.incident, shape: "dot" },
{ id: "incident", label: "高水位 / 溢流", color: MAP_STYLE_TOKENS.state.incident, shape: "dot" },
{ id: "agent", label: "Agent 推断", color: MAP_STYLE_TOKENS.state.agent, shape: "dot" },
{ id: "selected", label: "当前选中", color: MAP_STYLE_TOKENS.state.selected, shape: "ring" }
];
@@ -80,11 +80,19 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
];
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
return WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
id: sourceId,
...SOURCE_CONTROL_LABELS[sourceId],
visible: layerVisibility[sourceId]
}));
return [
...WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
id: sourceId,
...SOURCE_CONTROL_LABELS[sourceId],
visible: layerVisibility[sourceId]
})),
{
id: "simulation",
label: "模拟结果",
description: "高水位风险与影响范围",
visible: layerVisibility.simulation
}
];
}
export function getWorkbenchLayerIds(
+1 -1
View File
@@ -11,7 +11,7 @@ export const WATER_NETWORK_GLOBAL_VIEW = {
]
} as const;
const WATER_NETWORK_BOUNDS: [number, number, number, number] = [
export const WATER_NETWORK_BOUNDS: [number, number, number, number] = [
120.63483136963328,
27.957404243937606,
120.76346113516635,
+1 -1
View File
@@ -130,7 +130,7 @@ export type MapAnnotation = {
id: string;
label: string;
coordinate: [number, number];
kind: "burst" | "pipe-label" | "district" | "tooltip";
kind: "high-water" | "conduit-label" | "risk-zone" | "tooltip";
};
export type WorkbenchMap = MapLibreMap;
@@ -73,7 +73,7 @@ const PROPERTY_LABELS: Record<string, string> = {
status: "状态",
material: "材质",
elevation: "高程",
demand: "水量",
demand: "水量",
pressure: "压力",
flow: "流量",
velocity: "流速",
@@ -104,7 +104,7 @@ export function createAlertQueueConversationPrompt({
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
"2. 列出支撑判断的关键证据和不确定性。",
"3. 给出处置方案,包含建议动作、适用条件、影响范围和需要人工确认的事项。",
"4. 不要自动执行阀门、泵站或工单动作,先给调度员确认。",
"4. 不要自动执行管渠、泵站或工单动作,先给调度员确认。",
"",
`待复核工况数量:${alerts.length}`,
"",
+1 -4
View File
@@ -10,7 +10,6 @@ describe("runtime frontend configuration", () => {
DRAINAGE_MAP_URL: "https://maps.example.test/geoserver",
DRAINAGE_GEOSERVER_WORKSPACE: "project-a",
DRAINAGE_AGENT_API_BASE_URL: "https://agent.example.test",
DRAINAGE_TTS_API_URL: "https://speech.example.test/tts",
DRAINAGE_ENABLE_DEV_PANEL: "true",
DRAINAGE_ENABLE_MSW: "false"
})
@@ -19,7 +18,6 @@ describe("runtime frontend configuration", () => {
DRAINAGE_MAP_URL: "https://maps.example.test/geoserver",
DRAINAGE_GEOSERVER_WORKSPACE: "project-a",
DRAINAGE_AGENT_API_BASE_URL: "https://agent.example.test",
DRAINAGE_TTS_API_URL: "https://speech.example.test/tts",
DRAINAGE_ENABLE_DEV_PANEL: true,
DRAINAGE_ENABLE_MSW: false
});
@@ -39,8 +37,7 @@ describe("runtime frontend configuration", () => {
it.each([
["DRAINAGE_MAP_URL", "file:///etc/passwd"],
["DRAINAGE_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
["DRAINAGE_TTS_API_URL", "javascript:alert(1)"],
["DRAINAGE_TTS_API_URL", "//speech.example.test/tts"]
["DRAINAGE_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
])("rejects unsafe browser runtime address %s", (key, value) => {
expect(() => parseRuntimeConfig({ [key]: value })).toThrow();
});
-16
View File
@@ -22,27 +22,11 @@ const browserHttpUrl = z
}
});
const browserEndpoint = z
.string()
.min(1)
.superRefine((value, context) => {
if (value.startsWith("/") && !value.startsWith("//")) return;
const result = browserHttpUrl.safeParse(value);
if (!result.success) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "必须是站内路径或安全的 HTTP(S) 地址"
});
}
});
const runtimeConfigSchema = z.object({
DRAINAGE_MAPBOX_ACCESS_TOKEN: z.string().default(""),
DRAINAGE_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
DRAINAGE_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("wenzhou"),
DRAINAGE_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
DRAINAGE_TTS_API_URL: browserEndpoint.default("/api/tts/edge"),
DRAINAGE_ENABLE_DEV_PANEL: runtimeBoolean(false),
DRAINAGE_ENABLE_MSW: runtimeBoolean(false)
});
+39
View File
@@ -0,0 +1,39 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const workbenchRoot = join(process.cwd(), "src/features/workbench");
const forbiddenSupplyTerms = [
"给水管线",
"供水管网",
"阀门",
"保供",
"需水量",
"东直门",
"DN600",
"爆管",
"消防栓",
"低压片区"
] as const;
function listRuntimeFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = join(directory, entry.name);
if (entry.isDirectory()) return listRuntimeFiles(target);
if (!/\.(ts|tsx)$/.test(entry.name) || /\.(test|e2e)\.(ts|tsx)$/.test(entry.name)) return [];
return [target];
});
}
describe("drainage domain boundary", () => {
it("does not reintroduce supply-only operating language", () => {
const violations = listRuntimeFiles(workbenchRoot).flatMap((file) => {
const source = readFileSync(file, "utf8");
return forbiddenSupplyTerms
.filter((term) => source.includes(term))
.map((term) => `${file.replace(`${workbenchRoot}/`, "")}: ${term}`);
});
expect(violations).toEqual([]);
});
});
+6 -6
View File
@@ -1,7 +1,7 @@
import { expect, test } from "playwright/test";
test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
@@ -13,7 +13,7 @@ test("Agent panel resizes by drag without exceeding half the viewport", async ({
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);
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(720);
@@ -26,12 +26,12 @@ test("Agent panel resizes by drag without exceeding half the viewport", async ({
});
test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
const rail = page.locator('aside[aria-label="Agent 折叠栏"]');
const expandButton = page.getByRole("button", { name: /展开排水助手面板/ });
const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ });
await expect(rail).toBeVisible();
await expect(rail).toHaveCSS("width", "72px");
await expect(expandButton).toHaveAttribute("title", /当前|正在/);
@@ -51,7 +51,7 @@ test.describe("mobile touch layout", () => {
test.use({ hasTouch: true, isMobile: true, viewport: { width: 375, height: 812 } });
test("mobile Agent toggle opens the conversation panel by touch", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const openButton = page.getByRole("button", { name: "打开 Agent 面板" });
const buttonBox = await openButton.boundingBox();
@@ -67,7 +67,7 @@ test.describe("mobile touch layout", () => {
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
await page.getByRole("button", { name: "工况汇总" }).click();
@@ -528,6 +528,10 @@ test("stream completion and the plan card remain positionally stable", async ({
.getByRole("button", { name: /执行进度/ })
.filter({ visible: true })
.last();
const latestProgressRegion = latestProgressToggle.locator("..");
const latestExpandedProgress = latestProgressRegion.getByRole("list", {
name: "全部执行进度",
});
await expect(latestProgressToggle).toContainText("最近 3 条");
triggerProgressAppend();
await expect(
@@ -538,9 +542,9 @@ test("stream completion and the plan card remain positionally stable", async ({
resumeStream();
await expect(latestProgressToggle).toContainText("全部 4 条", { timeout: 10_000 });
await expect(page.getByRole("list", { name: "全部执行进度" })).toBeVisible();
await expect(latestExpandedProgress).toBeVisible();
await expect(
page.getByRole("list", { name: "全部执行进度" }).getByText("溢流风险判断完成", { exact: true })
latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })
).toBeVisible();
});
+3 -3
View File
@@ -15,7 +15,7 @@ test("speech capability detection preserves the server hydration tree", async ({
value: class SpeechRecognition {}
});
});
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible();
await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible();
@@ -45,7 +45,7 @@ test("speech recognition failure clears the active animation and explains the er
}
});
});
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "语音输入" }).click();
@@ -77,7 +77,7 @@ test("speech recognition writes final transcript into the prompt", async ({ page
}
});
});
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "语音输入" }).click();
+1 -1
View File
@@ -2,7 +2,7 @@ import { expect, test } from "playwright/test";
test("map notice is centered in the mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开用户菜单" }).click();
await page.getByRole("menuitem", { name: /查看运行状态/ }).click();
+15 -6
View File
@@ -4,7 +4,8 @@ test("frontend actions replace SCADA analysis results and remain legible at 375p
page
}) => {
const receipts: Array<Record<string, unknown>> = [];
await mockAgentActions(page, receipts);
const wfsRequests: string[] = [];
await mockAgentActions(page, receipts, wfsRequests);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() =>
Boolean(window.__waterNetworkMap?.getSource("agent-scada-analysis"))
@@ -105,6 +106,7 @@ test("frontend actions replace SCADA analysis results and remain legible at 375p
})
])
);
expect(wfsRequests).toEqual(expect.arrayContaining(["MP01", "MP02"]));
await page.setViewportSize({ width: 375, height: 812 });
await page.evaluate(() => window.__waterNetworkMap?.resize());
@@ -158,12 +160,19 @@ test("frontend actions replace SCADA analysis results and remain legible at 375p
await expect.poll(() => receipts.length).toBe(4);
});
async function mockAgentActions(page: Page, receipts: Array<Record<string, unknown>>) {
async function mockAgentActions(
page: Page,
receipts: Array<Record<string, unknown>>,
wfsRequests: string[]
) {
let streamRequestCount = 0;
await page.route("**/api/map-features", async (route) => {
const body = route.request().postDataJSON() as { featureIds?: string[] };
const sensorId = body.featureIds?.[0] ?? "";
await page.route(/\/wenzhou\/ows\?/, async (route) => {
const url = new URL(route.request().url());
expect(route.request().method()).toBe("GET");
expect(url.searchParams.get("typeNames")).toBe("wenzhou:geo_scadas_mat");
const sensorId = url.searchParams.get("cql_filter")?.match(/'([^']+)'/)?.[1] ?? "";
wfsRequests.push(sensorId);
if (sensorId === "MP01") await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({
json: {
@@ -179,7 +188,7 @@ async function mockAgentActions(page: Page, receipts: Array<Record<string, unkno
}
]
}
});
}).catch(() => undefined);
});
await page.route("**/api/v1/agent/chat/**", async (route) => {
@@ -2,7 +2,7 @@ import { expect, test } from "playwright/test";
test("top bar uses compact controls on narrow mobile viewports", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator('meta[name="viewport"]')).toHaveAttribute(
"content",
@@ -15,7 +15,7 @@ test("top bar uses compact controls on narrow mobile viewports", async ({ page }
test("top bar keeps text horizontal at the desktop breakpoint", async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 844 });
await page.goto("/", { waitUntil: "networkidle" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const header = page.locator("header").first();
await expect(header).toBeVisible();
+1 -1
View File
@@ -19,6 +19,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts", "eslint.config.js", "playwright.config.ts"],
"include": ["src", "server", "vite.config.ts", "eslint.config.js", "playwright.config.ts"],
"references": []
}
+7 -33
View File
@@ -2,26 +2,17 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig, loadEnv, type Plugin, type PreviewServer, type ViteDevServer } from "vite";
import { fileURLToPath, URL } from "node:url";
import { createEdgeTtsMiddleware } from "./server/edge-tts-service";
const RUNTIME_CONFIG_PATH = "/runtime-config.js";
function renderRuntimeConfig(values: Record<string, string>) {
const config = {
DRAINAGE_MAPBOX_ACCESS_TOKEN:
values.DRAINAGE_MAPBOX_ACCESS_TOKEN || values.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN || "",
DRAINAGE_MAP_URL:
values.DRAINAGE_MAP_URL ||
values.NEXT_PUBLIC_MAP_URL ||
"https://geoserver.waternetwork.cn/geoserver",
DRAINAGE_GEOSERVER_WORKSPACE:
values.DRAINAGE_GEOSERVER_WORKSPACE || values.NEXT_PUBLIC_GEOSERVER_WORKSPACE || "wenzhou",
DRAINAGE_AGENT_API_BASE_URL:
values.DRAINAGE_AGENT_API_BASE_URL ||
values.NEXT_PUBLIC_AGENT_API_BASE_URL ||
"http://127.0.0.1:8787",
DRAINAGE_TTS_API_URL: values.DRAINAGE_TTS_API_URL || "/api/tts/edge",
DRAINAGE_ENABLE_DEV_PANEL:
values.DRAINAGE_ENABLE_DEV_PANEL || values.NEXT_PUBLIC_ENABLE_DEV_PANEL || "false",
DRAINAGE_MAPBOX_ACCESS_TOKEN: values.DRAINAGE_MAPBOX_ACCESS_TOKEN || "",
DRAINAGE_MAP_URL: values.DRAINAGE_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
DRAINAGE_GEOSERVER_WORKSPACE: values.DRAINAGE_GEOSERVER_WORKSPACE || "wenzhou",
DRAINAGE_AGENT_API_BASE_URL: values.DRAINAGE_AGENT_API_BASE_URL || "http://127.0.0.1:8787",
DRAINAGE_ENABLE_DEV_PANEL: values.DRAINAGE_ENABLE_DEV_PANEL || "false",
DRAINAGE_ENABLE_MSW: values.DRAINAGE_ENABLE_MSW || "false"
};
@@ -30,6 +21,7 @@ function renderRuntimeConfig(values: Record<string, string>) {
function runtimeConfigPlugin(values: Record<string, string>): Plugin {
const installMiddleware = (server: ViteDevServer | PreviewServer) => {
server.middlewares.use(createEdgeTtsMiddleware());
server.middlewares.use((request, response, next) => {
if (request.url?.split("?", 1)[0] !== RUNTIME_CONFIG_PATH) {
next();
@@ -52,12 +44,6 @@ function runtimeConfigPlugin(values: Record<string, string>): Plugin {
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const agentBaseUrl =
env.AGENT_API_INTERNAL_BASE_URL ||
env.DRAINAGE_AGENT_API_BASE_URL ||
env.NEXT_PUBLIC_AGENT_API_BASE_URL ||
"http://127.0.0.1:8787";
const ttsBaseUrl = env.TTS_API_INTERNAL_BASE_URL || "http://127.0.0.1:8790";
return {
plugins: [runtimeConfigPlugin(env), react(), tailwindcss()],
@@ -66,18 +52,6 @@ export default defineConfig(({ mode }) => {
"@": fileURLToPath(new URL("./src", import.meta.url))
}
},
server: {
proxy: {
"/api/v1/agent/chat": {
target: agentBaseUrl,
changeOrigin: true
},
"/api/tts/edge": {
target: ttsBaseUrl,
changeOrigin: true
}
}
},
test: {
environment: "jsdom",
globals: true,