fix: align drainage frontend configuration
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
node_modules
|
||||
.git
|
||||
.next
|
||||
dist
|
||||
dist-server
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
dist-server
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+47
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "候选方案比较将汇总影响范围、管渠处置与输排风险。"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
"",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user