Compare commits

...
30 Commits
Author SHA1 Message Date
jiang 0dad61ff1f feat(chat): 优化分析过程与结果展示
Generic Container CI/CD / test-build-publish (push) Successful in 2m25s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m25s
2026-08-26 18:05:03 +08:00
jiang fee4fc6ce1 refactor(frontend): align TJWater Next integrations 2026-08-26 12:26:29 +08:00
jiang 6623f4f7fb merge: sync Server storage cleanup contract
Merge PR #2 after API contract, lint, test, and production build verification.
2026-08-18 18:35:23 +08:00
jiang d2ce1687f2 chore(api): sync storage cleanup contract 2026-08-18 18:29:09 +08:00
jiang dca2817320 merge: integrate agent-mvp into master
Merge PR #1 after frontend, cross-repository contract, and container gates passed.
2026-08-18 17:56:45 +08:00
jiang 41bf7d71e3 chore(api): sync backend contract mirror 2026-08-18 17:56:11 +08:00
jiang 5bea86dbf6 fix(deps): update XML parser and nanoid 2026-08-18 16:50:46 +08:00
jiang 78a470e89e merge(frontend): integrate master into agent-mvp 2026-08-18 16:41:52 +08:00
jiang 6d505ca461 fix(flushing): 收紧空阀门区域高度
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-08-17 19:44:11 +08:00
jiang f34d81c933 fix(flushing): 对齐选择项空状态
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-08-17 19:36:33 +08:00
jiang 5053ddcd1f fix(map): 修正属性查询数据源与模拟属性
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-08-17 19:31:54 +08:00
jiang 45def5bba3 fix(analysis): 统一方案名与接口错误提示
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s
2026-08-17 18:54:29 +08:00
jiang 9e79d52dc8 feat(flushing): 添加阀门状态与设置值控制 2026-08-17 18:29:00 +08:00
jiang 4a978de905 ci(frontend): inject public build configuration 2026-08-11 10:44:56 +08:00
TJWater CI f5bcf28f7e fix(map): preserve hook order while data loads
Generic Container CI/CD / test-build-publish (push) Failing after 1m50s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m50s
2026-08-11 10:32:18 +08:00
jiang 1ac7372585 ci: replace webhook deployment with v2 workflow
Generic Container CI/CD / test-build-publish (push) Successful in 4m7s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m7s
2026-08-11 10:17:03 +08:00
TJWater CI ab45c8da8e ci: add frontend v2 deployment workflow
Generic Container CI/CD / test-build-publish (push) Failing after 1m13s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m14s
2026-08-11 09:51:13 +08:00
jiang 5d730ba1b8 fix(auth): ensure Keycloak logout clears secure session 2026-08-07 14:47:20 +08:00
jiang caf18f706d fix(auth): prevent logout audit from blocking redirect 2026-08-07 12:01:39 +08:00
jiang 0dd521d8c9 fix(chat): distinguish auth outages from model failures
Runtime model checks previously mapped every authenticated endpoint failure to models_unavailable. Treat request failures as service outages and retry failed checks after five seconds; empty model configurations remain distinct.
2026-08-06 19:35:24 +08:00
jiang 0a47534ddb fix(chat): restore windowed turn alignment
Keep the virtual-window measurement wrapper as a flex column so AgentTurn alignSelf rules remain active. Add regression coverage for the alignment context.
2026-08-06 19:07:40 +08:00
jiang 9e75e2df8a feat(chat): 完善运行状态与权限交互 2026-08-06 18:41:53 +08:00
jiang 8d7c947897 fix(auth): keep expiry dialog above agent UI 2026-08-06 16:09:23 +08:00
jiang 71bde7d9e4 refactor(chat): centralize agent session URLs 2026-08-06 10:40:13 +08:00
jiang 5037089057 feat(chat): refresh agent credentials during tool calls 2026-08-06 10:16:50 +08:00
jiang b4c96f8524 fix(auth): set local logout redirect example 2026-08-05 17:57:49 +08:00
jiang e496fbe4b7 fix(auth): align session lifecycle with Keycloak 2026-08-05 17:10:59 +08:00
jiang 5d9c40e454 feat(sensor): 完善监测点结果编辑与图层切换 2026-08-03 19:00:16 +08:00
jiang 5592c27386 chore(api): sync internal server contract 2026-08-03 11:12:54 +08:00
jiang 94142d7031 fix(auth): complete Keycloak logout flow 2026-08-03 10:40:21 +08:00
109 changed files with 8576 additions and 24600 deletions
+3 -3
View File
@@ -7,8 +7,8 @@ NEXTAUTH_URL="https://frontend.example.com/"
BACKEND_URL="https://server.example.com" BACKEND_URL="https://server.example.com"
AGENT_URL="https://agent.example.com" AGENT_URL="https://agent.example.com"
MAP_URL="https://geoserver.example.com/geoserver" MAP_URL="https://geoserver.example.com/geoserver"
MAP_WORKSPACE="tjwater" MAP_WORKSPACE="tjwater_next"
MAP_EXTENT="13490131,3630016,13525879,3666968.25" MAP_EXTENT="13508801.93,3608163.35,13555650.64,3633685.14"
NETWORK_NAME="tjwater" NETWORK_NAME="tjwater_next"
MAPBOX_TOKEN="replace-with-public-mapbox-token" MAPBOX_TOKEN="replace-with-public-mapbox-token"
TIANDITU_TOKEN="replace-with-public-tianditu-token" TIANDITU_TOKEN="replace-with-public-tianditu-token"
+14 -152
View File
@@ -1,159 +1,21 @@
name: Build Push and Deploy name: Frontend CI/CD v2
on: on:
push: push:
tags: tags:
- "v*" - "v*"
- "latest" workflow_dispatch: {}
jobs: jobs:
docker-image: build-test-publish-and-deploy:
runs-on: ubuntu-22.04 uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
permissions: with:
contents: read image_name: gitea.waternetwork.cn/orgtjwater/tjwaterfrontend_refine
defaults: dockerfile: Dockerfile
run: build_context: .
shell: sh deploy_service: frontend
deploy_host: 192.168.1.114
steps: secrets:
- name: Checkout code REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
env: REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
SERVER_URL: ${{ github.server_url }} DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
GIT_USERNAME: ${{ github.actor }}
GIT_TOKEN: ${{ github.token }}
run: |
case "$SERVER_URL" in
http://*)
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
;;
https://*)
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
;;
*)
AUTH_SERVER_URL="$SERVER_URL"
;;
esac
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
else
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
fi
git fetch --depth=1 origin "$COMMIT_SHA"
git checkout --force --detach FETCH_HEAD
git clean -ffdx
- name: Normalize image metadata
env:
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
RAW_REPOSITORY: ${{ github.repository }}
IMAGE_TAG: ${{ github.ref_name }}
run: |
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
} >> "$GITHUB_ENV"
- name: Login to Gitea Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \
--username "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build and Push Image
run: |
push_with_retry() {
image_ref="$1"
attempt=1
max_attempts=3
while [ "$attempt" -le "$max_attempts" ]; do
if docker push "$image_ref"; then
return 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
return 1
fi
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
attempt=$((attempt + 1))
sleep 10
done
}
docker build \
--network=host \
-f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
--build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \
.
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest"
- name: Notify Deploy Server
run: |
post_deploy_webhook() {
label="$1"
payload="$2"
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
-d "$payload")
echo "[$label] webhook HTTP status: ${http_code}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
return 0
fi
echo "[$label] response headers:"
cat /tmp/deploy_headers.txt
echo "[$label] response body:"
cat /tmp/deploy_response.txt
return 1
}
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
exit 0
fi
echo "Primary webhook request failed, retrying with lowercase repo path..."
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
exit 0
fi
echo "Deploy webhook failed after primary and fallback attempts."
exit 1
deploy-fallback-log:
runs-on: ubuntu-22.04
needs: docker-image
if: failure()
steps:
- name: Deployment not triggered
run: echo "Image build/push failed, deployment webhook was not called."
+152 -1
View File
@@ -1011,8 +1011,10 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"request", "request",
"auto",
"always" "always"
] ],
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
} }
}, },
"required": [ "required": [
@@ -1386,6 +1388,155 @@
} }
} }
}, },
"/api/v1/agent/sessions/{session_id}/credential-refreshes": {
"post": {
"operationId": "post_sessions_session_id_credential_refreshes",
"tags": [
"Agent"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "Resume a waiting agent tool call with refreshed credentials",
"parameters": [
{
"schema": {
"type": "string",
"maxLength": 128
},
"required": true,
"name": "session_id",
"in": "path"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"required": [
"request_id"
]
}
}
}
},
"responses": {
"202": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"nullable": true
}
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Insufficient permission",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"409": {
"description": "Resource conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Validation error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"502": {
"description": "Upstream dependency error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"503": {
"description": "Dependency unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/agent/sessions/{session_id}/permission-responses": { "/api/v1/agent/sessions/{session_id}/permission-responses": {
"post": { "post": {
"operationId": "post_sessions_session_id_permission_responses", "operationId": "post_sessions_session_id_permission_responses",
+2 -2
View File
@@ -3,11 +3,11 @@
"contracts": { "contracts": {
"agent": { "agent": {
"file": "agent-v1.openapi.json", "file": "agent-v1.openapi.json",
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259" "sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
}, },
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c" "sha256": "404a196c0177faed2aa5b46ee86430a034dfe990e0a77a43428a727748a882b6"
} }
} }
} }
File diff suppressed because it is too large Load Diff
+22 -9
View File
@@ -5611,6 +5611,18 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@nodable/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -13640,9 +13652,9 @@
} }
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "5.5.9", "version": "5.7.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.0.tgz",
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", "integrity": "sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -13651,9 +13663,10 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-xml-builder": "^1.1.4", "@nodable/entities": "^2.1.0",
"path-expression-matcher": "^1.2.0", "fast-xml-builder": "^1.1.5",
"strnum": "^2.2.2" "path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
}, },
"bin": { "bin": {
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
@@ -18189,9 +18202,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
+1 -1
View File
@@ -59,7 +59,7 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"overrides": { "overrides": {
"fast-xml-parser": "5.5.9", "fast-xml-parser": "5.7.0",
"postcss": "8.5.25", "postcss": "8.5.25",
"sharp": "0.35.3" "sharp": "0.35.3"
}, },
+4 -4
View File
@@ -9,22 +9,22 @@ loadEnvConfig(projectDir, process.env.NODE_ENV !== "production");
const parseExtent = (value) => { const parseExtent = (value) => {
if (!value) { if (!value) {
return [13508849, 3608036, 13555781, 3633813]; return [13508801.93, 3608163.35, 13555650.64, 3633685.14];
} }
const extent = value.split(",").map(Number); const extent = value.split(",").map(Number);
return extent.length === 4 && extent.every(Number.isFinite) return extent.length === 4 && extent.every(Number.isFinite)
? extent ? extent
: [13508849, 3608036, 13555781, 3633813]; : [13508801.93, 3608163.35, 13555650.64, 3633685.14];
}; };
const config = { const config = {
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000", BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788", AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver", MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater", MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater_next",
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT), MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater", NETWORK_NAME: process.env.NETWORK_NAME || "tjwater_next",
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "", MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "", TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
}; };
+39 -22
View File
@@ -7,7 +7,7 @@ import {
} from "@refinedev/core"; } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui"; import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { SessionProvider, signIn, useSession } from "next-auth/react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
@@ -17,10 +17,13 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider"; import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext"; import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard"; import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore"; import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config"; import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
@@ -57,6 +60,8 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession(); const { data, status } = useSession();
const to = usePathname(); const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken); const setAccessToken = useAuthStore((state) => state.setAccessToken);
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
const permissions = useAccessStore((state) => state.permissions); const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext); const setAccessContext = useAccessStore((state) => state.setContext);
@@ -70,6 +75,20 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
); );
}, [data?.accessToken, setAccessToken]); }, [data?.accessToken, setAccessToken]);
useEffect(() => {
if (data?.error === "SessionExpired") {
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
markSessionExpired("refresh_failed");
return;
}
if (status === "authenticated") {
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
useEffect(() => { useEffect(() => {
if (status !== "authenticated") { if (status !== "authenticated") {
resetAccess(); resetAccess();
@@ -135,28 +154,25 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
}); });
return { success: true }; return { success: true };
}, },
logout: async () => { logout: () =>
try { completeLogout({
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, { reportAudit: () =>
method: "POST", reportLogoutAudit({
headers: { "Content-Type": "application/json" }, endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
body: JSON.stringify({ event: "logout" }), accessToken:
projectHeaderMode: "omit", typeof data?.accessToken === "string"
skipAuthRedirect: true, ? data.accessToken
}); : useAuthStore.getState().accessToken,
} catch { }),
// Logout must still complete when audit storage is unavailable. clearLocalState: () => {
} if (data?.user?.id) {
if (data?.user?.id) { sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); }
} clearSessionRecoveryDrafts();
signOut({ redirect: true, callbackUrl: "/login" }); },
return { success: true }; navigate: (path) => window.location.assign(path),
}, }),
onError: async (error) => { onError: async (error) => {
if (error.response?.status === 401) {
return { logout: true };
}
return { error }; return { error };
}, },
check: async () => check: async () =>
@@ -351,6 +367,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
warnWhenUnsavedChanges: true, warnWhenUnsavedChanges: true,
}} }}
> >
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard>{props.children}</RoutePermissionGuard> <RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar /> <RefineKbar />
</Refine> </Refine>
@@ -0,0 +1,111 @@
import authOptions from "./options";
describe("NextAuth access token refresh", () => {
it("forces a Keycloak refresh for an agent credential request", async () => {
const previousEnv = {
issuer: process.env.KEYCLOAK_ISSUER,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
};
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
process.env.KEYCLOAK_CLIENT_ID = "frontend";
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
const originalFetch = globalThis.fetch;
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "fresh-access-token",
expires_in: 900,
refresh_token: "rotated-refresh-token",
}),
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
accessToken?: string;
refreshToken?: string;
}>;
const result = await jwt({
token: {
accessToken: "still-fresh-access-token",
accessTokenExpires: Date.now() + 600_000,
accessTokenIssuedAt: Date.now(),
refreshToken: "refresh-token",
},
trigger: "update",
session: { forceRefresh: true },
});
expect(result.accessToken).toBe("fresh-access-token");
expect(result.refreshToken).toBe("rotated-refresh-token");
expect(fetchMock).toHaveBeenCalledWith(
"https://keycloak.example/realms/tjwater/protocol/openid-connect/token",
expect.objectContaining({ method: "POST" }),
);
} finally {
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
}
});
it.each([
["network failure", () => Promise.reject(new Error("connection refused"))],
[
"non-JSON response",
() =>
Promise.resolve({
ok: false,
json: async () => {
throw new SyntaxError("Unexpected token");
},
}),
],
[
"invalid token payload",
() =>
Promise.resolve({
ok: true,
json: async () => ({ access_token: " ", expires_in: 0 }),
}),
],
])("returns a session error for %s", async (_label, fetchResult) => {
const previousEnv = {
issuer: process.env.KEYCLOAK_ISSUER,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
};
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
process.env.KEYCLOAK_CLIENT_ID = "frontend";
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
const originalFetch = globalThis.fetch;
globalThis.fetch = jest.fn(fetchResult) as unknown as typeof fetch;
try {
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
error?: string;
}>;
await expect(
jwt({
token: { refreshToken: "refresh-token" },
trigger: "update",
session: { forceRefresh: true },
}),
).resolves.toMatchObject({ error: "RefreshAccessTokenError" });
} finally {
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
}
});
});
const restoreEnv = (key: string, value: string | undefined) => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
};
+73 -17
View File
@@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak"; import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg"; import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
type KeycloakTokenResponse = { type KeycloakTokenResponse = {
access_token: string; access_token: string;
expires_in: number; expires_in: number;
@@ -36,24 +39,43 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
refresh_token: token.refreshToken, refresh_token: token.refreshToken,
}); });
const response = await fetch(keycloakTokenEndpoint, { try {
method: "POST", const response = await fetch(keycloakTokenEndpoint, {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, method: "POST",
body, headers: { "Content-Type": "application/x-www-form-urlencoded" },
}); body,
const refreshed = (await response.json()) as KeycloakTokenResponse; });
const refreshed = (await response.json()) as Partial<KeycloakTokenResponse> | null;
if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") { if (
!response.ok ||
!refreshed ||
typeof refreshed.access_token !== "string" ||
!refreshed.access_token.trim() ||
typeof refreshed.expires_in !== "number" ||
!Number.isFinite(refreshed.expires_in) ||
refreshed.expires_in <= 0
) {
return { ...token, error: "RefreshAccessTokenError" };
}
const rotatedRefreshToken =
typeof refreshed.refresh_token === "string" &&
refreshed.refresh_token.trim()
? refreshed.refresh_token.trim()
: token.refreshToken;
return {
...token,
accessToken: refreshed.access_token.trim(),
accessTokenIssuedAt: Date.now(),
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
refreshToken: rotatedRefreshToken,
error: undefined,
};
} catch {
return { ...token, error: "RefreshAccessTokenError" }; return { ...token, error: "RefreshAccessTokenError" };
} }
return {
...token,
accessToken: refreshed.access_token,
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
error: undefined,
};
}; };
const authOptions: NextAuthOptions = { const authOptions: NextAuthOptions = {
@@ -76,7 +98,7 @@ const authOptions: NextAuthOptions = {
], ],
secret: process.env.NEXTAUTH_SECRET, secret: process.env.NEXTAUTH_SECRET,
callbacks: { callbacks: {
jwt: async ({ token, profile, account }) => { jwt: async ({ token, profile, account, trigger, session }) => {
if (profile?.sub) { if (profile?.sub) {
token.sub = profile.sub; token.sub = profile.sub;
} }
@@ -88,12 +110,17 @@ const authOptions: NextAuthOptions = {
} }
if (account) { if (account) {
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
if (account.access_token) { if (account.access_token) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.accessTokenIssuedAt = Date.now();
} }
if (account.refresh_token) { if (account.refresh_token) {
token.refreshToken = account.refresh_token; token.refreshToken = account.refresh_token;
} }
if (account.id_token) {
token.idToken = account.id_token;
}
if (typeof account.expires_at === "number") { if (typeof account.expires_at === "number") {
token.accessTokenExpires = account.expires_at * 1000; token.accessTokenExpires = account.expires_at * 1000;
} }
@@ -101,7 +128,26 @@ const authOptions: NextAuthOptions = {
return token; return token;
} }
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) { if (
typeof token.sessionExpiresAt === "number" &&
Date.now() >= token.sessionExpiresAt
) {
return { ...token, error: "SessionExpired" };
}
if (
trigger === "update" &&
(session as { forceRefresh?: unknown } | undefined)?.forceRefresh === true
) {
return refreshAccessToken(token);
}
const accessTokenIsFresh =
typeof token.accessTokenExpires === "number" &&
typeof token.accessTokenIssuedAt === "number" &&
Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS;
if (accessTokenIsFresh) {
return token; return token;
} }
@@ -120,9 +166,19 @@ const authOptions: NextAuthOptions = {
if (token.error) { if (token.error) {
session.error = token.error; session.error = token.error;
} }
if (typeof token.sessionExpiresAt === "number") {
session.sessionExpiresAt = token.sessionExpiresAt;
}
return session; return session;
}, },
}, },
session: {
strategy: "jwt",
maxAge: SESSION_MAX_AGE_SECONDS,
},
jwt: {
maxAge: SESSION_MAX_AGE_SECONDS,
},
}; };
export default authOptions; export default authOptions;
@@ -0,0 +1,97 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
import { GET } from "./route";
jest.mock("next-auth/jwt", () => ({
getToken: jest.fn(),
}));
const getTokenMock = getToken as jest.MockedFunction<typeof getToken>;
describe("GET /api/auth/keycloak-logout", () => {
const originalIssuer = process.env.KEYCLOAK_ISSUER;
const originalClientId = process.env.KEYCLOAK_CLIENT_ID;
const originalNextAuthUrl = process.env.NEXTAUTH_URL;
const originalPostLogoutRedirectUri =
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
beforeEach(() => {
process.env.KEYCLOAK_ISSUER = "https://keycloak.example.com/realms/tjwater";
process.env.KEYCLOAK_CLIENT_ID = "tjwater";
process.env.NEXTAUTH_URL = "https://frontend.example.com";
delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
getTokenMock.mockReset();
});
afterAll(() => {
process.env.KEYCLOAK_ISSUER = originalIssuer;
process.env.KEYCLOAK_CLIENT_ID = originalClientId;
process.env.NEXTAUTH_URL = originalNextAuthUrl;
if (originalPostLogoutRedirectUri) {
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI = originalPostLogoutRedirectUri;
} else {
delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
}
});
it("clears the local session and redirects the browser to Keycloak logout", async () => {
getTokenMock.mockResolvedValue({
idToken: "header.payload.signature",
});
const request = new NextRequest(
"https://frontend.example.com/api/auth/keycloak-logout",
{
headers: {
cookie:
"__Secure-next-auth.session-token.0=first; __Secure-next-auth.session-token.1=second; next-auth.session-token=local",
},
},
);
const response = await GET(request);
const logoutUrl = new URL(response.headers.get("location") ?? "");
expect(logoutUrl.origin).toBe("https://keycloak.example.com");
expect(logoutUrl.pathname).toBe(
"/realms/tjwater/protocol/openid-connect/logout",
);
expect(logoutUrl.searchParams.get("id_token_hint")).toBe(
"header.payload.signature",
);
expect(logoutUrl.searchParams.get("client_id")).toBe("tjwater");
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
expect(
response.cookies.get("__Secure-next-auth.session-token.0"),
).toMatchObject({ value: "", path: "/", secure: true });
expect(
response.cookies.get("__Secure-next-auth.session-token.1"),
).toMatchObject({ value: "", path: "/", secure: true });
expect(response.cookies.get("next-auth.session-token")).toMatchObject({
value: "",
path: "/",
});
expect(
response.cookies.get("next-auth.session-token")?.secure,
).toBeFalsy();
});
it("ignores a legacy automatic post-logout redirect setting", async () => {
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI =
"https://frontend.example.com/login";
getTokenMock.mockResolvedValue({ idToken: "header.payload.signature" });
const response = await GET(
new NextRequest(
"https://frontend.example.com/api/auth/keycloak-logout",
),
);
const logoutUrl = new URL(response.headers.get("location") ?? "");
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
type KeycloakToken = {
idToken?: string;
};
const sessionCookiePrefixes = [
"next-auth.session-token",
"__Secure-next-auth.session-token",
];
const isSessionCookie = (name: string) =>
sessionCookiePrefixes.some((prefix) => name.startsWith(prefix));
export const GET = async (request: NextRequest) => {
const localLoginUrl = new URL(
"/login",
process.env.NEXTAUTH_URL ?? request.nextUrl.origin,
);
const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, "");
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const token = (await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
})) as KeycloakToken | null;
const logoutUrl = issuer
? new URL(`${issuer}/protocol/openid-connect/logout`)
: localLoginUrl;
if (issuer) {
if (clientId) logoutUrl.searchParams.set("client_id", clientId);
if (token?.idToken) logoutUrl.searchParams.set("id_token_hint", token.idToken);
}
const response = NextResponse.redirect(logoutUrl);
for (const { name } of request.cookies.getAll()) {
if (isSessionCookie(name)) {
response.cookies.delete({
name,
path: "/",
secure: name.startsWith("__Secure-"),
});
}
}
return response;
};
+15
View File
@@ -57,6 +57,7 @@ import {
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { config } from "@config/config"; import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
type MetadataUser = { type MetadataUser = {
@@ -479,6 +480,20 @@ export const SystemAdminPanel = () => {
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms); const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth); const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth);
const recoveryDraft = useMemo(
() => ({ tab, projectId, memberForm, projectForm, createProjectOpen, createProjectForm }),
[createProjectForm, createProjectOpen, memberForm, projectForm, projectId, tab],
);
const restoreRecoveryDraft = useCallback((draft: typeof recoveryDraft) => {
setTab(draft.tab);
setProjectId(draft.projectId);
setMemberForm(draft.memberForm);
setProjectForm(draft.projectForm);
setCreateProjectOpen(draft.createProjectOpen);
setCreateProjectForm(draft.createProjectForm);
}, []);
useSessionRecoveryDraft("system-admin", recoveryDraft, restoreRecoveryDraft);
useEffect(() => { useEffect(() => {
openNotificationRef.current = openNotification; openNotificationRef.current = openNotification;
}, [openNotification]); }, [openNotification]);
@@ -0,0 +1,39 @@
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { act, render, screen } from "@testing-library/react";
import { useAuthStore } from "@/store/authStore";
import { SessionExpiryDialog } from "./SessionExpiryDialog";
jest.mock("next-auth/react", () => ({
signIn: jest.fn(),
}));
describe("SessionExpiryDialog", () => {
beforeEach(() => {
useAuthStore.setState({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
afterEach(() => {
act(() => useAuthStore.getState().clearSessionExpired());
});
it("renders above every regular application overlay", () => {
const theme = createTheme();
render(
<ThemeProvider theme={theme}>
<SessionExpiryDialog />
</ThemeProvider>,
);
const dialogRoot = screen.getByRole("dialog").closest(".MuiModal-root");
expect(dialogRoot).not.toBeNull();
expect(dialogRoot).toHaveStyle({
zIndex: theme.zIndex.tooltip + 1,
});
});
});
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { signIn } from "next-auth/react";
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useAuthStore } from "@/store/authStore";
const WARNING_WINDOW_MS = 15 * 60 * 1000;
type SessionExpiryDialogProps = {
expiresAt?: number;
};
export const SessionExpiryDialog = ({
expiresAt,
}: SessionExpiryDialogProps) => {
const theme = useTheme();
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const reason = useAuthStore((state) => state.sessionExpiryReason);
const [now, setNow] = useState(() => Date.now());
const [warningDismissed, setWarningDismissed] = useState(false);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(timer);
}, []);
const isExpiringSoon = useMemo(
() =>
!sessionExpired &&
!warningDismissed &&
typeof expiresAt === "number" &&
expiresAt > now &&
expiresAt - now <= WARNING_WINDOW_MS,
[expiresAt, now, sessionExpired, warningDismissed],
);
const handleReauthenticate = () => {
const callbackUrl = `${window.location.pathname}${window.location.search}`;
void signIn("keycloak", { callbackUrl, redirect: true });
};
const isOpen = sessionExpired || isExpiringSoon;
const title = sessionExpired ? "登录已过期" : "登录即将到期";
const detail = sessionExpired
? reason === "session_max_age"
? "已达到 12 小时的最长连续登录时间。请重新认证后继续。"
: "无法续期当前登录。请重新认证后继续。"
: "当前登录将在 15 分钟内到期。请先保存正在编辑的内容。";
return (
<Dialog
open={isOpen}
style={{ zIndex: theme.zIndex.tooltip + 1 }}
disableEscapeKeyDown={sessionExpired}
onClose={sessionExpired ? undefined : () => setWarningDismissed(true)}
aria-labelledby="session-expiry-dialog-title"
>
<DialogTitle id="session-expiry-dialog-title">{title}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 0.5 }}>
<Alert icon={<AccessTimeOutlinedIcon />} severity={sessionExpired ? "warning" : "info"}>
{detail}
</Alert>
<Typography variant="body2" color="text.secondary">
</Typography>
</Stack>
</DialogContent>
<DialogActions>
{!sessionExpired && (
<Button onClick={() => setWarningDismissed(true)}></Button>
)}
<Button variant="contained" onClick={handleReauthenticate}>
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,384 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Collapse,
IconButton,
LinearProgress,
Stack,
Typography,
alpha,
useMediaQuery,
useTheme,
} from "@mui/material";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import StopCircleRounded from "@mui/icons-material/StopCircleRounded";
import type { AgentActivity, AgentActivityAction } from "@/lib/chatStream";
const activityAccent = "#0097a7";
type TimedActivityItem = {
status: "running" | "completed" | "error" | "cancelled";
startedAt: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
const formatDuration = (durationMs: number | undefined) => {
if (durationMs === undefined || !Number.isFinite(durationMs)) return undefined;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
const seconds = Math.round(durationMs / 1000);
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
const getElapsedMs = (
item: TimedActivityItem,
now: number,
) => {
if (item.durationMs !== undefined) return item.durationMs;
if (item.status === "running") {
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
return Math.max(0, item.elapsedMs + now - item.elapsedSnapshotAt);
}
return Math.max(0, now - item.startedAt);
}
return item.endedAt ? Math.max(0, item.endedAt - item.startedAt) : undefined;
};
const StatusIcon = ({
status,
size = 18,
}: {
status: AgentActivity["status"];
size?: number;
}) => {
if (status === "completed") {
return <CheckCircleRounded color="success" sx={{ fontSize: size }} />;
}
if (status === "error") {
return <ErrorOutlineRounded color="error" sx={{ fontSize: size }} />;
}
if (status === "cancelled") {
return <StopCircleRounded color="disabled" sx={{ fontSize: size }} />;
}
return <AutoAwesomeRounded sx={{ fontSize: size, color: activityAccent }} />;
};
const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] }) => {
if (status === "error") {
return <ErrorOutlineRounded sx={{ mt: "2px", fontSize: 14, color: "error.main" }} />;
}
if (status === "completed") {
return <CheckCircleRounded sx={{ mt: "2px", fontSize: 14, color: "success.main" }} />;
}
return (
<RadioButtonUncheckedRounded
sx={{ mt: "2px", fontSize: 14, color: activityAccent }}
/>
);
};
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
const elapsed = getElapsedMs(action, now);
return (
<Stack
direction="row"
spacing={1}
alignItems="flex-start"
sx={{ minWidth: 0, py: 0.55 }}
>
<ActionStatusIcon status={action.status} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={{ xs: 0.2, sm: 1 }}
justifyContent="space-between"
>
<Typography variant="body2" fontWeight={650} sx={{ lineHeight: 1.45 }}>
{action.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{formatDuration(elapsed)}
</Typography>
</Stack>
{action.target ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
mt: 0.2,
fontFamily: "monospace",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{action.target}
</Typography>
) : null}
{action.error ? (
<Typography
variant="caption"
color="error.main"
sx={{ display: "block", mt: 0.2, wordBreak: "break-word" }}
>
{action.error}
</Typography>
) : null}
</Box>
</Stack>
);
};
export const AgentActivityTimeline = ({
activities,
}: {
activities: AgentActivity[];
}) => {
const theme = useTheme();
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const hasRunning = activities.some((activity) => activity.status === "running");
const hasError = activities.some((activity) => activity.status === "error");
const hasCancelled = activities.some((activity) => activity.status === "cancelled");
const [expanded, setExpanded] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!hasRunning) return;
const timer = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(timer);
}, [hasRunning]);
const current = [...activities]
.reverse()
.find((activity) => activity.status === "running") ?? activities.at(-1);
const totalDuration = useMemo(() => {
if (!activities.length) return undefined;
const start = Math.min(...activities.map((activity) => activity.startedAt));
const end = hasRunning
? now
: Math.max(
...activities.map((activity) => activity.endedAt ?? activity.startedAt),
);
return formatDuration(Math.max(0, end - start));
}, [activities, hasRunning, now]);
const overallStatus: AgentActivity["status"] = hasRunning
? "running"
: hasError
? "error"
: hasCancelled
? "cancelled"
: "completed";
const statusLabel = {
running: "进行中",
completed: "已完成",
error: "失败",
cancelled: "已停止",
}[overallStatus];
const statusColor = {
running: activityAccent,
completed: theme.palette.success.main,
error: theme.palette.error.main,
cancelled: theme.palette.text.secondary,
}[overallStatus];
const summary = hasRunning
? (current?.title ?? "正在分析")
: hasError
? "分析未完成"
: hasCancelled
? "分析已停止"
: `已完成 ${activities.length} 个阶段`;
return (
<Box
sx={{
overflow: "hidden",
borderRadius: 3,
border: `1px solid ${alpha(activityAccent, 0.16)}`,
bgcolor: alpha(activityAccent, 0.035),
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ px: 1.4, py: 1.05 }}
>
<Box
sx={{
width: 28,
height: 28,
flex: "0 0 auto",
display: "grid",
placeItems: "center",
borderRadius: 2,
color: activityAccent,
bgcolor: alpha(activityAccent, 0.1),
}}
>
<AutoAwesomeRounded sx={{ fontSize: 17 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={0.75} alignItems="center">
<Typography variant="body2" fontWeight={750}>
</Typography>
<Stack
component="span"
direction="row"
spacing={0.45}
alignItems="center"
sx={{ color: statusColor }}
>
<Box
component="span"
sx={{
width: 5,
height: 5,
borderRadius: "50%",
bgcolor: "currentColor",
}}
/>
<Typography
component="span"
variant="caption"
fontWeight={700}
color="inherit"
>
{statusLabel}
</Typography>
</Stack>
</Stack>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: "block", mt: 0.1 }}
>
{summary}
{totalDuration ? ` · ${totalDuration}` : ""}
</Typography>
</Box>
<IconButton
size="small"
aria-label={expanded ? "收起分析过程" : "展开分析过程"}
onClick={() => setExpanded((current) => !current)}
sx={{
width: 28,
height: 28,
flex: "0 0 auto",
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
</Stack>
{hasRunning ? (
<LinearProgress
sx={{
height: 2,
bgcolor: alpha(activityAccent, 0.08),
"& .MuiLinearProgress-bar": { bgcolor: activityAccent },
}}
/>
) : null}
<Collapse in={expanded} timeout={reduceMotion ? 0 : 180}>
<Stack
spacing={1.1}
sx={{
px: 1.4,
py: 1.15,
}}
>
{activities.map((activity, index) => {
const elapsed = formatDuration(getElapsedMs(activity, now));
return (
<Stack
key={activity.id}
direction="row"
spacing={1}
sx={{
position: "relative",
minWidth: 0,
}}
>
<Box
sx={{
width: 18,
flex: "0 0 18px",
position: "relative",
pt: "2px",
}}
>
{index < activities.length - 1 ? (
<Box
sx={{
position: "absolute",
top: 19,
bottom: -14,
left: 8.5,
width: "1px",
bgcolor: alpha(activityAccent, 0.18),
}}
/>
) : null}
<StatusIcon status={activity.status} size={17} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={1} justifyContent="space-between">
<Typography variant="body2" fontWeight={700} sx={{ lineHeight: 1.45 }}>
{activity.title}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{elapsed}
</Typography>
</Stack>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.25, lineHeight: 1.5 }}
>
{activity.reason}
</Typography>
{activity.actions.length ? (
<Stack
spacing={0}
sx={{
mt: 0.6,
pl: 1,
borderLeft: `1px solid ${alpha(activityAccent, 0.2)}`,
}}
>
{activity.actions.map((action) => (
<ActionRow key={action.id} action={action} now={now} />
))}
</Stack>
) : null}
</Box>
</Stack>
);
})}
</Stack>
</Collapse>
</Box>
);
};
+51 -1
View File
@@ -44,7 +44,7 @@ describe("AgentComposer", () => {
modelOptions={[{ id: "test-model", label: "测试模型" }]} modelOptions={[{ id: "test-model", label: "测试模型" }]}
selectedModel="test-model" selectedModel="test-model"
onModelChange={jest.fn()} onModelChange={jest.fn()}
approvalMode="request" approvalMode="auto"
onApprovalModeChange={jest.fn()} onApprovalModeChange={jest.fn()}
/> />
</ThemeProvider>, </ThemeProvider>,
@@ -56,6 +56,56 @@ describe("AgentComposer", () => {
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument(); expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument(); expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
expect(screen.getByText("自动批准")).toBeInTheDocument();
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true); expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
}); });
it("disables input while the Agent runtime is unavailable", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
runtimeState="unavailable"
isListening={false}
isSttSupported={false}
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[]}
onModelChange={jest.fn()}
approvalMode="auto"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByPlaceholderText("Agent 服务未就绪,暂时无法发送消息")).toBeDisabled();
expect(screen.getByRole("button", { name: "发送" })).toBeDisabled();
});
it("renders the always-allow mode as an explicit warning state", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
isListening={false}
isSttSupported={false}
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[]}
onModelChange={jest.fn()}
approvalMode="always"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByText("始终允许")).toBeInTheDocument();
expect(screen.getByTestId("WarningAmberRoundedIcon")).toBeInTheDocument();
});
}); });
+115 -35
View File
@@ -27,6 +27,8 @@ import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded"; import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
import WarningAmberRounded from "@mui/icons-material/WarningAmberRounded";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
import type { AgentModelOption } from "@/lib/chatModels"; import type { AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
@@ -40,6 +42,7 @@ export type AgentComposerHandle = {
type AgentComposerProps = { type AgentComposerProps = {
isHydrating?: boolean; isHydrating?: boolean;
runtimeState?: AgentRuntimeState;
isStreaming: boolean; isStreaming: boolean;
isListening: boolean; isListening: boolean;
isSttSupported: boolean; isSttSupported: boolean;
@@ -55,6 +58,35 @@ type AgentComposerProps = {
onApprovalModeChange: (mode: AgentApprovalMode) => void; onApprovalModeChange: (mode: AgentApprovalMode) => void;
}; };
const approvalModeOptions: ReadonlyArray<{
value: AgentApprovalMode;
label: string;
description: string;
icon: React.ElementType;
}> = [
{
value: "request",
label: "请求批准",
description: "白名单外的工具权限逐次确认",
icon: VerifiedUserRounded,
},
{
value: "auto",
label: "自动批准",
description: "低风险自动批准,其余仍需确认",
icon: AdminPanelSettingsRounded,
},
{
value: "always",
label: "始终允许",
description: "除明确禁止项外自动放行",
icon: WarningAmberRounded,
},
];
const getApprovalModeOption = (value: AgentApprovalMode) =>
approvalModeOptions.find((option) => option.value === value) ?? approvalModeOptions[0];
const renderModelIcon = ( const renderModelIcon = (
icon: AgentModelOption["icon"] | undefined, icon: AgentModelOption["icon"] | undefined,
props?: React.ComponentProps<typeof BoltRounded>, props?: React.ComponentProps<typeof BoltRounded>,
@@ -67,6 +99,7 @@ const renderModelIcon = (
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false, isHydrating = false,
runtimeState = "ready",
isStreaming, isStreaming,
isListening, isListening,
isSttSupported, isSttSupported,
@@ -85,8 +118,19 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState(""); const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false); const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; const isRuntimeReady = runtimeState === "ready";
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating && isRuntimeReady;
const placeholder = isHydrating
? "正在加载对话记录..."
: runtimeState === "checking"
? "正在连接 Agent 服务..."
: runtimeState === "models_unavailable"
? "模型尚未加载,暂时无法发送消息"
: runtimeState === "unavailable"
? "Agent 服务未就绪,暂时无法发送消息"
: "描述你的分析目标,或点击上方指令库...";
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel); const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
const selectedApprovalModeOption = getApprovalModeOption(approvalMode);
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
@@ -102,10 +146,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const handleSend = React.useCallback(() => { const handleSend = React.useCallback(() => {
const prompt = input.trim(); const prompt = input.trim();
if (!prompt || isStreaming || isHydrating) return; if (!prompt || isStreaming || isHydrating || !isRuntimeReady) return;
setInput(""); setInput("");
onSend(prompt); onSend(prompt);
}, [input, isHydrating, isStreaming, onSend]); }, [input, isHydrating, isRuntimeReady, isStreaming, onSend]);
return ( return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}> <Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
@@ -154,6 +198,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
label={prompt.replace(/[。.]$/, "")} label={prompt.replace(/[。.]$/, "")}
size="medium" size="medium"
clickable clickable
disabled={!isRuntimeReady || isHydrating || isStreaming}
onClick={() => { onClick={() => {
setInput(prompt); setInput(prompt);
setIsPresetOpen(false); setIsPresetOpen(false);
@@ -209,12 +254,12 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
handleSend(); handleSend();
} }
}} }}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} placeholder={placeholder}
fullWidth fullWidth
multiline multiline
maxRows={5} maxRows={5}
variant="standard" variant="standard"
disabled={isHydrating} disabled={isHydrating || !isRuntimeReady}
InputProps={{ InputProps={{
disableUnderline: true, disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" }, sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
@@ -223,26 +268,33 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}> <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center"> <Stack direction="row" spacing={0.5} alignItems="center">
<FormControl size="small" sx={{ minWidth: 96 }}> <FormControl size="small" sx={{ minWidth: 128 }}>
<Select <Select
value={approvalMode} value={approvalMode}
onChange={(event) => onChange={(event) =>
onApprovalModeChange(event.target.value as AgentApprovalMode) onApprovalModeChange(event.target.value as AgentApprovalMode)
} }
disabled={isHydrating || isStreaming} disabled={isHydrating || isStreaming || !isRuntimeReady}
aria-label="权限批准模式" aria-label="权限批准模式"
renderValue={(val) => ( renderValue={() => {
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}> const SelectedApprovalIcon = selectedApprovalModeOption.icon;
{val === "always" ? ( return (
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} /> <Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
) : ( <SelectedApprovalIcon
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} /> sx={{
)} fontSize: 18,
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}> color:
{val === "always" ? "始终允许" : "请求批准"} selectedApprovalModeOption.value === "always"
</Typography> ? "warning.main"
</Box> : "inherit",
)} }}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{selectedApprovalModeOption.label}
</Typography>
</Box>
);
}}
MenuProps={{ MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "left" }, anchorOrigin: { vertical: "top", horizontal: "left" },
transformOrigin: { vertical: "bottom", horizontal: "left" }, transformOrigin: { vertical: "bottom", horizontal: "left" },
@@ -250,7 +302,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
PaperProps: { PaperProps: {
sx: { sx: {
mb: 1.5, mb: 1.5,
width: 210, width: 248,
borderRadius: 4, borderRadius: 4,
bgcolor: alpha("#fff", 0.9), bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(24px)", backdropFilter: "blur(24px)",
@@ -269,6 +321,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
"&:hover": { bgcolor: alpha("#00acc1", 0.12) }, "&:hover": { bgcolor: alpha("#00acc1", 0.12) },
"& .title": { color: "#00838f" }, "& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" }, "& .icon": { color: "#00acc1" },
"& .always-icon": { color: "warning.main" },
}, },
}, },
}, },
@@ -298,20 +351,47 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
}, },
}} }}
> >
<MenuItem value="request"> {approvalModeOptions.map((option) => {
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} /> const ApprovalIcon = option.icon;
<Box> const isAlways = option.value === "always";
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography> return (
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography> <MenuItem key={option.value} value={option.value}>
</Box> <ApprovalIcon
</MenuItem> className={isAlways ? "icon always-icon" : "icon"}
<MenuItem value="always"> sx={{
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} /> mr: 1.5,
<Box> mt: 0.15,
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography> fontSize: 18,
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography> color: isAlways ? "warning.main" : "text.secondary",
</Box> }}
</MenuItem> />
<Box>
<Typography
className="title"
sx={{
mb: 0.2,
color: "text.primary",
fontSize: "0.85rem",
fontWeight: 700,
}}
>
{option.label}
</Typography>
<Typography
sx={{
color: "text.secondary",
fontSize: "0.7rem",
fontWeight: 500,
lineHeight: 1.3,
whiteSpace: "nowrap",
}}
>
{option.description}
</Typography>
</Box>
</MenuItem>
);
})}
</Select> </Select>
</FormControl> </FormControl>
</Stack> </Stack>
@@ -452,7 +532,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
) : ( ) : (
<IconButton <IconButton
onClick={onStartListening} onClick={onStartListening}
disabled={isStreaming || isHydrating} disabled={isStreaming || isHydrating || !isRuntimeReady}
aria-label="语音输入" aria-label="语音输入"
size="small" size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }} sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
+16 -3
View File
@@ -19,12 +19,14 @@ import CloseRounded from "@mui/icons-material/CloseRounded";
import EditRounded from "@mui/icons-material/EditRounded"; import EditRounded from "@mui/icons-material/EditRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded"; import HistoryRounded from "@mui/icons-material/HistoryRounded";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
type AgentHeaderProps = { type AgentHeaderProps = {
sessionTitle?: string; sessionTitle?: string;
canRenameSessionTitle?: boolean; canRenameSessionTitle?: boolean;
isHydrating?: boolean; isHydrating?: boolean;
isStreaming: boolean; isStreaming: boolean;
runtimeState?: AgentRuntimeState;
isHistoryOpen: boolean; isHistoryOpen: boolean;
onHistoryToggle: () => void; onHistoryToggle: () => void;
onRenameSessionTitle?: (title: string) => void; onRenameSessionTitle?: (title: string) => void;
@@ -37,6 +39,7 @@ export const AgentHeader = ({
canRenameSessionTitle = false, canRenameSessionTitle = false,
isHydrating = false, isHydrating = false,
isStreaming, isStreaming,
runtimeState = "ready",
isHistoryOpen, isHistoryOpen,
onHistoryToggle, onHistoryToggle,
onRenameSessionTitle, onRenameSessionTitle,
@@ -47,6 +50,14 @@ export const AgentHeader = ({
const displayTitle = sessionTitle?.trim() || "新对话"; const displayTitle = sessionTitle?.trim() || "新对话";
const [isEditingTitle, setIsEditingTitle] = React.useState(false); const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
const runtimeStatus =
runtimeState === "unavailable" || runtimeState === "models_unavailable"
? { color: "#e53935", label: "Agent 服务未就绪" }
: runtimeState === "checking"
? { color: "#ffb300", label: "正在连接 Agent 服务" }
: isStreaming
? { color: "#ff9800", label: "Agent 正在生成" }
: { color: "#00e676", label: "Agent 已就绪" };
React.useEffect(() => { React.useEffect(() => {
if (!isEditingTitle) { if (!isEditingTitle) {
@@ -109,17 +120,19 @@ export const AgentHeader = ({
/> />
</Avatar> </Avatar>
<Box <Box
role="status"
aria-label={runtimeStatus.label}
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: -2, bottom: -2,
right: -2, right: -2,
width: 14, width: 14,
height: 14, height: 14,
bgcolor: isStreaming ? "#ff9800" : "#00e676", bgcolor: runtimeStatus.color,
borderRadius: "50%", borderRadius: "50%",
border: "2.5px solid #fff", border: "2.5px solid #fff",
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`, boxShadow: `0 0 10px ${runtimeStatus.color}`,
animation: isStreaming ? "pulse 1.5s infinite" : "none", animation: isStreaming && runtimeState === "ready" ? "pulse 1.5s infinite" : "none",
"@keyframes pulse": { "@keyframes pulse": {
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` }, "0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` }, "70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
+129 -96
View File
@@ -19,12 +19,12 @@ import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded"; import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import GppGoodRounded from "@mui/icons-material/GppGoodRounded";
import type { PermissionReply } from "@/lib/chatStream"; import type { PermissionDecision } from "@/lib/chatStream";
import type { Message } from "./GlobalChatbox.types"; import type { Message } from "./GlobalChatbox.types";
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => { const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
@@ -58,7 +58,7 @@ const PermissionIcon = ({
}; };
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => { const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
if (status === "approved_always") return "已始终允许"; if (status === "approved_always") return "已保存授权";
if (status === "approved_once") return "已允许一次"; if (status === "approved_once") return "已允许一次";
if (status === "rejected") return "已拒绝"; if (status === "rejected") return "已拒绝";
if (status === "aborted") return "已中断"; if (status === "aborted") return "已中断";
@@ -99,7 +99,7 @@ const PermissionRequestCard = ({
}: { }: {
permission: NonNullable<Message["permissions"]>[number]; permission: NonNullable<Message["permissions"]>[number];
isRunning: boolean; isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void; onReply: (requestId: string, reply: PermissionDecision) => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const isPending = const isPending =
@@ -109,25 +109,19 @@ const PermissionRequestCard = ({
const accentColor = getPermissionStatusColor(permission.status, theme); const accentColor = getPermissionStatusColor(permission.status, theme);
const statusTextColor = getPermissionStatusTextColor(permission.status, theme); const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
const statusLabel = getPermissionStatusLabel(permission.status); const statusLabel = getPermissionStatusLabel(permission.status);
const persistentScope = permission.always.length > 0
? permission.always
: permission.patterns;
return ( return (
<Box <Box
sx={{ sx={{
borderRadius: 3, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`, border: `1px solid ${alpha(accentColor, 0.18)}`,
bgcolor: alpha("#fff", 0.5), bgcolor: alpha(accentColor, 0.035),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, boxShadow: `0 6px 18px ${alpha("#000", 0.04)}`,
backdropFilter: "blur(20px)",
position: "relative", position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: accentColor,
},
}} }}
> >
<Stack <Stack
@@ -177,6 +171,22 @@ const PermissionRequestCard = ({
</Stack> </Stack>
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}> <Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
<Box
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha(accentColor, 0.055),
border: `1px solid ${alpha(accentColor, 0.12)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
<Typography variant="body2" sx={{ mt: 0.25, lineHeight: 1.55 }}>
{permission.reason?.trim() || "Agent 未提供执行目的"}
</Typography>
</Box>
<Box <Box
sx={{ sx={{
px: 1.25, px: 1.25,
@@ -203,6 +213,29 @@ const PermissionRequestCard = ({
{primaryValue} {primaryValue}
</Typography> </Typography>
</Box> </Box>
{isPending || isSubmitting ? (
<Box
sx={{
px: 1.25,
py: 0.85,
borderRadius: 2.5,
bgcolor: alpha(theme.palette.success.main, 0.055),
border: `1px solid ${alpha(theme.palette.success.main, 0.11)}`,
}}
>
<Typography variant="caption" color="success.dark" fontWeight={800}>
</Typography>
<Typography
variant="caption"
color="text.secondary"
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
sx={{ display: "block", mt: 0.2, lineHeight: 1.45, wordBreak: "break-word", whiteSpace: "pre-wrap" }}
>
{persistentScope.join("\n")}
</Typography>
</Box>
) : null}
</Stack> </Stack>
{permission.error ? ( {permission.error ? (
@@ -232,84 +265,84 @@ const PermissionRequestCard = ({
useFlexGap useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }} sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
> >
<Button <Button
size="small" size="small"
variant="contained" variant="contained"
disableElevation disableElevation
disabled={isSubmitting} disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "once")} onClick={() => onReply(permission.requestId, "once")}
startIcon={ startIcon={
isSubmitting ? ( isSubmitting ? (
<CircularProgress size={14} color="inherit" /> <CircularProgress size={14} color="inherit" />
) : ( ) : (
<CheckCircleRounded fontSize="small" /> <CheckCircleRounded fontSize="small" />
) )
} }
sx={{ sx={{
minWidth: 94, minWidth: 94,
height: 34, height: 34,
borderRadius: "17px", borderRadius: "17px",
bgcolor: "#00838f", bgcolor: "#00838f",
fontWeight: 800, fontWeight: 800,
fontSize: "0.78rem", fontSize: "0.78rem",
textTransform: "none", textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`, boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
"&:hover": { "&:hover": {
bgcolor: "#006c78", bgcolor: "#006c78",
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`, boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
}, },
}} }}
> >
</Button> </Button>
<Button <Button
size="small" size="small"
variant="outlined" color="success"
disabled={isSubmitting} variant="outlined"
onClick={() => onReply(permission.requestId, "always")} disabled={isSubmitting}
startIcon={<PushPinRounded fontSize="small" />} onClick={() => onReply(permission.requestId, "always")}
sx={{ startIcon={<GppGoodRounded fontSize="small" />}
height: 34, sx={{
borderRadius: "17px", height: 34,
px: 1.5, borderRadius: "17px",
fontWeight: 800, px: 1.5,
fontSize: "0.78rem", fontWeight: 800,
textTransform: "none", fontSize: "0.78rem",
color: "#00838f", textTransform: "none",
borderColor: alpha("#00838f", 0.24), borderColor: alpha(theme.palette.success.main, 0.28),
bgcolor: alpha("#fff", 0.45), bgcolor: alpha("#fff", 0.45),
"&:hover": { "&:hover": {
borderColor: alpha("#00838f", 0.36), borderColor: alpha(theme.palette.success.main, 0.42),
bgcolor: alpha("#00838f", 0.08), bgcolor: alpha(theme.palette.success.main, 0.08),
}, },
}} }}
> >
</Button> </Button>
<Button <Button
size="small" size="small"
color="error" color="error"
variant="outlined" variant="outlined"
disabled={isSubmitting} disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "reject")} onClick={() => onReply(permission.requestId, "reject")}
startIcon={<BlockRounded fontSize="small" />} startIcon={<BlockRounded fontSize="small" />}
sx={{ sx={{
height: 34, height: 34,
borderRadius: "17px", borderRadius: "17px",
px: 1.5, px: 1.5,
fontWeight: 800, fontWeight: 800,
fontSize: "0.78rem", fontSize: "0.78rem",
textTransform: "none", textTransform: "none",
borderColor: alpha(theme.palette.error.main, 0.22), borderColor: alpha(theme.palette.error.main, 0.22),
bgcolor: alpha("#fff", 0.45), bgcolor: alpha("#fff", 0.45),
"&:hover": { "&:hover": {
borderColor: alpha(theme.palette.error.main, 0.34), borderColor: alpha(theme.palette.error.main, 0.34),
bgcolor: alpha(theme.palette.error.main, 0.07), bgcolor: alpha(theme.palette.error.main, 0.07),
}, },
}} }}
> >
</Button> </Button>
</Stack> </Stack>
) : null} ) : null}
</Box> </Box>
@@ -323,7 +356,7 @@ export const PermissionRequestGroup = ({
}: { }: {
permissions: NonNullable<Message["permissions"]>; permissions: NonNullable<Message["permissions"]>;
isRunning: boolean; isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void; onReply: (requestId: string, reply: PermissionDecision) => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
@@ -348,7 +381,7 @@ export const PermissionRequestGroup = ({
const summaryItems = [ const summaryItems = [
{ label: "共", value: permissions.length, color: theme.palette.text.secondary }, { label: "共", value: permissions.length, color: theme.palette.text.secondary },
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, { label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, { label: "保存授权", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) }, { label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) }, { label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
]; ];
+7 -6
View File
@@ -29,7 +29,9 @@ export const TodoPlanCard = ({
const theme = useTheme(); const theme = useTheme();
const total = todoUpdate.todos.length; const total = todoUpdate.todos.length;
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); const runningCount = todoUpdate.todos.filter(
(todo) => todo.status === "in_progress",
).length;
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length; const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0; const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -77,7 +79,7 @@ export const TodoPlanCard = ({
? `${completed} 完成 / ${cancelled} 中止` ? `${completed} 完成 / ${cancelled} 中止`
: [ : [
completed ? `${completed} 完成` : null, completed ? `${completed} 完成` : null,
running ? "1 进行中" : null, runningCount ? `${runningCount} 进行中` : null,
pending ? `${pending} 待办` : null, pending ? `${pending} 待办` : null,
cancelled ? `${cancelled} 中止` : null, cancelled ? `${cancelled} 中止` : null,
].filter(Boolean).join(" / ") || "等待任务"; ].filter(Boolean).join(" / ") || "等待任务";
@@ -221,14 +223,14 @@ export const TodoPlanCard = ({
</Typography> </Typography>
<Chip <Chip
size="small" size="small"
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"} label={runningCount ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
sx={{ sx={{
height: 20, height: 20,
borderRadius: "10px", borderRadius: "10px",
fontSize: "0.66rem", fontSize: "0.66rem",
fontWeight: 800, fontWeight: 800,
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f", color: runningCount ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08), bgcolor: alpha(runningCount ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
"& .MuiChip-label": { px: 0.75 }, "& .MuiChip-label": { px: 0.75 },
}} }}
/> />
@@ -305,4 +307,3 @@ export const TodoPlanCard = ({
); );
}; };
+236
View File
@@ -14,6 +14,7 @@ jest.mock("next/image", () => ({
jest.mock("framer-motion", () => ({ jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useReducedMotion: () => false,
motion: { motion: {
div: ({ div: ({
children, children,
@@ -44,6 +45,47 @@ jest.mock("./AgentMarkdownBlock", () => ({
})); }));
describe("AgentTurn speech selection", () => { describe("AgentTurn speech selection", () => {
it("mounts the answer only after the complete response is available", () => {
const sharedProps = {
messageSpeechState: "idle" as const,
onSpeak: jest.fn(),
onPause: jest.fn(),
onResume: jest.fn(),
onStopSpeech: jest.fn(),
isTtsSupported: true,
onCreateBranch: jest.fn(),
onReplyPermission: jest.fn(),
onReplyQuestion: jest.fn(),
onRejectQuestion: jest.fn(),
};
const { rerender } = render(
<AgentTurn
{...sharedProps}
message={{ id: "assistant-buffered", role: "assistant", content: "" }}
isStreaming
/>,
);
expect(screen.queryByTestId("agent-answer-content")).not.toBeInTheDocument();
expect(screen.getByText("正在生成")).toBeInTheDocument();
rerender(
<AgentTurn
{...sharedProps}
message={{
id: "assistant-buffered",
role: "assistant",
content: "完整分析结果已生成。",
}}
isStreaming
/>,
);
expect(screen.getByTestId("agent-answer-content")).toHaveTextContent(
"完整分析结果已生成。",
);
});
it("shows a floating action and reads from the selected text", async () => { it("shows a floating action and reads from the selected text", async () => {
const content = "第一段内容。\n\n第二段内容。"; const content = "第一段内容。\n\n第二段内容。";
const speechText = "第一段内容。\n第二段内容。"; const speechText = "第一段内容。\n第二段内容。";
@@ -112,4 +154,198 @@ describe("AgentTurn speech selection", () => {
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
}); });
}); });
it("offers one-time and saved permission approval with distinct actions", () => {
const onReplyPermission = jest.fn();
render(
<AgentTurn
message={{
id: "assistant-permission",
role: "assistant",
content: "",
progress: [
{
id: "permission-progress",
phase: "permission",
status: "running",
title: "等待权限确认",
},
],
permissions: [
{
requestId: "permission-1",
sessionId: "session-1",
permission: "bash",
patterns: ["npm test"],
target: "npm test",
reason: "需要运行测试确认本次改动没有引入回归。",
always: ["npm test"],
createdAt: 1,
status: "pending",
},
],
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={onReplyPermission}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
expect(screen.getByText("执行目的")).toBeInTheDocument();
expect(screen.getByText("需要运行测试确认本次改动没有引入回归。")).toBeInTheDocument();
expect(screen.getAllByText("npm test")).toHaveLength(2);
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
});
it("groups concrete actions under a business activity", () => {
render(
<AgentTurn
message={{
id: "assistant-activity",
role: "assistant",
content: "",
activities: [
{
id: "activity-1",
title: "准备供水分区数据",
reason: "需要确认拓扑与水库属性完整,才能计算服务范围。",
status: "running",
startedAt: Date.now(),
actions: [
{
id: "action-1",
tool: "tjwater_cli",
title: "查询后端数据",
status: "completed",
target: "network get-all-reservoirs-properties",
startedAt: Date.now() - 100,
endedAt: Date.now(),
durationMs: 100,
},
],
},
],
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText("分析过程")).toBeInTheDocument();
expect(screen.getByText("进行中")).toBeInTheDocument();
expect(screen.getAllByText("准备供水分区数据").length).toBeGreaterThan(0);
expect(screen.getByTestId("KeyboardArrowDownRoundedIcon")).toBeInTheDocument();
expect(
screen.queryByText("需要确认拓扑与水库属性完整,才能计算服务范围。"),
).not.toBeVisible();
expect(screen.queryByText("查询后端数据")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
expect(screen.getByTestId("KeyboardArrowUpRoundedIcon")).toBeInTheDocument();
expect(screen.getByText("需要确认拓扑与水库属性完整,才能计算服务范围。")).toBeVisible();
expect(screen.getByText("查询后端数据")).toBeVisible();
});
it("shows the actual number of in-progress session tasks", () => {
render(
<AgentTurn
message={{
id: "assistant-todos",
role: "assistant",
content: "",
todos: {
sessionId: "session-1",
createdAt: 1,
todos: [
{ id: "todo-1", content: "准备数据", status: "completed" },
{ id: "todo-2", content: "分析结果", status: "completed" },
{ id: "todo-3", content: "生成建议", status: "in_progress" },
{ id: "todo-4", content: "生成图表", status: "in_progress" },
],
},
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText(/2 \/ 2 /u)).toBeInTheDocument();
});
it("keeps a failed activity compact until the user expands it", () => {
render(
<AgentTurn
message={{
id: "assistant-activity-error",
role: "assistant",
content: "⚠️ **错误:** 模型请求失败",
activities: [
{
id: "activity-error",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
status: "error",
startedAt: Date.now() - 4100,
endedAt: Date.now(),
durationMs: 4100,
actions: [],
},
],
}}
isStreaming={false}
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText("失败")).toBeInTheDocument();
expect(screen.getByText("分析未完成 · 4.1s")).toBeInTheDocument();
expect(
screen.queryByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
expect(
screen.getByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
).toBeInTheDocument();
});
}); });
+98 -112
View File
@@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { motion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { import {
Avatar, Avatar,
Box, Box,
@@ -20,7 +20,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import { TbArrowsSplit2 } from "react-icons/tb"; import { TbArrowsSplit2 } from "react-icons/tb";
import type { PermissionReply } from "@/lib/chatStream"; import type { PermissionDecision } from "@/lib/chatStream";
import { import {
parseAssistantMessageSections, parseAssistantMessageSections,
parseContentWithToolCalls, parseContentWithToolCalls,
@@ -33,6 +33,7 @@ import type {
import { stripMarkdown } from "./globalChatboxUtils"; import { stripMarkdown } from "./globalChatboxUtils";
import { findSpeechSelectionStartOffset } from "./speechStartOptions"; import { findSpeechSelectionStartOffset } from "./speechStartOptions";
import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { AgentActivityTimeline } from "./AgentActivityTimeline";
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart"; import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
@@ -100,7 +101,7 @@ type AgentTurnProps = {
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void; onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
@@ -149,61 +150,6 @@ const StreamingStatus = () => {
); );
}; };
const StreamingMarkdownBlock = ({
text,
isStreaming,
segmentKey,
}: {
text: string;
isStreaming: boolean;
segmentKey: string;
}) => {
const [streamTextState, setStreamTextState] = React.useState<{
displayText: string;
animatedTailLength: number;
}>({
displayText: text,
animatedTailLength: 0,
});
React.useLayoutEffect(() => {
setStreamTextState((current) => {
if (current.displayText === text) {
return current;
}
if (!isStreaming) {
return {
displayText: text,
animatedTailLength: 0,
};
}
if (current.displayText === text) {
return current;
}
return {
displayText: text,
animatedTailLength:
text.length > current.displayText.length &&
text.startsWith(current.displayText)
? Math.min(48, text.length - current.displayText.length)
: 0,
};
});
}, [isStreaming, text]);
return (
<MarkdownBlock
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
streamFadeLength={streamTextState.animatedTailLength}
>
{streamTextState.displayText}
</MarkdownBlock>
);
};
export const AgentTurn = React.memo( export const AgentTurn = React.memo(
({ ({
message, message,
@@ -220,9 +166,11 @@ export const AgentTurn = React.memo(
onRejectQuestion, onRejectQuestion,
}: AgentTurnProps) => { }: AgentTurnProps) => {
const theme = useTheme(); const theme = useTheme();
const reduceMotion = useReducedMotion();
const isUser = message.role === "user"; const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError); const isErrorMessage = Boolean(message.isError);
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
const hasFinalAnswer = message.content.trim().length > 0;
const [isHovered, setIsHovered] = React.useState(false); const [isHovered, setIsHovered] = React.useState(false);
const answerContentRef = React.useRef<HTMLDivElement | null>(null); const answerContentRef = React.useRef<HTMLDivElement | null>(null);
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null); const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
@@ -230,7 +178,8 @@ export const AgentTurn = React.memo(
(item) => item.phase === "complete" && item.status === "completed", (item) => item.phase === "complete" && item.status === "completed",
) ?? false; ) ?? false;
const isProgressRunning = !isErrorMessage && !isProgressComplete && ( const isProgressRunning = !isErrorMessage && !isProgressComplete && (
message.progress?.some((item) => item.status === "running") ?? false (message.activities?.some((item) => item.status === "running") ?? false) ||
(message.progress?.some((item) => item.status === "running") ?? false)
); );
const parsedAssistantSections = useMemo( const parsedAssistantSections = useMemo(
@@ -456,7 +405,9 @@ export const AgentTurn = React.memo(
}} }}
> >
<Stack spacing={1.5}> <Stack spacing={1.5}>
{message.progress?.length ? ( {message.activities?.length ? (
<AgentActivityTimeline activities={message.activities} />
) : message.progress?.length ? (
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} /> <AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null} ) : null}
@@ -493,63 +444,98 @@ export const AgentTurn = React.memo(
}} }}
> >
<Stack spacing={1.2}> <Stack spacing={1.2}>
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}> <Stack
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
>
<Typography
variant="caption"
color="text.secondary"
fontWeight={800}
sx={{ letterSpacing: 0.5 }}
>
</Typography> </Typography>
{isStreamingAssistant ? <StreamingStatus /> : null} {isStreamingAssistant ? <StreamingStatus /> : null}
</Stack> </Stack>
{contentSegments.map((segment, segIdx) => { {hasFinalAnswer || !isStreamingAssistant ? (
if (segment.type === "text") { <motion.div
const text = segment.content.trim(); data-testid="agent-answer-content"
if (!text && contentSegments.length > 1) return null; initial={
return ( reduceMotion
<StreamingMarkdownBlock ? false
key={segIdx} : { opacity: 0, y: 6, filter: "blur(2px)" }
text={text || "..."}
isStreaming={isStreamingAssistant}
segmentKey={`${message.id}-${segIdx}`}
/>
);
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories}
series={p.series}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
isStreaming={isStreamingAssistant}
/>
);
} }
return ( animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
<ChatToolCallBlock transition={{
key={segment.toolCall.id} duration: reduceMotion ? 0 : 0.24,
toolCall={segment.toolCall} ease: [0.16, 1, 0.3, 1],
/> }}
); >
} <Stack spacing={1.2}>
if (segment.type === "tool_call_pending") { {contentSegments.map((segment, segIdx) => {
return ( if (segment.type === "text") {
<ChartGenerationSkeleton const text = segment.content.trim();
key="tool-pending" if (!text && contentSegments.length > 1) return null;
status={<StreamingStatus />} return (
/> <MarkdownBlock key={segIdx}>
); {text || "..."}
} </MarkdownBlock>
return null; );
})} }
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ??
"line"
}
x_data={
p.x_data ??
p.xData ??
p.labels ??
p.categories
}
series={p.series}
x_axis_name={
(p.x_axis_name as string) ?? undefined
}
y_axis_name={
(p.y_axis_name as string) ?? undefined
}
isStreaming={isStreamingAssistant}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<ChartGenerationSkeleton
key="tool-pending"
status={<StreamingStatus />}
/>
);
}
return null;
})}
</Stack>
</motion.div>
) : null}
</Stack> </Stack>
</Box> </Box>
+99 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { AgentWorkspace } from "./AgentWorkspace"; import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types"; import type { Message } from "./GlobalChatbox.types";
@@ -85,6 +85,38 @@ describe("AgentWorkspace", () => {
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument(); expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
}); });
it("shows runtime startup failures in the existing empty state and retries there", () => {
const onRetryRuntime = jest.fn();
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
runtimeState="unavailable"
onRetryRuntime={onRetryRuntime}
messages={[]}
/>,
);
expect(screen.getByText("Agent 服务未就绪")).toBeInTheDocument();
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "重新检测" }));
expect(onRetryRuntime).toHaveBeenCalledTimes(1);
});
it("distinguishes model loading failures from backend startup failures", () => {
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
runtimeState="models_unavailable"
messages={[]}
/>,
);
expect(screen.getByText("模型未加载")).toBeInTheDocument();
expect(screen.getByText(/模型配置加载失败/)).toBeInTheDocument();
});
it("keeps stable history turns from re-rendering while the last assistant message streams", () => { it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
const userMessage: Message = { const userMessage: Message = {
id: "user-1", id: "user-1",
@@ -164,4 +196,70 @@ describe("AgentWorkspace", () => {
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0); expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
expect(streamingFlags.get("assistant-1")).toBe(false); expect(streamingFlags.get("assistant-1")).toBe(false);
}); });
it("windows long conversations instead of mounting every turn", () => {
const messages = Array.from({ length: 200 }, (_, index): Message => ({
id: `message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
}));
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
expect(renderCounts.size).toBeGreaterThan(0);
expect(renderCounts.size).toBeLessThan(40);
});
it("preserves the flex alignment context around windowed turns", () => {
const messages = Array.from({ length: 41 }, (_, index): Message => ({
id: `message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
}));
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
expect(screen.getByTestId("turn-message-0").parentElement).toHaveStyle({
display: "flex",
flexDirection: "column",
});
});
it("keeps visible turns mounted when crossing the windowing threshold", () => {
const messages = Array.from({ length: 41 }, (_, index): Message => ({
id: `message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
}));
const { rerender } = render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages.slice(0, 40)}
/>,
);
rerender(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
expect(mountCounts.get("message-0")).toBe(1);
expect(unmountCounts.get("message-0") ?? 0).toBe(0);
});
}); });
+300 -41
View File
@@ -3,14 +3,16 @@
import Image from "next/image"; import Image from "next/image";
import React from "react"; import React from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; import { Box, Button, CircularProgress, Paper, Skeleton, Stack, Typography, alpha, Grid } from "@mui/material";
import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded";
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
import MapRounded from "@mui/icons-material/MapRounded"; import MapRounded from "@mui/icons-material/MapRounded";
import ReplayRounded from "@mui/icons-material/ReplayRounded";
import { AgentTurn } from "./AgentTurn"; import { AgentTurn } from "./AgentTurn";
import type { PermissionReply } from "@/lib/chatStream"; import type { AgentRuntimeState } from "@/lib/agentRuntime";
import type { PermissionDecision } from "@/lib/chatStream";
import type { import type {
Message, Message,
SpeechState, SpeechState,
@@ -19,6 +21,8 @@ import type {
type AgentWorkspaceProps = { type AgentWorkspaceProps = {
messages: Message[]; messages: Message[];
isStreaming: boolean; isStreaming: boolean;
runtimeState?: AgentRuntimeState;
onRetryRuntime?: () => void;
isLoadingSession?: boolean; isLoadingSession?: boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>; scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
@@ -35,13 +39,15 @@ type AgentWorkspaceProps = {
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void; onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
type TurnListProps = { type TurnListProps = {
messages: Message[]; messages: Message[];
scrollTop: number;
viewportHeight: number;
isAssistantStreaming: boolean; isAssistantStreaming: boolean;
streamingMessageId: string | null; streamingMessageId: string | null;
speakingMessageId: string | null; speakingMessageId: string | null;
@@ -56,13 +62,18 @@ type TurnListProps = {
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void; onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120; const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
const TURN_WINDOW_THRESHOLD = 40;
const TURN_ESTIMATED_HEIGHT_PX = 220;
const TURN_GAP_PX = 16;
const TURN_OVERSCAN_PX = 600;
const DEFAULT_VIEWPORT_HEIGHT_PX = 720;
const sameMessages = (left: Message[], right: Message[]) => const sameMessages = (left: Message[], right: Message[]) =>
left.length === right.length && left.length === right.length &&
@@ -72,6 +83,8 @@ const TurnItem = React.memo(AgentTurn);
const TurnListInner = ({ const TurnListInner = ({
messages, messages,
scrollTop,
viewportHeight,
isAssistantStreaming, isAssistantStreaming,
streamingMessageId, streamingMessageId,
speakingMessageId, speakingMessageId,
@@ -86,33 +99,179 @@ const TurnListInner = ({
onReplyQuestion, onReplyQuestion,
onRejectQuestion, onRejectQuestion,
}: TurnListProps) => { }: TurnListProps) => {
const [measuredHeights, setMeasuredHeights] = React.useState(
() => new Map<string, number>(),
);
const isWindowed = messages.length > TURN_WINDOW_THRESHOLD;
React.useEffect(() => {
const activeIds = new Set(messages.map((message) => message.id));
setMeasuredHeights((current) => {
if ([...current.keys()].every((messageId) => activeIds.has(messageId))) {
return current;
}
return new Map(
[...current].filter(([messageId]) => activeIds.has(messageId)),
);
});
}, [messages]);
const updateMeasuredHeight = React.useCallback(
(messageId: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) return;
const roundedHeight = Math.ceil(height);
setMeasuredHeights((current) => {
if (current.get(messageId) === roundedHeight) return current;
const next = new Map(current);
next.set(messageId, roundedHeight);
return next;
});
},
[],
);
const turnOffsets = React.useMemo(() => {
const offsets = new Array<number>(messages.length + 1).fill(0);
if (!isWindowed) return offsets;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
const size =
(measuredHeights.get(message.id) ?? TURN_ESTIMATED_HEIGHT_PX) +
TURN_GAP_PX;
offsets[index + 1] = offsets[index] + size;
}
return offsets;
}, [isWindowed, measuredHeights, messages]);
const windowState = React.useMemo(() => {
if (!isWindowed) {
return {
endIndex: messages.length,
startIndex: 0,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
}
const visibleStart = Math.max(0, scrollTop - TURN_OVERSCAN_PX);
const visibleEnd = scrollTop + viewportHeight + TURN_OVERSCAN_PX;
const startIndex = Math.max(
0,
lowerBound(turnOffsets, visibleStart, 1) - 1,
);
const endIndex = lowerBound(turnOffsets, visibleEnd, startIndex);
const boundedEndIndex = Math.min(
messages.length,
Math.max(startIndex + 1, endIndex),
);
return {
startIndex,
endIndex: boundedEndIndex,
topSpacerHeight: turnOffsets[startIndex],
bottomSpacerHeight:
turnOffsets[messages.length] - turnOffsets[boundedEndIndex],
};
}, [isWindowed, messages.length, scrollTop, turnOffsets, viewportHeight]);
const visibleMessages = isWindowed
? messages.slice(windowState.startIndex, windowState.endIndex)
: messages;
return ( return (
<> <>
{messages.map((message) => ( {windowState.topSpacerHeight > 0 ? (
<TurnItem <Box aria-hidden sx={{ height: windowState.topSpacerHeight, flexShrink: 0 }} />
) : null}
{visibleMessages.map((message) => (
<MeasuredTurn
key={message.id} key={message.id}
message={message} message={message}
isStreaming={isAssistantStreaming && message.id === streamingMessageId} measure={isWindowed}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} onHeightChange={updateMeasuredHeight}
onSpeak={onSpeak} >
onPause={onPauseSpeech} <TurnItem
onResume={onResumeSpeech} message={message}
onStopSpeech={onStopSpeech} isStreaming={isAssistantStreaming && message.id === streamingMessageId}
isTtsSupported={isTtsSupported} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onCreateBranch={onCreateBranch} onSpeak={onSpeak}
onReplyPermission={onReplyPermission} onPause={onPauseSpeech}
onReplyQuestion={onReplyQuestion} onResume={onResumeSpeech}
onRejectQuestion={onRejectQuestion} onStopSpeech={onStopSpeech}
/> isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</MeasuredTurn>
))} ))}
{windowState.bottomSpacerHeight > 0 ? (
<Box aria-hidden sx={{ height: windowState.bottomSpacerHeight, flexShrink: 0 }} />
) : null}
</> </>
); );
}; };
const lowerBound = (values: number[], target: number, fromIndex: number) => {
let low = Math.max(0, fromIndex);
let high = values.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if (values[middle] < target) low = middle + 1;
else high = middle;
}
return low;
};
type MeasuredTurnProps = {
children: React.ReactNode;
measure: boolean;
message: Message;
onHeightChange: (messageId: string, height: number) => void;
};
const MeasuredTurn = ({
children,
measure,
message,
onHeightChange,
}: MeasuredTurnProps) => {
const rowRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const row = rowRef.current;
if (!measure || !row) return;
const measureRow = () =>
onHeightChange(message.id, row.getBoundingClientRect().height);
measureRow();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measureRow);
observer.observe(row);
return () => observer.disconnect();
}, [measure, message.id, onHeightChange]);
return (
<Box
ref={rowRef}
sx={{
display: "flex",
flexDirection: "column",
flexShrink: 0,
mb: measure ? `${TURN_GAP_PX}px` : 0,
}}
>
{children}
</Box>
);
};
const TurnList = React.memo( const TurnList = React.memo(
TurnListInner, TurnListInner,
(prevProps, nextProps) => (prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) && sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.scrollTop === nextProps.scrollTop &&
prevProps.viewportHeight === nextProps.viewportHeight &&
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming && prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
prevProps.streamingMessageId === nextProps.streamingMessageId && prevProps.streamingMessageId === nextProps.streamingMessageId &&
prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId &&
@@ -130,8 +289,34 @@ const TurnList = React.memo(
TurnList.displayName = "TurnList"; TurnList.displayName = "TurnList";
const EmptyState = () => { const EmptyState = ({
const theme = useTheme(); runtimeState,
onRetryRuntime,
}: {
runtimeState: AgentRuntimeState;
onRetryRuntime?: () => void;
}) => {
const isReady = runtimeState === "ready";
const isChecking = runtimeState === "checking";
const statusCopy = isChecking
? {
title: "正在连接 Agent 服务",
detail: "正在确认后端运行时和模型加载状态,请稍候。",
}
: runtimeState === "models_unavailable"
? {
title: "模型未加载",
detail: "Agent 服务已启动,但模型配置加载失败。请检查模型配置后重新检测。",
}
: runtimeState === "unavailable"
? {
title: "Agent 服务未就绪",
detail: "无法连接 Agent 后端,或运行时启动失败。请检查服务后重新检测。",
}
: {
title: "我已就绪,请描述任务",
detail: "你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。",
};
const capabilities = [ const capabilities = [
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" }, { icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" }, { icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
@@ -147,6 +332,8 @@ const EmptyState = () => {
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }} style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
> >
<Paper <Paper
role={isReady || isChecking ? "status" : "alert"}
aria-live="polite"
elevation={0} elevation={0}
sx={{ sx={{
p: 4, p: 4,
@@ -170,9 +357,9 @@ const EmptyState = () => {
}} /> }} />
<motion.div <motion.div
animate={{ animate={{
y: [-6, 4, -6], y: isReady ? [-6, 4, -6] : 0,
scale: [1, 1.04, 1], scale: isReady ? [1, 1.04, 1] : 1,
rotate: [-3, 3, -3], rotate: isReady ? [-3, 3, -3] : 0,
}} }}
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }} transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
style={{ style={{
@@ -194,22 +381,37 @@ const EmptyState = () => {
height={54} height={54}
style={{ style={{
objectFit: "contain", objectFit: "contain",
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))", filter: isReady
? "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))"
: "grayscale(0.65) opacity(0.72)",
}} }}
/> />
</motion.div> </motion.div>
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom> <Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
{statusCopy.title}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: isReady ? 3 : 2 }}>
使 {statusCopy.detail}
</Typography> </Typography>
<Grid container spacing={1.5}> {isChecking ? (
{capabilities.map((item) => ( <CircularProgress size={28} thickness={4} aria-label="正在检测 Agent 服务" />
<Grid item xs={6} key={item.label}> ) : !isReady ? (
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}> <Button
<Stack variant="contained"
size="small"
startIcon={<ReplayRounded />}
onClick={onRetryRuntime}
sx={{ borderRadius: 999, px: 2, boxShadow: "none" }}
>
</Button>
) : (
<Grid container spacing={1.5}>
{capabilities.map((item) => (
<Grid item xs={6} key={item.label}>
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
<Stack
direction="row" direction="row"
spacing={1} spacing={1}
alignItems="center" alignItems="center"
@@ -234,11 +436,12 @@ const EmptyState = () => {
<Typography variant="caption" fontWeight={700}> <Typography variant="caption" fontWeight={700}>
{item.label} {item.label}
</Typography> </Typography>
</Stack> </Stack>
</motion.div> </motion.div>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
)}
</Paper> </Paper>
</motion.div> </motion.div>
); );
@@ -309,6 +512,8 @@ const SessionLoadingSkeleton = () => (
export const AgentWorkspace = ({ export const AgentWorkspace = ({
messages, messages,
isStreaming, isStreaming,
runtimeState = "ready",
onRetryRuntime,
isLoadingSession = false, isLoadingSession = false,
scrollContainerRef, scrollContainerRef,
bottomRef, bottomRef,
@@ -325,14 +530,23 @@ export const AgentWorkspace = ({
onReplyQuestion, onReplyQuestion,
onRejectQuestion, onRejectQuestion,
}: AgentWorkspaceProps) => { }: AgentWorkspaceProps) => {
const localScrollContainerRef = React.useRef<HTMLDivElement>(null);
const [scrollMetrics, setScrollMetrics] = React.useState({
scrollTop: 0,
viewportHeight: DEFAULT_VIEWPORT_HEIGHT_PX,
});
const streamingMessageId = const streamingMessageId =
isStreaming && messages.at(-1)?.role === "assistant" isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1)?.id ?? null ? messages.at(-1)?.id ?? null
: null; : null;
const handleScroll = React.useCallback( const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => { (event: React.UIEvent<HTMLDivElement>) => {
if (!onScrollStateChange) return;
const target = event.currentTarget; const target = event.currentTarget;
setScrollMetrics({
scrollTop: target.scrollTop,
viewportHeight: target.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX,
});
if (!onScrollStateChange) return;
const distanceToBottom = const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight; target.scrollHeight - target.scrollTop - target.clientHeight;
onScrollStateChange( onScrollStateChange(
@@ -343,9 +557,37 @@ export const AgentWorkspace = ({
[isStreaming, onScrollStateChange], [isStreaming, onScrollStateChange],
); );
const setScrollContainer = React.useCallback(
(node: HTMLDivElement | null) => {
localScrollContainerRef.current = node;
if (scrollContainerRef) {
scrollContainerRef.current = node;
}
},
[scrollContainerRef],
);
React.useEffect(() => {
const container = localScrollContainerRef.current;
if (!container || typeof ResizeObserver === "undefined") return;
const updateViewportHeight = () => {
const viewportHeight =
container.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX;
setScrollMetrics((current) =>
current.viewportHeight === viewportHeight
? current
: { ...current, viewportHeight },
);
};
updateViewportHeight();
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(container);
return () => observer.disconnect();
}, []);
return ( return (
<Box <Box
ref={scrollContainerRef} ref={setScrollContainer}
onScroll={handleScroll} onScroll={handleScroll}
sx={{ sx={{
flex: 1, flex: 1,
@@ -363,13 +605,30 @@ export const AgentWorkspace = ({
) : ( ) : (
<> <>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null} {messages.length === 0 ? (
<EmptyState
runtimeState={runtimeState}
onRetryRuntime={onRetryRuntime}
/>
) : null}
</AnimatePresence> </AnimatePresence>
{messages.length > 0 ? ( {messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box
sx={{
display: "flex",
flexDirection: "column",
gap: messages.length > TURN_WINDOW_THRESHOLD ? 0 : 2,
}}
>
<TurnList <TurnList
messages={messages} messages={messages}
scrollTop={
messages.length > TURN_WINDOW_THRESHOLD
? scrollMetrics.scrollTop
: 0
}
viewportHeight={scrollMetrics.viewportHeight}
isAssistantStreaming={isStreaming} isAssistantStreaming={isStreaming}
streamingMessageId={streamingMessageId} streamingMessageId={streamingMessageId}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
+12 -12
View File
@@ -45,12 +45,12 @@ type ToolMeta = {
const LOCATE_TOOL_TO_LAYER: Record<string, string> = { const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
locate_features: "", locate_features: "",
locate_junctions: "geo_junctions_mat", locate_junctions: "junctions",
locate_pipes: "geo_pipes_mat", locate_pipes: "pipes",
locate_valves: "geo_valves", locate_valves: "valves",
locate_reservoirs: "geo_reservoirs", locate_reservoirs: "reservoirs",
locate_pumps: "geo_pumps", locate_pumps: "pumps",
locate_tanks: "geo_tanks", locate_tanks: "tanks",
}; };
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]); const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
@@ -672,22 +672,22 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
switch (featureType) { switch (featureType) {
case "junction": case "junction":
case "junctions": case "junctions":
return { layer: "geo_junctions_mat", geometryKind: "point" }; return { layer: "junctions", geometryKind: "point" };
case "pipe": case "pipe":
case "pipes": case "pipes":
return { layer: "geo_pipes_mat", geometryKind: "line" }; return { layer: "pipes", geometryKind: "line" };
case "valve": case "valve":
case "valves": case "valves":
return { layer: "geo_valves", geometryKind: "point" }; return { layer: "valves", geometryKind: "point" };
case "reservoir": case "reservoir":
case "reservoirs": case "reservoirs":
return { layer: "geo_reservoirs", geometryKind: "point" }; return { layer: "reservoirs", geometryKind: "point" };
case "pump": case "pump":
case "pumps": case "pumps":
return { layer: "geo_pumps", geometryKind: "point" }; return { layer: "pumps", geometryKind: "point" };
case "tank": case "tank":
case "tanks": case "tanks":
return { layer: "geo_tanks", geometryKind: "point" }; return { layer: "tanks", geometryKind: "point" };
default: default:
return null; return null;
} }
+74 -4
View File
@@ -5,6 +5,8 @@ import { act, render, screen } from "@testing-library/react";
import { GlobalChatbox } from "./GlobalChatbox"; import { GlobalChatbox } from "./GlobalChatbox";
const createSession = jest.fn(); const createSession = jest.fn();
const mockFetchAgentRuntimeHealth = jest.fn();
const mockFetchAgentModels = jest.fn();
let mockCurrentProjectId = "project-1"; let mockCurrentProjectId = "project-1";
jest.mock("@refinedev/core", () => ({ jest.mock("@refinedev/core", () => ({
@@ -12,7 +14,12 @@ jest.mock("@refinedev/core", () => ({
})); }));
jest.mock("@/lib/chatModels", () => ({ jest.mock("@/lib/chatModels", () => ({
fetchAgentModels: jest.fn(() => new Promise(() => {})), fetchAgentModels: (...args: unknown[]) => mockFetchAgentModels(...args),
}));
jest.mock("@/lib/agentRuntime", () => ({
fetchAgentRuntimeHealth: (...args: unknown[]) =>
mockFetchAgentRuntimeHealth(...args),
})); }));
jest.mock("@/store/projectStore", () => ({ jest.mock("@/store/projectStore", () => ({
@@ -73,12 +80,14 @@ jest.mock("./AgentHistoryPanel", () => ({
})); }));
jest.mock("./AgentWorkspace", () => ({ jest.mock("./AgentWorkspace", () => ({
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>, AgentWorkspace: ({ runtimeState }: { runtimeState: string }) => (
<div data-testid="agent-workspace">Workspace state: {runtimeState}</div>
),
})); }));
jest.mock("./AgentComposer", () => ({ jest.mock("./AgentComposer", () => ({
AgentComposer: React.forwardRef(function MockAgentComposer() { AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) {
return <div>Composer</div>; return <div>Composer mode: {props.approvalMode}</div>;
}), }),
})); }));
@@ -90,12 +99,17 @@ describe("GlobalChatbox lifecycle", () => {
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
createSession.mockClear(); createSession.mockClear();
mockFetchAgentRuntimeHealth.mockReset();
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
mockFetchAgentModels.mockReset();
mockFetchAgentModels.mockImplementation(() => new Promise(() => {}));
mockCurrentProjectId = "project-1"; mockCurrentProjectId = "project-1";
}); });
afterEach(() => { afterEach(() => {
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
jest.useRealTimers(); jest.useRealTimers();
jest.restoreAllMocks();
}); });
it("keeps content mounted and preserves the session across close and reopen", async () => { it("keeps content mounted and preserves the session across close and reopen", async () => {
@@ -121,4 +135,60 @@ describe("GlobalChatbox lifecycle", () => {
expect(createSession).toHaveBeenCalledTimes(2); expect(createSession).toHaveBeenCalledTimes(2);
}); });
it("defaults the composer to automatic permission approval", () => {
render(<GlobalChatbox open onClose={jest.fn()} />);
expect(screen.getByText("Composer mode: auto")).toBeInTheDocument();
});
it("passes backend startup failures into the existing workspace empty state", async () => {
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(false);
render(<GlobalChatbox open onClose={jest.fn()} />);
await act(async () => Promise.resolve());
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
});
it("reserves models unavailable for an empty model configuration", async () => {
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(true);
mockFetchAgentModels.mockResolvedValueOnce({ models: [] });
render(<GlobalChatbox open onClose={jest.fn()} />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Workspace state: models_unavailable")).toBeInTheDocument();
});
it("reports an auth dependency failure as unavailable and retries quickly", async () => {
jest.spyOn(console, "error").mockImplementation(() => undefined);
mockFetchAgentRuntimeHealth.mockResolvedValue(true);
mockFetchAgentModels
.mockRejectedValueOnce(new Error("authentication service unavailable"))
.mockResolvedValueOnce({
defaultModel: "provider/model",
models: [{ id: "provider/model", label: "Model" }],
});
render(<GlobalChatbox open onClose={jest.fn()} />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
expect(mockFetchAgentModels).toHaveBeenCalledTimes(1);
await act(async () => {
jest.advanceTimersByTime(5_000);
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Workspace state: ready")).toBeInTheDocument();
expect(mockFetchAgentModels).toHaveBeenCalledTimes(2);
});
}); });
+97 -26
View File
@@ -10,6 +10,10 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { getAccessToken } from "@/lib/authToken"; import { getAccessToken } from "@/lib/authToken";
import {
fetchAgentRuntimeHealth,
type AgentRuntimeState,
} from "@/lib/agentRuntime";
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels"; import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
@@ -26,6 +30,9 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions";
const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36; const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
const AGENT_RUNTIME_POLL_MS = 30_000;
const AGENT_RUNTIME_RETRY_MS = 5_000;
const AGENT_RUNTIME_TIMEOUT_MS = 8_000;
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520); const [width, setWidth] = useState(520);
@@ -34,8 +41,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [isCheckingAuth, setIsCheckingAuth] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]); const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined); const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
const [runtimeState, setRuntimeState] = useState<AgentRuntimeState>("checking");
const [approvalMode, setApprovalMode] = const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request"); useState<AgentApprovalMode>("auto");
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const workspaceScrollRef = useRef<HTMLDivElement>(null); const workspaceScrollRef = useRef<HTMLDivElement>(null);
@@ -43,6 +51,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const streamingScrollFrameRef = useRef<number | null>(null); const streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
const initializedProjectIdRef = useRef<string | null | undefined>(undefined); const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
const runtimeRequestIdRef = useRef(0);
const runtimeAbortRef = useRef<AbortController | null>(null);
const theme = useTheme(); const theme = useTheme();
const { open: openNotification } = useNotification(); const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -68,35 +78,87 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isSupported: isSttSupported, isSupported: isSttSupported,
} = useSpeechRecognition(handleSpeechResult); } = useSpeechRecognition(handleSpeechResult);
useEffect(() => { const refreshAgentRuntime = useCallback(async (showChecking = true) => {
let cancelled = false; const requestId = ++runtimeRequestIdRef.current;
runtimeAbortRef.current?.abort();
const controller = new AbortController();
runtimeAbortRef.current = controller;
const timeoutId = window.setTimeout(
() => controller.abort(),
AGENT_RUNTIME_TIMEOUT_MS,
);
let runtimeHealthy = false;
const loadModels = async () => { if (showChecking) setRuntimeState("checking");
try {
const modelConfig = await fetchAgentModels(); try {
if (cancelled) return; runtimeHealthy = await fetchAgentRuntimeHealth(controller.signal);
setModelOptions(modelConfig.models); if (requestId !== runtimeRequestIdRef.current) return;
setSelectedModel((current) => { if (!runtimeHealthy) {
if (current && modelConfig.models.some((model) => model.id === current)) { setRuntimeState("unavailable");
return current; setModelOptions([]);
} setSelectedModel(undefined);
return modelConfig.defaultModel; return "unavailable" as const;
});
} catch (error) {
console.error("[GlobalChatbox] Failed to load agent models:", error);
if (!cancelled) {
setModelOptions([]);
setSelectedModel(undefined);
}
} }
const modelConfig = await fetchAgentModels(controller.signal);
if (requestId !== runtimeRequestIdRef.current) return;
if (modelConfig.models.length === 0) {
setRuntimeState("models_unavailable");
setModelOptions([]);
setSelectedModel(undefined);
return "models_unavailable" as const;
}
setModelOptions(modelConfig.models);
setSelectedModel((current) => {
if (current && modelConfig.models.some((model) => model.id === current)) {
return current;
}
return modelConfig.defaultModel;
});
setRuntimeState("ready");
return "ready" as const;
} catch (error) {
if (requestId !== runtimeRequestIdRef.current) return;
console.error("[GlobalChatbox] Failed to check agent runtime:", error);
setRuntimeState("unavailable");
setModelOptions([]);
setSelectedModel(undefined);
return "unavailable" as const;
} finally {
window.clearTimeout(timeoutId);
if (runtimeAbortRef.current === controller) {
runtimeAbortRef.current = null;
}
}
}, []);
useEffect(() => {
if (!open) return;
let cancelled = false;
let pollTimerId: number | undefined;
const pollRuntime = async (showChecking: boolean) => {
const nextState = await refreshAgentRuntime(showChecking);
if (cancelled || !nextState) return;
pollTimerId = window.setTimeout(
() => void pollRuntime(false),
nextState === "ready" ? AGENT_RUNTIME_POLL_MS : AGENT_RUNTIME_RETRY_MS,
);
}; };
void loadModels(); void pollRuntime(true);
return () => { return () => {
cancelled = true; cancelled = true;
if (pollTimerId !== undefined) window.clearTimeout(pollTimerId);
runtimeRequestIdRef.current += 1;
runtimeAbortRef.current?.abort();
runtimeAbortRef.current = null;
}; };
}, []); }, [open, refreshAgentRuntime]);
const handleToolCall = useAgentToolActions(); const handleToolCall = useAgentToolActions();
const { const {
@@ -167,18 +229,23 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
useEffect(() => { useEffect(() => {
if (isStreaming) { if (isStreaming) {
const latestAssistant = [...messages]
.reverse()
.find((message) => message.role === "assistant");
if (latestAssistant?.content.trim()) {
cancelStreamingScroll();
return;
}
if (!isNearBottomRef.current) return; if (!isNearBottomRef.current) return;
scheduleStreamingScrollToBottom(); scheduleStreamingScrollToBottom();
return; return;
} }
cancelStreamingScroll(); cancelStreamingScroll();
scrollToBottom("smooth");
}, [ }, [
cancelStreamingScroll, cancelStreamingScroll,
isStreaming, isStreaming,
messages, messages,
scheduleStreamingScrollToBottom, scheduleStreamingScrollToBottom,
scrollToBottom,
]); ]);
useEffect( useEffect(
@@ -203,7 +270,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]); }, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
const handleSend = useCallback(async (prompt: string) => { const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return; if (isStreaming || isCheckingAuth || runtimeState !== "ready") return;
setIsCheckingAuth(true); setIsCheckingAuth(true);
try { try {
@@ -229,7 +296,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
} finally { } finally {
setIsCheckingAuth(false); setIsCheckingAuth(false);
} }
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]); }, [isCheckingAuth, isStreaming, openNotification, runtimeState, sendPrompt]);
const handleNewConversation = useCallback(() => { const handleNewConversation = useCallback(() => {
handleStopSpeech(); handleStopSpeech();
@@ -373,6 +440,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
canRenameSessionTitle={Boolean(activeSessionId)} canRenameSessionTitle={Boolean(activeSessionId)}
isHydrating={isHydrating} isHydrating={isHydrating}
isStreaming={isStreaming} isStreaming={isStreaming}
runtimeState={runtimeState}
isHistoryOpen={isHistoryOpen} isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle} onHistoryToggle={handleHistoryToggle}
onRenameSessionTitle={handleRenameActiveSession} onRenameSessionTitle={handleRenameActiveSession}
@@ -430,6 +498,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentWorkspace <AgentWorkspace
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
runtimeState={runtimeState}
onRetryRuntime={() => void refreshAgentRuntime(true)}
isLoadingSession={Boolean(loadingSessionId)} isLoadingSession={Boolean(loadingSessionId)}
scrollContainerRef={workspaceScrollRef} scrollContainerRef={workspaceScrollRef}
bottomRef={bottomRef} bottomRef={bottomRef}
@@ -450,6 +520,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentComposer <AgentComposer
ref={composerRef} ref={composerRef}
isHydrating={isHydrating || isCheckingAuth} isHydrating={isHydrating || isCheckingAuth}
runtimeState={runtimeState}
isStreaming={isStreaming} isStreaming={isStreaming}
isListening={isListening} isListening={isListening}
isSttSupported={isSttSupported} isSttSupported={isSttSupported}
@@ -1,4 +1,5 @@
import type { import type {
AgentActivity,
AgentQuestionRequest, AgentQuestionRequest,
AgentTodoUpdate, AgentTodoUpdate,
} from "@/lib/chatStream"; } from "@/lib/chatStream";
@@ -42,6 +43,8 @@ export type AgentPermissionRequest = {
permission: string; permission: string;
patterns: string[]; patterns: string[];
target?: string; target?: string;
activityId?: string;
reason?: string;
always: string[]; always: string[];
tool?: { tool?: {
messageID: string; messageID: string;
@@ -59,6 +62,7 @@ export type Message = {
content: string; content: string;
isError?: boolean; isError?: boolean;
progress?: ChatProgress[]; progress?: ChatProgress[];
activities?: AgentActivity[];
artifacts?: AgentArtifact[]; artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[]; permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[]; questions?: AgentQuestionRequest[];
@@ -1,4 +1,5 @@
import type { import type {
AgentActivity,
AgentQuestionRequest, AgentQuestionRequest,
AgentTodoUpdate, AgentTodoUpdate,
PermissionReply, PermissionReply,
@@ -79,6 +80,48 @@ export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
}; };
}); });
export const upsertActivity = (
activities: AgentActivity[] | undefined,
event: StreamEvent & { type: "activity_update" },
) => {
const next = [...(activities ?? [])];
const index = next.findIndex((activity) => activity.id === event.activity.id);
if (index >= 0) next[index] = event.activity;
else next.push(event.activity);
return next;
};
export const completeRunningActivities = (
activities: AgentActivity[] | undefined,
status: "completed" | "error" | "cancelled" = "completed",
) => activities?.map((activity) => {
if (activity.status !== "running") return activity;
const endedAt = Date.now();
return {
...activity,
status,
actions: activity.actions.map((action) =>
action.status === "running"
? {
...action,
status: status === "error" ? "error" as const : "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - action.startedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
}
: action,
),
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - activity.startedAt),
};
});
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todoUpdate todoUpdate
? { ? {
@@ -107,6 +150,8 @@ export const upsertPermission = (
permission: event.permission, permission: event.permission,
patterns: event.patterns, patterns: event.patterns,
target: event.target, target: event.target,
activityId: event.activityId,
reason: event.reason,
always: event.always, always: event.always,
tool: event.tool, tool: event.tool,
createdAt: event.createdAt, createdAt: event.createdAt,
@@ -405,6 +450,7 @@ export const rejectOpenQuestionsAfterAbort = (
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => { export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress); const completedProgress = completeRunningProgress(message.progress);
const cancelledActivities = completeRunningActivities(message.activities, "cancelled");
const cancelledTodos = cancelRunningTodos(message.todos); const cancelledTodos = cancelRunningTodos(message.todos);
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions); const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
@@ -414,6 +460,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
Boolean(abortedPermissions?.length) || Boolean(abortedPermissions?.length) ||
Boolean(rejectedQuestions?.length) || Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) || Boolean(completedProgress?.length) ||
Boolean(cancelledActivities?.length) ||
Boolean(cancelledTodos); Boolean(cancelledTodos);
if (!hasVisibleOutput) { if (!hasVisibleOutput) {
@@ -425,6 +472,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
content: message.content || "⚠️ **请求已中断**", content: message.content || "⚠️ **请求已中断**",
isError: true, isError: true,
progress: completedProgress, progress: completedProgress,
activities: cancelledActivities,
permissions: abortedPermissions, permissions: abortedPermissions,
questions: rejectedQuestions, questions: rejectedQuestions,
todos: cancelledTodos, todos: cancelledTodos,
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
import { import {
abortAgentChat, abortAgentChat,
forkAgentChat, forkAgentChat,
replyAgentCredentialRefresh,
replyAgentPermission, replyAgentPermission,
replyAgentQuestion, replyAgentQuestion,
resumeAgentChatStream, resumeAgentChatStream,
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({ jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined), abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"), forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentCredentialRefresh: jest.fn(async () => undefined),
replyAgentPermission: jest.fn(async () => undefined), replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined), replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined), resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined),
})); }));
const mockUpdateSession = jest.fn();
jest.mock("next-auth/react", () => ({
useSession: () => ({ update: mockUpdateSession }),
}));
const listChatSessions = jest.fn(); const listChatSessions = jest.fn();
const deleteChatSession = jest.fn(); const deleteChatSession = jest.fn();
const updateChatSessionTitle = jest.fn(); const updateChatSessionTitle = jest.fn();
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
updateChatSessionTitle.mockReset(); updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset(); jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentCredentialRefresh).mockReset();
jest.mocked(replyAgentPermission).mockReset(); jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset(); jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset(); jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
@@ -122,6 +132,80 @@ describe("useAgentChatSession actions", () => {
); );
}); });
it("applies an activity phase and todo snapshot atomically before revealing the final answer", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "activity_update",
sessionId: "session-1",
activity: {
id: "activity-analyze",
title: "分析管网数据",
reason: "需要识别影响供水能力的关键管段。",
status: "running",
actions: [],
startedAt: 1000,
},
todos: [
{
id: "todo-data",
content: "准备管网数据",
status: "completed",
priority: "high",
},
{
id: "todo-analysis",
content: "识别瓶颈管段",
status: "in_progress",
priority: "high",
},
],
todosCreatedAt: 1001,
});
onEvent({
type: "final_answer",
sessionId: "session-1",
content: "已识别关键瓶颈管段。",
});
onEvent({ type: "done", sessionId: "session-1" });
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.sendPrompt("分析管网瓶颈");
});
const assistantMessage = result.current.messages.at(-1);
expect(assistantMessage).toMatchObject({
role: "assistant",
content: "已识别关键瓶颈管段。",
todos: {
sessionId: "session-1",
createdAt: 1001,
todos: [
expect.objectContaining({ id: "todo-data", status: "completed" }),
expect.objectContaining({ id: "todo-analysis", status: "completed" }),
],
},
});
expect(
assistantMessage?.activities?.find(
(activity) => activity.id === "activity-analyze",
),
).toMatchObject({
title: "分析管网数据",
status: "completed",
});
});
it("finalizes running progress when aborting an active prompt", async () => { it("finalizes running progress when aborting an active prompt", async () => {
listChatSessions.mockResolvedValue([]); listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce( jest.mocked(streamAgentChat).mockImplementationOnce(
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
import { import {
abortAgentChat, abortAgentChat,
forkAgentChat, forkAgentChat,
replyAgentCredentialRefresh,
replyAgentPermission, replyAgentPermission,
replyAgentQuestion, replyAgentQuestion,
resumeAgentChatStream, resumeAgentChatStream,
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({ jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined), abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"), forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentCredentialRefresh: jest.fn(async () => undefined),
replyAgentPermission: jest.fn(async () => undefined), replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined), replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined), resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined),
})); }));
const mockUpdateSession = jest.fn();
jest.mock("next-auth/react", () => ({
useSession: () => ({ update: mockUpdateSession }),
}));
const listChatSessions = jest.fn(); const listChatSessions = jest.fn();
const deleteChatSession = jest.fn(); const deleteChatSession = jest.fn();
const updateChatSessionTitle = jest.fn(); const updateChatSessionTitle = jest.fn();
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
updateChatSessionTitle.mockReset(); updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset(); jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentCredentialRefresh).mockReset();
jest.mocked(replyAgentPermission).mockReset(); jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset(); jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset(); jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
@@ -330,7 +340,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: "todo-2", id: "todo-2",
status: "in_progress", status: "completed",
}), }),
], ],
}), }),
+229 -168
View File
@@ -1,77 +1,70 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; import {
import type { PermissionReply, StreamEvent } from "@/lib/chatStream"; abortAgentChat,
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types"; forkAgentChat,
rejectAgentQuestion,
replyAgentCredentialRefresh,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { PermissionDecision, StreamEvent } from "@/lib/chatStream";
import { useAuthStore } from "@/store/authStore";
import type {
AgentArtifact,
ChatSessionSummary,
Message,
} from "../GlobalChatbox.types";
import { cloneMessages } from "../globalChatboxUtils"; import { cloneMessages } from "../globalChatboxUtils";
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage"; import {
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; createEmptyChatState,
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; deleteChatSession,
listChatSessions,
loadChatSessionById,
updateChatSessionTitle,
} from "../chatStorage";
import {
applyQuestionResponse,
cancelRunningTodos,
completeRunningActivities,
completeRunningProgress,
createAssistantMessage,
createTodoUpdateFromEvent,
createUserMessage,
dedupeQuestionsAcrossMessages,
finalizeAssistantMessageAfterAbort,
normalizeSessionTodos,
toPermissionStatus,
upsertPermission,
upsertActivity,
upsertProgress,
upsertQuestionAcrossMessages,
} from "./agentChatSessionState";
import type {
PromptRunOptions,
UseAgentChatSessionOptions,
} from "./useAgentChatSession.types";
const TOKEN_PLAYBACK_INTERVAL_MS = 16; const completeTodos = (todoUpdate: Message["todos"]) =>
const TOKEN_PLAYBACK_BASE_CHARS = 28; todoUpdate
const TOKEN_PLAYBACK_MAX_CHARS = 160; ? {
...todoUpdate,
const sliceCodePoints = (value: string, count: number) => todos: todoUpdate.todos.map((todo) =>
Array.from(value).slice(0, count).join(""); todo.status === "pending" || todo.status === "in_progress"
? {
let cachedSegmenter: Intl.Segmenter | null | undefined; ...todo,
status: "completed" as const,
const getSegmenter = () => { updatedAt: Date.now(),
if (cachedSegmenter !== undefined) return cachedSegmenter; }
cachedSegmenter = : todo,
typeof Intl !== "undefined" && "Segmenter" in Intl ),
? new Intl.Segmenter("zh", { granularity: "word" })
: null;
return cachedSegmenter;
};
const getPlaybackChunkSize = (bufferLength: number) => {
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
if (bufferLength >= 300) return 112;
if (bufferLength >= 140) return 72;
if (bufferLength >= 64) return 44;
return TOKEN_PLAYBACK_BASE_CHARS;
};
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
if (content.length <= maxChars) return content;
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
const segmenter = getSegmenter();
if (segmenter) {
let chunk = "";
for (const segment of segmenter.segment(content)) {
chunk += segment.segment;
if (
chunk.length >= maxChars ||
(chunk.length >= targetChars &&
/[\s,.!?;:]/u.test(segment.segment))
) {
return chunk;
} }
} : undefined;
}
const phrase = content.match(/^.{1,12}?[\s,.!?;:]+/u)?.[0];
if (phrase) return phrase;
const cjkChunk = content.match(
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
)?.[0];
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
const wordChunk = content.match(/^\S+\s*/u)?.[0];
if (wordChunk) {
return wordChunk.length <= maxChars
? wordChunk
: sliceCodePoints(wordChunk, maxChars);
}
return sliceCodePoints(content, Math.min(maxChars, 12));
};
export const useAgentChatSession = ({ export const useAgentChatSession = ({
projectId, projectId,
@@ -80,6 +73,7 @@ export const useAgentChatSession = ({
getModel, getModel,
getApprovalMode, getApprovalMode,
}: UseAgentChatSessionOptions) => { }: UseAgentChatSessionOptions) => {
const { update: updateSession } = useSession();
const hydrationNonceRef = useRef(0); const hydrationNonceRef = useRef(0);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
@@ -97,11 +91,7 @@ export const useAgentChatSession = ({
const isSessionTitleManuallyEditedRef = useRef(false); const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null); const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0); const titleUpdateNonceRef = useRef(0);
const pendingTokenRef = useRef<{ const credentialRefreshRequestIdsRef = useRef(new Set<string>());
assistantMessageId: string;
content: string;
} | null>(null);
const tokenPlaybackIntervalRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
sessionIdRef.current = sessionId; sessionIdRef.current = sessionId;
@@ -128,83 +118,6 @@ export const useAgentChatSession = ({
}); });
}, []); }, []);
const cancelTokenPlayback = useCallback(() => {
const intervalId = tokenPlaybackIntervalRef.current;
if (intervalId === null) return;
window.clearInterval(intervalId);
tokenPlaybackIntervalRef.current = null;
}, []);
const flushPendingTokens = useCallback(() => {
const pending = pendingTokenRef.current;
pendingTokenRef.current = null;
cancelTokenPlayback();
if (!pending) return;
applyTokenContent(pending.assistantMessageId, pending.content);
}, [applyTokenContent, cancelTokenPlayback]);
const scheduleTokenPlayback = useCallback(() => {
if (tokenPlaybackIntervalRef.current !== null) return;
const id = window.setInterval(() => {
const pending = pendingTokenRef.current;
if (!pending) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
return;
}
const chunk = takeNextTokenPlaybackChunk(
pending.content,
getPlaybackChunkSize(pending.content.length),
);
if (!chunk) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
pendingTokenRef.current = null;
return;
}
const remaining = pending.content.slice(chunk.length);
pendingTokenRef.current = remaining
? { assistantMessageId: pending.assistantMessageId, content: remaining }
: null;
applyTokenContent(pending.assistantMessageId, chunk);
if (!remaining) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
}
}, TOKEN_PLAYBACK_INTERVAL_MS);
tokenPlaybackIntervalRef.current = id;
}, [applyTokenContent]);
const queueTokenContent = useCallback(
(assistantMessageId: string, content: string) => {
const pending = pendingTokenRef.current;
if (pending && pending.assistantMessageId !== assistantMessageId) {
flushPendingTokens();
}
pendingTokenRef.current = {
assistantMessageId,
content:
pending?.assistantMessageId === assistantMessageId
? pending.content + content
: content,
};
scheduleTokenPlayback();
},
[flushPendingTokens, scheduleTokenPlayback],
);
useEffect(
() => () => {
pendingTokenRef.current = null;
cancelTokenPlayback();
},
[cancelTokenPlayback],
);
useEffect(() => { useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
}, [isSessionTitleManuallyEdited]); }, [isSessionTitleManuallyEdited]);
@@ -289,6 +202,61 @@ export const useAgentChatSession = ({
return assistant?.id ?? fallback; return assistant?.id ?? fallback;
}, []); }, []);
const handleCredentialRefresh = useCallback(
async (event: StreamEvent & { type: "credential_refresh_required" }) => {
if (
!event.sessionId ||
!event.requestId ||
credentialRefreshRequestIdsRef.current.has(event.requestId)
) {
return;
}
credentialRefreshRequestIdsRef.current.add(event.requestId);
try {
const refreshedSession = await updateSession({ forceRefresh: true });
if (
refreshedSession?.error ||
typeof refreshedSession?.accessToken !== "string" ||
!refreshedSession.accessToken
) {
throw new Error("登录凭据续期失败");
}
const authStore = useAuthStore.getState();
authStore.setAccessToken(refreshedSession.accessToken);
authStore.clearSessionExpired();
await replyAgentCredentialRefresh(event.sessionId, event.requestId);
} catch (error) {
useAuthStore.getState().markSessionExpired("refresh_failed");
const assistantMessageId = getLastAssistantMessageId();
if (assistantMessageId) {
const message = error instanceof Error ? error.message : String(error);
setMessages((prev) =>
prev.map((item) =>
item.id === assistantMessageId
? {
...item,
content: item.content || `⚠️ **${message}**`,
isError: true,
progress: upsertProgress(item.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "error",
title: "登录凭据续期失败",
detail: message,
}),
}
: item,
),
);
}
setIsStreaming(false);
}
},
[getLastAssistantMessageId, updateSession],
);
const applyStreamEvent = useCallback( const applyStreamEvent = useCallback(
( (
event: StreamEvent, event: StreamEvent,
@@ -296,10 +264,6 @@ export const useAgentChatSession = ({
assistantMessageId?: string; assistantMessageId?: string;
}, },
) => { ) => {
if (event.type !== "token") {
flushPendingTokens();
}
if ( if (
event.type !== "session_title" && event.type !== "session_title" &&
"sessionId" in event && "sessionId" in event &&
@@ -352,7 +316,36 @@ export const useAgentChatSession = ({
} }
if (event.type === "token") { if (event.type === "token") {
queueTokenContent(assistantMessageId, event.content); applyTokenContent(assistantMessageId, event.content);
} else if (event.type === "final_answer") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, content: event.content, isError: false }
: message,
);
messagesRef.current = next;
return next;
});
} else if (event.type === "activity_update") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, activities: upsertActivity(message.activities, event) }
: message,
);
return event.todos
? normalizeSessionTodos(
next,
{
sessionId: event.sessionId,
todos: event.todos,
createdAt: event.todosCreatedAt ?? Date.now(),
},
assistantMessageId,
)
: next;
});
} else if (event.type === "progress") { } else if (event.type === "progress") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
@@ -421,11 +414,74 @@ export const useAgentChatSession = ({
assistantMessageId, assistantMessageId,
), ),
); );
} else if (event.type === "credential_refresh_required") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "running",
title: "正在续期登录凭据",
detail: `当前工具调用保持等待,最长 ${Math.ceil((event.timeoutMs ?? 30_000) / 1000)}`,
startedAt: Date.now(),
}),
}
: message,
),
);
void handleCredentialRefresh(event);
} else if (event.type === "credential_refreshed") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "completed",
title: "登录凭据已续期",
}),
}
: message,
),
);
} else if (event.type === "credential_refresh_failed") {
useAuthStore.getState().markSessionExpired("refresh_failed");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content || `⚠️ **${event.message}**`,
isError: true,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "error",
title: "登录凭据续期失败",
detail: event.message,
}),
}
: message,
),
);
setIsStreaming(false);
} else if (event.type === "done") { } else if (event.type === "done") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => { prev.map((message) => {
if (message.id !== assistantMessageId) return message; if (message.id !== assistantMessageId) return message;
const completedProgress = completeRunningProgress(message.progress); const completedProgress = completeRunningProgress(message.progress);
const completedActivities = completeRunningActivities(message.activities);
if ( if (
message.content.trim().length === 0 && message.content.trim().length === 0 &&
!(message.artifacts?.length) !(message.artifacts?.length)
@@ -435,9 +491,16 @@ export const useAgentChatSession = ({
content: content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress, progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
}; };
} }
return { ...message, progress: completedProgress }; return {
...message,
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}), }),
); );
setIsStreaming(false); setIsStreaming(false);
@@ -450,6 +513,7 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **错误:** ${event.message}`, content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true, isError: true,
progress: completeRunningProgress(message.progress), progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos), todos: cancelRunningTodos(message.todos),
} }
: message, : message,
@@ -457,6 +521,7 @@ export const useAgentChatSession = ({
); );
setIsStreaming(false); setIsStreaming(false);
} else if (event.type === "auth_required") { } else if (event.type === "auth_required") {
useAuthStore.getState().markSessionExpired("unauthorized");
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
message.id === assistantMessageId message.id === assistantMessageId
@@ -465,6 +530,7 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **${event.message}**`, content: message.content || `⚠️ **${event.message}**`,
isError: true, isError: true,
progress: completeRunningProgress(message.progress), progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos), todos: cancelRunningTodos(message.todos),
} }
: message, : message,
@@ -474,11 +540,11 @@ export const useAgentChatSession = ({
} }
}, },
[ [
applyTokenContent,
appendArtifact, appendArtifact,
flushPendingTokens,
getLastAssistantMessageId, getLastAssistantMessageId,
handleCredentialRefresh,
onToolCall, onToolCall,
queueTokenContent,
], ],
); );
@@ -495,20 +561,18 @@ export const useAgentChatSession = ({
onEvent: (event) => applyStreamEvent(event), onEvent: (event) => applyStreamEvent(event),
}) })
.catch((error) => { .catch((error) => {
flushPendingTokens();
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
console.error("[GlobalChatbox] Failed to resume chat stream:", error); console.error("[GlobalChatbox] Failed to resume chat stream:", error);
setIsStreaming(false); setIsStreaming(false);
} }
}) })
.finally(() => { .finally(() => {
flushPendingTokens();
if (abortRef.current === controller) { if (abortRef.current === controller) {
abortRef.current = null; abortRef.current = null;
} }
}); });
}, },
[applyStreamEvent, flushPendingTokens], [applyStreamEvent],
); );
resumeStreamingSessionRef.current = resumeStreamingSession; resumeStreamingSessionRef.current = resumeStreamingSession;
@@ -557,7 +621,6 @@ export const useAgentChatSession = ({
}), }),
}); });
} catch (error) { } catch (error) {
flushPendingTokens();
if (controller.signal.aborted) { if (controller.signal.aborted) {
setMessages((prev) => setMessages((prev) =>
prev prev
@@ -574,6 +637,7 @@ export const useAgentChatSession = ({
message.content.trim().length === 0 && message.content.trim().length === 0 &&
!(message.artifacts?.length) && !(message.artifacts?.length) &&
!(message.progress?.length) && !(message.progress?.length) &&
!(message.activities?.length) &&
!message.todos !message.todos
), ),
), ),
@@ -588,20 +652,19 @@ export const useAgentChatSession = ({
content: `⚠️ **错误:** ${String(error)}`, content: `⚠️ **错误:** ${String(error)}`,
isError: true, isError: true,
progress: completeRunningProgress(message.progress), progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
} }
: message, : message,
), ),
); );
setIsStreaming(false); setIsStreaming(false);
} finally { } finally {
flushPendingTokens();
abortRef.current = null; abortRef.current = null;
setIsStreaming(false); setIsStreaming(false);
} }
}, },
[ [
applyStreamEvent, applyStreamEvent,
flushPendingTokens,
getApprovalMode, getApprovalMode,
getModel, getModel,
isHydrating, isHydrating,
@@ -614,7 +677,6 @@ export const useAgentChatSession = ({
const abort = useCallback(() => { const abort = useCallback(() => {
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
flushPendingTokens();
setIsStreaming(false); setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId(); const assistantMessageId = getLastAssistantMessageId();
@@ -637,10 +699,10 @@ export const useAgentChatSession = ({
} }
}); });
cancelPromiseRef.current = trackedCancelPromise; cancelPromiseRef.current = trackedCancelPromise;
}, [flushPendingTokens, getLastAssistantMessageId]); }, [getLastAssistantMessageId]);
const replyPermission = useCallback( const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => { async (requestId: string, reply: PermissionDecision) => {
const target = messagesRef.current const target = messagesRef.current
.flatMap((message) => message.permissions ?? []) .flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId); .find((permission) => permission.requestId === requestId);
@@ -850,7 +912,6 @@ export const useAgentChatSession = ({
const createSession = useCallback(() => { const createSession = useCallback(() => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
flushPendingTokens();
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
@@ -861,7 +922,7 @@ export const useAgentChatSession = ({
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setIsStreaming(false); setIsStreaming(false);
}, [flushPendingTokens, isHydrating, isStreaming]); }, [isHydrating, isStreaming]);
const switchSession = useCallback( const switchSession = useCallback(
async (nextSessionId: string, optimisticTitle?: string) => { async (nextSessionId: string, optimisticTitle?: string) => {
@@ -22,30 +22,30 @@ const FEATURE_TYPE_MAP: Record<
string, string,
{ layer: string; geometryKind: "point" | "line"; label: string } { layer: string; geometryKind: "point" | "line"; label: string }
> = { > = {
junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" }, junction: { layer: "junctions", geometryKind: "point", label: "节点" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" }, junctions: { layer: "junctions", geometryKind: "point", label: "节点" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" }, pipe: { layer: "pipes", geometryKind: "line", label: "管道" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" }, pipes: { layer: "pipes", geometryKind: "line", label: "管道" },
valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" }, valve: { layer: "valves", geometryKind: "point", label: "阀门" },
valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" }, valves: { layer: "valves", geometryKind: "point", label: "阀门" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" }, reservoir: { layer: "reservoirs", geometryKind: "point", label: "水源" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" }, reservoirs: { layer: "reservoirs", geometryKind: "point", label: "水源" },
pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" }, pump: { layer: "pumps", geometryKind: "point", label: "泵站" },
pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" }, pumps: { layer: "pumps", geometryKind: "point", label: "泵站" },
tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, tank: { layer: "tanks", geometryKind: "point", label: "水池" },
tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, tanks: { layer: "tanks", geometryKind: "point", label: "水池" },
}; };
const LOCATE_TOOL_CONFIG: Record< const LOCATE_TOOL_CONFIG: Record<
string, string,
{ layer: string; geometryKind: "point" | "line"; label: string } { layer: string; geometryKind: "point" | "line"; label: string }
> = { > = {
locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" }, locate_pipes: { layer: "pipes", geometryKind: "line", label: "管道" },
locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" }, locate_junctions: { layer: "junctions", geometryKind: "point", label: "节点" },
locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" }, locate_valves: { layer: "valves", geometryKind: "point", label: "阀门" },
locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" }, locate_reservoirs: { layer: "reservoirs", geometryKind: "point", label: "水源" },
locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" }, locate_pumps: { layer: "pumps", geometryKind: "point", label: "泵站" },
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, locate_tanks: { layer: "tanks", geometryKind: "point", label: "水池" },
}; };
const LOCATE_ID_PARAM_KEYS = [ const LOCATE_ID_PARAM_KEYS = [
@@ -20,7 +20,16 @@ import "dayjs/locale/zh-cn";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { BurstDetectionResult } from "./types"; import { BurstDetectionResult } from "./types";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props { interface Props {
onResult: (result: BurstDetectionResult) => void; onResult: (result: BurstDetectionResult) => void;
@@ -48,7 +57,7 @@ const currentQuarterHour = () => {
export const createBurstDetectionAnalysisParametersState = export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({ (): BurstDetectionAnalysisParametersState => ({
schemeName: `Burst_Detection_${Date.now()}`, schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection),
detectionMode: "latest", detectionMode: "latest",
targetTime: currentQuarterHour(), targetTime: currentQuarterHour(),
samplingIntervalMinutes: 15, samplingIntervalMinutes: 15,
@@ -93,7 +102,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
export const buildBurstDetectionRequest = ( export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState, parameters: BurstDetectionAnalysisParametersState,
) => ({ ) => ({
scheme_name: parameters.schemeName.trim(), scheme_name: normalizeSchemeName(parameters.schemeName),
sampling_interval_minutes: parameters.samplingIntervalMinutes, sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime ...(parameters.detectionMode === "historical" && parameters.targetTime
? { target_time: parameters.targetTime.toISOString() } ? { target_time: parameters.targetTime.toISOString() }
@@ -121,6 +130,12 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState; } = parametersState;
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [frequencyLoading, setFrequencyLoading] = useState(false); const [frequencyLoading, setFrequencyLoading] = useState(false);
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
setParametersState({
...draft,
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
}),
);
useEffect(() => { useEffect(() => {
if (samplingIntervalSource !== "metadata") return; if (samplingIntervalSource !== "metadata") return;
@@ -157,7 +172,7 @@ const AnalysisParameters: React.FC<Props> = ({
const isValid = useMemo( const isValid = useMemo(
() => () =>
schemeName.trim().length > 0 && isSchemeNameValid(schemeName) &&
samplingIntervalValid && samplingIntervalValid &&
(detectionMode === "latest" || Boolean(targetTime?.isValid())), (detectionMode === "latest" || Boolean(targetTime?.isValid())),
[detectionMode, samplingIntervalValid, schemeName, targetTime], [detectionMode, samplingIntervalValid, schemeName, targetTime],
@@ -196,12 +211,12 @@ const AnalysisParameters: React.FC<Props> = ({
? "目标时刻存在异常信号,请优先复核相关测点。" ? "目标时刻存在异常信号,请优先复核相关测点。"
: "目标时刻未发现爆管异常。", : "目标时刻未发现爆管异常。",
}); });
} catch (error: any) { } catch (error) {
open?.({ open?.({
key: "burst-detection-analysis-error", key: "burst-detection-analysis-error",
type: "error", type: "error",
message: "侦测失败", message: "侦测失败",
description: error?.response?.data?.detail ?? error?.message ?? "请求失败", description: getApiErrorMessage(error, "爆管侦测请求失败"),
}); });
} finally { } finally {
setRunning(false); setRunning(false);
@@ -219,6 +234,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName} value={schemeName}
onChange={(event) => setFormField("schemeName", event.target.value)} onChange={(event) => setFormField("schemeName", event.target.value)}
placeholder="请输入方案名称" placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
fullWidth fullWidth
size="small" size="small"
/> />
@@ -179,10 +179,7 @@ const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) =>
const locateSensors = async (sensorIds: string[]) => { const locateSensors = async (sensorIds: string[]) => {
if (!map || sensorIds.length === 0) return; if (!map || sensorIds.length === 0) return;
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat"); const features = await queryFeaturesByIds(sensorIds, "junctions");
if (features.length === 0) {
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
}
if (features.length === 0) return; if (features.length === 0) return;
setHighlightFeatures(features); setHighlightFeatures(features);
const format = new GeoJSON(); const format = new GeoJSON();
@@ -21,7 +21,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { getAnalysisScheme, listAnalysisSchemes } from "@/lib/analysisRuns";
import { NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
@@ -42,7 +42,7 @@ interface Props {
export interface BurstDetectionSchemeQueryState { export interface BurstDetectionSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: string | null;
hasQueried: boolean; hasQueried: boolean;
} }
@@ -130,15 +130,12 @@ const SchemeQuery: React.FC<Props> = ({
const handleQuery = async () => { const handleQuery = async () => {
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const nextSchemes = (await listAnalysisSchemes({
scheme_type: "burst_detection", runType: "burst_detection",
}; queryDate: !queryAll && queryDate
if (!queryAll && queryDate) { ? queryDate.format("YYYY-MM-DD")
params.query_date = queryDate.startOf("day").toISOString(); : undefined,
} })) as BurstDetectionSchemeRecord[];
const response = await api.get("/api/v1/schemes", { params });
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true); setQueryField("hasQueried", true);
open?.({ open?.({
@@ -157,13 +154,9 @@ const SchemeQuery: React.FC<Props> = ({
} }
}; };
const handleViewSchemeResult = async (schemeName: string) => { const handleViewSchemeResult = async (runId: string) => {
try { try {
const response = await api.get( const schemeRecord = (await getAnalysisScheme(runId)) as BurstDetectionSchemeRecord & {
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { scheme_type: "burst_detection" } },
);
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
result_payload?: BurstDetectionResult; result_payload?: BurstDetectionResult;
}; };
const normalizedResult = const normalizedResult =
@@ -185,7 +178,7 @@ const SchemeQuery: React.FC<Props> = ({
open?.({ open?.({
type: "success", type: "success",
message: "方案加载成功", message: "方案加载成功",
description: `已加载方案:${schemeName}`, description: `已加载方案:${schemeRecord.scheme_name}`,
}); });
} catch (error: any) { } catch (error: any) {
open?.({ open?.({
@@ -398,7 +391,7 @@ const SchemeQuery: React.FC<Props> = ({
size="small" size="small"
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }} sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => handleViewSchemeResult(scheme.scheme_name)} onClick={() => handleViewSchemeResult(scheme.scheme_id)}
> >
</Button> </Button>
+1 -1
View File
@@ -97,7 +97,7 @@ export interface BurstDetectionSchemeDetail {
} }
export interface BurstDetectionSchemeRecord { export interface BurstDetectionSchemeRecord {
scheme_id: number; scheme_id: string;
scheme_name: string; scheme_name: string;
scheme_type?: string; scheme_type?: string;
create_time: string; create_time: string;
@@ -24,11 +24,21 @@ import { useNotification } from "@refinedev/core";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { listAnalysisSchemes } from "@/lib/analysisRuns";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import { BurstLocationResult } from "./types"; import { BurstLocationResult } from "./types";
import { getBurstLocationErrorNotice } from "./burstLocationError"; import { getBurstLocationErrorNotice } from "./burstLocationError";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props { interface Props {
onResult: (result: BurstLocationResult) => void; onResult: (result: BurstLocationResult) => void;
@@ -37,7 +47,7 @@ interface Props {
} }
export interface SchemeItem { export interface SchemeItem {
scheme_id: number; scheme_id: string;
scheme_name: string; scheme_name: string;
scheme_type: string; scheme_type: string;
create_time: string; create_time: string;
@@ -54,7 +64,7 @@ export interface BurstLocationAnalysisParametersState {
schemeName: string; schemeName: string;
dataSource: DataSource; dataSource: DataSource;
schemes: SchemeItem[]; schemes: SchemeItem[];
selectedSchemeId: number | ""; selectedSchemeId: string;
burstLeakage: number; burstLeakage: number;
enableFlow: boolean; enableFlow: boolean;
burstStartTime: Dayjs | null; burstStartTime: Dayjs | null;
@@ -66,7 +76,7 @@ export interface BurstLocationAnalysisParametersState {
export const createBurstLocationAnalysisParametersState = export const createBurstLocationAnalysisParametersState =
(): BurstLocationAnalysisParametersState => ({ (): BurstLocationAnalysisParametersState => ({
schemeName: `Burst_Locate_${Date.now()}`, schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstLocation),
dataSource: "monitoring", dataSource: "monitoring",
schemes: [], schemes: [],
selectedSchemeId: "", selectedSchemeId: "",
@@ -105,6 +115,13 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState; } = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false); const [schemeLoading, setSchemeLoading] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
useSessionRecoveryDraft("burst-location", parametersState, (draft) =>
setParametersState({
...draft,
burstStartTime: draft.burstStartTime ? dayjs(draft.burstStartTime) : null,
burstEndTime: draft.burstEndTime ? dayjs(draft.burstEndTime) : null,
}),
);
const isSimulationMode = dataSource === "simulation"; const isSimulationMode = dataSource === "simulation";
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
@@ -125,12 +142,9 @@ const AnalysisParameters: React.FC<Props> = ({
setSchemeLoading(true); setSchemeLoading(true);
try { try {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { const burstSchemes = (await listAnalysisSchemes({
params: { scheme_type: "burst_analysis" }, runType: "burst_analysis",
}); }) as unknown as SchemeItem[]).sort(
const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis",
).sort(
(a, b) => (a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
); );
@@ -155,12 +169,14 @@ const AnalysisParameters: React.FC<Props> = ({
description: `当前可选爆管分析方案 ${burstSchemes.length}`, description: `当前可选爆管分析方案 ${burstSchemes.length}`,
}); });
} }
} catch (error: any) { } catch (error) {
open?.({ open?.({
type: "error", type: "error",
message: "刷新方案失败", message: "刷新方案失败",
description: description: getApiErrorMessage(
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表", error,
"无法获取爆管分析方案列表",
),
}); });
} finally { } finally {
setSchemeLoading(false); setSchemeLoading(false);
@@ -176,7 +192,7 @@ const AnalysisParameters: React.FC<Props> = ({
} }
}; };
const handleSchemeSelect = (schemeId: number) => { const handleSchemeSelect = (schemeId: string) => {
setFormField("selectedSchemeId", schemeId); setFormField("selectedSchemeId", schemeId);
const scheme = schemes.find((item) => item.scheme_id === schemeId); const scheme = schemes.find((item) => item.scheme_id === schemeId);
if (scheme) { if (scheme) {
@@ -185,6 +201,7 @@ const AnalysisParameters: React.FC<Props> = ({
}; };
const isValid = useMemo(() => { const isValid = useMemo(() => {
if (!isSchemeNameValid(schemeName)) return false;
if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false; if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false;
if (!burstStartTime || !burstEndTime) { if (!burstStartTime || !burstEndTime) {
return false; return false;
@@ -196,6 +213,7 @@ const AnalysisParameters: React.FC<Props> = ({
return burstStartTime.isBefore(burstEndTime); return burstStartTime.isBefore(burstEndTime);
}, [ }, [
burstLeakage, burstLeakage,
schemeName,
burstStartTime, burstStartTime,
burstEndTime, burstEndTime,
dataSource, dataSource,
@@ -226,15 +244,14 @@ const AnalysisParameters: React.FC<Props> = ({
`${config.BACKEND_URL}/api/v1/burst-locations`, `${config.BACKEND_URL}/api/v1/burst-locations`,
{ {
data_source: dataSource, data_source: dataSource,
scheme_name: schemeName.trim() || undefined, scheme_name: normalizeSchemeName(schemeName),
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT), burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
min_dpressure: minDpressure, min_dpressure: minDpressure,
basic_pressure: basicPressure, basic_pressure: basicPressure,
scada_burst_start: burstStartTime.toISOString(), scada_burst_start: burstStartTime.toISOString(),
scada_burst_end: burstEndTime.toISOString(), scada_burst_end: burstEndTime.toISOString(),
use_scada_flow: enableFlow || undefined, use_scada_flow: enableFlow || undefined,
simulation_scheme_name: selectedScheme?.scheme_name, simulation_run_id: selectedScheme?.scheme_id,
simulation_scheme_type: selectedScheme?.scheme_type,
}, },
); );
@@ -262,7 +279,7 @@ const AnalysisParameters: React.FC<Props> = ({
message: "爆管定位成功", message: "爆管定位成功",
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`, description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
}); });
} catch (error: any) { } catch (error) {
const notice = getBurstLocationErrorNotice(error); const notice = getBurstLocationErrorNotice(error);
open?.({ open?.({
key: "burst-location-analysis-error", key: "burst-location-analysis-error",
@@ -286,6 +303,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
fullWidth fullWidth
size="small" size="small"
/> />
@@ -343,7 +367,7 @@ const AnalysisParameters: React.FC<Props> = ({
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<Select <Select
value={selectedSchemeId} value={selectedSchemeId}
onChange={(e) => handleSchemeSelect(Number(e.target.value))} onChange={(e) => handleSchemeSelect(String(e.target.value))}
disabled={schemeLoading} disabled={schemeLoading}
displayEmpty displayEmpty
> >
@@ -202,10 +202,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
if (!pipeIds.length || !map) return; if (!pipeIds.length || !map) return;
try { try {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat"); const features = await queryFeaturesByIds(pipeIds, "pipes");
if (features.length === 0) {
features = await queryFeaturesByIds(pipeIds, "geo_pipes");
}
if (features.length === 0) return; if (features.length === 0) return;
setHighlightFeatures(features); setHighlightFeatures(features);
@@ -25,8 +25,8 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { getAnalysisScheme, listAnalysisSchemes } from "@/lib/analysisRuns";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useMap } from "@components/olmap/core/MapComponent"; import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { queryFeaturesByIds } from "@/utils/mapQueryService";
@@ -56,7 +56,7 @@ interface Props {
export interface BurstLocationSchemeQueryState { export interface BurstLocationSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: string | null;
simulationBurstIdsByName: Record<string, string[]>; simulationBurstIdsByName: Record<string, string[]>;
hasQueried: boolean; hasQueried: boolean;
} }
@@ -147,10 +147,7 @@ const SchemeQuery: React.FC<Props> = ({
if (!uniquePipeIds.length || !map) return; if (!uniquePipeIds.length || !map) return;
try { try {
let features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes_mat"); const features = await queryFeaturesByIds(uniquePipeIds, "pipes");
if (features.length === 0) {
features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes");
}
if (features.length === 0) return; if (features.length === 0) return;
setHighlightFeatures(features); setHighlightFeatures(features);
@@ -227,23 +224,17 @@ const SchemeQuery: React.FC<Props> = ({
const handleQuery = async () => { const handleQuery = async () => {
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const [nextSchemes, simulationSchemes] = await Promise.all([
scheme_type: "burst_location", listAnalysisSchemes({
}; runType: "burst_location",
if (!queryAll && queryDate) { queryDate: !queryAll && queryDate
params.query_date = queryDate.startOf("day").toISOString(); ? queryDate.format("YYYY-MM-DD")
} : undefined,
const [response, simulationResponse] = await Promise.all([
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { scheme_type: "burst_analysis" },
}), }),
listAnalysisSchemes({ runType: "burst_analysis" }),
]); ]);
const nextSchemes = response.data as BurstSchemeRecord[];
const nextSimulationBurstIdsByName = Object.fromEntries( const nextSimulationBurstIdsByName = Object.fromEntries(
(simulationResponse.data as BurstSimulationSchemeItem[]) (simulationSchemes as BurstSimulationSchemeItem[])
.filter((scheme) => scheme.scheme_type === "burst_analysis")
.map((scheme) => [ .map((scheme) => [
scheme.scheme_name, scheme.scheme_name,
normalizeBurstIds(scheme.scheme_detail?.burst_ID), normalizeBurstIds(scheme.scheme_detail?.burst_ID),
@@ -253,7 +244,7 @@ const SchemeQuery: React.FC<Props> = ({
setSchemes( setSchemes(
nextSchemes.map((scheme) => nextSchemes.map((scheme) =>
enrichSchemeWithSimulationBurstIds( enrichSchemeWithSimulationBurstIds(
scheme, scheme as BurstSchemeRecord,
nextSimulationBurstIdsByName, nextSimulationBurstIdsByName,
), ),
), ),
@@ -276,13 +267,9 @@ const SchemeQuery: React.FC<Props> = ({
} }
}; };
const handleViewSchemeResult = async (schemeName: string) => { const handleViewSchemeResult = async (runId: string) => {
try { try {
const response = await api.get( const schemeRecord = (await getAnalysisScheme(runId)) as BurstSchemeRecord & {
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { scheme_type: "burst_location" } },
);
const schemeRecord = response.data as BurstSchemeRecord & {
result_payload?: BurstLocationResult; result_payload?: BurstLocationResult;
}; };
const normalizedResult = const normalizedResult =
@@ -302,7 +289,7 @@ const SchemeQuery: React.FC<Props> = ({
open?.({ open?.({
type: "success", type: "success",
message: "方案加载成功", message: "方案加载成功",
description: `已加载方案: ${schemeName}`, description: `已加载方案: ${schemeRecord.scheme_name}`,
}); });
} catch (error: any) { } catch (error: any) {
open?.({ open?.({
@@ -516,7 +503,7 @@ const SchemeQuery: React.FC<Props> = ({
size="small" size="small"
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }} sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => handleViewSchemeResult(scheme.scheme_name)} onClick={() => handleViewSchemeResult(scheme.scheme_id)}
> >
</Button> </Button>
@@ -1,3 +1,5 @@
import { getApiErrorMessage } from "@/lib/apiError";
export interface BurstLocationErrorNotice { export interface BurstLocationErrorNotice {
message: string; message: string;
description: string; description: string;
@@ -6,26 +8,10 @@ export interface BurstLocationErrorNotice {
const DATA_GAP_PATTERN = const DATA_GAP_PATTERN =
/^(爆管压力数据|正常压力数据|爆管流量数据|正常流量数据) 在时间窗内无有效模拟数据: (.+)$/; /^(爆管压力数据|正常压力数据|爆管流量数据|正常流量数据) 在时间窗内无有效模拟数据: (.+)$/;
const extractErrorDetail = (error: unknown): string => {
const candidate = error as {
message?: string;
response?: { data?: { detail?: unknown } };
};
const detail = candidate?.response?.data?.detail;
if (typeof detail === "string" && detail.trim()) {
return detail.trim();
}
if (typeof candidate?.message === "string" && candidate.message.trim()) {
return candidate.message.trim();
}
return "请求失败";
};
export const getBurstLocationErrorNotice = ( export const getBurstLocationErrorNotice = (
error: unknown, error: unknown,
): BurstLocationErrorNotice => { ): BurstLocationErrorNotice => {
const detail = extractErrorDetail(error); const detail = getApiErrorMessage(error);
const match = detail.match(DATA_GAP_PATTERN); const match = detail.match(DATA_GAP_PATTERN);
if (!match) { if (!match) {
+1 -1
View File
@@ -66,7 +66,7 @@ export interface BurstLocationSchemeDetail {
} }
export interface BurstSchemeRecord { export interface BurstSchemeRecord {
scheme_id: number; scheme_id: string;
scheme_name: string; scheme_name: string;
scheme_type?: string; scheme_type?: string;
create_time: string; create_time: string;
@@ -29,9 +29,18 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { along, lineString, length, toMercator } from "@turf/turf"; import { along, lineString, length, toMercator } from "@turf/turf";
import { Point } from "ol/geom"; import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
export interface PipePoint { export interface PipePoint {
id: string; id: string;
@@ -51,7 +60,7 @@ export const createBurstAnalysisParametersState = (): BurstAnalysisParametersSta
pipePoints: [], pipePoints: [],
startTime: dayjs(new Date()), startTime: dayjs(new Date()),
duration: 3600, duration: 3600,
schemeName: "FANGAN" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis),
network: NETWORK_NAME, network: NETWORK_NAME,
}); });
@@ -74,6 +83,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
); );
const { pipePoints, startTime, duration, schemeName, network } = const { pipePoints, startTime, duration, schemeName, network } =
parametersState; parametersState;
useSessionRecoveryDraft("burst-simulation", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [isSelecting, setIsSelecting] = useState<boolean>(false); const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [highlightLayer, setHighlightLayer] = const [highlightLayer, setHighlightLayer] =
@@ -97,7 +112,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
pipePoints.length > 0 && pipePoints.length > 0 &&
startTime !== null && startTime !== null &&
duration > 0 && duration > 0 &&
schemeName.trim() !== ""; isSchemeNameValid(schemeName);
// 地图点击选择要素事件处理函数 // 地图点击选择要素事件处理函数
const handleMapClickSelectFeatures = useCallback( const handleMapClickSelectFeatures = useCallback(
@@ -109,7 +124,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
if (!feature) return; if (!feature) return;
if ( if (
feature.getGeometry()?.getType() === "Point" || feature.getGeometry()?.getType() === "Point" ||
(layer !== "geo_pipes_mat" && layer !== "geo_pipes") layer !== "pipes"
) { ) {
open?.({ open?.({
type: "error", type: "error",
@@ -233,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
if (highlightFeatures.length > 0 || pipePoints.length === 0) return; if (highlightFeatures.length > 0 || pipePoints.length === 0) return;
queryFeaturesByIds( queryFeaturesByIds(
pipePoints.map((pipe) => pipe.id), pipePoints.map((pipe) => pipe.id),
"geo_pipes_mat", "pipes",
).then((features) => { ).then((features) => {
if (features.length > 0) { if (features.length > 0) {
setHighlightFeatures(features); setHighlightFeatures(features);
@@ -326,7 +341,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
burst_id: burst_ID, burst_id: burst_ID,
burst_size: burst_size, burst_size: burst_size,
modify_total_duration: modify_total_duration, modify_total_duration: modify_total_duration,
scheme_name: schemeName, scheme_name: normalizeSchemeName(schemeName),
}; };
try { try {
@@ -351,8 +366,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
key: "burst-analysis", key: "burst-analysis",
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: description: getApiErrorMessage(error, "爆管模拟请求失败"),
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
}); });
} finally { } finally {
setAnalyzing(false); setAnalyzing(false);
@@ -518,6 +532,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setParameterField("schemeName", e.target.value)} onChange={(e) => setParameterField("schemeName", e.target.value)}
placeholder="输入方案名称" placeholder="输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
/> />
</Box> </Box>
@@ -21,7 +21,7 @@ jest.mock("@/utils/mapQueryService", () => ({
})); }));
const scheme: SchemeRecord = { const scheme: SchemeRecord = {
id: 17, id: "17",
schemeName: "burst-report-demo", schemeName: "burst-report-demo",
type: "burst_analysis", type: "burst_analysis",
username: "operator", username: "operator",
@@ -52,10 +52,12 @@ const feature = (id: string, diameter: number) => ({
describe("AnalysisReport", () => { describe("AnalysisReport", () => {
beforeEach(() => { beforeEach(() => {
(queryFeaturesByIds as jest.Mock).mockImplementation( (queryFeaturesByIds as jest.Mock).mockImplementation(
async (_ids: string[], layerName: string) => async (ids: string[], layerName: string) =>
layerName === "geo_pipes_mat" layerName === "pipes"
? [feature("P-1", 315)] ? ids.map((id) =>
: [feature("P-2", 800)], id === "P-1" ? feature(id, 315) : feature(id, 800),
)
: [],
); );
}); });
@@ -109,8 +111,8 @@ describe("AnalysisReport", () => {
expect(preview.getByText("800 mm")).toBeInTheDocument(); expect(preview.getByText("800 mm")).toBeInTheDocument();
}); });
expect(queryFeaturesByIds).toHaveBeenCalledWith( expect(queryFeaturesByIds).toHaveBeenCalledWith(
["P-2"], ["P-1", "P-2"],
"geo_pipes", "pipes",
); );
}); });
@@ -263,7 +265,7 @@ describe("AnalysisReport", () => {
), ),
).toBeInTheDocument(), ).toBeInTheDocument(),
); );
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2); expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
firstRender.unmount(); firstRender.unmount();
render(<AnalysisReport {...props} />); render(<AnalysisReport {...props} />);
@@ -273,6 +275,6 @@ describe("AnalysisReport", () => {
"800 mm", "800 mm",
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2); expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -27,9 +27,9 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; // 引入中文包 import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { api } from "@/lib/api"; import { listAnalysisSchemes } from "@/lib/analysisRuns";
import moment from "moment"; import moment from "moment";
import { config, NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
@@ -51,7 +51,7 @@ import {
import { Point } from "ol/geom"; import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
import Timeline from "@components/olmap/core/Controls/Timeline"; import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemaItem, SchemeRecord } from "./types"; import { SchemeRecord } from "./types";
import { import {
getCachedPipeDiameters, getCachedPipeDiameters,
getPipeDiameterDisplay, getPipeDiameterDisplay,
@@ -78,7 +78,7 @@ export interface BurstSchemeQueryState {
showTimeline: boolean; showTimeline: boolean;
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null; expandedId: string | null;
hasQueried: boolean; hasQueried: boolean;
} }
@@ -123,17 +123,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const creatorName = useSchemeCreatorName(); const creatorName = useSchemeCreatorName();
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素 const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState< const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
Record<number, PipeDiameterMap> Record<string, PipeDiameterMap>
>({}); >({});
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState< const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
Record<number, boolean> Record<string, boolean>
>({}); >({});
const { open } = useNotification(); const { open } = useNotification();
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
const { schemeName, setSchemeName } = data || {}; const { schemeName, setSchemeName, schemeRunId, setSchemeRunId } = data || {};
// 使用外部提供的 schemes 或内部状态 // 使用外部提供的 schemes 或内部状态
const schemes = const schemes =
@@ -161,38 +161,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const nextSchemes = (await listAnalysisSchemes({
scheme_type: SCHEME_TYPE, runType: SCHEME_TYPE,
}; queryDate: !queryAll && queryDate
if (!queryAll && queryDate) { ? queryDate.format("YYYY-MM-DD")
params.query_date = queryDate.startOf("day").toISOString(); : undefined,
} })) as unknown as SchemeRecord[];
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data;
if (!queryAll) {
const formattedDate = queryDate!.format("YYYY-MM-DD");
filteredResults = response.data.filter((item: SchemaItem) => {
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
return itemDate === formattedDate;
});
}
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
username: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true); setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (nextSchemes.length === 0) {
open?.({ open?.({
type: "success", type: "success",
message: "查询结果", message: "查询结果",
@@ -204,7 +182,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`, description: `共找到 ${nextSchemes.length} 条方案记录`,
}); });
} }
} catch (error) { } catch (error) {
@@ -221,7 +199,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const handleLocatePipes = (pipeIds: string[]) => { const handleLocatePipes = (pipeIds: string[]) => {
if (pipeIds.length > 0) { if (pipeIds.length > 0) {
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => { queryFeaturesByIds(pipeIds, "pipes").then((features) => {
if (features.length > 0) { if (features.length > 0) {
// 设置高亮要素 // 设置高亮要素
setHighlightFeatures(features); setHighlightFeatures(features);
@@ -299,7 +277,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}, [expandedId, filteredSchemes, pipeDiametersByScheme]); }, [expandedId, filteredSchemes, pipeDiametersByScheme]);
// 内部的方案查询函数 // 内部的方案查询函数
const handleViewDetails = (id: number) => { const handleViewDetails = (id: string) => {
const scheme = filteredSchemes.find((s) => s.id === id); const scheme = filteredSchemes.find((s) => s.id === id);
if (!scheme) return; if (!scheme) return;
@@ -321,6 +299,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
})); }));
} }
setSchemeName?.(scheme.schemeName); setSchemeName?.(scheme.schemeName);
setSchemeRunId?.(scheme.id);
handleLocatePipes(scheme.schemeDetail?.burst_ID || []); handleLocatePipes(scheme.schemeDetail?.burst_ID || []);
}; };
@@ -438,6 +417,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
timeRange={timeRange} timeRange={timeRange}
disableDateSelection={!!timeRange} disableDateSelection={!!timeRange}
schemeName={schemeName} schemeName={schemeName}
schemeRunId={schemeRunId}
schemeType={SCHEME_TYPE} schemeType={SCHEME_TYPE}
/>, />,
mapContainer, // 渲染到地图容器中,而不是 body mapContainer, // 渲染到地图容器中,而不是 body
@@ -210,7 +210,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
useEffect(() => { useEffect(() => {
if (!selectedPipeId || highlightFeature) return; if (!selectedPipeId || highlightFeature) return;
queryFeaturesByIds([selectedPipeId], "geo_pipes_mat").then((features) => { queryFeaturesByIds([selectedPipeId], "pipes").then((features) => {
setHighlightFeature(features[0] ?? null); setHighlightFeature(features[0] ?? null);
}); });
}, [highlightFeature, selectedPipeId]); }, [highlightFeature, selectedPipeId]);
@@ -245,7 +245,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => { const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
if (pipeIds.length > 0) { if (pipeIds.length > 0) {
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => { queryFeaturesByIds(pipeIds, "pipes").then((features) => {
if (features.length > 0) { if (features.length > 0) {
if (highlight) { if (highlight) {
// 设置高亮类型为管段 // 设置高亮类型为管段
@@ -270,7 +270,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
const handleLocateNodes = (nodeIds: string[]) => { const handleLocateNodes = (nodeIds: string[]) => {
if (nodeIds.length > 0) { if (nodeIds.length > 0) {
queryFeaturesByIds(nodeIds, "geo_junctions").then((features) => { queryFeaturesByIds(nodeIds, "junctions").then((features) => {
if (features.length > 0) { if (features.length > 0) {
// 设置高亮类型为受影响节点 // 设置高亮类型为受影响节点
setHighlightType("affected_node"); setHighlightType("affected_node");
@@ -294,7 +294,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
const handleLocateMustCloseValves = (valveIds: string[]) => { const handleLocateMustCloseValves = (valveIds: string[]) => {
if (valveIds.length > 0) { if (valveIds.length > 0) {
queryFeaturesByIds(valveIds, "geo_valves").then((features) => { queryFeaturesByIds(valveIds, "valves").then((features) => {
if (features.length > 0) { if (features.length > 0) {
// 设置高亮类型为必关阀门 // 设置高亮类型为必关阀门
setHighlightType("must_close"); setHighlightType("must_close");
@@ -318,7 +318,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
const handleLocateOptionalValves = (valveIds: string[]) => { const handleLocateOptionalValves = (valveIds: string[]) => {
if (valveIds.length > 0) { if (valveIds.length > 0) {
queryFeaturesByIds(valveIds, "geo_valves").then((features) => { queryFeaturesByIds(valveIds, "valves").then((features) => {
if (features.length > 0) { if (features.length > 0) {
// 设置高亮类型为可选阀门 // 设置高亮类型为可选阀门
setHighlightType("optional"); setHighlightType("optional");
@@ -411,7 +411,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
setSelectedPipeId(initialPipeIds[0]); setSelectedPipeId(initialPipeIds[0]);
// 尝试获取Feature以高亮 (可选) // 尝试获取Feature以高亮 (可选)
queryFeaturesByIds(initialPipeIds, "geo_pipes_mat").then((features) => { queryFeaturesByIds(initialPipeIds, "pipes").then((features) => {
if (features && features.length > 0) { if (features && features.length > 0) {
setHighlightFeature(features[0]); setHighlightFeature(features[0]);
} }
@@ -44,24 +44,10 @@ export const loadPipeDiameters = async (
} }
const request = (async () => { const request = (async () => {
let features = await queryFeaturesByIds( const features = await queryFeaturesByIds(
normalizedPipeIds, normalizedPipeIds,
"geo_pipes_mat", "pipes",
); );
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = normalizedPipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
const diameters: PipeDiameterMap = Object.fromEntries( const diameters: PipeDiameterMap = Object.fromEntries(
normalizedPipeIds.map((pipeId) => [pipeId, null]), normalizedPipeIds.map((pipeId) => [pipeId, null]),
+1 -11
View File
@@ -8,7 +8,7 @@ export interface SchemeDetail {
} }
export interface SchemeRecord { export interface SchemeRecord {
id: number; id: string;
schemeName: string; schemeName: string;
type: string; type: string;
username: string; username: string;
@@ -18,16 +18,6 @@ export interface SchemeRecord {
schemeDetail?: SchemeDetail; schemeDetail?: SchemeDetail;
} }
export interface SchemaItem {
scheme_id: number;
scheme_name: string;
scheme_type: string;
username: string;
create_time: string;
scheme_start_time: string;
scheme_detail?: SchemeDetail;
}
export interface ValveIsolationResult { export interface ValveIsolationResult {
accident_elements: string[]; accident_elements: string[];
affected_nodes: string[]; affected_nodes: string[];
@@ -29,6 +29,15 @@ import {
queryFeaturesByIds, queryFeaturesByIds,
} from "@/utils/mapQueryService"; } from "@/utils/mapQueryService";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
export interface ContaminantAnalysisParametersState { export interface ContaminantAnalysisParametersState {
schemeName: string; schemeName: string;
@@ -41,7 +50,7 @@ export interface ContaminantAnalysisParametersState {
export const createContaminantAnalysisParametersState = export const createContaminantAnalysisParametersState =
(): ContaminantAnalysisParametersState => ({ (): ContaminantAnalysisParametersState => ({
schemeName: "WQ_" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.contaminantAnalysis),
startTime: dayjs(new Date()), startTime: dayjs(new Date()),
sourceNode: "", sourceNode: "",
concentration: 100, concentration: 100,
@@ -62,7 +71,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
const { open } = useNotification(); const { open } = useNotification();
const network = NETWORK_NAME; const network = NETWORK_NAME;
const [parametersState, , setFormField] = useControllableObjectState( const [parametersState, setParametersState, setFormField] = useControllableObjectState(
state, state,
onStateChange, onStateChange,
createContaminantAnalysisParametersState(), createContaminantAnalysisParametersState(),
@@ -75,6 +84,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
duration, duration,
pattern, pattern,
} = parametersState; } = parametersState;
useSessionRecoveryDraft("contaminant-simulation", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [isSelecting, setIsSelecting] = useState<boolean>(false); const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [submitting, setSubmitting] = useState<boolean>(false); const [submitting, setSubmitting] = useState<boolean>(false);
@@ -91,7 +106,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
Boolean(sourceNode) && Boolean(sourceNode) &&
concentration > 0 && concentration > 0 &&
duration > 0 && duration > 0 &&
schemeName.trim() !== "" isSchemeNameValid(schemeName)
); );
}, [network, startTime, sourceNode, concentration, duration, schemeName]); }, [network, startTime, sourceNode, concentration, duration, schemeName]);
@@ -187,7 +202,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
return; return;
} }
if (highlightFeature) return; if (highlightFeature) return;
queryFeaturesByIds([sourceNode], "geo_junctions_mat").then((features) => { queryFeaturesByIds([sourceNode], "junctions").then((features) => {
setHighlightFeature(features[0] ?? null); setHighlightFeature(features[0] ?? null);
}); });
}, [highlightFeature, sourceNode]); }, [highlightFeature, sourceNode]);
@@ -233,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
concentration, concentration,
duration, duration,
pattern: pattern || undefined, pattern: pattern || undefined,
scheme_name: schemeName, scheme_name: normalizeSchemeName(schemeName),
}; };
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, { await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
@@ -252,8 +267,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
key: "contaminant-analysis", key: "contaminant-analysis",
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: description: getApiErrorMessage(error, "污染物模拟请求失败"),
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
}); });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -384,6 +398,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="输入方案名称" placeholder="输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
/> />
</Box> </Box>
@@ -25,10 +25,10 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { api } from "@/lib/api"; import { listAnalysisSchemes } from "@/lib/analysisRuns";
import moment from "moment"; import moment from "moment";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { config, NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useData, useMap } from "@components/olmap/core/MapComponent"; import { useData, useMap } from "@components/olmap/core/MapComponent";
@@ -39,7 +39,7 @@ import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature from "ol/Feature"; import Feature from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@components/olmap/core/Controls/Timeline"; import Timeline from "@components/olmap/core/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types"; import { ContaminantSchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
@@ -60,7 +60,7 @@ export interface ContaminantSchemeQueryState {
showTimeline: boolean; showTimeline: boolean;
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null; expandedId: string | null;
hasQueried: boolean; hasQueried: boolean;
} }
@@ -111,7 +111,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const { open } = useNotification(); const { open } = useNotification();
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
const { schemeName, setSchemeName } = data || {}; const { schemeName, setSchemeName, schemeRunId, setSchemeRunId } = data || {};
const schemes = const schemes =
externalSchemes !== undefined ? externalSchemes : internalSchemes; externalSchemes !== undefined ? externalSchemes : internalSchemes;
@@ -222,40 +222,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
if (!queryAll && !queryDate) return; if (!queryAll && !queryDate) return;
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const nextSchemes = (await listAnalysisSchemes({
scheme_type: SCHEME_TYPE, runType: SCHEME_TYPE,
}; queryDate: !queryAll && queryDate
if (!queryAll && queryDate) { ? queryDate.format("YYYY-MM-DD")
params.query_date = queryDate.startOf("day").toISOString(); : undefined,
} })) as unknown as ContaminantSchemeRecord[];
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data;
if (!queryAll) {
const formattedDate = queryDate!.format("YYYY-MM-DD");
filteredResults = response.data.filter(
(item: ContaminantSchemaItem) => {
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
return itemDate === formattedDate;
},
);
}
const nextSchemes = filteredResults.map((item: ContaminantSchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
username: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true); setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (nextSchemes.length === 0) {
open?.({ open?.({
type: "success", type: "success",
message: "查询结果", message: "查询结果",
@@ -267,7 +243,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`, description: `共找到 ${nextSchemes.length} 条方案记录`,
}); });
} }
} catch (error) { } catch (error) {
@@ -284,7 +260,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const handleLocateSource = (sourceIds: string[]) => { const handleLocateSource = (sourceIds: string[]) => {
if (sourceIds.length > 0) { if (sourceIds.length > 0) {
queryFeaturesByIds(sourceIds, "geo_junctions_mat").then((features) => { queryFeaturesByIds(sourceIds, "junctions").then((features) => {
if (features.length > 0) { if (features.length > 0) {
// 设置高亮要素 // 设置高亮要素
setHighlightFeatures(features); setHighlightFeatures(features);
@@ -307,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
} }
}; };
const handleViewDetails = (id: number) => { const handleViewDetails = (id: string) => {
const scheme = filteredSchemes.find((s) => s.id === id); const scheme = filteredSchemes.find((s) => s.id === id);
if (!scheme) return; if (!scheme) return;
@@ -328,6 +304,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
})); }));
} }
setSchemeName?.(scheme.schemeName); setSchemeName?.(scheme.schemeName);
setSchemeRunId?.(scheme.id);
if (scheme.schemeDetail?.source) { if (scheme.schemeDetail?.source) {
handleLocateSource([scheme.schemeDetail.source]); handleLocateSource([scheme.schemeDetail.source]);
} }
@@ -343,6 +320,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
timeRange={timeRange} timeRange={timeRange}
disableDateSelection={!!timeRange} disableDateSelection={!!timeRange}
schemeName={schemeName} schemeName={schemeName}
schemeRunId={schemeRunId}
schemeType={SCHEME_TYPE} schemeType={SCHEME_TYPE}
/>, />,
mapContainer, mapContainer,
@@ -6,7 +6,7 @@ export interface ContaminantSchemeDetail {
} }
export interface ContaminantSchemeRecord { export interface ContaminantSchemeRecord {
id: number; id: string;
schemeName: string; schemeName: string;
type: string; type: string;
username: string; username: string;
@@ -14,13 +14,3 @@ export interface ContaminantSchemeRecord {
startTime: string; startTime: string;
schemeDetail?: ContaminantSchemeDetail; schemeDetail?: ContaminantSchemeDetail;
} }
export interface ContaminantSchemaItem {
scheme_id: number;
scheme_name: string;
scheme_type: string;
username: string;
create_time: string;
scheme_start_time: string;
scheme_detail?: ContaminantSchemeDetail;
}
@@ -20,8 +20,17 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { config } from "@config/config"; import { config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { LeakageResultDetail } from "./types"; import { LeakageResultDetail } from "./types";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props { interface Props {
onResult: (result: LeakageResultDetail) => void; onResult: (result: LeakageResultDetail) => void;
@@ -42,7 +51,7 @@ export interface DMALeakAnalysisParametersState {
export const createDMALeakAnalysisParametersState = export const createDMALeakAnalysisParametersState =
(): DMALeakAnalysisParametersState => ({ (): DMALeakAnalysisParametersState => ({
schemeName: `DMA_Leak_${Date.now()}`, schemeName: createSchemeName(SCHEME_NAME_PREFIXES.dmaLeakIdentification),
dmaCount: 5, dmaCount: 5,
startTime: dayjs().subtract(2, "hour"), startTime: dayjs().subtract(2, "hour"),
endTime: dayjs(), endTime: dayjs(),
@@ -58,7 +67,7 @@ const AnalysisParameters: React.FC<Props> = ({
onStateChange, onStateChange,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const [parametersState, , setFormField] = useControllableObjectState( const [parametersState, setParametersState, setFormField] = useControllableObjectState(
state, state,
onStateChange, onStateChange,
createDMALeakAnalysisParametersState(), createDMALeakAnalysisParametersState(),
@@ -73,6 +82,13 @@ const AnalysisParameters: React.FC<Props> = ({
qSum, qSum,
advancedOpen, advancedOpen,
} = parametersState; } = parametersState;
useSessionRecoveryDraft("dma-leak-detection", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
endTime: draft.endTime ? dayjs(draft.endTime) : null,
}),
);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [qSumInput, setQSumInput] = useState(() => String(qSum)); const [qSumInput, setQSumInput] = useState(() => String(qSum));
@@ -85,7 +101,7 @@ const AnalysisParameters: React.FC<Props> = ({
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0; qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0;
const isValid = useMemo(() => { const isValid = useMemo(() => {
if (!schemeName.trim() || !startTime || !endTime) return false; if (!isSchemeNameValid(schemeName) || !startTime || !endTime) return false;
return startTime.isBefore(endTime) && qSumIsValid; return startTime.isBefore(endTime) && qSumIsValid;
}, [schemeName, startTime, endTime, qSumIsValid]); }, [schemeName, startTime, endTime, qSumIsValid]);
@@ -110,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({
const response = await api.post( const response = await api.post(
`${config.BACKEND_URL}/api/v1/leakage-identifications`, `${config.BACKEND_URL}/api/v1/leakage-identifications`,
{ {
scheme_name: schemeName.trim(), scheme_name: normalizeSchemeName(schemeName),
dma_count: dmaCount, dma_count: dmaCount,
scada_start: startTime.toISOString(), scada_start: startTime.toISOString(),
scada_end: endTime.toISOString(), scada_end: endTime.toISOString(),
@@ -128,12 +144,12 @@ const AnalysisParameters: React.FC<Props> = ({
message: "方案分析成功", message: "方案分析成功",
description: "DMA 漏损识别完成,请在方案查询中查看结果。", description: "DMA 漏损识别完成,请在方案查询中查看结果。",
}); });
} catch (error: any) { } catch (error) {
open?.({ open?.({
key: "dma-leak-analysis-error", key: "dma-leak-analysis-error",
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: error?.response?.data?.detail ?? "请求失败", description: getApiErrorMessage(error, "DMA 漏损识别请求失败"),
}); });
} finally { } finally {
setRunning(false); setRunning(false);
@@ -155,6 +171,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
fullWidth fullWidth
size="small" size="small"
/> />
@@ -21,8 +21,11 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import {
import { NETWORK_NAME, config } from "@config/config"; getAnalysisResults,
getAnalysisScheme,
listAnalysisSchemes,
} from "@/lib/analysisRuns";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
@@ -40,7 +43,7 @@ interface Props {
export interface DMALeakSchemeQueryState { export interface DMALeakSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: string | null;
hasQueried: boolean; hasQueried: boolean;
} }
@@ -84,16 +87,12 @@ const SchemeQuery: React.FC<Props> = ({
const handleQuery = async () => { const handleQuery = async () => {
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const nextSchemes = (await listAnalysisSchemes({
scheme_type: "dma_leak_identification", runType: "dma_leak_identification",
}; queryDate: !queryAll && queryDate
if (!queryAll && queryDate) { ? queryDate.format("YYYY-MM-DD")
params.query_date = queryDate.startOf("day").toISOString(); : undefined,
} })) as LeakageSchemeRecord[];
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
const nextSchemes = response.data as LeakageSchemeRecord[];
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true); setQueryField("hasQueried", true);
if (nextSchemes.length === 0) { if (nextSchemes.length === 0) {
@@ -122,22 +121,30 @@ const SchemeQuery: React.FC<Props> = ({
} }
}; };
const handleViewSchemeResult = async (schemeName: string) => { const handleViewSchemeResult = async (runId: string) => {
try { try {
const response = await api.get( const [scheme, results] = await Promise.all([
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`, getAnalysisScheme(runId),
{ getAnalysisResults(runId, "leakage_identification"),
params: { ]);
scheme_type: "dma_leak_identification", const result = results[0]?.payload;
}, if (!result) {
}, throw new Error("方案详情缺少漏损识别结果");
); }
onViewResult(response.data as LeakageResultDetail); onViewResult({
...result,
scheme_name: scheme.scheme_name,
scheme_detail: scheme.scheme_detail,
scheme_start_time: scheme.scheme_start_time,
create_time: scheme.create_time,
username: scheme.username,
} as LeakageResultDetail);
} catch (error: any) { } catch (error: any) {
open?.({ open?.({
type: "error", type: "error",
message: "查看详情失败", message: "查看详情失败",
description: error?.response?.data?.detail ?? "无法获取方案详情", description:
error?.response?.data?.detail ?? error?.message ?? "无法获取方案详情",
}); });
} }
}; };
@@ -293,7 +300,7 @@ const SchemeQuery: React.FC<Props> = ({
size="small" size="small"
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }} sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => handleViewSchemeResult(scheme.scheme_name)} onClick={() => handleViewSchemeResult(scheme.scheme_id)}
> >
</Button> </Button>
@@ -6,7 +6,7 @@ export interface LeakageRow {
} }
export interface LeakageSchemeRecord { export interface LeakageSchemeRecord {
scheme_id: number; scheme_id: string;
scheme_name: string; scheme_name: string;
scheme_type: string; scheme_type: string;
username: string; username: string;
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { import {
Box, Box,
TextField, TextField,
@@ -8,8 +8,8 @@ import {
Typography, Typography,
IconButton, IconButton,
Stack, Stack,
Alert,
Divider, Divider,
MenuItem,
} from "@mui/material"; } from "@mui/material";
import { import {
AdjustOutlined, AdjustOutlined,
@@ -33,13 +33,34 @@ import {
import Feature, { FeatureLike } from "ol/Feature"; import Feature, { FeatureLike } from "ol/Feature";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config"; import { config } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
import {
type LinkStatus,
getValveSettingHelperText,
isLinkStatus,
normalizeValveSetting,
VALVE_STATUS_OPTIONS,
validateValveSetting,
} from "@components/olmap/core/Controls/valveControl";
import { getValveListClassName } from "./analysisParametersLayout";
export interface ValveItem { export interface ValveItem {
id: string; id: string;
k: number; setting?: string | null;
vType?: string | null;
status?: LinkStatus | null;
detailsLoaded?: boolean;
} }
export interface FlushingAnalysisParametersState { export interface FlushingAnalysisParametersState {
@@ -53,7 +74,7 @@ export interface FlushingAnalysisParametersState {
export const createFlushingAnalysisParametersState = export const createFlushingAnalysisParametersState =
(): FlushingAnalysisParametersState => ({ (): FlushingAnalysisParametersState => ({
schemeName: "Flushing_" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis),
valves: [], valves: [],
drainageNode: null, drainageNode: null,
startTime: dayjs(new Date()), startTime: dayjs(new Date()),
@@ -80,11 +101,18 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
); );
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } = const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
parametersState; parametersState;
useSessionRecoveryDraft("flushing-analysis", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]); const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null); const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none'); const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none');
const [analyzing, setAnalyzing] = useState<boolean>(false); const [analyzing, setAnalyzing] = useState<boolean>(false);
const valveSettingRequests = useRef(new Set<string>());
const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null);
@@ -112,10 +140,10 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
if (!feature) return; if (!feature) return;
const layer = feature.getId()?.toString().split(".")[0]; const layer = feature.getId()?.toString().split(".")[0];
const featureId = feature.getProperties().id; const featureId = String(feature.getProperties().id);
if (selectionMode === 'valve') { if (selectionMode === 'valve') {
if (layer !== 'geo_valves') { if (layer !== 'valves') {
open?.({ open?.({
type: "error", type: "error",
message: "请选择阀门要素", message: "请选择阀门要素",
@@ -132,11 +160,20 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
return prev; return prev;
} }
setValveFeatures((features) => [...features, feature]); setValveFeatures((features) => [...features, feature]);
return [...prev, { id: featureId, k: 1.0 }]; return [
...prev,
{
id: featureId,
setting: undefined,
vType: undefined,
status: undefined,
detailsLoaded: false,
},
];
}); });
} else if (selectionMode === 'drainage') { } else if (selectionMode === 'drainage') {
if (layer !== 'geo_junctions') { if (layer !== 'junctions') {
open?.({ open?.({
type: "error", type: "error",
message: "请选择节点要素作为排水点", message: "请选择节点要素作为排水点",
@@ -240,19 +277,104 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
if (valveFeatures.length > 0) return; if (valveFeatures.length > 0) return;
queryFeaturesByIds( queryFeaturesByIds(
valves.map((valve) => valve.id), valves.map((valve) => valve.id),
"geo_valves", "valves",
).then((features) => { ).then((features) => {
setValveFeatures(features); setValveFeatures(features);
}); });
}, [valveFeatures.length, valves]); }, [valveFeatures.length, valves]);
useEffect(() => {
valves.forEach((valve) => {
if (valve.detailsLoaded || valveSettingRequests.current.has(valve.id)) {
return;
}
valveSettingRequests.current.add(valve.id);
void Promise.allSettled([
api.get(`${config.BACKEND_URL}/api/v1/valves/properties`, {
params: { valve: valve.id },
}),
api.get(`${config.BACKEND_URL}/api/v1/status`, {
params: { link: valve.id },
}),
])
.then(([propertiesResult, statusResult]) => {
const properties =
propertiesResult.status === "fulfilled"
? propertiesResult.value.data
: null;
const rawSetting = properties?.setting;
const rawStatus =
statusResult.status === "fulfilled"
? statusResult.value.data?.status
: null;
const status = isLinkStatus(rawStatus) ? rawStatus : null;
setValves((previous) =>
previous.map((item) =>
item.id === valve.id
? {
...item,
setting: normalizeValveSetting(rawSetting),
vType: properties?.v_type
? String(properties.v_type)
: null,
status,
detailsLoaded: true,
}
: item,
),
);
if (
propertiesResult.status === "rejected" ||
statusResult.status === "rejected"
) {
console.error("读取阀门属性失败", {
propertiesError:
propertiesResult.status === "rejected"
? propertiesResult.reason
: undefined,
statusError:
statusResult.status === "rejected"
? statusResult.reason
: undefined,
});
open?.({
type: "error",
message: `阀门 ${valve.id} 的部分属性读取失败`,
description: [
propertiesResult.status === "rejected"
? `阀门属性:${getApiErrorMessage(
propertiesResult.reason,
"读取失败",
)}`
: null,
statusResult.status === "rejected"
? `开关状态:${getApiErrorMessage(
statusResult.reason,
"读取失败",
)}`
: null,
]
.filter(Boolean)
.join(""),
});
}
})
.finally(() => {
valveSettingRequests.current.delete(valve.id);
});
});
}, [open, setValves, valves]);
useEffect(() => { useEffect(() => {
if (!drainageNode) { if (!drainageNode) {
setDrainageFeature(null); setDrainageFeature(null);
return; return;
} }
if (drainageFeature) return; if (drainageFeature) return;
queryFeaturesByIds([drainageNode], "geo_junctions").then((features) => { queryFeaturesByIds([drainageNode], "junctions").then((features) => {
setDrainageFeature(features[0] ?? null); setDrainageFeature(features[0] ?? null);
}); });
}, [drainageFeature, drainageNode]); }, [drainageFeature, drainageNode]);
@@ -285,13 +407,25 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
); );
}; };
const handleValveKChange = (id: string, k: string) => { const handleValveStatusChange = (id: string, status: string) => {
const numK = parseFloat(k); if (!isLinkStatus(status)) return;
setValves(prev => prev.map(v => v.id === id ? { ...v, k: isNaN(numK) ? 0 : numK } : v)); setValves((previous) =>
previous.map((valve) =>
valve.id === id ? { ...valve, status } : valve,
),
);
};
const handleValveSettingChange = (id: string, setting: string) => {
setValves((previous) =>
previous.map((valve) =>
valve.id === id ? { ...valve, setting } : valve,
),
);
}; };
const handleAnalyze = async () => { const handleAnalyze = async () => {
if (!startTime || !drainageNode || !schemeName.trim()) { if (!startTime || !drainageNode || !isSchemeNameValid(schemeName)) {
open?.({ open?.({
type: "error", type: "error",
message: "请填写完整参数", message: "请填写完整参数",
@@ -300,16 +434,48 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
return; return;
} }
if (valves.some((valve) => !isLinkStatus(valve.status))) {
open?.({
type: "error",
message: "阀门开关状态未设置",
description: "请为所有参与阀门选择开启、关闭或激活状态",
});
return;
}
const invalidActiveValve = valves.find(
(valve) =>
valve.status === "ACTIVE" &&
validateValveSetting(valve.vType, valve.setting ?? ""),
);
if (invalidActiveValve) {
open?.({
type: "error",
message: `阀门 ${invalidActiveValve.id} 的设置值无效`,
description:
validateValveSetting(
invalidActiveValve.vType,
invalidActiveValve.setting ?? "",
) ?? undefined,
});
return;
}
setAnalyzing(true); setAnalyzing(true);
try { try {
const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00 const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00
const params = { const params = {
scheme_name: schemeName, scheme_name: normalizeSchemeName(schemeName),
start_time: formattedTime, start_time: formattedTime,
valves: valves.map(v => v.id), ...(valves.length > 0 && {
valves_k: valves.map(v => v.k), valves: valves.map(v => v.id),
valve_statuses: valves.map((v) => v.status),
valve_settings: valves.map((v) =>
v.status === "ACTIVE" ? (v.setting ?? "").trim() : "",
),
}),
drainage_node_id: drainageNode, drainage_node_id: drainageNode,
flush_flow: flushFlow, flush_flow: flushFlow,
duration: duration duration: duration
@@ -339,7 +505,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
open?.({ open?.({
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: error instanceof Error ? error.message : "未知错误", description: getApiErrorMessage(error, "管道冲洗分析请求失败"),
}); });
} finally { } finally {
setAnalyzing(false); setAnalyzing(false);
@@ -348,62 +514,11 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
return ( return (
<Box className="flex flex-col h-full gap-4 pb-4"> <Box className="flex flex-col h-full gap-4 pb-4">
{/* 1. Valve Selection */} {/* 1. Drainage Node Selection */}
<Box> <Box>
<Box className="flex items-center justify-between mb-2"> <Box className="flex items-center justify-between mb-2">
<Typography variant="subtitle2" className="font-medium"> <Typography variant="subtitle2" className="font-medium">
</Typography>
<Button
variant={selectionMode === 'valve' ? "contained" : "outlined"}
color={selectionMode === 'valve' ? "error" : "primary"}
size="small"
onClick={() => toggleSelection('valve')}
>
{selectionMode === 'valve' ? "停止选择" : "选择阀门"}
</Button>
</Box>
{selectionMode === 'valve' && (
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
💡
</Box>
)}
<Stack spacing={1} className="max-h-50 h-48 overflow-auto">
{valves.map((valve) => (
<Box key={valve.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Typography className="text-sm flex-1 pl-1">{valve.id}</Typography>
<TextField
label="开度"
size="small"
type="number"
value={valve.k}
onChange={(e) => handleValveKChange(valve.id, e.target.value)}
className="w-20"
slotProps={{ htmlInput: { step: 0.1, min: 0, max: 1 } }}
/>
<IconButton size="small" onClick={() => handleRemoveValve(valve.id)}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
))}
{valves.length === 0 && (
<PanelEmptyState
variant="compact"
icon={<AdjustOutlined />}
title="尚未选择阀门"
description="点击“选择阀门”,然后在地图上添加。"
/>
)}
</Stack>
</Box>
<Divider />
{/* 2. Drainage Node Selection */}
<Box>
<Box className="flex items-center justify-between mb-2">
<Typography variant="subtitle2" className="font-medium">
</Typography> </Typography>
<Button <Button
variant={selectionMode === 'drainage' ? "contained" : "outlined"} variant={selectionMode === 'drainage' ? "contained" : "outlined"}
@@ -437,6 +552,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
{!drainageNode && ( {!drainageNode && (
<PanelEmptyState <PanelEmptyState
variant="compact" variant="compact"
horizontalAlign="start"
icon={<WaterDropOutlined />} icon={<WaterDropOutlined />}
title="尚未选择排水节点" title="尚未选择排水节点"
description="点击“选择节点”,然后在地图上指定排水点。" description="点击“选择节点”,然后在地图上指定排水点。"
@@ -447,6 +563,110 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
<Divider /> <Divider />
{/* 2. Optional Valve Selection */}
<Box>
<Box className="flex items-center justify-between mb-2">
<Typography variant="subtitle2" className="font-medium">
</Typography>
<Button
variant={selectionMode === 'valve' ? "contained" : "outlined"}
color={selectionMode === 'valve' ? "error" : "primary"}
size="small"
onClick={() => toggleSelection('valve')}
>
{selectionMode === 'valve' ? "停止选择" : "选择阀门"}
</Button>
</Box>
{selectionMode === 'valve' && (
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
💡
</Box>
)}
<Stack
spacing={1}
className={getValveListClassName(valves.length)}
>
{valves.map((valve) => {
const settingValidation =
valve.status === "ACTIVE"
? validateValveSetting(valve.vType, valve.setting ?? "")
: null;
const isSettingDisabled =
!valve.detailsLoaded ||
valve.status === "OPEN" ||
valve.status === "CLOSED";
return (
<Box key={valve.id} className="p-2 bg-gray-50 rounded">
<Box className="flex items-center gap-2 mb-2">
<Typography className="text-sm min-w-0 flex-1 pl-1">
{valve.id}
</Typography>
<IconButton
size="small"
aria-label={`移除阀门 ${valve.id}`}
onClick={() => handleRemoveValve(valve.id)}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Box className="grid grid-cols-2 gap-2">
<TextField
select
fullWidth
size="small"
label="开关状态"
value={valve.status ?? ""}
disabled={!valve.detailsLoaded}
onChange={(event) =>
handleValveStatusChange(valve.id, event.target.value)
}
>
{VALVE_STATUS_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
fullWidth
size="small"
label="阀门设置值"
value={valve.setting ?? ""}
disabled={isSettingDisabled}
error={Boolean(settingValidation)}
helperText={
valve.detailsLoaded
? settingValidation ??
getValveSettingHelperText(
valve.vType,
valve.status,
)
: "加载中"
}
onChange={(event) =>
handleValveSettingChange(valve.id, event.target.value)
}
/>
</Box>
</Box>
);
})}
{valves.length === 0 && (
<PanelEmptyState
variant="compact"
horizontalAlign="start"
icon={<AdjustOutlined />}
title="未选择参与阀门"
description="无需调整阀门时可跳过,也可点击“选择阀门”添加。"
/>
)}
</Stack>
</Box>
<Divider />
{/* 3. Parameters */} {/* 3. Parameters */}
<Box className="flex flex-col gap-3"> <Box className="flex flex-col gap-3">
<Box> <Box>
@@ -478,6 +698,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
/> />
</Box> </Box>
@@ -520,7 +747,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
onClick={handleAnalyze} onClick={handleAnalyze}
disabled={ disabled={
analyzing || analyzing ||
!schemeName.trim() || !isSchemeNameValid(schemeName) ||
!drainageNode || !drainageNode ||
!startTime || !startTime ||
// !flushFlow || // !flushFlow ||
@@ -26,9 +26,9 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { api } from "@/lib/api"; import { listAnalysisSchemes } from "@/lib/analysisRuns";
import moment from "moment"; import moment from "moment";
import { config, NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { useData, useMap } from "@components/olmap/core/MapComponent"; import { useData, useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { queryFeaturesByIds } from "@/utils/mapQueryService";
@@ -40,10 +40,11 @@ import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature"; import Feature, { FeatureLike } from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@components/olmap/core/Controls/Timeline"; import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemeRecord, SchemaItem } from "./types"; import { SchemeRecord } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units"; import { FLOW_DISPLAY_UNIT } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
import { formatValveControlSummary } from "@components/olmap/core/Controls/toolbarFeatureHelpers";
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
@@ -58,7 +59,7 @@ const SCHEME_TYPE = "flushing_analysis";
export interface FlushingSchemeQueryState { export interface FlushingSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: string | null;
showTimeline: boolean; showTimeline: boolean;
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
@@ -110,7 +111,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const { open } = useNotification(); const { open } = useNotification();
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
const { schemeName, setSchemeName } = data || {}; const { schemeName, setSchemeName, schemeRunId, setSchemeRunId } = data || {};
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
@@ -213,7 +214,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const handleLocateDrainageNode = (nodeId: string) => { const handleLocateDrainageNode = (nodeId: string) => {
if (!nodeId) return; if (!nodeId) return;
queryFeaturesByIds([nodeId], "geo_junctions_mat").then((features) => { queryFeaturesByIds([nodeId], "junctions").then((features) => {
if (features.length > 0) { if (features.length > 0) {
// Add type property to distinguish styling // Add type property to distinguish styling
features.forEach(f => f.set("type", "drainage")); features.forEach(f => f.set("type", "drainage"));
@@ -230,7 +231,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const handleLocateValves = (valveIds: string[]) => { const handleLocateValves = (valveIds: string[]) => {
if (!valveIds || valveIds.length === 0) return; if (!valveIds || valveIds.length === 0) return;
queryFeaturesByIds(valveIds, "geo_valves").then((features) => { queryFeaturesByIds(valveIds, "valves").then((features) => {
if (features.length > 0) { if (features.length > 0) {
features.forEach(f => f.set("type", "valve")); features.forEach(f => f.set("type", "valve"));
setHighlightFeatures(features); setHighlightFeatures(features);
@@ -268,42 +269,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const nextSchemes = (await listAnalysisSchemes({
scheme_type: SCHEME_TYPE, runType: SCHEME_TYPE,
}; queryDate: !queryAll && queryDate
if (!queryAll && queryDate) { ? queryDate.format("YYYY-MM-DD")
params.query_date = queryDate.startOf("day").toISOString(); : undefined,
} })) as unknown as SchemeRecord[];
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data;
// Filter by type
filteredResults = filteredResults.filter((item: SchemaItem) => item.scheme_type === SCHEME_TYPE);
if (!queryAll && queryDate) {
const formattedDate = queryDate.format("YYYY-MM-DD");
filteredResults = filteredResults.filter((item: SchemaItem) => {
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
return itemDate === formattedDate;
});
}
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
username: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true); setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (nextSchemes.length === 0) {
open?.({ open?.({
type: "success", type: "success",
message: "未找到相关方案", message: "未找到相关方案",
@@ -313,7 +288,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`, description: `共找到 ${nextSchemes.length} 条方案记录`,
}); });
} }
} catch (error) { } catch (error) {
@@ -345,6 +320,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
} }
setSchemeName?.(scheme.schemeName); setSchemeName?.(scheme.schemeName);
setSchemeRunId?.(scheme.id);
// Locate drainage node by default if available // Locate drainage node by default if available
if (scheme.schemeDetail?.drainage_node_ID) { if (scheme.schemeDetail?.drainage_node_ID) {
@@ -362,6 +338,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
timeRange={timeRange} timeRange={timeRange}
disableDateSelection={!!timeRange} disableDateSelection={!!timeRange}
schemeName={schemeName} schemeName={schemeName}
schemeRunId={schemeRunId}
schemeType={SCHEME_TYPE} schemeType={SCHEME_TYPE}
/>, />,
mapContainer, mapContainer,
@@ -589,10 +566,22 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 阀门列表 */} {/* 阀门列表 */}
<Box className="col-span-2 pl-2"> <Box className="col-span-2 pl-2">
<Typography variant="caption" className="text-gray-600 block mb-1"> <Typography variant="caption" className="text-gray-600 block mb-1">
: :
</Typography> </Typography>
<Box className="flex flex-wrap gap-2"> <Box className="flex flex-wrap gap-2">
{scheme.schemeDetail?.valve_opening && Object.entries(scheme.schemeDetail.valve_opening).length > 0 ? ( {scheme.schemeDetail?.valve_control && Object.entries(scheme.schemeDetail.valve_control).length > 0 ? (
Object.entries(scheme.schemeDetail.valve_control).map(([id, control]) => (
<Tooltip key={id} title="点击定位阀门">
<Chip
label={formatValveControlSummary(id, control)}
size="small"
variant="outlined"
onClick={() => handleLocateValves([id])}
className="text-xs h-6 bg-gray-50 cursor-pointer hover:bg-orange-50 hover:border-orange-200"
/>
</Tooltip>
))
) : scheme.schemeDetail?.valve_opening && Object.entries(scheme.schemeDetail.valve_opening).length > 0 ? (
Object.entries(scheme.schemeDetail.valve_opening).map(([id, k]) => ( Object.entries(scheme.schemeDetail.valve_opening).map(([id, k]) => (
<Tooltip key={id} title="点击定位阀门"> <Tooltip key={id} title="点击定位阀门">
<Chip <Chip
@@ -0,0 +1,13 @@
import { getValveListClassName } from "./analysisParametersLayout";
describe("getValveListClassName", () => {
it("uses compact height when no valves are selected", () => {
expect(getValveListClassName(0)).toBe("min-h-16 overflow-auto");
});
it("restores the fixed scroll region when valves are selected", () => {
expect(getValveListClassName(1)).toBe(
"max-h-50 h-48 overflow-auto",
);
});
});
@@ -0,0 +1,4 @@
export const getValveListClassName = (valveCount: number): string =>
valveCount > 0
? "max-h-50 h-48 overflow-auto"
: "min-h-16 overflow-auto";
+10 -12
View File
@@ -1,12 +1,20 @@
export interface SchemeDetail { export interface SchemeDetail {
valve_opening: Record<string, number>; valve_opening?: Record<string, number> | null;
valve_control?: Record<
string,
{
status?: "OPEN" | "CLOSED" | "ACTIVE";
setting?: string | number;
k?: number;
}
> | null;
drainage_node_ID: string; drainage_node_ID: string;
flushing_flow: number; flushing_flow: number;
duration: number; duration: number;
} }
export interface SchemeRecord { export interface SchemeRecord {
id: number; id: string;
schemeName: string; schemeName: string;
type: string; type: string;
username: string; username: string;
@@ -15,13 +23,3 @@ export interface SchemeRecord {
// 详情信息 // 详情信息
schemeDetail?: SchemeDetail; schemeDetail?: SchemeDetail;
} }
export interface SchemaItem {
scheme_id: number;
scheme_name: string;
scheme_type: string;
username: string;
create_time: string;
scheme_start_time: string;
scheme_detail?: SchemeDetail;
}
@@ -1,5 +1,6 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import MonitoringPlaceOptimizationPanel, { import MonitoringPlaceOptimizationPanel, {
getMonitoringLayerVisibility,
getTabIndicatorSx, getTabIndicatorSx,
getTabIndicatorTransform, getTabIndicatorTransform,
} from "./MonitoringPlaceOptimizationPanel"; } from "./MonitoringPlaceOptimizationPanel";
@@ -7,6 +8,7 @@ import { getSensorPlacementScheme } from "./schemeApi";
import type { SensorPlacementScheme } from "./types"; import type { SensorPlacementScheme } from "./types";
const mockSchemeEditorRender = jest.fn(); const mockSchemeEditorRender = jest.fn();
const mockSchemeQueryRender = jest.fn();
jest.mock("@refinedev/core", () => ({ jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: jest.fn() }), useNotification: () => ({ open: jest.fn() }),
@@ -20,9 +22,16 @@ jest.mock("./OptimizationParameters", () => ({
jest.mock("./SchemeQuery", () => ({ jest.mock("./SchemeQuery", () => ({
__esModule: true, __esModule: true,
default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => ( default: ({
<button onClick={() => onEdit(7)}></button> onEdit,
), active,
}: {
onEdit: (schemeId: string) => void;
active?: boolean;
}) => {
mockSchemeQueryRender(active);
return <button onClick={() => onEdit("run-7")}></button>;
},
createMonitoringSchemeQueryState: () => ({}), createMonitoringSchemeQueryState: () => ({}),
})); }));
@@ -52,7 +61,7 @@ jest.mock("@components/olmap/common/PanelEmptyState", () => ({
const mockGetSensorPlacementScheme = jest.mocked(getSensorPlacementScheme); const mockGetSensorPlacementScheme = jest.mocked(getSensorPlacementScheme);
const scheme: SensorPlacementScheme = { const scheme: SensorPlacementScheme = {
id: 7, id: "run-7",
scheme_name: "测试方案", scheme_name: "测试方案",
sensor_number: 1, sensor_number: 1,
min_diameter: 100, min_diameter: 100,
@@ -66,6 +75,7 @@ const scheme: SensorPlacementScheme = {
describe("MonitoringPlaceOptimizationPanel", () => { describe("MonitoringPlaceOptimizationPanel", () => {
beforeEach(() => { beforeEach(() => {
mockSchemeEditorRender.mockClear(); mockSchemeEditorRender.mockClear();
mockSchemeQueryRender.mockClear();
}); });
it("applies equal-width positioning to the rendered tab indicator", () => { it("applies equal-width positioning to the rendered tab indicator", () => {
@@ -90,6 +100,21 @@ describe("MonitoringPlaceOptimizationPanel", () => {
expect(indicator).toHaveStyle({ transform: "translateX(200%)" }); expect(indicator).toHaveStyle({ transform: "translateX(200%)" });
}); });
it("keeps query and editor layers mutually exclusive by active tab", () => {
expect(getMonitoringLayerVisibility(0)).toEqual({
editor: false,
query: false,
});
expect(getMonitoringLayerVisibility(1)).toEqual({
editor: true,
query: false,
});
expect(getMonitoringLayerVisibility(2)).toEqual({
editor: false,
query: true,
});
});
it("prepares a queried scheme before switching to the result editor", async () => { it("prepares a queried scheme before switching to the result editor", async () => {
let resolveScheme: (value: SensorPlacementScheme) => void = () => {}; let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
mockGetSensorPlacementScheme.mockReturnValue( mockGetSensorPlacementScheme.mockReturnValue(
@@ -119,5 +144,23 @@ describe("MonitoringPlaceOptimizationPanel", () => {
false, false,
true, true,
]); ]);
expect(mockSchemeQueryRender.mock.calls.at(-1)?.[0]).toBe(false);
});
it("keeps the active result layer visible when the panel is collapsed", async () => {
mockGetSensorPlacementScheme.mockResolvedValue(scheme);
render(<MonitoringPlaceOptimizationPanel />);
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(screen.getByRole("button", { name: "打开测试方案" }));
await screen.findByTestId("scheme-editor");
const collapseButton = screen
.getByTestId("ChevronRightIcon")
.closest("button");
expect(collapseButton).not.toBeNull();
fireEvent.click(collapseButton!);
expect(mockSchemeEditorRender.mock.calls.at(-1)?.[0]).toBe(true);
}); });
}); });
@@ -69,6 +69,11 @@ export const getTabIndicatorSx = (tabIndex: number) => ({
}, },
}); });
export const getMonitoringLayerVisibility = (tabIndex: number) => ({
editor: tabIndex === 1,
query: tabIndex === 2,
});
interface PreparedSchemeEditorProps interface PreparedSchemeEditorProps
extends React.ComponentProps<typeof SchemeEditor> { extends React.ComponentProps<typeof SchemeEditor> {
onReady?: () => void; onReady?: () => void;
@@ -98,7 +103,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
const [activeScheme, setActiveScheme] = const [activeScheme, setActiveScheme] =
useState<SensorPlacementScheme | null>(null); useState<SensorPlacementScheme | null>(null);
const [loadingScheme, setLoadingScheme] = useState(false); const [loadingScheme, setLoadingScheme] = useState(false);
const [pendingOpenSchemeId, setPendingOpenSchemeId] = useState<number | null>( const [pendingOpenSchemeId, setPendingOpenSchemeId] = useState<string | null>(
null, null,
); );
const { open: notify } = useNotification(); const { open: notify } = useNotification();
@@ -126,13 +131,14 @@ const MonitoringPlaceOptimizationPanel: React.FC<
}; };
const drawerWidth = currentTab === 1 ? 820 : 520; const drawerWidth = currentTab === 1 ? 820 : 520;
const layerVisibility = getMonitoringLayerVisibility(currentTab);
const handleSchemeEditorReady = useCallback(() => { const handleSchemeEditorReady = useCallback(() => {
setCurrentTab(1); setCurrentTab(1);
setPendingOpenSchemeId(null); setPendingOpenSchemeId(null);
}, []); }, []);
const handleOpenScheme = async (schemeId: number) => { const handleOpenScheme = async (schemeId: string) => {
setLoadingScheme(true); setLoadingScheme(true);
try { try {
const scheme = await getSensorPlacementScheme(schemeId); const scheme = await getSensorPlacementScheme(schemeId);
@@ -326,7 +332,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
<PreparedSchemeEditor <PreparedSchemeEditor
scheme={activeScheme} scheme={activeScheme}
network={NETWORK_NAME} network={NETWORK_NAME}
active={isOpen && currentTab === 1} active={layerVisibility.editor}
onSaved={handleSchemeSaved} onSaved={handleSchemeSaved}
onReady={ onReady={
pendingOpenSchemeId === activeScheme.id pendingOpenSchemeId === activeScheme.id
@@ -346,6 +352,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
<TabPanel value={currentTab} index={2}> <TabPanel value={currentTab} index={2}>
<SchemeQuery <SchemeQuery
schemes={schemes} schemes={schemes}
active={layerVisibility.query}
onSchemesChange={setSchemes} onSchemesChange={setSchemes}
state={queryState} state={queryState}
onStateChange={setQueryState} onStateChange={setQueryState}
@@ -12,8 +12,16 @@ import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { NETWORK_NAME } from "@/config/config"; import { NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { getApiErrorMessage } from "@/lib/apiError";
import { optimizeSensorPlacement } from "./schemeApi"; import { optimizeSensorPlacement } from "./schemeApi";
import type { SensorPlacementScheme } from "./types"; import type { SensorPlacementScheme } from "./types";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
export interface OptimizationParametersState { export interface OptimizationParametersState {
method: string; method: string;
@@ -27,7 +35,7 @@ export const createOptimizationParametersState =
method: "kmeans", method: "kmeans",
sensorCount: 5, sensorCount: 5,
minDiameter: 5, minDiameter: 5,
schemeName: "Fangan" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
}); });
interface OptimizationParametersProps { interface OptimizationParametersProps {
@@ -62,7 +70,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
// 创建方案 // 创建方案
const handleCreateScheme = async () => { const handleCreateScheme = async () => {
// 验证输入 // 验证输入
if (!schemeName.trim()) { if (!isSchemeNameValid(schemeName)) {
open?.({ open?.({
type: "error", type: "error",
message: "请输入方案名称", message: "请输入方案名称",
@@ -90,7 +98,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
try { try {
const created = await optimizeSensorPlacement({ const created = await optimizeSensorPlacement({
scheme_name: schemeName, run_name: normalizeSchemeName(schemeName),
sensor_type: "pressure", sensor_type: "pressure",
method: method as "sensitivity" | "kmeans", method: method as "sensitivity" | "kmeans",
sensor_count: sensorCount, sensor_count: sensorCount,
@@ -102,14 +110,16 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
description: `方案 "${schemeName}" 已完成优化分析`, description: `方案 "${schemeName}" 已完成优化分析`,
}); });
onSchemeCreated?.(created); onSchemeCreated?.(created);
setFormField("schemeName", "Fangan" + new Date().getTime()); setFormField(
} catch (error: any) { "schemeName",
createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
);
} catch (error) {
console.error("创建方案失败:", error); console.error("创建方案失败:", error);
open?.({ open?.({
type: "error", type: "error",
message: "创建方案失败", message: "创建方案失败",
description: description: getApiErrorMessage(error, "监测点优化请求失败"),
error.response?.data?.message || error.message || "未知错误",
}); });
} finally { } finally {
setAnalyzing(false); setAnalyzing(false);
@@ -255,6 +265,13 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
"&:hover fieldset": { "&:hover fieldset": {
@@ -1,9 +1,16 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import SchemeEditor from "./SchemeEditor"; import SchemeEditor from "./SchemeEditor";
import { getSensorPlacementCandidate } from "./schemeApi";
import type { SensorPlacementScheme } from "./types"; import type { SensorPlacementScheme } from "./types";
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
const mockOpen = jest.fn(); const mockOpen = jest.fn();
let mockSingleClickHandler: ((event: unknown) => void) | undefined; let mockSingleClickHandler: ((event: unknown) => void) | undefined;
let mockGridColumns: Array<{
field: string;
headerName?: string;
valueFormatter?: (value: unknown) => string;
}> = [];
const mockMap = { const mockMap = {
addLayer: jest.fn(), addLayer: jest.fn(),
@@ -32,19 +39,23 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
jest.mock("@mui/x-data-grid", () => ({ jest.mock("@mui/x-data-grid", () => ({
DataGrid: (props: { DataGrid: (props: {
columns: typeof mockGridColumns;
density?: string; density?: string;
initialState?: { density?: string }; initialState?: { density?: string };
rowHeight?: number; rowHeight?: number;
columnHeaderHeight?: number; columnHeaderHeight?: number;
}) => ( }) => {
<div mockGridColumns = props.columns;
data-testid="scheme-grid" return (
data-density={props.density ?? "uncontrolled"} <div
data-initial-density={props.initialState?.density ?? ""} data-testid="scheme-grid"
data-row-height={props.rowHeight ?? "automatic"} data-density={props.density ?? "uncontrolled"}
data-column-header-height={props.columnHeaderHeight ?? "automatic"} data-initial-density={props.initialState?.density ?? ""}
/> data-row-height={props.rowHeight ?? "automatic"}
), data-column-header-height={props.columnHeaderHeight ?? "automatic"}
/>
);
},
GridToolbar: () => null, GridToolbar: () => null,
})); }));
@@ -119,11 +130,6 @@ jest.mock("ol/style", () => ({
Text: class {}, Text: class {},
})); }));
jest.mock("ol/proj", () => ({
fromLonLat: (coordinates: number[]) => coordinates,
toLonLat: (coordinates: number[]) => coordinates,
}));
jest.mock("@/utils/mapQueryService", () => ({ jest.mock("@/utils/mapQueryService", () => ({
handleMapClickSelectFeatures: jest.fn(), handleMapClickSelectFeatures: jest.fn(),
})); }));
@@ -135,11 +141,12 @@ jest.mock("./SchemeDrawingDialog", () => ({
jest.mock("./schemeApi", () => ({ jest.mock("./schemeApi", () => ({
exportSensorPlacementExcel: jest.fn(), exportSensorPlacementExcel: jest.fn(),
getSensorPlacementCandidate: jest.fn(),
overwriteSensorPlacementScheme: jest.fn(), overwriteSensorPlacementScheme: jest.fn(),
})); }));
const scheme: SensorPlacementScheme = { const scheme: SensorPlacementScheme = {
id: 1, id: "run-1",
scheme_name: "测试方案", scheme_name: "测试方案",
sensor_number: 1, sensor_number: 1,
min_diameter: 300, min_diameter: 300,
@@ -149,6 +156,7 @@ const scheme: SensorPlacementScheme = {
sensor_points: [ sensor_points: [
{ {
node_id: "J1", node_id: "J1",
max_pipe_diameter: 400,
project_x: 10, project_x: 10,
project_y: 20, project_y: 20,
map_x: 13500010, map_x: 13500010,
@@ -161,10 +169,18 @@ const scheme: SensorPlacementScheme = {
can_edit: true, can_edit: true,
}; };
const mockGetSensorPlacementCandidate = jest.mocked(
getSensorPlacementCandidate,
);
const mockHandleMapClickSelectFeatures = jest.mocked(
handleMapClickSelectFeatures,
);
describe("SchemeEditor notifications", () => { describe("SchemeEditor notifications", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockSingleClickHandler = undefined; mockSingleClickHandler = undefined;
mockGridColumns = [];
}); });
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => { it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
@@ -187,6 +203,38 @@ describe("SchemeEditor notifications", () => {
}); });
}); });
it("loads authoritative point details when adding a map-selected node", async () => {
mockHandleMapClickSelectFeatures.mockResolvedValue({
get: (key: string) => (key === "id" ? "J2" : undefined),
getId: () => "J2",
} as never);
mockGetSensorPlacementCandidate.mockResolvedValue({
node_id: "J2",
max_pipe_diameter: 500,
project_x: 30,
project_y: 40,
map_x: 13500030,
map_y: 3600040,
longitude: 121.1,
latitude: 31.1,
elevation: 6,
});
render(<SchemeEditor scheme={scheme} network="fengyang" />);
fireEvent.click(screen.getByRole("button", { name: "添加" }));
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
await act(async () => {
mockSingleClickHandler?.({
pixel: [0, 0],
coordinate: [13500030, 3600040],
stopPropagation: jest.fn(),
});
await Promise.resolve();
});
expect(mockGetSensorPlacementCandidate).toHaveBeenCalledWith("J2");
});
it("lets the Data Grid density selector control row sizing", () => { it("lets the Data Grid density selector control row sizing", () => {
render(<SchemeEditor scheme={scheme} network="fengyang" />); render(<SchemeEditor scheme={scheme} network="fengyang" />);
@@ -207,4 +255,22 @@ describe("SchemeEditor notifications", () => {
"automatic", "automatic",
); );
}); });
it("places actions and status first and displays the selected pipe diameter", () => {
render(<SchemeEditor scheme={scheme} network="fengyang" />);
expect(mockGridColumns.slice(0, 5).map((column) => column.field)).toEqual([
"actions",
"adjustment_status",
"sequence",
"node_id",
"max_pipe_diameter",
]);
const diameterColumn = mockGridColumns.find(
(column) => column.field === "max_pipe_diameter",
);
expect(diameterColumn?.headerName).toBe("最大管径 (mm)");
expect(diameterColumn?.valueFormatter?.(400)).toBe("400");
expect(diameterColumn?.valueFormatter?.(null)).toBe("无");
});
}); });
@@ -45,10 +45,7 @@ import Point from "ol/geom/Point";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector"; import VectorSource from "ol/source/Vector";
import { Circle, Fill, Stroke, Style, Text } from "ol/style"; import { Circle, Fill, Stroke, Style, Text } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config } from "@/config/config";
import { useMap } from "@components/olmap/core/MapComponent"; import { useMap } from "@components/olmap/core/MapComponent";
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService"; import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
import { import {
@@ -65,6 +62,7 @@ import {
} from "./schemeEditor"; } from "./schemeEditor";
import { import {
exportSensorPlacementExcel, exportSensorPlacementExcel,
getSensorPlacementCandidate,
overwriteSensorPlacementScheme, overwriteSensorPlacementScheme,
} from "./schemeApi"; } from "./schemeApi";
import SchemeDrawingDialog from "./SchemeDrawingDialog"; import SchemeDrawingDialog from "./SchemeDrawingDialog";
@@ -147,6 +145,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [drawingOpen, setDrawingOpen] = useState(false); const [drawingOpen, setDrawingOpen] = useState(false);
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const activeRef = useRef(active);
activeRef.current = active;
useEffect(() => { useEffect(() => {
setEditor(createSchemeEditorState(scheme)); setEditor(createSchemeEditorState(scheme));
@@ -171,6 +171,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
}, },
zIndex: 120, zIndex: 120,
}); });
layer.setVisible(activeRef.current);
markerLayerRef.current = layer; markerLayerRef.current = layer;
map.addLayer(layer); map.addLayer(layer);
return () => { return () => {
@@ -206,47 +207,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
const feature = await handleMapClickSelectFeatures(event, map); const feature = await handleMapClickSelectFeatures(event, map);
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim(); const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
if (!nodeId) return null; if (!nodeId) return null;
const featureGeometry = feature?.getGeometry();
const featureCoordinate =
featureGeometry instanceof Point
? featureGeometry.getCoordinates()
: event.coordinate;
const projectedFeatureCoordinate =
Math.abs(featureCoordinate[0]) <= 180 &&
Math.abs(featureCoordinate[1]) <= 90
? fromLonLat(featureCoordinate)
: featureCoordinate;
const resolution = map.getView().getResolution() ?? 1;
const isAlignedWithMap =
Math.hypot(
projectedFeatureCoordinate[0] - event.coordinate[0],
projectedFeatureCoordinate[1] - event.coordinate[1],
) <=
resolution * 20;
const [mapX, mapY] = isAlignedWithMap
? projectedFeatureCoordinate
: event.coordinate;
try { try {
const response = await api.get<{ return await getSensorPlacementCandidate(nodeId);
id: string;
x: number;
y: number;
elevation: number;
}>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
params: { junction: nodeId },
});
if (!response.data?.id) return null;
const [longitude, latitude] = toLonLat([mapX, mapY]);
return {
node_id: String(response.data.id),
project_x: Number(response.data.x),
project_y: Number(response.data.y),
map_x: mapX,
map_y: mapY,
longitude,
latitude,
elevation: Number(response.data.elevation),
};
} catch { } catch {
return null; return null;
} }
@@ -436,6 +398,70 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
const columns = useMemo<GridColDef<SensorPointRow>[]>( const columns = useMemo<GridColDef<SensorPointRow>[]>(
() => [ () => [
{
field: "actions",
headerName: "操作",
width: scheme.can_edit ? 138 : 54,
sortable: false,
filterable: false,
renderCell: ({ row }) => (
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
<Tooltip title="地图定位">
<IconButton
aria-label={`定位节点 ${row.node_id}`}
size="small"
onClick={() => locateRow(row)}
sx={{ width: 40, height: 40 }}
>
<LocateIcon fontSize="small" />
</IconButton>
</Tooltip>
{scheme.can_edit && (
<>
<Tooltip title="替换节点">
<IconButton
aria-label={`替换节点 ${row.node_id}`}
size="small"
onClick={() => activateMode("replace", row.node_id)}
sx={{ width: 40, height: 40 }}
>
<ReplaceIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="删除节点">
<span>
<IconButton
aria-label={`删除节点 ${row.node_id}`}
size="small"
onClick={() => handleDelete(row.node_id)}
disabled={rows.length <= 1}
sx={{ width: 40, height: 40 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</>
)}
</Stack>
),
},
{
field: "adjustment_status",
headerName: "调整状态",
width: 108,
renderCell: ({ value }) => {
const status = value as AdjustmentStatus;
return (
<Chip
label={STATUS_LABELS[status]}
color={STATUS_COLORS[status]}
variant="outlined"
size="small"
/>
);
},
},
{ {
field: "sequence", field: "sequence",
headerName: "序号", headerName: "序号",
@@ -445,6 +471,21 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
sortable: false, sortable: false,
}, },
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 }, { field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
{
field: "max_pipe_diameter",
headerName: "最大管径 (mm)",
description: "节点关联管道中的最大管径",
minWidth: 132,
flex: 0.75,
align: "right",
headerAlign: "right",
valueFormatter: (value) => {
if (value == null || !Number.isFinite(Number(value))) return "无";
return Number(value).toLocaleString("zh-CN", {
maximumFractionDigits: 3,
});
},
},
{ {
field: "longitude", field: "longitude",
headerName: "经度", headerName: "经度",
@@ -507,70 +548,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
headerAlign: "right", headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3), valueFormatter: (value) => Number(value).toFixed(3),
}, },
{
field: "adjustment_status",
headerName: "调整状态",
width: 108,
renderCell: ({ value }) => {
const status = value as AdjustmentStatus;
return (
<Chip
label={STATUS_LABELS[status]}
color={STATUS_COLORS[status]}
variant="outlined"
size="small"
/>
);
},
},
{
field: "actions",
headerName: "操作",
width: scheme.can_edit ? 138 : 54,
sortable: false,
filterable: false,
renderCell: ({ row }) => (
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
<Tooltip title="地图定位">
<IconButton
aria-label={`定位节点 ${row.node_id}`}
size="small"
onClick={() => locateRow(row)}
sx={{ width: 40, height: 40 }}
>
<LocateIcon fontSize="small" />
</IconButton>
</Tooltip>
{scheme.can_edit && (
<>
<Tooltip title="替换节点">
<IconButton
aria-label={`替换节点 ${row.node_id}`}
size="small"
onClick={() => activateMode("replace", row.node_id)}
sx={{ width: 40, height: 40 }}
>
<ReplaceIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="删除节点">
<span>
<IconButton
aria-label={`删除节点 ${row.node_id}`}
size="small"
onClick={() => handleDelete(row.node_id)}
disabled={rows.length <= 1}
sx={{ width: 40, height: 40 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</>
)}
</Stack>
),
},
], ],
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit], [activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
); );
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -24,9 +24,8 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; // 引入中文包 import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { api } from "@/lib/api";
import moment from "moment"; import moment from "moment";
import { config, NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { useMap } from "@components/olmap/core/MapComponent"; import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { queryFeaturesByIds } from "@/utils/mapQueryService";
@@ -40,21 +39,13 @@ import { bbox, featureCollection } from "@turf/turf";
import type { SchemeRecord } from "./types"; import type { SchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
import { listSensorPlacementSchemes } from "./schemeApi";
interface SchemaItem {
id: number;
scheme_name: string;
sensor_number: number;
min_diameter: number;
username: string;
create_time: string;
sensor_location?: string[];
}
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
active?: boolean;
onSchemesChange?: (schemes: SchemeRecord[]) => void; onSchemesChange?: (schemes: SchemeRecord[]) => void;
onEdit?: (id: number) => void; onEdit?: (id: string) => void;
network?: string; network?: string;
state?: MonitoringSchemeQueryState; state?: MonitoringSchemeQueryState;
onStateChange?: (state: MonitoringSchemeQueryState) => void; onStateChange?: (state: MonitoringSchemeQueryState) => void;
@@ -63,7 +54,7 @@ interface SchemeQueryProps {
export interface MonitoringSchemeQueryState { export interface MonitoringSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: string | null;
hasQueried: boolean; hasQueried: boolean;
} }
@@ -77,6 +68,7 @@ export const createMonitoringSchemeQueryState =
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes, schemes: externalSchemes,
active = true,
onSchemesChange, onSchemesChange,
onEdit, onEdit,
network = NETWORK_NAME, network = NETWORK_NAME,
@@ -98,6 +90,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const [highlightLayer, setHighlightLayer] = const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null); useState<VectorLayer<VectorSource> | null>(null);
const activeRef = useRef(active);
activeRef.current = active;
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
// 使用外部提供的 schemes 或内部状态 // 使用外部提供的 schemes 或内部状态
const schemes = const schemes =
@@ -142,6 +136,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
queryable: false, queryable: false,
}, },
}); });
highlightLayer.setVisible(activeRef.current);
map.addLayer(highlightLayer); map.addLayer(highlightLayer);
setHighlightLayer(highlightLayer); setHighlightLayer(highlightLayer);
@@ -151,6 +146,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}; };
}, [map]); }, [map]);
useEffect(() => {
highlightLayer?.setVisible(active);
}, [active, highlightLayer]);
// 高亮要素的函数 // 高亮要素的函数
useEffect(() => { useEffect(() => {
if (!highlightLayer) { if (!highlightLayer) {
@@ -176,22 +175,18 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const response = await api.get( let filteredResults = await listSensorPlacementSchemes();
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
);
let filteredResults = response.data;
// 按日期过滤 // 按日期过滤
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
const formattedDate = queryDate.format("YYYY-MM-DD"); const formattedDate = queryDate.format("YYYY-MM-DD");
filteredResults = filteredResults.filter((item: SchemaItem) => { filteredResults = filteredResults.filter((item) => {
const itemDate = moment(item.create_time).format("YYYY-MM-DD"); const itemDate = moment(item.create_time).format("YYYY-MM-DD");
return itemDate === formattedDate; return itemDate === formattedDate;
}); });
} }
const nextSchemes = filteredResults.map((item: SchemaItem) => ({ const nextSchemes = filteredResults.map((item) => ({
id: item.id, id: item.id,
schemeName: item.scheme_name, schemeName: item.scheme_name,
sensorNumber: item.sensor_number, sensorNumber: item.sensor_number,
@@ -241,7 +236,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
} }
if (sensorIds.length > 0) { if (sensorIds.length > 0) {
queryFeaturesByIds(sensorIds, "geo_junctions_mat").then((features) => { queryFeaturesByIds(sensorIds, "junctions").then((features) => {
if (features.length > 0) { if (features.length > 0) {
// 设置高亮要素 // 设置高亮要素
setHighlightFeatures(features); setHighlightFeatures(features);
@@ -262,7 +257,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}; };
// 查看详情(展开/收起) // 查看详情(展开/收起)
const handleViewDetails = (id: number) => { const handleViewDetails = (id: string) => {
setQueryField("expandedId", expandedId === id ? null : id); setQueryField("expandedId", expandedId === id ? null : id);
}; };
@@ -107,6 +107,7 @@ const point = (
map_y: number, map_y: number,
): SensorPointRow => ({ ): SensorPointRow => ({
node_id, node_id,
max_pipe_diameter: 300,
sequence, sequence,
map_x, map_x,
map_y, map_y,
@@ -121,7 +122,7 @@ const point = (
const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)]; const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)];
const scheme: SensorPlacementScheme = { const scheme: SensorPlacementScheme = {
id: 1, id: "run-1",
scheme_name: "测试方案", scheme_name: "测试方案",
sensor_number: rows.length, sensor_number: rows.length,
min_diameter: 300, min_diameter: 300,
@@ -0,0 +1,98 @@
import { api } from "@/lib/api";
import {
exportSensorPlacementExcel,
getSensorPlacementScheme,
listSensorPlacementSchemes,
optimizeSensorPlacement,
overwriteSensorPlacementScheme,
} from "./schemeApi";
jest.mock("@/lib/api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
},
}));
const run = {
run_id: "9fb546cb-5f72-4ddd-8a46-d13e9aee2c6b",
name: "sensor-plan-a",
sensor_count: 2,
min_diameter: 300,
created_by: "operator",
created_at: "2026-08-25T08:00:00Z",
sensor_locations: ["J-1", "J-2"],
sensor_points: [],
can_edit: true,
status: "completed",
};
describe("sensor placement runs API adapter", () => {
beforeEach(() => jest.clearAllMocks());
it("lists paged runs and maps run fields to the existing screen model", async () => {
jest.mocked(api.get).mockResolvedValue({
data: { items: [run], total: 1, limit: 1000, offset: 0 },
});
await expect(listSensorPlacementSchemes()).resolves.toEqual([
expect.objectContaining({
id: run.run_id,
scheme_name: run.name,
sensor_number: 2,
sensor_location: ["J-1", "J-2"],
}),
]);
expect(api.get).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/sensor-placement-runs"),
{ params: { limit: 1000, offset: 0 } },
);
});
it("uses run endpoints for creation and detail lookup", async () => {
jest.mocked(api.post).mockResolvedValue({ data: run });
jest.mocked(api.get).mockResolvedValue({ data: run });
await optimizeSensorPlacement({
run_name: run.name,
sensor_type: "pressure",
method: "sensitivity",
sensor_count: 2,
min_diameter: 300,
});
await getSensorPlacementScheme(run.run_id);
expect(api.post).toHaveBeenCalledWith(
expect.stringMatching(/\/api\/v1\/sensor-placement-runs$/),
expect.objectContaining({ run_name: run.name }),
);
expect(api.get).toHaveBeenLastCalledWith(
expect.stringContaining(`/api/v1/sensor-placement-runs/${run.run_id}`),
);
});
it("uses the plural result fields required by update and export", async () => {
jest.mocked(api.put).mockResolvedValue({ data: run });
jest.mocked(api.post).mockResolvedValue({ data: new Blob() });
await overwriteSensorPlacementScheme(run.run_id, ["J-1"], ["J-2"]);
await exportSensorPlacementExcel(run.run_id, ["J-2"], { "J-2": "added" });
expect(api.put).toHaveBeenCalledWith(
expect.stringContaining(`/sensor-placement-runs/${run.run_id}`),
{
expected_sensor_locations: ["J-1"],
sensor_locations: ["J-2"],
},
);
expect(api.post).toHaveBeenCalledWith(
expect.stringContaining(`/sensor-placement-runs/${run.run_id}/exports/excel`),
{
sensor_locations: ["J-2"],
adjustment_status: { "J-2": "added" },
},
{ responseType: "blob" },
);
});
});
@@ -3,59 +3,106 @@ import { config } from "@/config/config";
import type { import type {
AdjustmentStatus, AdjustmentStatus,
SensorPlacementScheme, SensorPlacementScheme,
SensorPoint,
} from "./types"; } from "./types";
export interface OptimizeSchemeInput { export interface OptimizeSchemeInput {
scheme_name: string; run_name: string;
sensor_type: "pressure"; sensor_type: "pressure";
method: "sensitivity" | "kmeans"; method: "sensitivity" | "kmeans";
sensor_count: number; sensor_count: number;
min_diameter: number; min_diameter: number;
} }
type SensorPlacementRunResponse = {
run_id: string;
name: string;
sensor_count: number;
min_diameter: number;
created_by: string;
created_at: string;
sensor_locations: string[];
sensor_points: SensorPoint[];
can_edit: boolean;
};
const toSensorPlacementScheme = (
run: SensorPlacementRunResponse,
): SensorPlacementScheme => ({
id: run.run_id,
scheme_name: run.name,
sensor_number: run.sensor_count,
min_diameter: run.min_diameter,
username: run.created_by,
create_time: run.created_at,
sensor_location: run.sensor_locations,
sensor_points: run.sensor_points,
can_edit: run.can_edit,
});
export const optimizeSensorPlacement = async ( export const optimizeSensorPlacement = async (
input: OptimizeSchemeInput, input: OptimizeSchemeInput,
): Promise<SensorPlacementScheme> => { ): Promise<SensorPlacementScheme> => {
const response = await api.post<SensorPlacementScheme>( const response = await api.post<SensorPlacementRunResponse>(
`${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`, `${config.BACKEND_URL}/api/v1/sensor-placement-runs`,
input, input,
); );
return response.data; return toSensorPlacementScheme(response.data);
};
export const listSensorPlacementSchemes = async (): Promise<
SensorPlacementScheme[]
> => {
const response = await api.get<{
items: SensorPlacementRunResponse[];
}>(`${config.BACKEND_URL}/api/v1/sensor-placement-runs`, {
params: { limit: 1000, offset: 0 },
});
return response.data.items.map(toSensorPlacementScheme);
}; };
export const getSensorPlacementScheme = async ( export const getSensorPlacementScheme = async (
schemeId: number, schemeId: string,
): Promise<SensorPlacementScheme> => { ): Promise<SensorPlacementScheme> => {
const response = await api.get<SensorPlacementScheme>( const response = await api.get<SensorPlacementRunResponse>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`, `${config.BACKEND_URL}/api/v1/sensor-placement-runs/${encodeURIComponent(schemeId)}`,
);
return toSensorPlacementScheme(response.data);
};
export const getSensorPlacementCandidate = async (
nodeId: string,
): Promise<SensorPoint> => {
const response = await api.get<SensorPoint>(
`${config.BACKEND_URL}/api/v1/sensor-placement-candidates/${encodeURIComponent(nodeId)}`,
); );
return response.data; return response.data;
}; };
export const overwriteSensorPlacementScheme = async ( export const overwriteSensorPlacementScheme = async (
schemeId: number, schemeId: string,
expectedSensorLocation: string[], expectedSensorLocation: string[],
sensorLocation: string[], sensorLocation: string[],
): Promise<SensorPlacementScheme> => { ): Promise<SensorPlacementScheme> => {
const response = await api.put<SensorPlacementScheme>( const response = await api.put<SensorPlacementRunResponse>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`, `${config.BACKEND_URL}/api/v1/sensor-placement-runs/${encodeURIComponent(schemeId)}`,
{ {
expected_sensor_location: expectedSensorLocation, expected_sensor_locations: expectedSensorLocation,
sensor_location: sensorLocation, sensor_locations: sensorLocation,
}, },
); );
return response.data; return toSensorPlacementScheme(response.data);
}; };
export const exportSensorPlacementExcel = async ( export const exportSensorPlacementExcel = async (
schemeId: number, schemeId: string,
sensorLocation: string[], sensorLocation: string[],
adjustmentStatus: Record<string, AdjustmentStatus>, adjustmentStatus: Record<string, AdjustmentStatus>,
) => { ) => {
const response = await api.post<Blob>( const response = await api.post<Blob>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}/exports/excel`, `${config.BACKEND_URL}/api/v1/sensor-placement-runs/${encodeURIComponent(schemeId)}/exports/excel`,
{ {
sensor_location: sensorLocation, sensor_locations: sensorLocation,
adjustment_status: adjustmentStatus, adjustment_status: adjustmentStatus,
}, },
{ {
@@ -13,6 +13,7 @@ import type { SensorPlacementScheme, SensorPoint } from "./types";
const point = (node_id: string): SensorPoint => ({ const point = (node_id: string): SensorPoint => ({
node_id, node_id,
max_pipe_diameter: 300,
project_x: Number(node_id.slice(1)) * 10, project_x: Number(node_id.slice(1)) * 10,
project_y: Number(node_id.slice(1)) * 20, project_y: Number(node_id.slice(1)) * 20,
map_x: 13500000 + Number(node_id.slice(1)) * 10, map_x: 13500000 + Number(node_id.slice(1)) * 10,
@@ -23,7 +24,7 @@ const point = (node_id: string): SensorPoint => ({
}); });
const scheme: SensorPlacementScheme = { const scheme: SensorPlacementScheme = {
id: 1, id: "run-1",
scheme_name: "测试方案", scheme_name: "测试方案",
sensor_number: 2, sensor_number: 2,
min_diameter: 300, min_diameter: 300,
@@ -2,6 +2,7 @@ export type AdjustmentStatus = "current" | "original" | "added" | "replaced";
export interface SensorPoint { export interface SensorPoint {
node_id: string; node_id: string;
max_pipe_diameter: number | null;
project_x: number; project_x: number;
project_y: number; project_y: number;
map_x: number; map_x: number;
@@ -12,7 +13,7 @@ export interface SensorPoint {
} }
export interface SensorPlacementScheme { export interface SensorPlacementScheme {
id: number; id: string;
scheme_name: string; scheme_name: string;
sensor_number: number; sensor_number: number;
min_diameter: number; min_diameter: number;
@@ -24,7 +25,7 @@ export interface SensorPlacementScheme {
} }
export interface SchemeRecord { export interface SchemeRecord {
id: number; id: string;
schemeName: string; schemeName: string;
sensorNumber: number; sensorNumber: number;
minDiameter: number; minDiameter: number;
@@ -192,7 +192,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
setLoading(true); setLoading(true);
try { try {
const activeWorkspace = workspace || config.MAP_WORKSPACE; const activeWorkspace = workspace || config.MAP_WORKSPACE;
const url = `${config.MAP_URL}/${activeWorkspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${activeWorkspace}:geo_scada&outputFormat=application/json`; const url = `${config.MAP_URL}/${activeWorkspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${activeWorkspace}:scada_devices&outputFormat=application/json`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch SCADA devices"); if (!response.ok) throw new Error("Failed to fetch SCADA devices");
const json = await response.json(); const json = await response.json();
@@ -202,7 +202,8 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
name: feature.get("id") || feature.getId(), name: feature.get("id") || feature.getId(),
transmission_frequency: feature.get("transmission_frequency"), transmission_frequency: feature.get("transmission_frequency"),
reliability: feature.get("reliability"), reliability: feature.get("reliability"),
type: feature.get("type") === "pipe_flow" ? "流量" : "压力", type:
feature.get("device_type") === "pipe_flow" ? "流量" : "压力",
status: STATUS_OPTIONS[Math.floor(Math.random() * 4)], status: STATUS_OPTIONS[Math.floor(Math.random() * 4)],
coordinates: (feature.getGeometry() as Point)?.getCoordinates() as [ coordinates: (feature.getGeometry() as Point)?.getCoordinates() as [
number, number,
@@ -488,7 +489,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
const layer = feature?.getId()?.toString().split(".")[0]; const layer = feature?.getId()?.toString().split(".")[0];
if (!feature) return; if (!feature) return;
if (layer !== "geo_scada_mat" && layer !== "geo_scada") { if (layer !== "scada_devices") {
open?.({ open?.({
type: "error", type: "error",
message: "请选择 SCADA 设备。", message: "请选择 SCADA 设备。",
@@ -36,11 +36,15 @@ describe("PanelEmptyState", () => {
render( render(
<PanelEmptyState <PanelEmptyState
variant="compact" variant="compact"
horizontalAlign="start"
title="尚未选择阀门" title="尚未选择阀门"
description="点击“选择阀门”,然后在地图上添加。" description="点击“选择阀门”,然后在地图上添加。"
/>, />,
); );
expect(screen.getByText("尚未选择阀门")).toBeInTheDocument(); expect(screen.getByText("尚未选择阀门")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveStyle({
justifyContent: "flex-start",
});
}); });
}); });
@@ -10,6 +10,7 @@ export interface PanelEmptyStateProps {
title: string; title: string;
description?: string; description?: string;
variant?: "panel" | "compact"; variant?: "panel" | "compact";
horizontalAlign?: "center" | "start";
} }
const PanelEmptyState = ({ const PanelEmptyState = ({
@@ -17,6 +18,7 @@ const PanelEmptyState = ({
title, title,
description, description,
variant = "panel", variant = "panel",
horizontalAlign = "center",
}: PanelEmptyStateProps) => { }: PanelEmptyStateProps) => {
const compact = variant === "compact"; const compact = variant === "compact";
@@ -32,7 +34,8 @@ const PanelEmptyState = ({
display: "flex", display: "flex",
flexDirection: compact ? "row" : { xs: "column", sm: "row" }, flexDirection: compact ? "row" : { xs: "column", sm: "row" },
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent:
horizontalAlign === "start" ? "flex-start" : "center",
gap: compact ? 1.5 : { xs: 1.75, sm: 2.5 }, gap: compact ? 1.5 : { xs: 1.75, sm: 2.5 },
px: compact ? 1.5 : { xs: 2, sm: 4 }, px: compact ? 1.5 : { xs: 2, sm: 4 },
py: compact ? 1.25 : { xs: 4, sm: 5 }, py: compact ? 1.25 : { xs: 4, sm: 5 },
+37 -12
View File
@@ -45,6 +45,7 @@ interface TimelineProps {
timeRange?: { start: Date; end: Date }; timeRange?: { start: Date; end: Date };
disableDateSelection?: boolean; disableDateSelection?: boolean;
schemeName?: string; schemeName?: string;
schemeRunId?: string;
schemeType?: string; schemeType?: string;
} }
@@ -80,6 +81,7 @@ const Timeline: React.FC<TimelineProps> = ({
timeRange, timeRange,
disableDateSelection = false, disableDateSelection = false,
schemeName = "", schemeName = "",
schemeRunId = "",
schemeType = "burst_analysis", schemeType = "burst_analysis",
}) => { }) => {
const data = useData(); const data = useData();
@@ -207,6 +209,7 @@ const Timeline: React.FC<TimelineProps> = ({
sourceType, sourceType,
target, target,
schemeName, schemeName,
schemeRunId,
schemeType, schemeType,
signal, signal,
}: { }: {
@@ -216,6 +219,7 @@ const Timeline: React.FC<TimelineProps> = ({
sourceType: "scheme" | "realtime"; sourceType: "scheme" | "realtime";
target: "primary" | "compare"; target: "primary" | "compare";
schemeName?: string; schemeName?: string;
schemeRunId?: string;
schemeType?: string; schemeType?: string;
signal?: AbortSignal; signal?: AbortSignal;
}) => { }) => {
@@ -232,16 +236,16 @@ const Timeline: React.FC<TimelineProps> = ({
junctionProperties, junctionProperties,
sourceType, sourceType,
"node", "node",
schemeName || "", schemeRunId || schemeName || "",
schemeType || "" schemeType || ""
); );
if (nodeCacheRef.current.has(nodeCacheKey)) { if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!; nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else { } else {
nodePromise = nodePromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeRunId
? apiFetch( ? apiFetch(
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`, `${config.BACKEND_URL}/api/v1/timeseries/analysis/runs/${encodeURIComponent(schemeRunId)}/values?result_time=${encodeURIComponent(query_time)}&element_type=node&field=${encodeURIComponent(junctionProperties)}`,
{ signal }, { signal },
) )
: apiFetch( : apiFetch(
@@ -261,16 +265,16 @@ const Timeline: React.FC<TimelineProps> = ({
normalizedPipeProperties, normalizedPipeProperties,
sourceType, sourceType,
"link", "link",
schemeName || "", schemeRunId || schemeName || "",
schemeType || "" schemeType || ""
); );
if (linkCacheRef.current.has(linkCacheKey)) { if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!; linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else { } else {
linkPromise = linkPromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeRunId
? apiFetch( ? apiFetch(
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`, `${config.BACKEND_URL}/api/v1/timeseries/analysis/runs/${encodeURIComponent(schemeRunId)}/values?result_time=${encodeURIComponent(query_time)}&element_type=link&field=${encodeURIComponent(normalizedPipeProperties)}`,
{ signal }, { signal },
) )
: apiFetch( : apiFetch(
@@ -288,14 +292,22 @@ const Timeline: React.FC<TimelineProps> = ({
if (!nodeResponse.ok) { if (!nodeResponse.ok) {
throw new Error(`Node fetch failed: ${nodeResponse.status}`); throw new Error(`Node fetch failed: ${nodeResponse.status}`);
} }
nodeRecords = await nodeResponse.json(); const payload = await nodeResponse.json();
nodeRecords = sourceType === "scheme"
? {
results: Object.entries(payload ?? {}).map(([ID, value]) => ({
ID,
value,
})),
}
: payload;
nodeCacheRef.current.set( nodeCacheRef.current.set(
buildCacheKey( buildCacheKey(
query_time, query_time,
junctionProperties, junctionProperties,
sourceType, sourceType,
"node", "node",
schemeName || "", schemeRunId || schemeName || "",
schemeType || "" schemeType || ""
), ),
nodeRecords || [] nodeRecords || []
@@ -307,14 +319,22 @@ const Timeline: React.FC<TimelineProps> = ({
if (!linkResponse.ok) { if (!linkResponse.ok) {
throw new Error(`Link fetch failed: ${linkResponse.status}`); throw new Error(`Link fetch failed: ${linkResponse.status}`);
} }
linkRecords = await linkResponse.json(); const payload = await linkResponse.json();
linkRecords = sourceType === "scheme"
? {
results: Object.entries(payload ?? {}).map(([ID, value]) => ({
ID,
value,
})),
}
: payload;
linkCacheRef.current.set( linkCacheRef.current.set(
buildCacheKey( buildCacheKey(
query_time, query_time,
normalizedPipeProperties, normalizedPipeProperties,
sourceType, sourceType,
"link", "link",
schemeName || "", schemeRunId || schemeName || "",
schemeType || "" schemeType || ""
), ),
linkRecords || [] linkRecords || []
@@ -336,6 +356,7 @@ const Timeline: React.FC<TimelineProps> = ({
junctionProperties: string, junctionProperties: string,
pipeProperties: string, pipeProperties: string,
schemeName: string, schemeName: string,
schemeRunId: string,
schemeType: string schemeType: string
) => { ) => {
const revision = frameRequestRevisionRef.current + 1; const revision = frameRequestRevisionRef.current + 1;
@@ -344,7 +365,7 @@ const Timeline: React.FC<TimelineProps> = ({
const abortController = new AbortController(); const abortController = new AbortController();
frameAbortControllerRef.current = abortController; frameAbortControllerRef.current = abortController;
const primarySourceType = const primarySourceType =
disableDateSelection && schemeName ? "scheme" : "realtime"; disableDateSelection && schemeRunId ? "scheme" : "realtime";
const tasks = [ const tasks = [
fetchDataBySource({ fetchDataBySource({
queryTime, queryTime,
@@ -353,12 +374,13 @@ const Timeline: React.FC<TimelineProps> = ({
sourceType: primarySourceType, sourceType: primarySourceType,
target: "primary", target: "primary",
schemeName, schemeName,
schemeRunId,
schemeType, schemeType,
signal: abortController.signal, signal: abortController.signal,
}), }),
]; ];
if (isCompareMode && disableDateSelection && schemeName) { if (isCompareMode && disableDateSelection && schemeRunId) {
tasks.push( tasks.push(
fetchDataBySource({ fetchDataBySource({
queryTime, queryTime,
@@ -600,6 +622,7 @@ const Timeline: React.FC<TimelineProps> = ({
junctionText, junctionText,
pipeText, pipeText,
schemeName, schemeName,
schemeRunId,
schemeType, schemeType,
); );
} }
@@ -612,6 +635,7 @@ const Timeline: React.FC<TimelineProps> = ({
timelineCurrentTime, timelineCurrentTime,
selectedDate, selectedDate,
schemeName, schemeName,
schemeRunId,
schemeType, schemeType,
]); ]);
@@ -687,6 +711,7 @@ const Timeline: React.FC<TimelineProps> = ({
junctionText, junctionText,
pipeText, pipeText,
schemeName, schemeName,
schemeRunId,
schemeType, schemeType,
); );
}; };
+179 -70
View File
@@ -22,13 +22,22 @@ import { useNotification } from "@refinedev/core";
import ToolbarHistoryPanel from "./ToolbarHistoryPanel"; import ToolbarHistoryPanel from "./ToolbarHistoryPanel";
import { import {
buildFeatureProperties, buildFeatureProperties,
getSimulationElementType,
getValvePropertySource,
} from "./toolbarFeatureHelpers"; } from "./toolbarFeatureHelpers";
import { useToolbarChatActions } from "./useToolbarChatActions"; import { useToolbarChatActions } from "./useToolbarChatActions";
import { useStyleEditor } from "./useStyleEditor"; import { useStyleEditor } from "./useStyleEditor";
import {
type LinkStatus,
isLinkStatus,
normalizeValveSetting,
validateValveSetting,
} from "./valveControl";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { useProject } from "@/contexts/ProjectContext"; import { useProject } from "@/contexts/ProjectContext";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { getAnalysisScheme } from "@/lib/analysisRuns";
import { permissionCodes } from "@/lib/permissions"; import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore"; import { useAccessStore } from "@/store/accessStore";
@@ -41,51 +50,24 @@ interface ToolbarProps {
enableCompare?: boolean; enableCompare?: boolean;
} }
type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE";
type ValveProperties = { type ValveProperties = {
vType: string | null; vType: string | null;
setting: string | null; setting: string | null;
}; };
const isValveLayer = (layerId: string | undefined) => type SchemeValveControl = {
layerId === "geo_valves_mat" || layerId === "geo_valves"; status?: string;
setting?: string | number;
const isLinkStatus = (value: unknown): value is LinkStatus => k?: number;
value === "OPEN" || value === "CLOSED" || value === "ACTIVE";
const normalizeValveSetting = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
return String(value);
}; };
const validateValveSetting = ( type ActiveSchemeDetail = {
valveType: string | null, valve_control?: Record<string, SchemeValveControl> | null;
value: string, valve_opening?: Record<string, number> | null;
): string | null => {
const normalizedType = valveType?.toUpperCase();
const trimmedValue = value.trim();
const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]);
if (normalizedType === "GPV") {
return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。";
}
if (numericTypes.has(normalizedType ?? "")) {
if (!trimmedValue) {
return "阀门设置值必须是 0 或正数。";
}
const numericValue = Number(trimmedValue);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return "阀门设置值必须是有限数字,且大于或等于 0。";
}
return null;
}
return trimmedValue ? null : "阀门设置值不能为空。";
}; };
const isValveLayer = (layerId: string | undefined) => layerId === "valves";
const Toolbar: React.FC<ToolbarProps> = ({ const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons, hiddenButtons,
queryType, queryType,
@@ -111,11 +93,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
const currentTime = data?.currentTime; const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate; const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName; const schemeName = data?.schemeName;
const schemeRunId = data?.schemeRunId;
const networkName = project?.networkName || NETWORK_NAME; const networkName = project?.networkName || NETWORK_NAME;
const isCompareMode = data?.isCompareMode ?? false; const isCompareMode = data?.isCompareMode ?? false;
const toggleCompareMode = data?.toggleCompareMode; const toggleCompareMode = data?.toggleCompareMode;
const canToggleCompare = Boolean( const canToggleCompare = Boolean(
enableCompare && (isCompareMode || (queryType === "scheme" && schemeName)), enableCompare && (isCompareMode || (queryType === "scheme" && schemeRunId)),
); );
useEffect(() => { useEffect(() => {
@@ -400,6 +383,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
const [isValvePropertiesLoading, setIsValvePropertiesLoading] = const [isValvePropertiesLoading, setIsValvePropertiesLoading] =
useState(false); useState(false);
const [isValveSettingSaving, setIsValveSettingSaving] = useState(false); const [isValveSettingSaving, setIsValveSettingSaving] = useState(false);
const [activeSchemeDetail, setActiveSchemeDetail] =
useState<ActiveSchemeDetail | null>(null);
const selectedFeature = highlightFeatures[0]; const selectedFeature = highlightFeatures[0];
const selectedFeatureLayer = selectedFeature const selectedFeatureLayer = selectedFeature
@@ -415,9 +400,49 @@ const Toolbar: React.FC<ToolbarProps> = ({
showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId
? String(selectedFeatureId) ? String(selectedFeatureId)
: null; : null;
const isSimulationDataActive =
getValvePropertySource(queryType, schemeName) === "simulation";
useEffect(() => { useEffect(() => {
if (!selectedValveId) { if (
queryType !== "scheme" ||
schemeType !== "flushing_analysis" ||
!schemeRunId ||
!selectedValveId
) {
setActiveSchemeDetail(null);
return;
}
setActiveSchemeDetail(null);
let cancelled = false;
const querySchemeDetail = async () => {
try {
const payload = await getAnalysisScheme(schemeRunId);
if (!cancelled) {
setActiveSchemeDetail(payload?.scheme_detail ?? null);
}
} catch (error) {
console.error("Error querying scheme valve settings:", error);
if (!cancelled) {
setActiveSchemeDetail(null);
open?.({
type: "error",
message: "读取方案阀门设置失败。",
});
}
}
};
void querySchemeDetail();
return () => {
cancelled = true;
};
}, [open, queryType, schemeRunId, schemeType, selectedValveId]);
useEffect(() => {
if (!selectedValveId || isSimulationDataActive) {
setValveStatus(null); setValveStatus(null);
setIsValveStatusLoading(false); setIsValveStatusLoading(false);
return; return;
@@ -466,10 +491,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [networkName, open, selectedValveId]); }, [isSimulationDataActive, networkName, open, selectedValveId]);
useEffect(() => { useEffect(() => {
if (!selectedValveId) { if (!selectedValveId || isSimulationDataActive) {
setValveProperties({ vType: null, setting: null }); setValveProperties({ vType: null, setting: null });
setIsValvePropertiesLoading(false); setIsValvePropertiesLoading(false);
return; return;
@@ -521,7 +546,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [networkName, open, selectedValveId]); }, [isSimulationDataActive, networkName, open, selectedValveId]);
const handleValveStatusSave = useCallback( const handleValveStatusSave = useCallback(
async (nextStatus: string) => { async (nextStatus: string) => {
@@ -642,7 +667,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
// 添加 useEffect 来查询计算属性 // 添加 useEffect 来查询计算属性
useEffect(() => { useEffect(() => {
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) { const canQuerySimulation =
getValvePropertySource(queryType, schemeName) === "simulation";
if (
highlightFeatures.length === 0 ||
!selectedDate ||
!showPropertyPanel ||
!canQuerySimulation
) {
setComputedProperties({}); setComputedProperties({});
return; return;
} }
@@ -654,11 +686,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
return; return;
} }
let cancelled = false;
setComputedProperties({});
const queryComputedProperties = async () => { const queryComputedProperties = async () => {
try { try {
const properties = highlightFeature?.getProperties?.() || {}; const type = getSimulationElementType(highlightFeature);
const type =
properties.geometry?.getType?.() === "LineString" ? "link" : "node";
// selectedDate 格式化为 YYYY-MM-DD // selectedDate 格式化为 YYYY-MM-DD
let dateObj: Date; let dateObj: Date;
if (selectedDate instanceof Date) { if (selectedDate instanceof Date) {
@@ -670,56 +703,131 @@ const Toolbar: React.FC<ToolbarProps> = ({
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0); dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
// 转为 UTC ISO 字符串 // 转为 UTC ISO 字符串
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z" const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
let response;
if (queryType === "scheme") { if (queryType === "scheme") {
response = await apiFetch( if (!schemeRunId) {
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}` throw new Error("Analysis run ID is missing");
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`, }
); const fields = type === "node"
} else { ? ["actual_demand", "total_head", "pressure", "quality"]
response = await apiFetch( : [
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}` "flow",
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?id=${id}&type=${type}&query_time=${querytime}`, "friction",
"headloss",
"quality",
"reaction",
"setting",
"status",
"velocity",
];
const values = await Promise.all(
fields.map(async (field) => {
const params = new URLSearchParams({
result_time: querytime,
element_type: type,
field,
});
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/timeseries/analysis/runs/${encodeURIComponent(schemeRunId)}/values?${params.toString()}`,
);
if (!response.ok) {
throw new Error(`Analysis value fetch failed: ${response.status}`);
}
const payload = await response.json();
return [field, payload?.[String(id)]] as const;
}),
); );
if (!cancelled) {
setComputedProperties(
Object.fromEntries(values.filter(([, value]) => value !== undefined)),
);
}
return;
} }
if (!response.ok) {
throw new Error("API request failed"); const params = new URLSearchParams({
} id: String(id),
type,
query_time: querytime,
});
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?${params.toString()}`,
);
if (!response.ok) throw new Error("API request failed");
const data = await response.json(); const data = await response.json();
if (!data.result || data.result.length === 0) { if (cancelled) return;
setComputedProperties({}); setComputedProperties(data.result?.[0] || {});
} else {
setComputedProperties(data.result[0] || {});
// console.log("查询到的计算属性:", data.result[0]);
}
} catch (error) { } catch (error) {
console.error("Error querying computed properties:", error); console.error("Error querying computed properties:", error);
setComputedProperties({}); if (!cancelled) {
setComputedProperties({});
open?.({
type: "error",
message: "读取模拟属性失败,请检查方案、时间和数据源。",
});
}
} }
}; };
// 仅当 currentTime 有效时查询 // 仅当 currentTime 有效时查询
if (currentTime !== -1 && queryType) queryComputedProperties(); if (currentTime !== -1 && queryType) void queryComputedProperties();
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]); return () => {
cancelled = true;
};
}, [highlightFeatures, currentTime, open, selectedDate, queryType, schemeName, schemeRunId, showPropertyPanel]);
const displayedComputedProperties = useMemo(() => {
if (!isSimulationDataActive || !selectedValveId) {
return computedProperties;
}
const control = activeSchemeDetail?.valve_control?.[selectedValveId];
const legacyOpening = activeSchemeDetail?.valve_opening?.[selectedValveId];
return {
...computedProperties,
...(control?.status !== undefined
? { scheme_status: control.status }
: {}),
...(control
? {
scheme_setting:
control.status === "OPEN" || control.status === "CLOSED"
? "不适用"
: (control.setting ?? "未设置"),
}
: {}),
...(control?.k !== undefined
? { scheme_opening: control.k }
: legacyOpening !== undefined
? { scheme_opening: legacyOpening }
: {}),
};
}, [
activeSchemeDetail,
computedProperties,
isSimulationDataActive,
selectedValveId,
]);
const propertyPanelData = useMemo( const propertyPanelData = useMemo(
() => () =>
buildFeatureProperties( buildFeatureProperties(
selectedFeature, selectedFeature,
computedProperties, displayedComputedProperties,
canEditNetwork && selectedValveId !isSimulationDataActive && selectedValveId
? { ? {
value: valveStatus, value: valveStatus,
loading: isValveStatusLoading, loading: isValveStatusLoading,
saving: isValveStatusSaving, saving: isValveStatusSaving,
disabled: !canEditNetwork,
onSave: handleValveStatusSave, onSave: handleValveStatusSave,
} }
: undefined, : undefined,
canEditNetwork && selectedValveId !isSimulationDataActive && selectedValveId
? { ? {
value: valveProperties.setting, value: valveProperties.setting,
vType: selectedValveType, vType: selectedValveType,
loading: isValvePropertiesLoading, loading: isValvePropertiesLoading,
saving: isValveSettingSaving, saving: isValveSettingSaving,
disabled: !canEditNetwork,
status: valveStatus, status: valveStatus,
onSave: handleValveSettingSave, onSave: handleValveSettingSave,
} }
@@ -727,7 +835,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
), ),
[ [
selectedFeature, selectedFeature,
computedProperties, displayedComputedProperties,
isSimulationDataActive,
canEditNetwork, canEditNetwork,
selectedValveId, selectedValveId,
valveStatus, valveStatus,
@@ -0,0 +1,221 @@
jest.mock("ol/Feature", () => ({
__esModule: true,
default: class Feature {},
}));
import type Feature from "ol/Feature";
import {
buildFeatureProperties,
formatValveControlSummary,
getSimulationElementType,
getValvePropertySource,
} from "./toolbarFeatureHelpers";
const createValveFeature = () => {
const properties = {
id: "V1",
node1: "J1",
node2: "J2",
diameter: 200,
v_type: "PRV",
minor_loss: 0,
};
return {
getId: () => "valves.V1",
getProperties: () => properties,
} as unknown as Feature;
};
const createFeature = (
layer: string,
id: string,
properties: Record<string, unknown>,
) =>
({
getId: () => `${layer}.${id}`,
getProperties: () => ({ id, ...properties }),
}) as unknown as Feature;
describe("buildFeatureProperties valve simulation values", () => {
it("shows TimescaleDB status and setting for a simulated valve", () => {
const result = buildFeatureProperties(createValveFeature(), {
status: 2,
setting: 2.5,
});
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "模拟开关状态", value: "激活" }),
expect.objectContaining({ label: "模拟设置值", value: 2.5 }),
]),
);
});
it("keeps the other TimescaleDB link results for a simulated valve", () => {
const result = buildFeatureProperties(createValveFeature(), {
flow: 10,
pressure: 999,
status: 1,
setting: 2.5,
velocity: 0.75,
});
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "流量", value: "36.000" }),
expect.objectContaining({ label: "流速", value: "0.750" }),
]),
);
expect(result.properties).not.toEqual(
expect.arrayContaining([expect.objectContaining({ label: "压力" })]),
);
});
it("shows the selected scheme control next to TimescaleDB values", () => {
const result = buildFeatureProperties(createValveFeature(), {
status: 1,
setting: 0,
scheme_status: "CLOSED",
scheme_setting: "不适用",
});
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "模拟开关状态", value: "开启" }),
expect.objectContaining({ label: "方案设置状态", value: "关闭" }),
expect.objectContaining({ label: "方案设置值", value: "不适用" }),
]),
);
});
it("keeps PostgreSQL valve controls when simulation values are absent", () => {
const onStatusSave = jest.fn(async () => undefined);
const onSettingSave = jest.fn(async () => undefined);
const result = buildFeatureProperties(
createValveFeature(),
{},
{ value: "ACTIVE", onSave: onStatusSave },
{
value: "2.5",
vType: "PRV",
status: "ACTIVE",
onSave: onSettingSave,
},
);
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "select",
label: "开关状态",
value: "ACTIVE",
}),
expect.objectContaining({
type: "text",
label: "阀门设置值",
value: "2.5",
}),
]),
);
expect(result.properties).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "模拟开关状态" }),
]),
);
});
});
describe("getValvePropertySource", () => {
it("uses business PostgreSQL until simulation data is opened", () => {
expect(getValvePropertySource(undefined, undefined)).toBe("business");
expect(getValvePropertySource("scheme", "")).toBe("business");
expect(getValvePropertySource("scheme", "flush_260817_153045123")).toBe(
"simulation",
);
expect(getValvePropertySource("realtime", undefined)).toBe("simulation");
});
});
describe("formatValveControlSummary", () => {
it.each(["OPEN", "CLOSED"])(
"marks the setting as not applicable when status is %s",
(status) => {
expect(
formatValveControlSummary("V1", { status, setting: 2.5 }),
).toBe(`V1: ${status === "OPEN" ? "开启" : "关闭"} / 不适用`);
},
);
it("keeps the setting for an active valve", () => {
expect(
formatValveControlSummary("V1", { status: "ACTIVE", setting: 2.5 }),
).toBe("V1: 激活 / 2.5");
});
});
describe("getSimulationElementType", () => {
it("treats a point-rendered valve as a hydraulic link", () => {
expect(getSimulationElementType(createValveFeature())).toBe("link");
});
it("keeps point-rendered pumps on the same hydraulic-link path", () => {
const pump = {
getId: () => "pumps.P1",
getProperties: () => ({
id: "P1",
geometry: { getType: () => "Point" },
}),
} as unknown as Feature;
expect(getSimulationElementType(pump)).toBe("link");
});
});
describe("buildFeatureProperties simulation values by hydraulic type", () => {
it("shows link simulation results for a point-rendered pump", () => {
const pump = createFeature("pumps", "P1", {
node1: "J1",
node2: "J2",
geometry: { getType: () => "Point" },
});
const result = buildFeatureProperties(pump, {
flow: 10,
status: 1,
velocity: 0.75,
});
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "流量", value: "36.000" }),
expect.objectContaining({ label: "状态", value: "1.000" }),
expect.objectContaining({ label: "流速", value: "0.750" }),
]),
);
});
it.each([
["tanks", "T1", "水池"],
["reservoirs", "R1", "水库"],
])("shows node simulation results for %s", (layer, id, type) => {
const feature = createFeature(layer, id, {
geometry: { getType: () => "Point" },
});
const result = buildFeatureProperties(feature, {
actual_demand: 5,
total_head: 42,
pressure: 18,
});
expect(result.type).toBe(type);
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "实际需水量", value: "18.000" }),
expect.objectContaining({ label: "水头", value: "42.000" }),
expect.objectContaining({ label: "压力", value: "18.000" }),
]),
);
});
});
@@ -1,6 +1,10 @@
import Feature from "ol/Feature"; import Feature from "ol/Feature";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import {
getValveSettingHelperText,
VALVE_STATUS_OPTIONS,
} from "./valveControl";
type ToolbarBaseProperty = { type ToolbarBaseProperty = {
label: string; label: string;
@@ -54,6 +58,7 @@ export type ValveStatusPropertyOptions = {
value: string | null; value: string | null;
loading?: boolean; loading?: boolean;
saving?: boolean; saving?: boolean;
disabled?: boolean;
onSave: (value: string) => Promise<void>; onSave: (value: string) => Promise<void>;
}; };
@@ -62,32 +67,71 @@ export type ValveSettingPropertyOptions = {
vType: string | null; vType: string | null;
loading?: boolean; loading?: boolean;
saving?: boolean; saving?: boolean;
disabled?: boolean;
status?: string | null; status?: string | null;
onSave: (value: string) => Promise<void>; onSave: (value: string) => Promise<void>;
}; };
const getValveSettingHelperText = ( export const getValvePropertySource = (
vType: string | null, queryType: string | undefined,
status?: string | null, schemeName: string | undefined,
) => { ): "business" | "simulation" =>
if (status === "OPEN" || status === "CLOSED") { queryType === "realtime" || (queryType === "scheme" && Boolean(schemeName))
return "开启/关闭状态下 EPANET 会忽略阀门设置值"; ? "simulation"
: "business";
export const getSimulationElementType = (
feature: Feature,
): "link" | "node" => {
const layerId = feature.getId()?.toString().split(".")[0] ?? "";
if (
layerId.includes("pipe") ||
layerId.includes("pump") ||
layerId.includes("valve")
) {
return "link";
}
return feature.getProperties().geometry?.getType?.() === "LineString"
? "link"
: "node";
};
const formatValveStatus = (value: unknown): string => {
if (typeof value === "string") {
const normalized = value.toUpperCase();
const option = VALVE_STATUS_OPTIONS.find(
(candidate) => candidate.value === normalized,
);
if (option) return option.label;
} }
switch (vType?.toUpperCase()) { const numericStatus = Number(value);
case "PRV": if (Number.isFinite(numericStatus)) {
case "PSV": if (numericStatus === 0) return "关闭";
case "PBV": if (numericStatus === 1) return "开启";
return "压力设置值,需为 0 或正数"; if (numericStatus === 2) return "激活";
case "FCV":
return "流量设置值,需为 0 或正数";
case "TCV":
return "损失系数,需为 0 或正数";
case "GPV":
return "水头损失曲线 ID";
default:
return "阀门类型相关设置值";
} }
return value === null || value === undefined || value === ""
? "未返回"
: String(value);
};
const displayValue = (value: unknown): string | number =>
typeof value === "string" || typeof value === "number"
? value
: "未设置";
export const formatValveControlSummary = (
id: string,
control: { status?: string; setting?: unknown },
): string => {
const normalizedStatus = control.status?.toUpperCase();
const setting =
normalizedStatus === "OPEN" || normalizedStatus === "CLOSED"
? "不适用"
: displayValue(control.setting);
return `${id}: ${formatValveStatus(control.status)} / ${setting}`;
}; };
const getFeatureHistoryType = (feature: Feature): string | null => { const getFeatureHistoryType = (feature: Feature): string | null => {
@@ -147,13 +191,58 @@ export const buildFeatureProperties = (
{ key: "quality", label: "水质", unit: "mg/L" }, { key: "quality", label: "水质", unit: "mg/L" },
]; ];
if (layer === "geo_pipes_mat" || layer === "geo_pipes") { const appendLinkComputedProperties = (
result: ToolbarPropertyPanelData,
excludedKeys: string[] = [],
) => {
pipeComputedFields.forEach(({ key, label, unit }) => {
if (excludedKeys.includes(key)) return;
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
if (
key === "unit_headloss" &&
value === undefined &&
computedProperties.headloss !== undefined &&
properties.length
) {
value = (computedProperties.headloss / properties.length) * 1000;
}
if (value !== undefined) {
result.properties?.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
}
});
};
const appendNodeComputedProperties = (result: ToolbarPropertyPanelData) => {
nodeComputedFields.forEach(({ key, label, unit }) => {
if (computedProperties[key] === undefined) return;
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties?.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
});
};
if (layer === "pipes") {
const result: ToolbarPropertyPanelData = { const result: ToolbarPropertyPanelData = {
id: properties.id, id: properties.id,
type: "管道", type: "管道",
properties: [ properties: [
{ label: "起始节点ID", value: properties.node1 }, { label: "起始节点ID", value: properties.start_node_id },
{ label: "终点节点ID", value: properties.node2 }, { label: "终点节点ID", value: properties.end_node_id },
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" }, { label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
{ {
label: "管径", label: "管径",
@@ -166,35 +255,12 @@ export const buildFeatureProperties = (
], ],
}; };
pipeComputedFields.forEach(({ key, label, unit }) => { appendLinkComputedProperties(result);
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
if (
key === "unit_headloss" &&
value === undefined &&
computedProperties.headloss !== undefined &&
properties.length
) {
value = (computedProperties.headloss / properties.length) * 1000;
}
if (value !== undefined) {
result.properties?.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
}
});
return result; return result;
} }
if (layer === "geo_junctions_mat" || layer === "geo_junctions") { if (layer === "junctions") {
const result: ToolbarPropertyPanelData = { const result: ToolbarPropertyPanelData = {
id: properties.id, id: properties.id,
type: "节点", type: "节点",
@@ -205,50 +271,26 @@ export const buildFeatureProperties = (
unit: "m", unit: "m",
}, },
{ {
type: "table",
label: "基本需水量", label: "基本需水量",
columns: ["demand", "pattern"], value: Number.isFinite(Number(properties.base_demand))
rows: Array.from({ length: 5 }, (_, i) => i + 1) ? toM3h(Number(properties.base_demand), "lps").toFixed(3)
.map((idx) => { : properties.base_demand,
let demand = properties?.[`demand${idx}`]; unit: "m³/h",
const pattern = properties?.[`pattern${idx}`]; },
if ( {
demand !== undefined && label: "需水配置",
demand !== null && value: properties.demands,
demand !== ""
) {
demand = toM3h(Number(demand), "lps");
return [
typeof demand === "number" ? demand.toFixed(3) : demand,
pattern ?? "-",
];
}
return null;
})
.filter(Boolean) as (string | number)[][],
}, },
], ],
}; };
nodeComputedFields.forEach(({ key, label, unit }) => { appendNodeComputedProperties(result);
if (computedProperties[key] !== undefined) {
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties?.push({
label,
value: value?.toFixed?.(3) || value,
unit,
});
}
});
return result; return result;
} }
if (layer === "geo_tanks_mat" || layer === "geo_tanks") { if (layer === "tanks") {
return { const result: ToolbarPropertyPanelData = {
id: properties.id, id: properties.id,
type: "水池", type: "水池",
properties: [ properties: [
@@ -259,17 +301,17 @@ export const buildFeatureProperties = (
}, },
{ {
label: "初始水位", label: "初始水位",
value: properties.init_level?.toFixed?.(1), value: properties.initial_level?.toFixed?.(1),
unit: "m", unit: "m",
}, },
{ {
label: "最低水位", label: "最低水位",
value: properties.min_level?.toFixed?.(1), value: properties.minimum_level?.toFixed?.(1),
unit: "m", unit: "m",
}, },
{ {
label: "最高水位", label: "最高水位",
value: properties.max_level?.toFixed?.(1), value: properties.maximum_level?.toFixed?.(1),
unit: "m", unit: "m",
}, },
{ {
@@ -279,7 +321,7 @@ export const buildFeatureProperties = (
}, },
{ {
label: "最小容积", label: "最小容积",
value: properties.min_vol?.toFixed?.(1), value: properties.minimum_volume?.toFixed?.(1),
unit: "m³", unit: "m³",
}, },
{ {
@@ -288,65 +330,75 @@ export const buildFeatureProperties = (
}, },
], ],
}; };
appendNodeComputedProperties(result);
return result;
} }
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") { if (layer === "reservoirs") {
return { const result: ToolbarPropertyPanelData = {
id: properties.id, id: properties.id,
type: "水库", type: "水库",
properties: [ properties: [
{ {
label: "水头", label: "基础水头",
value: properties.head?.toFixed?.(1), value: properties.head?.toFixed?.(1),
unit: "m", unit: "m",
}, },
{ label: "模式", value: properties.pattern_id },
], ],
}; };
appendNodeComputedProperties(result);
return result;
} }
if (layer === "geo_pumps_mat" || layer === "geo_pumps") { if (layer === "pumps") {
return { const result: ToolbarPropertyPanelData = {
id: properties.id, id: properties.id,
type: "水泵", type: "水泵",
properties: [ properties: [
{ label: "起始节点 ID", value: properties.node1 }, { label: "起始节点 ID", value: properties.start_node_id },
{ label: "终点节点 ID", value: properties.node2 }, { label: "终点节点 ID", value: properties.end_node_id },
{ {
label: "功率", label: "功率",
value: properties.power?.toFixed?.(1), value: properties.power?.toFixed?.(1),
unit: "kW", unit: "kW",
}, },
{ {
label: "扬程", label: "扬程曲线",
value: properties.head?.toFixed?.(1), value: properties.head_curve_id,
unit: "m",
}, },
{ {
label: "转速", label: "转速",
value: properties.speed?.toFixed?.(1), value: properties.speed?.toFixed?.(1),
unit: "rpm", unit: "",
}, },
{ {
label: "模式", label: "模式",
value: properties.pattern, value: properties.pattern_id,
}, },
], ],
}; };
appendLinkComputedProperties(result);
return result;
} }
if (layer === "geo_valves_mat" || layer === "geo_valves") { if (layer === "valves") {
const valveType = valveSetting?.vType ?? properties.v_type; const valveType = valveSetting?.vType ?? properties.valve_type;
const hasSimulationValues =
Object.hasOwn(computedProperties, "status") ||
Object.hasOwn(computedProperties, "setting");
const isValveSettingDisabled = const isValveSettingDisabled =
valveSetting?.loading || valveSetting?.loading ||
valveSetting?.disabled ||
valveSetting?.status === "OPEN" || valveSetting?.status === "OPEN" ||
valveSetting?.status === "CLOSED"; valveSetting?.status === "CLOSED";
return { const result: ToolbarPropertyPanelData = {
id: properties.id, id: properties.id,
type: "阀门", type: "阀门",
properties: [ properties: [
{ label: "起始节点 ID", value: properties.node1 }, { label: "起始节点 ID", value: properties.start_node_id },
{ label: "终点节点 ID", value: properties.node2 }, { label: "终点节点 ID", value: properties.end_node_id },
{ {
label: "直径", label: "直径",
value: properties.diameter?.toFixed?.(1), value: properties.diameter?.toFixed?.(1),
@@ -360,25 +412,57 @@ export const buildFeatureProperties = (
label: "局部损失", label: "局部损失",
value: properties.minor_loss?.toFixed?.(2), value: properties.minor_loss?.toFixed?.(2),
}, },
...(valveStatus ...(hasSimulationValues
? [
{
label: "模拟开关状态",
value: formatValveStatus(computedProperties.status),
},
{
label: "模拟设置值",
value: displayValue(computedProperties.setting),
},
]
: []),
...(Object.hasOwn(computedProperties, "scheme_status")
? [
{
label: "方案设置状态",
value: formatValveStatus(computedProperties.scheme_status),
},
]
: []),
...(Object.hasOwn(computedProperties, "scheme_setting")
? [
{
label: "方案设置值",
value: displayValue(computedProperties.scheme_setting),
},
]
: []),
...(Object.hasOwn(computedProperties, "scheme_opening")
? [
{
label: "方案开度",
value: displayValue(computedProperties.scheme_opening),
},
]
: []),
...(!hasSimulationValues && valveStatus
? [ ? [
{ {
type: "select" as const, type: "select" as const,
label: "开关状态", label: "开关状态",
value: valveStatus.value ?? "", value: valveStatus.value ?? "",
options: [ options: VALVE_STATUS_OPTIONS,
{ label: "开启", value: "OPEN" },
{ label: "关闭", value: "CLOSED" },
{ label: "激活", value: "ACTIVE" },
],
placeholder: valveStatus.loading ? "加载中" : "未设置", placeholder: valveStatus.loading ? "加载中" : "未设置",
disabled: valveStatus.loading, disabled: valveStatus.loading || valveStatus.disabled,
saving: valveStatus.saving, saving: valveStatus.saving,
onSave: valveStatus.onSave, onSave: valveStatus.onSave,
}, },
] ]
: []), : []),
...(valveSetting ...(!hasSimulationValues && valveSetting
? [ ? [
{ {
type: "text" as const, type: "text" as const,
@@ -397,6 +481,8 @@ export const buildFeatureProperties = (
: []), : []),
], ],
}; };
appendLinkComputedProperties(result, ["setting", "status"]);
return result;
} }
const getTransmissionFrequency = (transmissionFrequency: string) => { const getTransmissionFrequency = (transmissionFrequency: string) => {
@@ -421,7 +507,7 @@ export const buildFeatureProperties = (
} }
}; };
if (layer === "geo_scada_mat" || layer === "geo_scada") { if (layer === "scada_devices") {
return { return {
id: properties.id, id: properties.id,
type: "SCADA设备", type: "SCADA设备",
@@ -429,11 +515,13 @@ export const buildFeatureProperties = (
{ {
label: "类型", label: "类型",
value: value:
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器", properties.device_type === "pipe_flow"
? "流量传感器"
: "压力传感器",
}, },
{ {
label: "关联节点 ID", label: "关联节点 ID",
value: properties.associated_element_id, value: properties.node_id ?? properties.link_id,
}, },
{ {
label: "传输模式", label: "传输模式",
@@ -0,0 +1,36 @@
import {
getValveSettingHelperText,
isLinkStatus,
normalizeValveSetting,
VALVE_STATUS_OPTIONS,
validateValveSetting,
} from "./valveControl";
describe("valveControl", () => {
it("provides the status translations shared by valve editors", () => {
expect(VALVE_STATUS_OPTIONS).toEqual([
{ label: "开启", value: "OPEN" },
{ label: "关闭", value: "CLOSED" },
{ label: "激活", value: "ACTIVE" },
]);
expect(isLinkStatus("ACTIVE")).toBe(true);
expect(isLinkStatus("UNKNOWN")).toBe(false);
});
it("normalizes setting values without dropping zero", () => {
expect(normalizeValveSetting(0)).toBe("0");
expect(normalizeValveSetting(null)).toBeNull();
});
it("validates numeric and curve settings by valve type", () => {
expect(validateValveSetting("PRV", "2.5")).toBeNull();
expect(validateValveSetting("PRV", "-1")).toContain("大于或等于 0");
expect(validateValveSetting("GPV", "curve-1")).toBeNull();
expect(validateValveSetting("GPV", " ")).toContain("曲线 ID");
});
it("explains that OPEN and CLOSED ignore the setting", () => {
expect(getValveSettingHelperText("PRV", "OPEN")).toContain("忽略");
expect(getValveSettingHelperText("FCV", "ACTIVE")).toContain("流量");
});
});
@@ -0,0 +1,70 @@
export type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE";
export const VALVE_STATUS_OPTIONS: Array<{
label: string;
value: LinkStatus;
}> = [
{ label: "开启", value: "OPEN" },
{ label: "关闭", value: "CLOSED" },
{ label: "激活", value: "ACTIVE" },
];
export const isLinkStatus = (value: unknown): value is LinkStatus =>
value === "OPEN" || value === "CLOSED" || value === "ACTIVE";
export const normalizeValveSetting = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
return String(value);
};
export const validateValveSetting = (
valveType: string | null | undefined,
value: string,
): string | null => {
const normalizedType = valveType?.toUpperCase();
const trimmedValue = value.trim();
const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]);
if (normalizedType === "GPV") {
return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。";
}
if (numericTypes.has(normalizedType ?? "")) {
if (!trimmedValue) {
return "阀门设置值必须是 0 或正数。";
}
const numericValue = Number(trimmedValue);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return "阀门设置值必须是有限数字,且大于或等于 0。";
}
return null;
}
return trimmedValue ? null : "阀门设置值不能为空。";
};
export const getValveSettingHelperText = (
valveType: string | null | undefined,
status?: LinkStatus | string | null,
) => {
if (status === "OPEN" || status === "CLOSED") {
return "开启/关闭状态下 EPANET 会忽略阀门设置值";
}
switch (valveType?.toUpperCase()) {
case "PRV":
case "PSV":
case "PBV":
return "压力设置值,需为 0 或正数";
case "FCV":
return "流量设置值,需为 0 或正数";
case "TCV":
return "损失系数,需为 0 或正数";
case "GPV":
return "水头损失曲线 ID";
default:
return "阀门类型相关设置值";
}
};
@@ -81,6 +81,8 @@ interface DataContextType {
selectedDate?: Date; // 选择的日期 selectedDate?: Date; // 选择的日期
schemeName?: string; // 当前方案名称 schemeName?: string; // 当前方案名称
setSchemeName?: React.Dispatch<React.SetStateAction<string>>; setSchemeName?: React.Dispatch<React.SetStateAction<string>>;
schemeRunId?: string; // 当前分析运行 ID
setSchemeRunId?: React.Dispatch<React.SetStateAction<string>>;
setSelectedDate?: React.Dispatch<React.SetStateAction<Date>>; setSelectedDate?: React.Dispatch<React.SetStateAction<Date>>;
currentJunctionCalData?: any[]; // 当前计算结果 currentJunctionCalData?: any[]; // 当前计算结果
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>; setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
@@ -226,6 +228,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17")); // const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天 const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称 const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
const [schemeRunId, setSchemeRunId] = useState<string>("");
// 记录 id、对应属性的计算值 // 记录 id、对应属性的计算值
const [currentJunctionCalData, setCurrentJunctionCalData] = useState<any[]>( const [currentJunctionCalData, setCurrentJunctionCalData] = useState<any[]>(
[], [],
@@ -791,6 +794,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
); );
setSelectedDate(new Date()); setSelectedDate(new Date());
setSchemeName(""); setSchemeName("");
setSchemeRunId("");
setCurrentJunctionCalData([]); setCurrentJunctionCalData([]);
setCurrentPipeCalData([]); setCurrentPipeCalData([]);
setCompareJunctionCalData([]); setCompareJunctionCalData([]);
@@ -1118,6 +1122,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setSelectedDate, setSelectedDate,
schemeName, schemeName,
setSchemeName, setSchemeName,
schemeRunId,
setSchemeRunId,
currentJunctionCalData, currentJunctionCalData,
setCurrentJunctionCalData, setCurrentJunctionCalData,
currentPipeCalData, currentPipeCalData,
@@ -63,6 +63,35 @@ import {
} from "./operationalLayers"; } from "./operationalLayers";
describe("operational map resources", () => { describe("operational map resources", () => {
it("requests the published tjwater_next layer names", () => {
const sources = createOperationalMapSources({
mapUrl: "https://maps.example.test/geoserver",
workspace: "tjwater_next",
});
expect((sources.junctions as any).options.url).toContain(
"tjwater_next:junctions@WebMercatorQuad@pbf",
);
expect((sources.pipes as any).options.url).toContain(
"tjwater_next:pipes@WebMercatorQuad@pbf",
);
expect((sources.valves as any).options.url).toContain(
"tjwater_next:valves@WebMercatorQuad@pbf",
);
expect((sources.reservoirs as any).options.url).toContain(
"typeName=tjwater_next:reservoirs",
);
expect((sources.pumps as any).options.url).toContain(
"typeName=tjwater_next:pumps",
);
expect((sources.tanks as any).options.url).toContain(
"typeName=tjwater_next:tanks",
);
expect((sources.scada as any).options.url).toContain(
"typeName=tjwater_next:scada_devices",
);
});
it("shares sources while keeping per-map layer instances independent", () => { it("shares sources while keeping per-map layer instances independent", () => {
const options = { const options = {
mapUrl: "https://maps.example.test/geoserver", mapUrl: "https://maps.example.test/geoserver",
+11 -36
View File
@@ -1,12 +1,9 @@
import { config } from "@/config/config"; import { config } from "@/config/config";
import { along, lineString, length, toMercator } from "@turf/turf";
import type { FeatureLike } from "ol/Feature"; import type { FeatureLike } from "ol/Feature";
import MVT from "ol/format/MVT"; import MVT from "ol/format/MVT";
import { Point } from "ol/geom";
import type BaseLayer from "ol/layer/Base"; import type BaseLayer from "ol/layer/Base";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { toLonLat } from "ol/proj";
import GeoJSON from "ol/format/GeoJSON"; import GeoJSON from "ol/format/GeoJSON";
import VectorSource from "ol/source/Vector"; import VectorSource from "ol/source/Vector";
import VectorTileSource from "ol/source/VectorTile"; import VectorTileSource from "ol/source/VectorTile";
@@ -49,34 +46,12 @@ const createIconStyle = (src: string, scale = 0.1) =>
const scadaStyle = (feature: FeatureLike) => const scadaStyle = (feature: FeatureLike) =>
createIconStyle( createIconStyle(
feature.get("type") === "pipe_flow" feature.get("device_type") === "pipe_flow"
? "/icons/scada_flow.svg" ? "/icons/scada_flow.svg"
: "/icons/scada_pressure.svg", : "/icons/scada_pressure.svg",
); );
const pumpStyle = (feature: FeatureLike) => { const pumpStyle = () => createIconStyle("/icons/pump.svg", 0.12);
const geometry = feature.getGeometry();
if (!geometry || geometry.getType() !== "LineString") return [];
const coordinates = (geometry as any)
.getCoordinates()
.map((coordinate: number[]) => toLonLat(coordinate));
if (coordinates.length < 2) return [];
const featureLine = lineString(coordinates);
const midpoint = along(featureLine, length(featureLine) / 2).geometry
.coordinates;
return [
new Style({
geometry: new Point(toMercator(midpoint)),
image: new Icon({
src: "/icons/pump.svg",
scale: 0.12,
anchor: [0.5, 0.5],
}),
}),
];
};
const pointProperties = [ const pointProperties = [
{ name: "高程", value: "elevation" }, { name: "高程", value: "elevation" },
@@ -110,34 +85,34 @@ export const createOperationalMapSources = ({
return { return {
junctions: new VectorTileSource({ junctions: new VectorTileSource({
url: vectorTileUrl("geo_junctions"), url: vectorTileUrl("junctions"),
format: new MVT(), format: new MVT(),
projection: "EPSG:3857", projection: "EPSG:3857",
}), }),
pipes: new VectorTileSource({ pipes: new VectorTileSource({
url: vectorTileUrl("geo_pipes"), url: vectorTileUrl("pipes"),
format: new MVT(), format: new MVT(),
projection: "EPSG:3857", projection: "EPSG:3857",
}), }),
valves: new VectorTileSource({ valves: new VectorTileSource({
url: vectorTileUrl("geo_valves"), url: vectorTileUrl("valves"),
format: new MVT(), format: new MVT(),
projection: "EPSG:3857", projection: "EPSG:3857",
}), }),
reservoirs: new VectorSource({ reservoirs: new VectorSource({
url: vectorUrl("geo_reservoirs"), url: vectorUrl("reservoirs"),
format: new GeoJSON(), format: new GeoJSON(),
}), }),
pumps: new VectorSource({ pumps: new VectorSource({
url: vectorUrl("geo_pumps"), url: vectorUrl("pumps"),
format: new GeoJSON(), format: new GeoJSON(),
}), }),
tanks: new VectorSource({ tanks: new VectorSource({
url: vectorUrl("geo_tanks"), url: vectorUrl("tanks"),
format: new GeoJSON(), format: new GeoJSON(),
}), }),
scada: new VectorSource({ scada: new VectorSource({
url: vectorUrl("geo_scada"), url: vectorUrl("scada_devices"),
format: new GeoJSON(), format: new GeoJSON(),
}), }),
}; };
@@ -187,7 +162,7 @@ export const createOperationalMapResources = ({
properties: { properties: {
name: "阀门", name: "阀门",
value: "valves", value: "valves",
type: "linestring", type: "point",
properties: [], properties: [],
}, },
}), }),
@@ -213,7 +188,7 @@ export const createOperationalMapResources = ({
properties: { properties: {
name: "水泵", name: "水泵",
value: "pumps", value: "pumps",
type: "linestring", type: "point",
properties: [], properties: [],
}, },
}), }),
+3 -3
View File
@@ -27,14 +27,14 @@ const parseMapExtent = (value: RuntimeConfig["MAP_EXTENT"]): number[] => {
return value.split(",").map(Number); return value.split(",").map(Number);
} }
return [13508849, 3608036, 13555781, 3633813]; return [13508801.93, 3608163.35, 13555650.64, 3633685.14];
}; };
export const config = { export const config = {
BACKEND_URL: runtimeConfig.BACKEND_URL || "http://127.0.0.1:8000", BACKEND_URL: runtimeConfig.BACKEND_URL || "http://127.0.0.1:8000",
AGENT_URL: runtimeConfig.AGENT_URL || "http://127.0.0.1:8788", AGENT_URL: runtimeConfig.AGENT_URL || "http://127.0.0.1:8788",
MAP_URL: runtimeConfig.MAP_URL || "http://127.0.0.1:8080/geoserver", MAP_URL: runtimeConfig.MAP_URL || "http://127.0.0.1:8080/geoserver",
MAP_WORKSPACE: runtimeConfig.MAP_WORKSPACE || "tjwater", MAP_WORKSPACE: runtimeConfig.MAP_WORKSPACE || "tjwater_next",
MAP_EXTENT: parseMapExtent(runtimeConfig.MAP_EXTENT), MAP_EXTENT: parseMapExtent(runtimeConfig.MAP_EXTENT),
MAP_DEFAULT_STYLE: { MAP_DEFAULT_STYLE: {
"stroke-width": 3, "stroke-width": 3,
@@ -61,7 +61,7 @@ export const config = {
"scada", "scada",
], ],
}; };
export let NETWORK_NAME = runtimeConfig.NETWORK_NAME || "tjwater"; export let NETWORK_NAME = runtimeConfig.NETWORK_NAME || "tjwater_next";
export const setNetworkName = (name: string) => { export const setNetworkName = (name: string) => {
NETWORK_NAME = name; NETWORK_NAME = name;
+133 -2
View File
@@ -109,6 +109,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/agent/sessions/{session_id}/credential-refreshes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Resume a waiting agent tool call with refreshed credentials */
post: operations["post_sessions_session_id_credential_refreshes"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agent/sessions/{session_id}/permission-responses": { "/api/v1/agent/sessions/{session_id}/permission-responses": {
parameters: { parameters: {
query?: never; query?: never;
@@ -882,8 +899,11 @@ export interface operations {
"application/json": { "application/json": {
message: string; message: string;
model?: string; model?: string;
/** @enum {string} */ /**
approval_mode?: "request" | "always"; * @description request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.
* @enum {string}
*/
approval_mode?: "request" | "auto" | "always";
}; };
}; };
}; };
@@ -1195,6 +1215,117 @@ export interface operations {
}; };
}; };
}; };
post_sessions_session_id_credential_refreshes: {
parameters: {
query?: never;
header?: never;
path: {
session_id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
request_id: string;
};
};
};
responses: {
/** @description Successful response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Upstream dependency error */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
};
};
};
};
post_sessions_session_id_permission_responses: { post_sessions_session_id_permission_responses: {
parameters: { parameters: {
query?: never; query?: never;
+979 -10613
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import { fetchAgentRuntimeHealth } from "./agentRuntime";
const apiFetch = jest.fn();
jest.mock("@/lib/apiFetch", () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
describe("fetchAgentRuntimeHealth", () => {
beforeEach(() => {
apiFetch.mockReset();
});
it("reports ready only after runtime warmup is complete", async () => {
apiFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
ok: true,
ready: true,
warmed_up: true,
runtime: { healthy: true },
}),
});
await expect(fetchAgentRuntimeHealth()).resolves.toBe(true);
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/health"),
expect.objectContaining({
method: "GET",
projectHeaderMode: "omit",
skipAuthRedirect: true,
}),
);
});
it("reports unavailable when runtime health is not ready", async () => {
apiFetch.mockResolvedValueOnce({
ok: false,
json: async () => ({ ok: false, ready: false, warmed_up: true }),
});
await expect(fetchAgentRuntimeHealth()).resolves.toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
export type AgentRuntimeState =
| "checking"
| "ready"
| "unavailable"
| "models_unavailable";
type AgentHealthPayload = {
ok?: unknown;
ready?: unknown;
warmed_up?: unknown;
runtime?: {
healthy?: unknown;
};
};
export const fetchAgentRuntimeHealth = async (
signal?: AbortSignal,
): Promise<boolean> => {
const response = await apiFetch(`${config.AGENT_URL}/health`, {
method: "GET",
signal,
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
const payload = (await response.json().catch(() => null)) as AgentHealthPayload | null;
return Boolean(
response.ok &&
payload?.ok === true &&
payload.ready === true &&
payload.warmed_up === true &&
payload.runtime?.healthy === true,
);
};
+96
View File
@@ -0,0 +1,96 @@
import { api } from "@/lib/api";
import {
getAnalysisResults,
getAnalysisScheme,
listAnalysisSchemes,
} from "./analysisRuns";
jest.mock("@/lib/api", () => ({
api: { get: jest.fn() },
}));
const get = api.get as jest.Mock;
describe("analysis runs API adapter", () => {
beforeEach(() => get.mockReset());
it("filters runs and exposes the new run identity to scheme screens", async () => {
get.mockResolvedValue({
data: {
success: true,
count: 2,
data: [
{
run_id: "run-1",
name: "burst-a",
run_type: "burst_analysis",
created_by: "alice",
created_at: "2026-08-25T02:00:00Z",
started_at: "2026-08-25T01:00:00Z",
status: "completed",
parameters: { burst_ID: ["P-1"] },
},
{
run_id: "run-2",
name: "flush-a",
run_type: "flushing_analysis",
created_by: "bob",
created_at: "2026-08-24T02:00:00Z",
started_at: "2026-08-24T01:00:00Z",
status: "completed",
parameters: {},
},
],
},
});
const result = await listAnalysisSchemes({
runType: "burst_analysis",
queryDate: "2026-08-25",
});
expect(get).toHaveBeenCalledWith("/api/v1/analysis/runs");
expect(result).toEqual([
expect.objectContaining({
id: "run-1",
scheme_id: "run-1",
scheme_name: "burst-a",
schemeDetail: { burst_ID: ["P-1"] },
}),
]);
});
it("loads a run and its structured results by run id", async () => {
get
.mockResolvedValueOnce({
data: {
run_id: "run-1",
name: "burst-a",
run_type: "burst_analysis",
created_by: "alice",
created_at: "2026-08-25T02:00:00Z",
started_at: "2026-08-25T01:00:00Z",
status: "completed",
parameters: {},
},
})
.mockResolvedValueOnce({
data: [
{
result_id: "result-1",
run_id: "run-1",
result_type: "summary",
payload: { ok: true },
created_at: "2026-08-25T03:00:00Z",
},
],
});
expect((await getAnalysisScheme("run-1")).scheme_id).toBe("run-1");
expect(await getAnalysisResults("run-1", "summary")).toHaveLength(1);
expect(get).toHaveBeenLastCalledWith(
"/api/v1/analysis/runs/run-1/results",
{ params: { result_type: "summary" } },
);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { api } from "@/lib/api";
export type AnalysisRun = {
run_id: string;
name: string;
run_type: string;
created_by: string;
created_at: string;
started_at: string;
status: string;
parameters: Record<string, unknown>;
};
export type AnalysisResult = {
result_id: string;
run_id: string;
result_type: string;
node_id?: string | null;
link_id?: string | null;
payload: Record<string, unknown>;
created_at: string;
};
type AnalysisRunListResponse = {
success: boolean;
data: AnalysisRun[];
count: number;
};
export type AnalysisSchemeRecord = AnalysisRun & {
id: string;
scheme_id: string;
scheme_name: string;
scheme_type: string;
username: string;
create_time: string;
scheme_start_time: string;
scheme_detail: Record<string, unknown>;
schemeName: string;
type: string;
startTime: string;
schemeDetail: Record<string, unknown>;
};
const normalizeParameters = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
export const toAnalysisSchemeRecord = (run: AnalysisRun): AnalysisSchemeRecord => {
const parameters = normalizeParameters(run.parameters);
return {
...run,
parameters,
id: run.run_id,
scheme_id: run.run_id,
scheme_name: run.name,
scheme_type: run.run_type,
username: run.created_by,
create_time: run.created_at,
scheme_start_time: run.started_at,
scheme_detail: parameters,
schemeName: run.name,
type: run.run_type,
startTime: run.started_at,
schemeDetail: parameters,
};
};
export const listAnalysisSchemes = async ({
runType,
queryDate,
}: {
runType?: string;
queryDate?: string;
} = {}): Promise<AnalysisSchemeRecord[]> => {
const response = await api.get<AnalysisRunListResponse>(
"/api/v1/analysis/runs",
);
const runs = Array.isArray(response.data.data) ? response.data.data : [];
return runs
.filter((run) => !runType || run.run_type === runType)
.filter((run) => !queryDate || run.created_at.slice(0, 10) === queryDate)
.map(toAnalysisSchemeRecord);
};
export const getAnalysisScheme = async (
runId: string,
): Promise<AnalysisSchemeRecord> => {
const response = await api.get<AnalysisRun>(
`/api/v1/analysis/runs/${encodeURIComponent(runId)}`,
);
return toAnalysisSchemeRecord(response.data);
};
export const getAnalysisResults = async (
runId: string,
resultType?: string,
): Promise<AnalysisResult[]> => {
const response = await api.get<AnalysisResult[]>(
`/api/v1/analysis/runs/${encodeURIComponent(runId)}/results`,
{ params: resultType ? { result_type: resultType } : undefined },
);
return Array.isArray(response.data) ? response.data : [];
};
+21
View File
@@ -1,4 +1,5 @@
import { resolveRequestUrl } from "@/lib/api"; import { resolveRequestUrl } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
describe("resolveRequestUrl", () => { describe("resolveRequestUrl", () => {
it("does not prepend baseURL to an absolute request URL", () => { it("does not prepend baseURL to an absolute request URL", () => {
@@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => {
).toBe("http://localhost:8000/api/v1/schemes"); ).toBe("http://localhost:8000/api/v1/schemes");
}); });
}); });
describe("authentication lifecycle state", () => {
beforeEach(() => {
useAuthStore.setState({
accessToken: "access-token",
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("clears only the local access token when a request becomes unauthorized", () => {
useAuthStore.getState().markSessionExpired("unauthorized");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
});
+1 -8
View File
@@ -1,6 +1,5 @@
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios"; import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
import { config } from "@config/config"; import { config } from "@config/config";
import { signOut } from "next-auth/react";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { import {
applyAuthContextHeaders, applyAuthContextHeaders,
@@ -13,8 +12,6 @@ export const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
}); });
let isSigningOut = false;
export const resolveRequestUrl = (request: { export const resolveRequestUrl = (request: {
baseURL?: string; baseURL?: string;
url?: string; url?: string;
@@ -63,11 +60,7 @@ api.interceptors.response.use(
}, },
async (error) => { async (error) => {
if (error?.response?.status === 401 && typeof window !== "undefined") { if (error?.response?.status === 401 && typeof window !== "undefined") {
useAuthStore.getState().setAccessToken(null); useAuthStore.getState().markSessionExpired("unauthorized");
if (!isSigningOut) {
isSigningOut = true;
await signOut({ redirect: true, callbackUrl: "/login" });
}
} }
return Promise.reject(error); return Promise.reject(error);
}, },
+209
View File
@@ -0,0 +1,209 @@
import { getApiErrorMessage } from "./apiError";
describe("getApiErrorMessage", () => {
it("uses a business detail from Problem Details", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
detail: "ACTIVE 状态的阀门 V1 必须提供设置值",
errors: [],
},
},
}),
).toBe("ACTIVE 状态的阀门 V1 必须提供设置值");
});
it("formats field-level validation errors in Chinese", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
detail: "Request validation failed",
errors: [
{
type: "missing",
loc: ["query", "drainage_node_id"],
msg: "Field required",
},
{
type: "literal_error",
loc: ["query", "valve_statuses", 1],
msg: "Input should be 'OPEN', 'CLOSED' or 'ACTIVE'",
ctx: { expected: "'OPEN', 'CLOSED' or 'ACTIVE'" },
},
],
},
},
}),
).toBe(
"排水节点:不能为空;阀门开关状态[2]:可选值为 'OPEN', 'CLOSED' 或 'ACTIVE'",
);
});
it("supports legacy FastAPI validation details", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
detail: [
{
type: "greater_than_equal",
loc: ["body", "sensor_count"],
msg: "Input should be greater than or equal to 1",
ctx: { ge: 1 },
},
],
},
},
}),
).toBe("监测点数量:必须大于或等于 1");
});
it("limits long validation responses while preserving the remaining count", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
errors: Array.from({ length: 5 }, (_, index) => ({
type: "missing",
loc: ["body", `field_${index}`],
msg: "Field required",
})),
},
},
}),
).toBe(
"field_0:不能为空;field_1:不能为空;field_2:不能为空;另有 2 项参数错误",
);
});
it("bounds hostile validation payloads before formatting", () => {
const message = getApiErrorMessage({
response: {
status: 422,
data: {
errors: Array.from({ length: 100_000 }, () => ({
type: "custom_error",
loc: ["body", "x".repeat(1_000)],
msg: "y".repeat(10_000),
})),
},
},
});
expect(message.length).toBeLessThan(600);
expect(message).toContain("另有 99997 项参数错误");
});
it("bounds validation constraint values", () => {
const message = getApiErrorMessage({
response: {
status: 422,
data: {
errors: [
{
type: "greater_than",
loc: ["body", "duration"],
ctx: { gt: "9".repeat(10_000) },
},
],
},
},
});
expect(message.length).toBeLessThan(100);
});
it("distinguishes network and timeout failures", () => {
expect(getApiErrorMessage({ code: "ERR_NETWORK" })).toContain("无法连接服务");
expect(getApiErrorMessage({ code: "ECONNABORTED" })).toContain("请求超时");
});
it("uses a localized message and trace id for server failures", () => {
expect(
getApiErrorMessage({
response: {
status: 503,
data: {
detail: "仿真服务暂时不可用",
trace_id: "trace-123",
},
},
}),
).toBe("服务暂时不可用,请稍后重试(追踪 ID:trace-123");
});
it("does not expose authentication details from the response", () => {
expect(
getApiErrorMessage({
response: {
status: 401,
data: { detail: "Not authenticated" },
},
}),
).toBe("登录状态已失效,请重新登录");
});
it("preserves a clear Chinese permission detail", () => {
expect(
getApiErrorMessage({
response: {
status: 403,
data: { detail: "当前项目角色为只读,不能创建方案" },
},
}),
).toBe("当前项目角色为只读,不能创建方案");
});
it("localizes plain-text server failures", () => {
expect(
getApiErrorMessage({
response: {
status: 500,
data: "Internal Server Error",
},
}),
).toBe("服务处理失败,请稍后重试");
});
it("does not misreport server error arrays as validation failures", () => {
expect(
getApiErrorMessage({
response: {
status: 500,
data: {
errors: [{ loc: ["body", "scheme_name"], msg: "failed" }],
trace_id: "trace-500",
},
},
}),
).toBe("服务处理失败,请稍后重试(追踪 IDtrace-500");
});
it("does not display HTML gateway responses", () => {
expect(
getApiErrorMessage({
response: {
status: 502,
data: "<!doctype html><html><body>Bad Gateway</body></html>",
},
}),
).toBe("上游服务暂时不可用,请稍后重试");
});
it("uses a localized fallback for an unknown HTTP status", () => {
expect(
getApiErrorMessage({
response: {
status: 418,
},
message: "Request failed with status code 418",
}),
).toBe("请求失败(HTTP 418");
});
});
+257
View File
@@ -0,0 +1,257 @@
type UnknownRecord = Record<string, unknown>;
type ErrorResponse = {
status?: number;
data?: unknown;
};
const FIELD_LABELS: Record<string, string> = {
scheme_name: "方案名称",
start_time: "开始时间",
modify_pattern_start_time: "开始时间",
target_time: "目标时间",
sampling_interval_minutes: "采样间隔",
duration: "持续时长",
modify_total_duration: "持续时长",
valves: "参与阀门",
valve_statuses: "阀门开关状态",
valve_settings: "阀门设置值",
valves_k: "阀门开度",
drainage_node_id: "排水节点",
drainage_node_ID: "排水节点",
burst_id: "爆管点",
burst_ID: "爆管点",
burst_size: "爆管流量",
burst_leakage: "爆管流量",
min_dpressure: "最小压降",
basic_pressure: "基准压力",
data_source: "数据来源",
scada_burst_start: "爆管开始时间",
scada_burst_end: "爆管结束时间",
use_scada_flow: "使用流量监测数据",
simulation_run_id: "模拟运行 ID",
source: "污染源节点",
concentration: "污染物浓度",
pattern: "污染物注入模式",
sensor_count: "监测点数量",
sensor_type: "监测点类型",
method: "优化方法",
min_diameter: "最小管径",
dma_count: "DMA 数量",
scada_start: "监测开始时间",
scada_end: "监测结束时间",
q_sum: "总漏损流量",
pop_size: "种群规模",
max_gen: "最大迭代次数",
flush_flow: "冲洗流量",
};
const STATUS_MESSAGES: Record<number, string> = {
400: "请求参数不正确",
401: "登录状态已失效,请重新登录",
403: "当前账号无权执行此操作",
404: "请求的数据不存在",
409: "当前数据已发生变化,请刷新后重试",
422: "请求参数校验失败",
429: "请求过于频繁,请稍后重试",
500: "服务处理失败,请稍后重试",
502: "上游服务暂时不可用,请稍后重试",
503: "服务暂时不可用,请稍后重试",
504: "服务响应超时,请稍后重试",
};
const asRecord = (value: unknown): UnknownRecord | null =>
value !== null && typeof value === "object"
? (value as UnknownRecord)
: null;
const nonEmptyString = (value: unknown): string | null =>
typeof value === "string" && value.trim() ? value.trim() : null;
const safeServerMessage = (value: unknown): string | null => {
const message = nonEmptyString(value);
if (!message || message.length > 300) return null;
if (/<!doctype|<html|<body|<script|<style|<[^>]+>/i.test(message)) {
return null;
}
return message;
};
const boundedText = (value: unknown, maxLength: number): string | null => {
const text = nonEmptyString(value);
if (!text) return null;
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
};
const boundedConstraint = (value: unknown, fallback: string) =>
value === null || value === undefined
? fallback
: (boundedText(String(value), 40) ?? fallback);
const getErrorResponse = (error: unknown): ErrorResponse | null => {
const candidate = asRecord(error);
const response = asRecord(candidate?.response);
if (!response) return null;
return {
status:
typeof response.status === "number" ? response.status : undefined,
data: response.data,
};
};
const formatValidationLocation = (location: unknown): string => {
if (!Array.isArray(location)) return "请求参数";
const segments = location.slice(0, 5).filter(
(segment, index) =>
!(
index === 0 &&
["body", "query", "path", "header"].includes(String(segment))
),
);
if (segments.length === 0) return "请求参数";
return segments.reduce<string>((result, segment, index) => {
if (typeof segment === "number") {
return `${result}[${segment + 1}]`;
}
const rawSegment = String(segment);
const label = FIELD_LABELS[rawSegment] ?? boundedText(rawSegment, 40) ?? "参数";
return index === 0 ? label : `${result}.${label}`;
}, "");
};
const translateValidationMessage = (error: UnknownRecord): string => {
const type = nonEmptyString(error.type);
const message = nonEmptyString(error.msg);
const context = asRecord(error.ctx);
switch (type) {
case "missing":
return "不能为空";
case "literal_error": {
const expected = boundedText(context?.expected, 100);
return expected
? `可选值为 ${expected.replace(/\s+or\s+/g, " 或 ")}`
: "取值不在允许范围内";
}
case "greater_than":
return `必须大于 ${boundedConstraint(context?.gt, "限定值")}`;
case "greater_than_equal":
return `必须大于或等于 ${boundedConstraint(context?.ge, "限定值")}`;
case "less_than":
return `必须小于 ${boundedConstraint(context?.lt, "限定值")}`;
case "less_than_equal":
return `必须小于或等于 ${boundedConstraint(context?.le, "限定值")}`;
case "int_parsing":
return "必须是整数";
case "float_parsing":
case "decimal_parsing":
return "必须是数字";
case "datetime_from_date_parsing":
case "datetime_parsing":
return "日期时间格式不正确";
case "string_too_short":
return `长度不能少于 ${boundedConstraint(context?.min_length, "要求的")} 个字符`;
case "string_too_long":
return `长度不能超过 ${boundedConstraint(context?.max_length, "允许的")} 个字符`;
default:
if (message === "Field required") return "不能为空";
if (message?.startsWith("Input should be ")) {
return boundedText(
message.replace("Input should be ", "可选值为 "),
120,
)!;
}
return boundedText(message, 120) ?? "参数无效";
}
};
const formatValidationErrors = (value: unknown): string | null => {
if (!Array.isArray(value) || value.length === 0) return null;
const messages = value
.slice(0, 3)
.map(asRecord)
.filter((item): item is UnknownRecord => item !== null)
.map(
(item) =>
`${formatValidationLocation(item.loc)}${translateValidationMessage(item)}`,
);
if (messages.length === 0) return null;
const visibleMessages = messages.join("");
const remainingCount = value.length - messages.length;
return remainingCount > 0
? `${visibleMessages};另有 ${remainingCount} 项参数错误`
: visibleMessages;
};
const appendTraceId = (message: string, traceId: string | null) =>
traceId ? `${message}(追踪 ID${traceId}` : message;
export const getApiErrorMessage = (
error: unknown,
fallback = "请求失败,请稍后重试",
): string => {
const candidate = asRecord(error);
const response = getErrorResponse(error);
const payload = asRecord(response?.data);
const status = response?.status;
const traceId = safeServerMessage(payload?.trace_id);
const isValidationResponse =
status === 400 ||
status === 422 ||
nonEmptyString(payload?.code) === "validation_error";
const validationErrors = isValidationResponse
? formatValidationErrors(
payload?.errors ??
(Array.isArray(payload?.detail) ? payload.detail : null),
)
: null;
if (validationErrors) {
return validationErrors;
}
const responseText = safeServerMessage(response?.data);
const detail = safeServerMessage(payload?.detail);
const responseMessage = safeServerMessage(payload?.message);
const statusMessage = status ? STATUS_MESSAGES[status] : null;
const unknownStatusMessage = status ? `请求失败(HTTP ${status}` : null;
if (status === 403) {
const permissionDetail = detail ?? responseMessage;
return permissionDetail && /[\u3400-\u9fff]/u.test(permissionDetail)
? permissionDetail
: STATUS_MESSAGES[403];
}
if (status && (status === 401 || status >= 500)) {
return appendTraceId(
statusMessage ?? unknownStatusMessage ?? fallback,
status >= 500 ? traceId : null,
);
}
const message =
detail ?? responseMessage ?? responseText ?? statusMessage ?? unknownStatusMessage;
if (message) {
return message;
}
const code = nonEmptyString(candidate?.code);
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
return "请求超时,请稍后重试";
}
if (
code === "ERR_NETWORK" ||
(candidate?.isAxiosError === true && !response)
) {
return "无法连接服务,请检查网络连接或服务状态";
}
return nonEmptyString(candidate?.message) ?? fallback;
};
+1 -8
View File
@@ -1,12 +1,9 @@
import { signOut } from "next-auth/react";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { import {
applyAuthContextHeaders, applyAuthContextHeaders,
type AuthContextHeaderOptions, type AuthContextHeaderOptions,
} from "@/lib/requestHeaders"; } from "@/lib/requestHeaders";
let isSigningOut = false;
const unwrapPage = async (response: Response) => { const unwrapPage = async (response: Response) => {
if ( if (
!response.headers.get("content-type")?.includes("application/json") !response.headers.get("content-type")?.includes("application/json")
@@ -58,11 +55,7 @@ export const apiFetch = async (
const response = await fetch(input, requestInit); const response = await fetch(input, requestInit);
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) { if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
useAuthStore.getState().setAccessToken(null); useAuthStore.getState().markSessionExpired("unauthorized");
if (!isSigningOut) {
isSigningOut = true;
await signOut({ redirect: true, callbackUrl: "/login" });
}
} }
return unwrapPage(response); return unwrapPage(response);
+3
View File
@@ -42,6 +42,9 @@ export const getAccessToken = async () => {
setAccessToken(null); setAccessToken(null);
} }
const session = await getSession(); const session = await getSession();
if (session?.error) {
return null;
}
const token = typeof session?.accessToken === "string" ? session.accessToken : null; const token = typeof session?.accessToken === "string" ? session.accessToken : null;
if (token && !isTokenExpired(token)) { if (token && !isTokenExpired(token)) {
setAccessToken(token); setAccessToken(token);
+2 -1
View File
@@ -46,9 +46,10 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => {
}; };
}; };
export const fetchAgentModels = async (): Promise<AgentModelConfig> => { export const fetchAgentModels = async (signal?: AbortSignal): Promise<AgentModelConfig> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
method: "GET", method: "GET",
signal,
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
}); });
+113
View File
@@ -2,6 +2,7 @@ import {
abortAgentChat, abortAgentChat,
forkAgentChat, forkAgentChat,
rejectAgentQuestion, rejectAgentQuestion,
replyAgentCredentialRefresh,
replyAgentPermission, replyAgentPermission,
replyAgentQuestion, replyAgentQuestion,
type StreamEvent, type StreamEvent,
@@ -99,6 +100,27 @@ describe("streamAgentChat", () => {
]); ]);
}); });
it("parses one complete final answer event", async () => {
mockNewSessionStream({
ok: true,
body: makeStream([
'event: final_answer\ndata: {"session_id":"s1","content":"完整分析结果"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({
message: "分析",
onEvent: (event) => events.push(event),
});
expect(events).toEqual([
{ type: "final_answer", sessionId: "s1", content: "完整分析结果" },
{ type: "done", sessionId: "s1" },
]);
});
it("parses state events from a resumed stream", async () => { it("parses state events from a resumed stream", async () => {
apiFetch.mockResolvedValue({ apiFetch.mockResolvedValue({
ok: true, ok: true,
@@ -171,6 +193,67 @@ describe("streamAgentChat", () => {
}); });
}); });
it("parses grouped activities and inherited permission reasons", async () => {
mockNewSessionStream({
ok: true,
body: makeStream([
'event: activity_update\ndata: {"session_id":"s1","activity":{"id":"a1","title":"准备分析数据","reason":"需要先确认输入数据完整。","status":"running","started_at":100,"elapsed_ms":25,"actions":[{"id":"x1","tool":"tjwater_cli","title":"查询后端数据","status":"running","target":"data list","started_at":110,"elapsed_ms":15}]},"todos":[{"id":"t1","content":"准备分析数据","status":"completed","priority":"high"},{"id":"t2","content":"生成分析结果","status":"in_progress","priority":"medium"}],"todos_created_at":125}\n\n',
'event: permission_request\ndata: {"session_id":"s1","request_id":"p1","permission":"bash","patterns":["python3 analysis.py"],"target":"python3 analysis.py","always":[],"activity_id":"a1","reason":"需要先确认输入数据完整。","created_at":123}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({ message: "分析", onEvent: (event) => events.push(event) });
expect(events[0]).toMatchObject({
type: "activity_update",
sessionId: "s1",
activity: {
id: "a1",
title: "准备分析数据",
reason: "需要先确认输入数据完整。",
status: "running",
actions: [expect.objectContaining({ id: "x1", target: "data list" })],
},
todos: [
expect.objectContaining({ id: "t1", status: "completed" }),
expect.objectContaining({ id: "t2", status: "in_progress" }),
],
todosCreatedAt: 125,
});
expect(events[1]).toMatchObject({
type: "permission_request",
activityId: "a1",
reason: "需要先确认输入数据完整。",
});
});
it("parses credential refresh lifecycle events", async () => {
mockNewSessionStream({
ok: true,
body: makeStream([
'event: credential_refresh_required\ndata: {"session_id":"s1","request_id":"credential-1","reason":"access_token_rejected","timeout_ms":30000}\n\n',
'event: credential_refreshed\ndata: {"session_id":"s1","request_id":"credential-1"}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event) });
expect(events).toEqual([
{
type: "credential_refresh_required",
sessionId: "s1",
requestId: "credential-1",
reason: "access_token_rejected",
timeoutMs: 30000,
},
{
type: "credential_refreshed",
sessionId: "s1",
requestId: "credential-1",
},
]);
});
it("parses tool_call arguments when params is empty", async () => { it("parses tool_call arguments when params is empty", async () => {
mockNewSessionStream({ mockNewSessionStream({
ok: true, ok: true,
@@ -219,6 +302,8 @@ describe("streamAgentChat", () => {
permission: "bash", permission: "bash",
patterns: ["rm *"], patterns: ["rm *"],
target: "rm tmp.txt", target: "rm tmp.txt",
activityId: undefined,
reason: undefined,
always: ["rm *"], always: ["rm *"],
tool: undefined, tool: undefined,
createdAt: 123, createdAt: 123,
@@ -363,6 +448,7 @@ describe("streamAgentChat", () => {
skipAuthRedirect: true, skipAuthRedirect: true,
}), }),
); );
}); });
it("calls permission reply endpoint", async () => { it("calls permission reply endpoint", async () => {
@@ -386,6 +472,33 @@ describe("streamAgentChat", () => {
}), }),
}), }),
); );
await replyAgentPermission("s1", "perm-2", "always");
expect(apiFetch).toHaveBeenLastCalledWith(
expect.stringContaining("/api/v1/agent/sessions/s1/permission-responses"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
request_id: "perm-2",
reply: "always",
}),
}),
);
});
it("submits refreshed credentials through the authenticated context", async () => {
apiFetch.mockResolvedValue({ ok: true, status: 202, text: async () => "" });
await replyAgentCredentialRefresh("s1", "credential-1");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/sessions/s1/credential-refreshes"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({ request_id: "credential-1" }),
projectHeaderMode: "include",
skipAuthRedirect: true,
}),
);
}); });
it("calls question reply and reject endpoints", async () => { it("calls question reply and reject endpoints", async () => {
+210 -26
View File
@@ -1,10 +1,16 @@
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config"; import { config } from "@config/config";
const AGENT_SESSIONS_URL = `${config.AGENT_URL}/api/v1/agent/sessions`;
const getAgentSessionUrl = (sessionId: string, suffix = "") =>
`${AGENT_SESSIONS_URL}/${encodeURIComponent(sessionId)}${suffix}`;
export type AgentModel = string; export type AgentModel = string;
export type PermissionReply = "once" | "always" | "reject"; export type PermissionDecision = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always"; export type PermissionReply = PermissionDecision;
export type AgentApprovalMode = "request" | "auto" | "always";
export type AgentQuestionStatus = export type AgentQuestionStatus =
| "pending" | "pending"
@@ -53,6 +59,35 @@ export type AgentTodoUpdate = {
createdAt: number; createdAt: number;
}; };
export type AgentActivityStatus = "running" | "completed" | "error" | "cancelled";
export type AgentActivityAction = {
id: string;
tool: string;
title: string;
status: "running" | "completed" | "error";
target?: string;
error?: string;
startedAt: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type AgentActivity = {
id: string;
title: string;
reason: string;
status: AgentActivityStatus;
actions: AgentActivityAction[];
startedAt: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type StreamEvent = export type StreamEvent =
| { | {
type: "state"; type: "state";
@@ -62,8 +97,16 @@ export type StreamEvent =
runStatus?: string; runStatus?: string;
} }
| { type: "token"; sessionId: string; content: string } | { type: "token"; sessionId: string; content: string }
| { type: "final_answer"; sessionId: string; content: string }
| { type: "done"; sessionId: string; totalDurationMs?: number } | { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string } | { type: "session_title"; sessionId: string; title: string }
| {
type: "activity_update";
sessionId: string;
activity: AgentActivity;
todos?: AgentTodoItem[];
todosCreatedAt?: number;
}
| { | {
type: "progress"; type: "progress";
sessionId: string; sessionId: string;
@@ -90,6 +133,24 @@ export type StreamEvent =
reason?: string; reason?: string;
message: string; message: string;
} }
| {
type: "credential_refresh_required";
sessionId: string;
requestId: string;
reason?: string;
timeoutMs?: number;
}
| {
type: "credential_refreshed";
sessionId: string;
requestId: string;
}
| {
type: "credential_refresh_failed";
sessionId: string;
requestId: string;
message: string;
}
| { | {
type: "tool_call"; type: "tool_call";
sessionId: string; sessionId: string;
@@ -103,6 +164,8 @@ export type StreamEvent =
permission: string; permission: string;
patterns: string[]; patterns: string[];
target?: string; target?: string;
activityId?: string;
reason?: string;
always: string[]; always: string[];
tool?: { tool?: {
messageID: string; messageID: string;
@@ -271,6 +334,47 @@ const normalizeTodos = (value: unknown): AgentTodoItem[] => {
})); }));
}; };
const normalizeActivityStatus = (value: unknown): AgentActivityStatus => {
if (value === "completed" || value === "error" || value === "cancelled") {
return value;
}
return "running";
};
const normalizeActivity = (value: unknown): AgentActivity | undefined => {
if (!isObjectRecord(value) || typeof value.id !== "string") return undefined;
const now = Date.now();
const actions: AgentActivityAction[] = Array.isArray(value.actions)
? value.actions.filter(isObjectRecord).map((action, index) => ({
id: typeof action.id === "string" ? action.id : `${value.id}-action-${index}`,
tool: typeof action.tool === "string" ? action.tool : "tool",
title: typeof action.title === "string" ? action.title : "执行操作",
status: action.status === "completed" || action.status === "error"
? action.status
: "running",
target: typeof action.target === "string" ? action.target : undefined,
error: typeof action.error === "string" ? action.error : undefined,
startedAt: typeof action.started_at === "number" ? action.started_at : now,
endedAt: typeof action.ended_at === "number" ? action.ended_at : undefined,
elapsedMs: typeof action.elapsed_ms === "number" ? action.elapsed_ms : undefined,
elapsedSnapshotAt: typeof action.elapsed_ms === "number" ? now : undefined,
durationMs: typeof action.duration_ms === "number" ? action.duration_ms : undefined,
}))
: [];
return {
id: value.id,
title: typeof value.title === "string" ? value.title : "正在处理",
reason: typeof value.reason === "string" ? value.reason : "",
status: normalizeActivityStatus(value.status),
actions,
startedAt: typeof value.started_at === "number" ? value.started_at : now,
endedAt: typeof value.ended_at === "number" ? value.ended_at : undefined,
elapsedMs: typeof value.elapsed_ms === "number" ? value.elapsed_ms : undefined,
elapsedSnapshotAt: typeof value.elapsed_ms === "number" ? now : undefined,
durationMs: typeof value.duration_ms === "number" ? value.duration_ms : undefined,
};
};
const emitParsedStreamEvent = ( const emitParsedStreamEvent = (
event: string, event: string,
data: string, data: string,
@@ -303,6 +407,7 @@ const emitParsedStreamEvent = (
target?: string; target?: string;
always?: unknown; always?: unknown;
created_at?: number; created_at?: number;
todos_created_at?: number;
reply?: PermissionReply; reply?: PermissionReply;
questions?: unknown; questions?: unknown;
answers?: unknown; answers?: unknown;
@@ -310,6 +415,9 @@ const emitParsedStreamEvent = (
message_id?: string; message_id?: string;
todos?: unknown; todos?: unknown;
reason?: string; reason?: string;
timeout_ms?: number;
activity?: unknown;
activity_id?: string;
}; };
if (event === "state") { if (event === "state") {
onEvent({ onEvent({
@@ -325,6 +433,12 @@ const emitParsedStreamEvent = (
sessionId: parsed.session_id ?? "", sessionId: parsed.session_id ?? "",
content: parsed.content ?? "", content: parsed.content ?? "",
}); });
} else if (event === "final_answer") {
onEvent({
type: "final_answer",
sessionId: parsed.session_id ?? "",
content: parsed.content ?? "",
});
} else if (event === "progress") { } else if (event === "progress") {
onEvent({ onEvent({
type: "progress", type: "progress",
@@ -339,6 +453,19 @@ const emitParsedStreamEvent = (
elapsedMs: parsed.elapsed_ms, elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms, durationMs: parsed.duration_ms,
}); });
} else if (event === "activity_update") {
const activity = normalizeActivity(parsed.activity);
if (activity) {
onEvent({
type: "activity_update",
sessionId: parsed.session_id ?? "",
activity,
todos: Array.isArray(parsed.todos)
? normalizeTodos(parsed.todos)
: undefined,
todosCreatedAt: parsed.todos_created_at,
});
}
} else if (event === "done") { } else if (event === "done") {
onEvent({ onEvent({
type: "done", type: "done",
@@ -366,6 +493,27 @@ const emitParsedStreamEvent = (
reason: parsed.reason, reason: parsed.reason,
message: parsed.message ?? "登录态已过期,请刷新登录后重试", message: parsed.message ?? "登录态已过期,请刷新登录后重试",
}); });
} else if (event === "credential_refresh_required") {
onEvent({
type: "credential_refresh_required",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
reason: parsed.reason,
timeoutMs: parsed.timeout_ms,
});
} else if (event === "credential_refreshed") {
onEvent({
type: "credential_refreshed",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
});
} else if (event === "credential_refresh_failed") {
onEvent({
type: "credential_refresh_failed",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
message: parsed.message ?? "登录凭据续期失败",
});
} else if (event === "tool_call") { } else if (event === "tool_call") {
onEvent({ onEvent({
type: "tool_call", type: "tool_call",
@@ -383,6 +531,8 @@ const emitParsedStreamEvent = (
? parsed.patterns.filter((item): item is string => typeof item === "string") ? parsed.patterns.filter((item): item is string => typeof item === "string")
: [], : [],
target: typeof parsed.target === "string" ? parsed.target : undefined, target: typeof parsed.target === "string" ? parsed.target : undefined,
activityId: typeof parsed.activity_id === "string" ? parsed.activity_id : undefined,
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
always: Array.isArray(parsed.always) always: Array.isArray(parsed.always)
? parsed.always.filter((item): item is string => typeof item === "string") ? parsed.always.filter((item): item is string => typeof item === "string")
: [], : [],
@@ -469,7 +619,7 @@ const readStreamEvents = async (
const ensureAgentSession = async (sessionId?: string) => { const ensureAgentSession = async (sessionId?: string) => {
if (sessionId) return sessionId; if (sessionId) return sessionId;
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, { const response = await apiFetch(AGENT_SESSIONS_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -479,7 +629,10 @@ const ensureAgentSession = async (sessionId?: string) => {
skipAuthRedirect: true, skipAuthRedirect: true,
}); });
if (!response.ok) { if (!response.ok) {
throw new Error((await response.text()) || `session creation failed: ${response.status}`); throw new Error(
(await response.text()) ||
`session creation failed: ${response.status}`,
);
} }
const payload = (await response.json()) as { session_id?: string }; const payload = (await response.json()) as { session_id?: string };
if (!payload.session_id) { if (!payload.session_id) {
@@ -500,7 +653,7 @@ export const streamAgentChat = async ({
try { try {
const effectiveSessionId = await ensureAgentSession(sessionId); const effectiveSessionId = await ensureAgentSession(sessionId);
response = await apiFetch( response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(effectiveSessionId)}/runs`, getAgentSessionUrl(effectiveSessionId, "/runs"),
{ {
method: "POST", method: "POST",
signal, signal,
@@ -557,7 +710,7 @@ export const resumeAgentChatStream = async ({
let response: Response; let response: Response;
try { try {
response = await apiFetch( response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current/events`, getAgentSessionUrl(sessionId, "/runs/current/events"),
{ {
method: "GET", method: "GET",
signal, signal,
@@ -598,11 +751,14 @@ export const abortAgentChat = async (sessionId?: string) => {
return; return;
} }
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current`, { const response = await apiFetch(
method: "DELETE", getAgentSessionUrl(sessionId, "/runs/current"),
projectHeaderMode: "include", {
skipAuthRedirect: true, method: "DELETE",
}); projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();
@@ -613,10 +769,10 @@ export const abortAgentChat = async (sessionId?: string) => {
export const replyAgentPermission = async ( export const replyAgentPermission = async (
sessionId: string, sessionId: string,
requestId: string, requestId: string,
reply: PermissionReply, reply: PermissionDecision,
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/permission-responses`, getAgentSessionUrl(sessionId, "/permission-responses"),
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -637,13 +793,35 @@ export const replyAgentPermission = async (
} }
}; };
export const replyAgentCredentialRefresh = async (
sessionId: string,
requestId: string,
) => {
const response = await apiFetch(
getAgentSessionUrl(sessionId, "/credential-refreshes"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId }),
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(
detail || `credential refresh reply failed: ${response.status}`,
);
}
};
export const replyAgentQuestion = async ( export const replyAgentQuestion = async (
sessionId: string, sessionId: string,
requestId: string, requestId: string,
answers: string[][], answers: string[][],
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`, getAgentSessionUrl(sessionId, "/question-responses"),
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -670,7 +848,7 @@ export const rejectAgentQuestion = async (
requestId: string, requestId: string,
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`, getAgentSessionUrl(sessionId, "/question-responses"),
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -691,18 +869,24 @@ export const rejectAgentQuestion = async (
} }
}; };
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => { export const forkAgentChat = async (
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId ?? "")}/forks`, { sessionId: string | undefined,
method: "POST", keepMessageCount: number,
headers: { ) => {
"Content-Type": "application/json", const response = await apiFetch(
getAgentSessionUrl(sessionId ?? "", "/forks"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
keep_message_count: keepMessageCount,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
}, },
body: JSON.stringify({ );
keep_message_count: keepMessageCount,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();

Some files were not shown because too many files have changed in this diff Show More