Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ebe3328aa | ||
|
|
b04378397c | ||
|
|
bad8769000 | ||
|
|
29b8babd68 | ||
|
|
0dad61ff1f | ||
|
|
fee4fc6ce1 | ||
|
|
6623f4f7fb | ||
|
|
d2ce1687f2 | ||
|
|
dca2817320 | ||
|
|
41bf7d71e3 | ||
|
|
5bea86dbf6 | ||
|
|
78a470e89e | ||
|
|
6d505ca461 | ||
|
|
f34d81c933 | ||
|
|
5053ddcd1f | ||
|
|
45def5bba3 | ||
|
|
9e79d52dc8 | ||
|
|
4a978de905 | ||
|
|
f5bcf28f7e | ||
|
|
1ac7372585 | ||
|
|
ab45c8da8e | ||
|
|
5d730ba1b8 | ||
|
|
caf18f706d | ||
|
|
0dd521d8c9 | ||
|
|
0a47534ddb | ||
|
|
9e75e2df8a | ||
|
|
8d7c947897 | ||
|
|
71bde7d9e4 | ||
|
|
5037089057 | ||
|
|
b4c96f8524 | ||
|
|
e496fbe4b7 | ||
|
|
5d9c40e454 | ||
|
|
5592c27386 | ||
|
|
94142d7031 |
+3
-3
@@ -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
@@ -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."
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ npm run start
|
|||||||
|
|
||||||
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
||||||
|
|
||||||
|
When the Server API changes, sync `contracts/server-v1.openapi.json` from the
|
||||||
|
backend source of truth, update its SHA-256 in `contracts/manifest.json`, then
|
||||||
|
run `npm run api:generate` and `npm run api:check` so the generated types and
|
||||||
|
contract mirror remain aligned.
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1998
-13998
File diff suppressed because it is too large
Load Diff
Generated
+22
-9
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { App } from "./RefineContext";
|
||||||
|
|
||||||
|
let mockSessionState: {
|
||||||
|
data: {
|
||||||
|
accessToken: string;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
} | null;
|
||||||
|
status: "authenticated" | "unauthenticated";
|
||||||
|
} = {
|
||||||
|
data: {
|
||||||
|
accessToken: "expired-access-token",
|
||||||
|
user: { id: "user-1", name: "Test User" },
|
||||||
|
},
|
||||||
|
status: "authenticated",
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("next-auth/react", () => ({
|
||||||
|
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
signIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
useSession: () => mockSessionState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/network-simulation",
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
Refine: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@refinedev/kbar", () => ({
|
||||||
|
RefineKbar: () => null,
|
||||||
|
RefineKbarProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@refinedev/mui", () => ({
|
||||||
|
RefineSnackbarProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@refinedev/nextjs-router", () => ({}));
|
||||||
|
jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
|
||||||
|
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
|
||||||
|
useAppNotificationProvider: {},
|
||||||
|
}));
|
||||||
|
jest.mock("@contexts/color-mode", () => ({
|
||||||
|
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
jest.mock("@/contexts/ProjectContext", () => ({
|
||||||
|
ProjectProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
jest.mock("@/lib/authToken", () => ({
|
||||||
|
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("RefineContext access authentication", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalRequest = global.Request;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSessionState = {
|
||||||
|
data: {
|
||||||
|
accessToken: "expired-access-token",
|
||||||
|
user: { id: "user-1", name: "Test User" },
|
||||||
|
},
|
||||||
|
status: "authenticated",
|
||||||
|
};
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
useAccessStore.setState({
|
||||||
|
context: null,
|
||||||
|
permissions: [],
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
global.Request = class TestRequest {} as unknown as typeof Request;
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
headers: new Headers(),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
global.Request = originalRequest;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the session expired when access-context rejects an expired token", async () => {
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/access-context"),
|
||||||
|
expect.not.objectContaining({ skipAuthRedirect: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes an unauthenticated session over route permissions", async () => {
|
||||||
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+47
-25
@@ -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";
|
||||||
@@ -53,10 +56,12 @@ type AppProps = {
|
|||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
export 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,24 @@ 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 === "unauthenticated") {
|
||||||
|
markSessionExpired("unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "authenticated") {
|
||||||
|
clearSessionExpired();
|
||||||
|
}
|
||||||
|
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
resetAccess();
|
resetAccess();
|
||||||
@@ -80,7 +103,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
setAccessLoading(true);
|
setAccessLoading(true);
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||||
skipAuthRedirect: true,
|
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -135,28 +157,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,7 +370,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
warnWhenUnsavedChanges: true,
|
warnWhenUnsavedChanges: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
|
<RoutePermissionGuard authenticated={status === "authenticated"}>
|
||||||
|
{props.children}
|
||||||
|
</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<RefineKbar />
|
||||||
</Refine>
|
</Refine>
|
||||||
</RefineSnackbarProvider>
|
</RefineSnackbarProvider>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
@@ -642,7 +657,6 @@ export const SystemAdminPanel = () => {
|
|||||||
try {
|
try {
|
||||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
||||||
projectHeaderMode: "omit",
|
projectHeaderMode: "omit",
|
||||||
skipAuthRedirect: true,
|
|
||||||
});
|
});
|
||||||
if (!adminResponse.ok) {
|
if (!adminResponse.ok) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { RoutePermissionGuard } from "./RoutePermissionGuard";
|
||||||
|
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/network-simulation",
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("RoutePermissionGuard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAccessStore.setState({
|
||||||
|
context: null,
|
||||||
|
permissions: [],
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes an expired session over a missing route permission", () => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard authenticated>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("正在跳转到登录页面…")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/simulation\.view/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the permission error when the session is still valid", () => {
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard authenticated>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks authentication before the expired state effect runs", () => {
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard authenticated={false}>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,17 +7,36 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { permissionForPath } from "@/lib/permissions";
|
import { permissionForPath } from "@/lib/permissions";
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export const RoutePermissionGuard = ({
|
export const RoutePermissionGuard = ({
|
||||||
children,
|
children,
|
||||||
|
authenticated,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
authenticated: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
const loading = useAccessStore((state) => state.loading);
|
const loading = useAccessStore((state) => state.loading);
|
||||||
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
const requiredPermission = permissionForPath(pathname);
|
const requiredPermission = permissionForPath(pathname);
|
||||||
|
|
||||||
|
if (!authenticated || sessionExpired) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="body1">登录状态已失效</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
正在跳转到登录页面…
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (requiredPermission && loading) {
|
if (requiredPermission && loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { SessionExpiryDialog } from "./SessionExpiryDialog";
|
||||||
|
|
||||||
|
jest.mock("next-auth/react", () => ({
|
||||||
|
signIn: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("SessionExpiryDialog", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.mocked(signIn).mockReset().mockResolvedValue(undefined);
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => useAuthStore.getState().clearSessionExpired());
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the expired state before automatically starting login", () => {
|
||||||
|
window.history.replaceState({}, "", "/network-simulation?tab=history");
|
||||||
|
|
||||||
|
render(<SessionExpiryDialog />);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录已过期")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("即将自动跳转到登录页面。"))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(signIn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signIn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(signIn).toHaveBeenCalledWith("keycloak", {
|
||||||
|
callbackUrl: "/network-simulation?tab=history",
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a retry when starting automatic login fails", async () => {
|
||||||
|
jest.mocked(signIn)
|
||||||
|
.mockRejectedValueOnce(new Error("network unavailable"))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
render(<SessionExpiryDialog />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(2_000);
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重新认证" }));
|
||||||
|
|
||||||
|
expect(signIn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, 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;
|
||||||
|
const EXPIRED_REDIRECT_DELAY_MS = 2_000;
|
||||||
|
|
||||||
|
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);
|
||||||
|
const redirectStartedRef = useRef(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 = useCallback(() => {
|
||||||
|
if (redirectStartedRef.current) return;
|
||||||
|
redirectStartedRef.current = true;
|
||||||
|
const callbackUrl = `${window.location.pathname}${window.location.search}`;
|
||||||
|
void signIn("keycloak", { callbackUrl, redirect: true }).catch(() => {
|
||||||
|
redirectStartedRef.current = false;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionExpired) {
|
||||||
|
redirectStartedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(
|
||||||
|
handleReauthenticate,
|
||||||
|
EXPIRED_REDIRECT_DELAY_MS,
|
||||||
|
);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [handleReauthenticate, sessionExpired]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
{sessionExpired && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) }}
|
||||||
|
|||||||
@@ -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)}` },
|
||||||
|
|||||||
@@ -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) },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
describeApplyLayerStyle,
|
describeApplyLayerStyle,
|
||||||
parseApplyLayerStylePayload,
|
parseApplyLayerStylePayload,
|
||||||
} from "./toolCallStyleHelpers";
|
} from "./toolCallStyleHelpers";
|
||||||
|
import { buildViewHistoryAction } from "./historyToolAction";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Interactive card rendered inside a chat bubble for tool actions */
|
/* Interactive card rendered inside a chat bubble for tool actions */
|
||||||
@@ -45,12 +46,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"]);
|
||||||
@@ -428,17 +429,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "view_history": {
|
case "view_history": {
|
||||||
const historyRange = resolveTimeRange();
|
return buildViewHistoryAction(params);
|
||||||
return {
|
|
||||||
type: "view_history",
|
|
||||||
featureInfos:
|
|
||||||
(params.feature_infos as [string, string][] | undefined) ?? [],
|
|
||||||
dataType:
|
|
||||||
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
|
||||||
"realtime",
|
|
||||||
startTime: historyRange.startTime,
|
|
||||||
endTime: historyRange.endTime,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case "view_scada": {
|
case "view_scada": {
|
||||||
const scadaRange = resolveTimeRange();
|
const scadaRange = resolveTimeRange();
|
||||||
@@ -672,22 +663,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { buildViewHistoryAction } from "./historyToolAction";
|
||||||
|
|
||||||
|
describe("buildViewHistoryAction", () => {
|
||||||
|
it("preserves the scheme run ID from Agent tool parameters", () => {
|
||||||
|
expect(
|
||||||
|
buildViewHistoryAction({
|
||||||
|
feature_infos: [["P-1", "pipe"]],
|
||||||
|
data_type: "scheme",
|
||||||
|
run_id: " 99dd4142-368b-54cb-bfca-d59ee48f6298 ",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
featureInfos: [["P-1", "pipe"]],
|
||||||
|
dataType: "scheme",
|
||||||
|
runId: "99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes invalid history parameters", () => {
|
||||||
|
expect(
|
||||||
|
buildViewHistoryAction({
|
||||||
|
feature_infos: [["", "pipe"], "invalid"],
|
||||||
|
data_type: "unexpected",
|
||||||
|
runId: " ",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "view_history",
|
||||||
|
featureInfos: [],
|
||||||
|
dataType: "realtime",
|
||||||
|
runId: undefined,
|
||||||
|
startTime: undefined,
|
||||||
|
endTime: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ChatToolAction } from "@/store/chatToolStore";
|
||||||
|
|
||||||
|
type ViewHistoryAction = Extract<ChatToolAction, { type: "view_history" }>;
|
||||||
|
|
||||||
|
const readOptionalString = (value: unknown) =>
|
||||||
|
typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
|
||||||
|
export const buildViewHistoryAction = (
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): ViewHistoryAction => {
|
||||||
|
const rawFeatureInfos = Array.isArray(params.feature_infos)
|
||||||
|
? params.feature_infos
|
||||||
|
: [];
|
||||||
|
const featureInfos = rawFeatureInfos
|
||||||
|
.filter(
|
||||||
|
(item): item is [unknown, unknown] =>
|
||||||
|
Array.isArray(item) && item.length >= 2,
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
([id, type]) => [String(id).trim(), String(type).trim()] as [string, string],
|
||||||
|
)
|
||||||
|
.filter(([id, type]) => id.length > 0 && type.length > 0);
|
||||||
|
const rawDataType = params.data_type;
|
||||||
|
const dataType =
|
||||||
|
rawDataType === "realtime" ||
|
||||||
|
rawDataType === "scheme" ||
|
||||||
|
rawDataType === "none"
|
||||||
|
? rawDataType
|
||||||
|
: "realtime";
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "view_history",
|
||||||
|
featureInfos,
|
||||||
|
dataType,
|
||||||
|
runId: readOptionalString(params.run_id ?? params.runId),
|
||||||
|
startTime: readOptionalString(
|
||||||
|
params.start_time ?? params.startTime ?? params.from ?? params.start,
|
||||||
|
),
|
||||||
|
endTime: readOptionalString(
|
||||||
|
params.end_time ?? params.endTime ?? params.to ?? params.end,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
describeApplyLayerStyle,
|
describeApplyLayerStyle,
|
||||||
parseApplyLayerStylePayload,
|
parseApplyLayerStylePayload,
|
||||||
} from "../toolCallStyleHelpers";
|
} from "../toolCallStyleHelpers";
|
||||||
|
import { buildViewHistoryAction } from "../historyToolAction";
|
||||||
|
|
||||||
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
||||||
|
|
||||||
@@ -22,30 +23,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 = [
|
||||||
@@ -253,21 +254,12 @@ const buildToolAction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tool === "view_history") {
|
if (tool === "view_history") {
|
||||||
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
|
const action = buildViewHistoryAction(params);
|
||||||
const { startTime, endTime } = resolveTimeRange(params);
|
|
||||||
return {
|
return {
|
||||||
action: {
|
action,
|
||||||
type: "view_history",
|
|
||||||
featureInfos,
|
|
||||||
dataType:
|
|
||||||
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
|
||||||
"realtime",
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
},
|
|
||||||
kind: "panel",
|
kind: "panel",
|
||||||
title: "打开计算结果曲线",
|
title: "打开计算结果曲线",
|
||||||
description: compactNames(featureInfos.map(([id]) => id)),
|
description: compactNames(action.featureInfos.map(([id]) => id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
buildBurstDetectionRequest,
|
buildBurstDetectionRequest,
|
||||||
createBurstDetectionAnalysisParametersState,
|
createBurstDetectionAnalysisParametersState,
|
||||||
|
fetchPressureSamplingInterval,
|
||||||
parseScadaFrequencyMinutes,
|
parseScadaFrequencyMinutes,
|
||||||
resolvePressureSamplingInterval,
|
resolvePressureSamplingInterval,
|
||||||
} from "./AnalysisParameters";
|
} from "./AnalysisParameters";
|
||||||
|
|
||||||
|
jest.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const get = api.get as jest.Mock;
|
||||||
|
|
||||||
describe("burst detection request", () => {
|
describe("burst detection request", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
it("requests the latest complete monitoring time by default", () => {
|
it("requests the latest complete monitoring time by default", () => {
|
||||||
const state = createBurstDetectionAnalysisParametersState();
|
const state = createBurstDetectionAnalysisParametersState();
|
||||||
state.schemeName = " latest-case ";
|
state.schemeName = " latest-case ";
|
||||||
@@ -45,11 +59,27 @@ describe("burst detection request", () => {
|
|||||||
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
|
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
|
||||||
expect(
|
expect(
|
||||||
resolvePressureSamplingInterval([
|
resolvePressureSamplingInterval([
|
||||||
{ type: "pressure", transmission_frequency: "0:15:00" },
|
{ device_type: "pressure", transmission_frequency: "0:15:00" },
|
||||||
{ type: "pressure", transmission_frequency: "0:15:00" },
|
{ device_type: "pressure", transmission_frequency: "0:15:00" },
|
||||||
{ type: "pressure", transmission_frequency: "0:30:00" },
|
{ device_type: "pressure", transmission_frequency: "0:30:00" },
|
||||||
{ type: "pipe_flow", transmission_frequency: "1:00:00" },
|
{ device_type: "pipe_flow", transmission_frequency: "1:00:00" },
|
||||||
]),
|
]),
|
||||||
).toBe(15);
|
).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads the pressure frequency from the pooled SCADA metadata endpoint", async () => {
|
||||||
|
get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
count: 2,
|
||||||
|
data: [
|
||||||
|
{ device_type: "pressure", transmission_frequency: "0:30:00" },
|
||||||
|
{ device_type: "pipe_flow", transmission_frequency: "0:15:00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchPressureSamplingInterval()).resolves.toBe(30);
|
||||||
|
expect(get).toHaveBeenCalledWith("/api/v1/scada-info/database-view");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -37,10 +46,14 @@ export interface BurstDetectionAnalysisParametersState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ScadaInfoItem {
|
interface ScadaInfoItem {
|
||||||
type?: string;
|
device_type?: string;
|
||||||
transmission_frequency?: string | number | null;
|
transmission_frequency?: string | number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScadaInfoResponse {
|
||||||
|
data?: ScadaInfoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
const currentQuarterHour = () => {
|
const currentQuarterHour = () => {
|
||||||
const now = dayjs().second(0).millisecond(0);
|
const now = dayjs().second(0).millisecond(0);
|
||||||
return now.minute(Math.floor(now.minute() / 15) * 15);
|
return now.minute(Math.floor(now.minute() / 15) * 15);
|
||||||
@@ -48,7 +61,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,
|
||||||
@@ -77,7 +90,7 @@ export const parseScadaFrequencyMinutes = (
|
|||||||
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||||
const counts = new Map<number, number>();
|
const counts = new Map<number, number>();
|
||||||
items
|
items
|
||||||
.filter((item) => item.type?.toLowerCase() === "pressure")
|
.filter((item) => item.device_type?.toLowerCase() === "pressure")
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
|
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
|
||||||
if (minutes && 1440 % minutes === 0) {
|
if (minutes && 1440 % minutes === 0) {
|
||||||
@@ -90,10 +103,19 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
|||||||
)[0]?.[0] ?? 15;
|
)[0]?.[0] ?? 15;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchPressureSamplingInterval = async () => {
|
||||||
|
const response = await api.get<ScadaInfoResponse>(
|
||||||
|
"/api/v1/scada-info/database-view",
|
||||||
|
);
|
||||||
|
return resolvePressureSamplingInterval(
|
||||||
|
Array.isArray(response.data.data) ? response.data.data : [],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
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,18 +143,20 @@ 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;
|
||||||
let active = true;
|
let active = true;
|
||||||
setFrequencyLoading(true);
|
setFrequencyLoading(true);
|
||||||
api
|
fetchPressureSamplingInterval()
|
||||||
.get("/api/v1/scada-info")
|
.then((interval) => {
|
||||||
.then((response) => {
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const interval = resolvePressureSamplingInterval(
|
|
||||||
response.data as ScadaInfoItem[],
|
|
||||||
);
|
|
||||||
setParametersState((previous) =>
|
setParametersState((previous) =>
|
||||||
previous.samplingIntervalSource === "metadata"
|
previous.samplingIntervalSource === "metadata"
|
||||||
? { ...previous, samplingIntervalMinutes: interval }
|
? { ...previous, samplingIntervalMinutes: interval }
|
||||||
@@ -157,7 +181,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 +220,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 +243,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import SchemeQuery, {
|
|||||||
createBurstDetectionSchemeQueryState,
|
createBurstDetectionSchemeQueryState,
|
||||||
type BurstDetectionSchemeQueryState,
|
type BurstDetectionSchemeQueryState,
|
||||||
} from "./SchemeQuery";
|
} from "./SchemeQuery";
|
||||||
|
import { useData } from "@components/olmap/core/MapComponent";
|
||||||
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
|
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
@@ -38,6 +39,7 @@ const TabPanel = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const BurstDetectionPanel: React.FC = () => {
|
const BurstDetectionPanel: React.FC = () => {
|
||||||
|
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
||||||
@@ -58,10 +60,23 @@ const BurstDetectionPanel: React.FC = () => {
|
|||||||
const drawerWidth = 450;
|
const drawerWidth = 450;
|
||||||
const panelTitle = "爆管侦测";
|
const panelTitle = "爆管侦测";
|
||||||
|
|
||||||
const handleResult = useCallback((payload: BurstDetectionResult) => {
|
const handleResult = useCallback(
|
||||||
setResult(payload);
|
(payload: BurstDetectionResult) => {
|
||||||
setTab(2);
|
setSchemeRunId?.("");
|
||||||
}, []);
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewResult = useCallback(
|
||||||
|
(payload: BurstDetectionResult, runId: string) => {
|
||||||
|
setSchemeRunId?.(runId);
|
||||||
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -166,7 +181,7 @@ const BurstDetectionPanel: React.FC = () => {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={1}>
|
<TabPanel value={tab} index={1}>
|
||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
onViewResult={handleResult}
|
onViewResult={handleViewResult}
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
state={queryState}
|
state={queryState}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: BurstDetectionResult) => void;
|
onViewResult: (result: BurstDetectionResult, runId: string) => void;
|
||||||
schemes?: BurstDetectionSchemeRecord[];
|
schemes?: BurstDetectionSchemeRecord[];
|
||||||
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
||||||
state?: BurstDetectionSchemeQueryState;
|
state?: BurstDetectionSchemeQueryState;
|
||||||
@@ -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 =
|
||||||
@@ -181,11 +174,11 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
throw new Error("方案详情缺少侦测结果数据");
|
throw new Error("方案详情缺少侦测结果数据");
|
||||||
}
|
}
|
||||||
|
|
||||||
onViewResult(normalizedResult);
|
onViewResult(normalizedResult, runId);
|
||||||
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>
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import SchemeQuery, {
|
|||||||
type BurstLocationSchemeQueryState,
|
type BurstLocationSchemeQueryState,
|
||||||
} from "./SchemeQuery";
|
} from "./SchemeQuery";
|
||||||
import { BurstLocationResult, BurstSchemeRecord } from "./types";
|
import { BurstLocationResult, BurstSchemeRecord } from "./types";
|
||||||
|
import { useData } from "@components/olmap/core/MapComponent";
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
value,
|
value,
|
||||||
@@ -35,6 +36,7 @@ const TabPanel = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const BurstLocationPanel: React.FC = () => {
|
const BurstLocationPanel: React.FC = () => {
|
||||||
|
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
||||||
@@ -51,15 +53,23 @@ const BurstLocationPanel: React.FC = () => {
|
|||||||
const drawerWidth = 450;
|
const drawerWidth = 450;
|
||||||
const panelTitle = "爆管定位";
|
const panelTitle = "爆管定位";
|
||||||
|
|
||||||
const handleResult = useCallback((payload: BurstLocationResult) => {
|
const handleResult = useCallback(
|
||||||
setResult(payload);
|
(payload: BurstLocationResult) => {
|
||||||
setTab(2);
|
setSchemeRunId?.("");
|
||||||
}, []);
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewResult = useCallback((payload: BurstLocationResult) => {
|
const handleViewResult = useCallback(
|
||||||
setResult(payload);
|
(payload: BurstLocationResult, runId: string) => {
|
||||||
setTab(2);
|
setSchemeRunId?.(runId);
|
||||||
}, []);
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -46,7 +46,7 @@ import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorNam
|
|||||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: BurstLocationResult) => void;
|
onViewResult: (result: BurstLocationResult, runId: string) => void;
|
||||||
schemes?: BurstSchemeRecord[];
|
schemes?: BurstSchemeRecord[];
|
||||||
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
|
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
|
||||||
state?: BurstLocationSchemeQueryState;
|
state?: BurstLocationSchemeQueryState;
|
||||||
@@ -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 =
|
||||||
@@ -298,11 +285,11 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
if (!normalizedResult) {
|
if (!normalizedResult) {
|
||||||
throw new Error("方案详情缺少定位结果数据");
|
throw new Error("方案详情缺少定位结果数据");
|
||||||
}
|
}
|
||||||
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
|
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult), runId);
|
||||||
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) {
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FormatListBulleted,
|
FormatListBulleted,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
||||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
import AnalysisParameters, {
|
import AnalysisParameters, {
|
||||||
createDMALeakAnalysisParametersState,
|
createDMALeakAnalysisParametersState,
|
||||||
@@ -50,6 +50,7 @@ const DMA_AREA_INDEX_PROPERTY = "dma_area_index";
|
|||||||
|
|
||||||
const DMALeakDetectionPanel: React.FC = () => {
|
const DMALeakDetectionPanel: React.FC = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<LeakageResultDetail | null>(null);
|
const [result, setResult] = useState<LeakageResultDetail | null>(null);
|
||||||
@@ -79,15 +80,23 @@ const DMALeakDetectionPanel: React.FC = () => {
|
|||||||
[activeAreas.length],
|
[activeAreas.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAnalysisResult = useCallback((res: LeakageResultDetail) => {
|
const handleAnalysisResult = useCallback(
|
||||||
setResult(res);
|
(res: LeakageResultDetail) => {
|
||||||
}, []);
|
setSchemeRunId?.("");
|
||||||
|
setResult(res);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewResult = useCallback((res: LeakageResultDetail) => {
|
const handleViewResult = useCallback(
|
||||||
setResult(res);
|
(res: LeakageResultDetail, runId: string) => {
|
||||||
setLoadedResult(res);
|
setSchemeRunId?.(runId);
|
||||||
setTab(2);
|
setResult(res);
|
||||||
}, []);
|
setLoadedResult(res);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|||||||
@@ -21,16 +21,23 @@ 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,
|
||||||
|
normalizeLeakageRows,
|
||||||
|
} from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3h } 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";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: LeakageResultDetail) => void;
|
onViewResult: (result: LeakageResultDetail, runId: string) => void;
|
||||||
schemes?: LeakageSchemeRecord[];
|
schemes?: LeakageSchemeRecord[];
|
||||||
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
|
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
|
||||||
state?: DMALeakSchemeQueryState;
|
state?: DMALeakSchemeQueryState;
|
||||||
@@ -40,7 +47,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 +91,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 +125,34 @@ 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"),
|
||||||
|
]);
|
||||||
|
const result = results[0]?.payload;
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("方案详情缺少漏损识别结果");
|
||||||
|
}
|
||||||
|
onViewResult(
|
||||||
{
|
{
|
||||||
params: {
|
...result,
|
||||||
scheme_type: "dma_leak_identification",
|
rows: normalizeLeakageRows(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,
|
||||||
|
runId,
|
||||||
);
|
);
|
||||||
onViewResult(response.data 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 +308,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>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { normalizeLeakageRows } from "./types";
|
||||||
|
|
||||||
|
const migratedRow = {
|
||||||
|
Area: "DMA-01",
|
||||||
|
LeakageRatioRaw: 0.12,
|
||||||
|
LeakageRatio: 0.1,
|
||||||
|
LeakageFlow_m3_per_s: 0.02,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("normalizeLeakageRows", () => {
|
||||||
|
it("restores migrated DMA results stored under result_rows", () => {
|
||||||
|
expect(normalizeLeakageRows({ result_rows: [migratedRow] })).toEqual([
|
||||||
|
migratedRow,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the canonical rows field when both shapes are present", () => {
|
||||||
|
const canonicalRow = { ...migratedRow, Area: "DMA-02" };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeLeakageRows({
|
||||||
|
rows: [canonicalRow],
|
||||||
|
result_rows: [migratedRow],
|
||||||
|
}),
|
||||||
|
).toEqual([canonicalRow]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,20 @@ export interface LeakageRow {
|
|||||||
LeakageFlow_m3_per_s: number;
|
LeakageFlow_m3_per_s: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeLeakageRows = (
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): LeakageRow[] => {
|
||||||
|
if (Array.isArray(payload.rows)) {
|
||||||
|
return payload.rows as LeakageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(payload.result_rows)
|
||||||
|
? (payload.result_rows as 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";
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
+47
-4
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-3
@@ -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,101 @@
|
|||||||
|
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.each([
|
||||||
|
["contract page", { items: [run], total: 1, limit: 1000, offset: 0 }],
|
||||||
|
["interceptor-unwrapped page", [run]],
|
||||||
|
])("maps runs from the %s response", async (_label, data) => {
|
||||||
|
jest.mocked(api.get).mockResolvedValue({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,117 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SensorPlacementRunPage = {
|
||||||
|
items: SensorPlacementRunResponse[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<
|
||||||
|
SensorPlacementRunPage | SensorPlacementRunResponse[]
|
||||||
|
>(
|
||||||
|
`${config.BACKEND_URL}/api/v1/sensor-placement-runs`,
|
||||||
|
{ params: { limit: 1000, offset: 0 } },
|
||||||
|
);
|
||||||
|
const runs = Array.isArray(response.data)
|
||||||
|
? response.data
|
||||||
|
: response.data.items;
|
||||||
|
return runs.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 },
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { fetchHistoryData } from "./HistoryDataPanel";
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||||
|
useData: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jsonResponse = (payload: unknown, status = 200) =>
|
||||||
|
({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: jest.fn().mockResolvedValue(payload),
|
||||||
|
}) as unknown as Response;
|
||||||
|
|
||||||
|
const range = {
|
||||||
|
from: new Date("2026-08-17T09:00:00.000Z"),
|
||||||
|
to: new Date("2026-08-17T10:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("fetchHistoryData", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("queries SCADA readings once per selected network element", async () => {
|
||||||
|
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const elementId = url.searchParams.get("element_id") ?? "";
|
||||||
|
return jsonResponse({
|
||||||
|
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchHistoryData(
|
||||||
|
[
|
||||||
|
["J-1", "junction"],
|
||||||
|
["J-2", "junction"],
|
||||||
|
],
|
||||||
|
range,
|
||||||
|
"none",
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementIds = jest
|
||||||
|
.mocked(apiFetch)
|
||||||
|
.mock.calls.map(([input]) =>
|
||||||
|
new URL(String(input)).searchParams.get("element_id"),
|
||||||
|
);
|
||||||
|
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the analysis run ID for historical scheme simulation data", async () => {
|
||||||
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] }));
|
||||||
|
|
||||||
|
await fetchHistoryData(
|
||||||
|
[["P-1", "pipe"]],
|
||||||
|
range,
|
||||||
|
"scheme",
|
||||||
|
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
|
);
|
||||||
|
|
||||||
|
const simulationUrls = jest
|
||||||
|
.mocked(apiFetch)
|
||||||
|
.mock.calls.map(([input]) => new URL(String(input)))
|
||||||
|
.filter((url) => url.pathname.endsWith("/element-simulations"));
|
||||||
|
|
||||||
|
expect(simulationUrls).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
simulationUrls.map((url) => url.searchParams.get("run_id")),
|
||||||
|
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized element selections before issuing requests", async () => {
|
||||||
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
|
||||||
|
const featureInfos = Array.from(
|
||||||
|
{ length: 201 },
|
||||||
|
(_, index) => [`J-${index}`, "junction"] as [string, string],
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchHistoryData(featureInfos, range, "none"),
|
||||||
|
).rejects.toThrow("历史数据一次最多查询 200 个管网元素");
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits concurrent SCADA requests for multi-element history", async () => {
|
||||||
|
let activeRequests = 0;
|
||||||
|
let peakRequests = 0;
|
||||||
|
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||||
|
activeRequests += 1;
|
||||||
|
peakRequests = Math.max(peakRequests, activeRequests);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
activeRequests -= 1;
|
||||||
|
const elementId = new URL(String(input)).searchParams.get("element_id") ?? "";
|
||||||
|
return jsonResponse({ [elementId]: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchHistoryData(
|
||||||
|
Array.from(
|
||||||
|
{ length: 20 },
|
||||||
|
(_, index) => [`J-${index}`, "junction"] as [string, string],
|
||||||
|
),
|
||||||
|
range,
|
||||||
|
"none",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(peakRequests).toBeLessThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,10 +53,6 @@ export interface SCADADataPanelProps {
|
|||||||
featureInfos: [string, string][];
|
featureInfos: [string, string][];
|
||||||
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
|
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
|
||||||
type?: "realtime" | "scheme" | "none";
|
type?: "realtime" | "scheme" | "none";
|
||||||
/** 策略类型 */
|
|
||||||
scheme_type?: string;
|
|
||||||
/** 策略名称 */
|
|
||||||
scheme_name?: string;
|
|
||||||
/** 默认展示的选项卡 */
|
/** 默认展示的选项卡 */
|
||||||
defaultTab?: "chart" | "table";
|
defaultTab?: "chart" | "table";
|
||||||
/** Y 轴数值的小数位数 */
|
/** Y 轴数值的小数位数 */
|
||||||
@@ -65,6 +61,8 @@ export interface SCADADataPanelProps {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
|
/** 方案分析运行 ID;方案数据必须显式指定,避免读取到其他页面残留的全局方案。 */
|
||||||
|
runId?: string;
|
||||||
/** 关闭面板 */
|
/** 关闭面板 */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -73,6 +71,10 @@ type PanelTab = "chart" | "table";
|
|||||||
|
|
||||||
type LoadingState = "idle" | "loading" | "success" | "error";
|
type LoadingState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
const MAX_HISTORY_ELEMENTS = 200;
|
||||||
|
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
|
||||||
|
const HISTORY_SCADA_CONCURRENCY = 4;
|
||||||
|
|
||||||
const panelHeaderActionSx = {
|
const panelHeaderActionSx = {
|
||||||
color: "primary.contrastText",
|
color: "primary.contrastText",
|
||||||
backgroundColor: "rgba(255,255,255,0.08)",
|
backgroundColor: "rgba(255,255,255,0.08)",
|
||||||
@@ -81,51 +83,126 @@ const panelHeaderActionSx = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const buildApiUrl = (
|
||||||
* 从后端 API 获取 SCADA 数据
|
path: string,
|
||||||
*/
|
params: Record<string, string | boolean>,
|
||||||
const fetchFromBackend = async (
|
) => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
return `${config.BACKEND_URL}${path}?${searchParams.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptionalJson = async (url: string, signal?: AbortSignal) => {
|
||||||
|
const response = await apiFetch(url, { signal });
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`历史数据请求失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapWithConcurrency = async <Input, Output>(
|
||||||
|
items: Input[],
|
||||||
|
limit: number,
|
||||||
|
mapper: (item: Input, index: number) => Promise<Output>,
|
||||||
|
): Promise<Output[]> => {
|
||||||
|
const results = new Array<Output>(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workerCount = Math.min(limit, items.length);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: workerCount }, async () => {
|
||||||
|
while (nextIndex < items.length) {
|
||||||
|
const currentIndex = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
|
||||||
|
export const fetchHistoryData = async (
|
||||||
featureInfos: [string, string][],
|
featureInfos: [string, string][],
|
||||||
range: { from: Date; to: Date },
|
range: { from: Date; to: Date },
|
||||||
type: "realtime" | "scheme" | "none",
|
type: "realtime" | "scheme" | "none",
|
||||||
scheme_type?: string,
|
schemeRunId?: string,
|
||||||
scheme_name?: string
|
signal?: AbortSignal,
|
||||||
): Promise<TimeSeriesPoint[]> => {
|
): Promise<TimeSeriesPoint[]> => {
|
||||||
if (featureInfos.length === 0) {
|
if (featureInfos.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
|
||||||
|
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
featureInfos.some(
|
||||||
|
([id]) =>
|
||||||
|
id.trim().length === 0 || id.length > MAX_HISTORY_ELEMENT_ID_LENGTH,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("历史数据包含无效的管网元素 ID");
|
||||||
|
}
|
||||||
|
|
||||||
// 提取设备 ID 列表
|
const uniqueFeatureInfos = Array.from(
|
||||||
const featureIds = featureInfos.map(([id]) => id);
|
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(),
|
||||||
|
);
|
||||||
|
const featureIds = uniqueFeatureInfos.map(([id]) => id);
|
||||||
|
|
||||||
const feature_ids = featureIds.join(",");
|
|
||||||
const start_time = dayjs(range.from).toISOString();
|
const start_time = dayjs(range.from).toISOString();
|
||||||
const end_time = dayjs(range.to).toISOString();
|
const end_time = dayjs(range.to).toISOString();
|
||||||
|
|
||||||
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
|
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
|
||||||
const feature_infos = featureInfos
|
const feature_infos = uniqueFeatureInfos
|
||||||
.map(([id, type]) => `${id}:${type}`)
|
.map(([id, type]) => `${id}:${type}`)
|
||||||
.join(",");
|
.join(",");
|
||||||
|
|
||||||
// 监测值数据接口(use_cleaned=false)
|
const fetchElementScadaData = async (useCleaned: boolean) => {
|
||||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`;
|
const results = await mapWithConcurrency(
|
||||||
// 清洗数据接口(use_cleaned=true)
|
featureIds,
|
||||||
const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`;
|
HISTORY_SCADA_CONCURRENCY,
|
||||||
// 模拟数据接口
|
(elementId) =>
|
||||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
|
fetchOptionalJson(
|
||||||
// 策略模拟数据接口
|
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
|
||||||
const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`;
|
element_id: elementId,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
use_cleaned: useCleaned,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Object.assign({}, ...results.filter(Boolean));
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulationDataUrl = buildApiUrl(
|
||||||
|
"/api/v1/timeseries/views/element-simulations",
|
||||||
|
{ feature_infos, start_time, end_time },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type === "scheme" && !schemeRunId) {
|
||||||
|
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemeSimulationDataUrl = schemeRunId
|
||||||
|
? buildApiUrl("/api/v1/timeseries/views/element-simulations", {
|
||||||
|
feature_infos,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
run_id: schemeRunId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === "none") {
|
if (type === "none") {
|
||||||
// 查询清洗值和监测值
|
// 查询清洗值和监测值
|
||||||
const [cleanedRes, rawRes] = await Promise.all([
|
const [cleanedRes, rawRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetchElementScadaData(true),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
fetchElementScadaData(false),
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(rawDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||||
@@ -143,18 +220,10 @@ const fetchFromBackend = async (
|
|||||||
} else if (type === "scheme") {
|
} else if (type === "scheme") {
|
||||||
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetchElementScadaData(true),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
fetchElementScadaData(false),
|
||||||
.catch(() => null),
|
fetchOptionalJson(simulationDataUrl, signal),
|
||||||
apiFetch(rawDataUrl)
|
fetchOptionalJson(schemeSimulationDataUrl!, signal),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(simulationDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(schemeSimulationDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||||
@@ -176,15 +245,9 @@ const fetchFromBackend = async (
|
|||||||
} else {
|
} else {
|
||||||
// realtime: 查询模拟值、清洗值和监测值
|
// realtime: 查询模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetchElementScadaData(true),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
fetchElementScadaData(false),
|
||||||
.catch(() => null),
|
fetchOptionalJson(simulationDataUrl, signal),
|
||||||
apiFetch(rawDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(simulationDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||||
@@ -425,12 +488,11 @@ const emptyStateMessages: Record<
|
|||||||
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||||
featureInfos,
|
featureInfos,
|
||||||
type = "none",
|
type = "none",
|
||||||
scheme_type = "burst_analysis",
|
|
||||||
scheme_name,
|
|
||||||
defaultTab = "chart",
|
defaultTab = "chart",
|
||||||
fractionDigits = 2,
|
fractionDigits = 2,
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
|
runId,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
// 从 featureInfos 中提取设备 ID 列表
|
// 从 featureInfos 中提取设备 ID 列表
|
||||||
@@ -465,6 +527,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
"raw" | "clean" | "sim" | "all"
|
"raw" | "clean" | "sim" | "all"
|
||||||
>(() => (featureInfos.length === 1 ? "all" : "clean"));
|
>(() => (featureInfos.length === 1 ? "all" : "clean"));
|
||||||
const draggableRef = useRef<HTMLDivElement>(null);
|
const draggableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const requestControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
@@ -503,6 +566,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const handleFetch = useCallback(
|
const handleFetch = useCallback(
|
||||||
async (reason: string) => {
|
async (reason: string) => {
|
||||||
if (!hasDevices) {
|
if (!hasDevices) {
|
||||||
|
requestControllerRef.current?.abort();
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -511,26 +575,43 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
|
|
||||||
setLoadingState("loading");
|
setLoadingState("loading");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
requestControllerRef.current?.abort();
|
||||||
|
const requestController = new AbortController();
|
||||||
|
requestControllerRef.current = requestController;
|
||||||
try {
|
try {
|
||||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||||
const result = await fetchFromBackend(
|
const result = await fetchHistoryData(
|
||||||
featureInfos,
|
featureInfos,
|
||||||
{
|
{
|
||||||
from: rangeFrom.toDate(),
|
from: rangeFrom.toDate(),
|
||||||
to: rangeTo.toDate(),
|
to: rangeTo.toDate(),
|
||||||
},
|
},
|
||||||
type,
|
type,
|
||||||
scheme_type,
|
runId,
|
||||||
scheme_name
|
requestController.signal,
|
||||||
);
|
);
|
||||||
|
if (requestControllerRef.current !== requestController) return;
|
||||||
setTimeSeries(result);
|
setTimeSeries(result);
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
requestControllerRef.current !== requestController ||
|
||||||
|
(err instanceof Error && err.name === "AbortError")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : "未知错误");
|
setError(err instanceof Error ? err.message : "未知错误");
|
||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[featureInfos, hasDevices, normalizedRange, type, scheme_type, scheme_name]
|
[featureInfos, hasDevices, normalizedRange, runId, type],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
requestControllerRef.current?.abort();
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 设备变化时自动查询
|
// 设备变化时自动查询
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -140,6 +123,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const [chatPanelType, setChatPanelType] = useState<
|
const [chatPanelType, setChatPanelType] = useState<
|
||||||
"realtime" | "scheme" | "none"
|
"realtime" | "scheme" | "none"
|
||||||
>("none");
|
>("none");
|
||||||
|
const [chatPanelRunId, setChatPanelRunId] = useState<string | null>(null);
|
||||||
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
|
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
@@ -159,6 +143,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
|
setChatPanelRunId,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
setShowStyleEditor,
|
setShowStyleEditor,
|
||||||
@@ -366,6 +351,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
}
|
}
|
||||||
setChatPanelFeatureInfos(null);
|
setChatPanelFeatureInfos(null);
|
||||||
|
setChatPanelRunId(null);
|
||||||
setChatPanelTimeRange(null);
|
setChatPanelTimeRange(null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -400,6 +386,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 +403,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 +494,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 +549,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 +670,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 +689,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 +706,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 +838,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
),
|
),
|
||||||
[
|
[
|
||||||
selectedFeature,
|
selectedFeature,
|
||||||
computedProperties,
|
displayedComputedProperties,
|
||||||
|
isSimulationDataActive,
|
||||||
canEditNetwork,
|
canEditNetwork,
|
||||||
selectedValveId,
|
selectedValveId,
|
||||||
valveStatus,
|
valveStatus,
|
||||||
@@ -825,12 +937,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<ToolbarHistoryPanel
|
<ToolbarHistoryPanel
|
||||||
showHistoryPanel={showHistoryPanel}
|
showHistoryPanel={showHistoryPanel}
|
||||||
chatPanelType={chatPanelType}
|
chatPanelType={chatPanelType}
|
||||||
|
chatPanelRunId={chatPanelRunId}
|
||||||
chatPanelFeatureInfos={chatPanelFeatureInfos}
|
chatPanelFeatureInfos={chatPanelFeatureInfos}
|
||||||
chatPanelTimeRange={chatPanelTimeRange}
|
chatPanelTimeRange={chatPanelTimeRange}
|
||||||
highlightFeatures={highlightFeatures}
|
highlightFeatures={highlightFeatures}
|
||||||
HistoryPanel={HistoryPanel}
|
HistoryPanel={HistoryPanel}
|
||||||
schemeName={schemeName}
|
schemeName={schemeName}
|
||||||
queryType={queryType}
|
queryType={queryType}
|
||||||
|
schemeRunId={schemeRunId}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
deactivateTool("history");
|
deactivateTool("history");
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import HistoryDataPanel from "./HistoryDataPanel";
|
|||||||
type ToolbarHistoryPanelProps = {
|
type ToolbarHistoryPanelProps = {
|
||||||
showHistoryPanel: boolean;
|
showHistoryPanel: boolean;
|
||||||
chatPanelType: "realtime" | "scheme" | "none";
|
chatPanelType: "realtime" | "scheme" | "none";
|
||||||
|
chatPanelRunId: string | null;
|
||||||
chatPanelFeatureInfos: [string, string][] | null;
|
chatPanelFeatureInfos: [string, string][] | null;
|
||||||
chatPanelTimeRange: {
|
chatPanelTimeRange: {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
@@ -19,18 +20,21 @@ type ToolbarHistoryPanelProps = {
|
|||||||
HistoryPanel?: React.ComponentType<any>;
|
HistoryPanel?: React.ComponentType<any>;
|
||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
queryType?: string;
|
queryType?: string;
|
||||||
|
schemeRunId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
||||||
showHistoryPanel,
|
showHistoryPanel,
|
||||||
chatPanelType,
|
chatPanelType,
|
||||||
|
chatPanelRunId,
|
||||||
chatPanelFeatureInfos,
|
chatPanelFeatureInfos,
|
||||||
chatPanelTimeRange,
|
chatPanelTimeRange,
|
||||||
highlightFeatures,
|
highlightFeatures,
|
||||||
HistoryPanel,
|
HistoryPanel,
|
||||||
schemeName,
|
schemeName,
|
||||||
queryType,
|
queryType,
|
||||||
|
schemeRunId,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const featureInfos = useMemo(
|
const featureInfos = useMemo(
|
||||||
@@ -75,8 +79,6 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<HistoryDataPanel
|
<HistoryDataPanel
|
||||||
featureInfos={featureInfos}
|
featureInfos={featureInfos}
|
||||||
scheme_type="burst_analysis"
|
|
||||||
scheme_name={schemeName}
|
|
||||||
type={
|
type={
|
||||||
chatPanelFeatureInfos
|
chatPanelFeatureInfos
|
||||||
? chatPanelType
|
? chatPanelType
|
||||||
@@ -84,6 +86,7 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
|||||||
}
|
}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
|
runId={chatPanelFeatureInfos ? chatPanelRunId ?? undefined : schemeRunId}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: "传输模式",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type UseToolbarChatActionsParams = {
|
|||||||
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
|
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
|
||||||
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
|
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
|
||||||
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
|
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
|
||||||
|
setChatPanelRunId: Dispatch<SetStateAction<string | null>>;
|
||||||
setChatPanelTimeRange: Dispatch<
|
setChatPanelTimeRange: Dispatch<
|
||||||
SetStateAction<{ startTime?: string; endTime?: string } | null>
|
SetStateAction<{ startTime?: string; endTime?: string } | null>
|
||||||
>;
|
>;
|
||||||
@@ -37,6 +38,7 @@ export const useToolbarChatActions = ({
|
|||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
|
setChatPanelRunId,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
setShowStyleEditor,
|
setShowStyleEditor,
|
||||||
@@ -126,6 +128,7 @@ export const useToolbarChatActions = ({
|
|||||||
case "view_history": {
|
case "view_history": {
|
||||||
setChatPanelFeatureInfos(action.featureInfos);
|
setChatPanelFeatureInfos(action.featureInfos);
|
||||||
setChatPanelType(action.dataType);
|
setChatPanelType(action.dataType);
|
||||||
|
setChatPanelRunId(action.runId ?? null);
|
||||||
setChatPanelTimeRange({
|
setChatPanelTimeRange({
|
||||||
startTime: action.startTime,
|
startTime: action.startTime,
|
||||||
endTime: action.endTime,
|
endTime: action.endTime,
|
||||||
@@ -136,6 +139,7 @@ export const useToolbarChatActions = ({
|
|||||||
case "view_scada": {
|
case "view_scada": {
|
||||||
setChatPanelFeatureInfos(action.featureInfos);
|
setChatPanelFeatureInfos(action.featureInfos);
|
||||||
setChatPanelType("none");
|
setChatPanelType("none");
|
||||||
|
setChatPanelRunId(null);
|
||||||
setChatPanelTimeRange({
|
setChatPanelTimeRange({
|
||||||
startTime: action.startTime,
|
startTime: action.startTime,
|
||||||
endTime: action.endTime,
|
endTime: action.endTime,
|
||||||
@@ -249,6 +253,7 @@ export const useToolbarChatActions = ({
|
|||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
|
setChatPanelRunId,
|
||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
setShowStyleEditor,
|
setShowStyleEditor,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user