Compare commits
19
Commits
latest
...
v2026.08.17.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d505ca461 | ||
|
|
f34d81c933 | ||
|
|
5053ddcd1f | ||
|
|
45def5bba3 | ||
|
|
9e79d52dc8 | ||
|
|
1ac7372585 | ||
|
|
5d730ba1b8 | ||
|
|
caf18f706d | ||
|
|
0dd521d8c9 | ||
|
|
0a47534ddb | ||
|
|
9e75e2df8a | ||
|
|
8d7c947897 | ||
|
|
71bde7d9e4 | ||
|
|
5037089057 | ||
|
|
b4c96f8524 | ||
|
|
e496fbe4b7 | ||
|
|
5d9c40e454 | ||
|
|
5592c27386 | ||
|
|
94142d7031 |
+14
-152
@@ -1,159 +1,21 @@
|
||||
name: Build Push and Deploy
|
||||
name: Frontend CI/CD v2
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "latest"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
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."
|
||||
build-test-publish-and-deploy:
|
||||
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||
with:
|
||||
image_name: gitea.waternetwork.cn/orgtjwater/tjwaterfrontend_refine
|
||||
dockerfile: Dockerfile
|
||||
build_context: .
|
||||
deploy_service: frontend
|
||||
deploy_host: 192.168.1.114
|
||||
secrets:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||
|
||||
@@ -1011,8 +1011,10 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"request",
|
||||
"auto",
|
||||
"always"
|
||||
]
|
||||
],
|
||||
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"post": {
|
||||
"operationId": "post_sessions_session_id_permission_responses",
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
"contracts": {
|
||||
"agent": {
|
||||
"file": "agent-v1.openapi.json",
|
||||
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
|
||||
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+200
-351
@@ -2598,6 +2598,18 @@
|
||||
"title": "Map Y",
|
||||
"type": "number"
|
||||
},
|
||||
"max_pipe_diameter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "节点关联管道的最大管径,单位:毫米",
|
||||
"title": "Max Pipe Diameter"
|
||||
},
|
||||
"node_id": {
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
@@ -2613,6 +2625,7 @@
|
||||
},
|
||||
"required": [
|
||||
"node_id",
|
||||
"max_pipe_diameter",
|
||||
"project_x",
|
||||
"project_y",
|
||||
"map_x",
|
||||
@@ -11025,7 +11038,7 @@
|
||||
},
|
||||
"/api/v1/flushing-analyses": {
|
||||
"post": {
|
||||
"description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。",
|
||||
"description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。",
|
||||
"operationId": "post_flushing_analyses",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -11040,31 +11053,92 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "要开启的阀门ID列表",
|
||||
"description": "参与控制的阀门ID列表(可选)",
|
||||
"in": "query",
|
||||
"name": "valves",
|
||||
"required": true,
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "要开启的阀门ID列表",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Valves",
|
||||
"type": "array"
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "参与控制的阀门ID列表(可选)",
|
||||
"title": "Valves"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "对应各阀门的开度列表(0-1)",
|
||||
"description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)",
|
||||
"in": "query",
|
||||
"name": "valves_k",
|
||||
"required": true,
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "对应各阀门的开度列表(0-1)",
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"title": "Valves K",
|
||||
"type": "array"
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)",
|
||||
"title": "Valves K"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)",
|
||||
"in": "query",
|
||||
"name": "valve_statuses",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"OPEN",
|
||||
"CLOSED",
|
||||
"ACTIVE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)",
|
||||
"title": "Valve Statuses"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "对应各阀门的设置值列表(ACTIVE状态下必填)",
|
||||
"in": "query",
|
||||
"name": "valve_settings",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "对应各阀门的设置值列表(ACTIVE状态下必填)",
|
||||
"title": "Valve Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -19425,108 +19499,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/network-schemas/user": {
|
||||
"get": {
|
||||
"description": "获取指定网络的用户模式定义",
|
||||
"operationId": "get_network_schemas_user",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Get Network Schemas User",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取用户模式",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/network-schemas/valve": {
|
||||
"get": {
|
||||
"description": "获取指定水网中所有阀门的架构和字段定义",
|
||||
@@ -27430,7 +27402,7 @@
|
||||
},
|
||||
"/api/v1/projects/current/lock/ownership": {
|
||||
"get": {
|
||||
"description": "检查指定项目是否被当前客户端 (IP) 锁定。",
|
||||
"description": "检查指定项目是否被当前访问地址 (IP) 锁定。",
|
||||
"operationId": "get_projects_current_lock_ownership",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -35648,6 +35620,114 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||
"get": {
|
||||
"operationId": "get_sensor_placement_candidates_node_id",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "node_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 32,
|
||||
"minLength": 1,
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SensorPointResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取监测点候选节点详情",
|
||||
"tags": [
|
||||
"Sensor Placement"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-optimization-runs": {
|
||||
"post": {
|
||||
"operationId": "post_sensor_placement_optimization_runs",
|
||||
@@ -47552,237 +47632,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"description": "获取指定网络的所有用户列表",
|
||||
"operationId": "get_users",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 100,
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"title": "Offset",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Page_dict_Any__Any__"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取所有用户",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/detail": {
|
||||
"get": {
|
||||
"description": "获取指定网络中的单个用户信息",
|
||||
"operationId": "get_users_detail",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "用户名",
|
||||
"in": "query",
|
||||
"name": "user_name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "用户名",
|
||||
"title": "User Name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"title": "Response Get Users Detail",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取单个用户",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/valve-closure-analyses": {
|
||||
"post": {
|
||||
"description": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。",
|
||||
|
||||
+39
-22
@@ -7,7 +7,7 @@ import {
|
||||
} from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
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 React, { useEffect } from "react";
|
||||
|
||||
@@ -17,10 +17,13 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||
import { dataProvider } from "@providers/data-provider";
|
||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
||||
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
|
||||
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||
import { config } from "@config/config";
|
||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||
@@ -57,6 +60,8 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
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 permissions = useAccessStore((state) => state.permissions);
|
||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||
@@ -70,6 +75,20 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
);
|
||||
}, [data?.accessToken, setAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.error === "SessionExpired") {
|
||||
markSessionExpired("session_max_age");
|
||||
return;
|
||||
}
|
||||
if (data?.error === "RefreshAccessTokenError") {
|
||||
markSessionExpired("refresh_failed");
|
||||
return;
|
||||
}
|
||||
if (status === "authenticated") {
|
||||
clearSessionExpired();
|
||||
}
|
||||
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated") {
|
||||
resetAccess();
|
||||
@@ -135,28 +154,25 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "logout" }),
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
} catch {
|
||||
// Logout must still complete when audit storage is unavailable.
|
||||
}
|
||||
if (data?.user?.id) {
|
||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||
}
|
||||
signOut({ redirect: true, callbackUrl: "/login" });
|
||||
return { success: true };
|
||||
},
|
||||
logout: () =>
|
||||
completeLogout({
|
||||
reportAudit: () =>
|
||||
reportLogoutAudit({
|
||||
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
|
||||
accessToken:
|
||||
typeof data?.accessToken === "string"
|
||||
? data.accessToken
|
||||
: useAuthStore.getState().accessToken,
|
||||
}),
|
||||
clearLocalState: () => {
|
||||
if (data?.user?.id) {
|
||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||
}
|
||||
clearSessionRecoveryDrafts();
|
||||
},
|
||||
navigate: (path) => window.location.assign(path),
|
||||
}),
|
||||
onError: async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
return { logout: true };
|
||||
}
|
||||
return { error };
|
||||
},
|
||||
check: async () =>
|
||||
@@ -351,6 +367,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
>
|
||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import authOptions from "./options";
|
||||
|
||||
describe("NextAuth access token refresh", () => {
|
||||
it("forces a Keycloak refresh for an agent credential request", async () => {
|
||||
const previousEnv = {
|
||||
issuer: process.env.KEYCLOAK_ISSUER,
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
};
|
||||
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
|
||||
process.env.KEYCLOAK_CLIENT_ID = "frontend";
|
||||
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: "fresh-access-token",
|
||||
expires_in: 900,
|
||||
refresh_token: "rotated-refresh-token",
|
||||
}),
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}>;
|
||||
const result = await jwt({
|
||||
token: {
|
||||
accessToken: "still-fresh-access-token",
|
||||
accessTokenExpires: Date.now() + 600_000,
|
||||
accessTokenIssuedAt: Date.now(),
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
trigger: "update",
|
||||
session: { forceRefresh: true },
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBe("fresh-access-token");
|
||||
expect(result.refreshToken).toBe("rotated-refresh-token");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://keycloak.example/realms/tjwater/protocol/openid-connect/token",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
} finally {
|
||||
if (originalFetch) globalThis.fetch = originalFetch;
|
||||
else delete (globalThis as { fetch?: typeof fetch }).fetch;
|
||||
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
|
||||
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
|
||||
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["network failure", () => Promise.reject(new Error("connection refused"))],
|
||||
[
|
||||
"non-JSON response",
|
||||
() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"invalid token payload",
|
||||
() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ access_token: " ", expires_in: 0 }),
|
||||
}),
|
||||
],
|
||||
])("returns a session error for %s", async (_label, fetchResult) => {
|
||||
const previousEnv = {
|
||||
issuer: process.env.KEYCLOAK_ISSUER,
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
};
|
||||
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
|
||||
process.env.KEYCLOAK_CLIENT_ID = "frontend";
|
||||
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(fetchResult) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
|
||||
error?: string;
|
||||
}>;
|
||||
await expect(
|
||||
jwt({
|
||||
token: { refreshToken: "refresh-token" },
|
||||
trigger: "update",
|
||||
session: { forceRefresh: true },
|
||||
}),
|
||||
).resolves.toMatchObject({ error: "RefreshAccessTokenError" });
|
||||
} finally {
|
||||
if (originalFetch) globalThis.fetch = originalFetch;
|
||||
else delete (globalThis as { fetch?: typeof fetch }).fetch;
|
||||
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
|
||||
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
|
||||
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const restoreEnv = (key: string, value: string | undefined) => {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
};
|
||||
@@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
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 = {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
@@ -36,24 +39,43 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||
refresh_token: token.refreshToken,
|
||||
});
|
||||
|
||||
const response = await fetch(keycloakTokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const refreshed = (await response.json()) as KeycloakTokenResponse;
|
||||
try {
|
||||
const response = await fetch(keycloakTokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
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,
|
||||
accessToken: refreshed.access_token,
|
||||
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
||||
error: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const authOptions: NextAuthOptions = {
|
||||
@@ -76,7 +98,7 @@ const authOptions: NextAuthOptions = {
|
||||
],
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
callbacks: {
|
||||
jwt: async ({ token, profile, account }) => {
|
||||
jwt: async ({ token, profile, account, trigger, session }) => {
|
||||
if (profile?.sub) {
|
||||
token.sub = profile.sub;
|
||||
}
|
||||
@@ -88,12 +110,17 @@ const authOptions: NextAuthOptions = {
|
||||
}
|
||||
|
||||
if (account) {
|
||||
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
|
||||
if (account.access_token) {
|
||||
token.accessToken = account.access_token;
|
||||
token.accessTokenIssuedAt = Date.now();
|
||||
}
|
||||
if (account.refresh_token) {
|
||||
token.refreshToken = account.refresh_token;
|
||||
}
|
||||
if (account.id_token) {
|
||||
token.idToken = account.id_token;
|
||||
}
|
||||
if (typeof account.expires_at === "number") {
|
||||
token.accessTokenExpires = account.expires_at * 1000;
|
||||
}
|
||||
@@ -101,7 +128,26 @@ const authOptions: NextAuthOptions = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -120,9 +166,19 @@ const authOptions: NextAuthOptions = {
|
||||
if (token.error) {
|
||||
session.error = token.error;
|
||||
}
|
||||
if (typeof token.sessionExpiresAt === "number") {
|
||||
session.sessionExpiresAt = token.sessionExpiresAt;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
},
|
||||
jwt: {
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
},
|
||||
};
|
||||
|
||||
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 { config } from "@config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
|
||||
type MetadataUser = {
|
||||
@@ -479,6 +480,20 @@ export const SystemAdminPanel = () => {
|
||||
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
|
||||
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(() => {
|
||||
openNotificationRef.current = openNotification;
|
||||
}, [openNotification]);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { SessionExpiryDialog } from "./SessionExpiryDialog";
|
||||
|
||||
jest.mock("next-auth/react", () => ({
|
||||
signIn: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("SessionExpiryDialog", () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: "unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => useAuthStore.getState().clearSessionExpired());
|
||||
});
|
||||
|
||||
it("renders above every regular application overlay", () => {
|
||||
const theme = createTheme();
|
||||
|
||||
render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<SessionExpiryDialog />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const dialogRoot = screen.getByRole("dialog").closest(".MuiModal-root");
|
||||
expect(dialogRoot).not.toBeNull();
|
||||
expect(dialogRoot).toHaveStyle({
|
||||
zIndex: theme.zIndex.tooltip + 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const WARNING_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
type SessionExpiryDialogProps = {
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export const SessionExpiryDialog = ({
|
||||
expiresAt,
|
||||
}: SessionExpiryDialogProps) => {
|
||||
const theme = useTheme();
|
||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||
const reason = useAuthStore((state) => state.sessionExpiryReason);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [warningDismissed, setWarningDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const isExpiringSoon = useMemo(
|
||||
() =>
|
||||
!sessionExpired &&
|
||||
!warningDismissed &&
|
||||
typeof expiresAt === "number" &&
|
||||
expiresAt > now &&
|
||||
expiresAt - now <= WARNING_WINDOW_MS,
|
||||
[expiresAt, now, sessionExpired, warningDismissed],
|
||||
);
|
||||
|
||||
const handleReauthenticate = () => {
|
||||
const callbackUrl = `${window.location.pathname}${window.location.search}`;
|
||||
void signIn("keycloak", { callbackUrl, redirect: true });
|
||||
};
|
||||
|
||||
const isOpen = sessionExpired || isExpiringSoon;
|
||||
const title = sessionExpired ? "登录已过期" : "登录即将到期";
|
||||
const detail = sessionExpired
|
||||
? reason === "session_max_age"
|
||||
? "已达到 12 小时的最长连续登录时间。请重新认证后继续。"
|
||||
: "无法续期当前登录。请重新认证后继续。"
|
||||
: "当前登录将在 15 分钟内到期。请先保存正在编辑的内容。";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
style={{ zIndex: theme.zIndex.tooltip + 1 }}
|
||||
disableEscapeKeyDown={sessionExpired}
|
||||
onClose={sessionExpired ? undefined : () => setWarningDismissed(true)}
|
||||
aria-labelledby="session-expiry-dialog-title"
|
||||
>
|
||||
<DialogTitle id="session-expiry-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ pt: 0.5 }}>
|
||||
<Alert icon={<AccessTimeOutlinedIcon />} severity={sessionExpired ? "warning" : "info"}>
|
||||
{detail}
|
||||
</Alert>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。
|
||||
</Typography>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!sessionExpired && (
|
||||
<Button onClick={() => setWarningDismissed(true)}>稍后处理</Button>
|
||||
)}
|
||||
<Button variant="contained" onClick={handleReauthenticate}>
|
||||
重新认证
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -44,7 +44,7 @@ describe("AgentComposer", () => {
|
||||
modelOptions={[{ id: "test-model", label: "测试模型" }]}
|
||||
selectedModel="test-model"
|
||||
onModelChange={jest.fn()}
|
||||
approvalMode="request"
|
||||
approvalMode="auto"
|
||||
onApprovalModeChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
@@ -56,6 +56,56 @@ describe("AgentComposer", () => {
|
||||
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("自动批准")).toBeInTheDocument();
|
||||
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 VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||
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 { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||
|
||||
@@ -40,6 +42,7 @@ export type AgentComposerHandle = {
|
||||
|
||||
type AgentComposerProps = {
|
||||
isHydrating?: boolean;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
isStreaming: boolean;
|
||||
isListening: boolean;
|
||||
isSttSupported: boolean;
|
||||
@@ -55,6 +58,35 @@ type AgentComposerProps = {
|
||||
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 = (
|
||||
icon: AgentModelOption["icon"] | undefined,
|
||||
props?: React.ComponentProps<typeof BoltRounded>,
|
||||
@@ -67,6 +99,7 @@ const renderModelIcon = (
|
||||
|
||||
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||
isHydrating = false,
|
||||
runtimeState = "ready",
|
||||
isStreaming,
|
||||
isListening,
|
||||
isSttSupported,
|
||||
@@ -85,8 +118,19 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
const [input, setInput] = React.useState("");
|
||||
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 selectedApprovalModeOption = getApprovalModeOption(approvalMode);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
@@ -102,10 +146,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
|
||||
const handleSend = React.useCallback(() => {
|
||||
const prompt = input.trim();
|
||||
if (!prompt || isStreaming || isHydrating) return;
|
||||
if (!prompt || isStreaming || isHydrating || !isRuntimeReady) return;
|
||||
setInput("");
|
||||
onSend(prompt);
|
||||
}, [input, isHydrating, isStreaming, onSend]);
|
||||
}, [input, isHydrating, isRuntimeReady, isStreaming, onSend]);
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
||||
@@ -154,6 +198,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
label={prompt.replace(/[。.]$/, "")}
|
||||
size="medium"
|
||||
clickable
|
||||
disabled={!isRuntimeReady || isHydrating || isStreaming}
|
||||
onClick={() => {
|
||||
setInput(prompt);
|
||||
setIsPresetOpen(false);
|
||||
@@ -209,12 +254,12 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
||||
placeholder={placeholder}
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={5}
|
||||
variant="standard"
|
||||
disabled={isHydrating}
|
||||
disabled={isHydrating || !isRuntimeReady}
|
||||
InputProps={{
|
||||
disableUnderline: true,
|
||||
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" spacing={0.5} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 128 }}>
|
||||
<Select
|
||||
value={approvalMode}
|
||||
onChange={(event) =>
|
||||
onApprovalModeChange(event.target.value as AgentApprovalMode)
|
||||
}
|
||||
disabled={isHydrating || isStreaming}
|
||||
disabled={isHydrating || isStreaming || !isRuntimeReady}
|
||||
aria-label="权限批准模式"
|
||||
renderValue={(val) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
|
||||
{val === "always" ? (
|
||||
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
|
||||
) : (
|
||||
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
|
||||
{val === "always" ? "始终允许" : "请求批准"}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
renderValue={() => {
|
||||
const SelectedApprovalIcon = selectedApprovalModeOption.icon;
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
|
||||
<SelectedApprovalIcon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
color:
|
||||
selectedApprovalModeOption.value === "always"
|
||||
? "warning.main"
|
||||
: "inherit",
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
|
||||
{selectedApprovalModeOption.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
MenuProps={{
|
||||
anchorOrigin: { vertical: "top", horizontal: "left" },
|
||||
transformOrigin: { vertical: "bottom", horizontal: "left" },
|
||||
@@ -250,7 +302,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
PaperProps: {
|
||||
sx: {
|
||||
mb: 1.5,
|
||||
width: 210,
|
||||
width: 248,
|
||||
borderRadius: 4,
|
||||
bgcolor: alpha("#fff", 0.9),
|
||||
backdropFilter: "blur(24px)",
|
||||
@@ -269,6 +321,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
|
||||
"& .title": { color: "#00838f" },
|
||||
"& .icon": { color: "#00acc1" },
|
||||
"& .always-icon": { color: "warning.main" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -298,20 +351,47 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="request">
|
||||
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>请求批准</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>工具权限逐次确认</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="always">
|
||||
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>始终允许</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>自动允许本轮权限请求</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{approvalModeOptions.map((option) => {
|
||||
const ApprovalIcon = option.icon;
|
||||
const isAlways = option.value === "always";
|
||||
return (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
<ApprovalIcon
|
||||
className={isAlways ? "icon always-icon" : "icon"}
|
||||
sx={{
|
||||
mr: 1.5,
|
||||
mt: 0.15,
|
||||
fontSize: 18,
|
||||
color: isAlways ? "warning.main" : "text.secondary",
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
@@ -452,7 +532,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={onStartListening}
|
||||
disabled={isStreaming || isHydrating}
|
||||
disabled={isStreaming || isHydrating || !isRuntimeReady}
|
||||
aria-label="语音输入"
|
||||
size="small"
|
||||
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 EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
||||
import type { AgentRuntimeState } from "@/lib/agentRuntime";
|
||||
|
||||
type AgentHeaderProps = {
|
||||
sessionTitle?: string;
|
||||
canRenameSessionTitle?: boolean;
|
||||
isHydrating?: boolean;
|
||||
isStreaming: boolean;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
isHistoryOpen: boolean;
|
||||
onHistoryToggle: () => void;
|
||||
onRenameSessionTitle?: (title: string) => void;
|
||||
@@ -37,6 +39,7 @@ export const AgentHeader = ({
|
||||
canRenameSessionTitle = false,
|
||||
isHydrating = false,
|
||||
isStreaming,
|
||||
runtimeState = "ready",
|
||||
isHistoryOpen,
|
||||
onHistoryToggle,
|
||||
onRenameSessionTitle,
|
||||
@@ -47,6 +50,14 @@ export const AgentHeader = ({
|
||||
const displayTitle = sessionTitle?.trim() || "新对话";
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
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(() => {
|
||||
if (!isEditingTitle) {
|
||||
@@ -109,17 +120,19 @@ export const AgentHeader = ({
|
||||
/>
|
||||
</Avatar>
|
||||
<Box
|
||||
role="status"
|
||||
aria-label={runtimeStatus.label}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
bgcolor: isStreaming ? "#ff9800" : "#00e676",
|
||||
bgcolor: runtimeStatus.color,
|
||||
borderRadius: "50%",
|
||||
border: "2.5px solid #fff",
|
||||
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
|
||||
animation: isStreaming ? "pulse 1.5s infinite" : "none",
|
||||
boxShadow: `0 0 10px ${runtimeStatus.color}`,
|
||||
animation: isStreaming && runtimeState === "ready" ? "pulse 1.5s infinite" : "none",
|
||||
"@keyframes pulse": {
|
||||
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
||||
"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 CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
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";
|
||||
|
||||
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
||||
@@ -58,7 +58,7 @@ const PermissionIcon = ({
|
||||
};
|
||||
|
||||
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 === "rejected") return "已拒绝";
|
||||
if (status === "aborted") return "已中断";
|
||||
@@ -99,7 +99,7 @@ const PermissionRequestCard = ({
|
||||
}: {
|
||||
permission: NonNullable<Message["permissions"]>[number];
|
||||
isRunning: boolean;
|
||||
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||
onReply: (requestId: string, reply: PermissionDecision) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isPending =
|
||||
@@ -109,6 +109,9 @@ const PermissionRequestCard = ({
|
||||
const accentColor = getPermissionStatusColor(permission.status, theme);
|
||||
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||
const statusLabel = getPermissionStatusLabel(permission.status);
|
||||
const persistentScope = permission.always.length > 0
|
||||
? permission.always
|
||||
: permission.patterns;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -203,6 +206,29 @@ const PermissionRequestCard = ({
|
||||
{primaryValue}
|
||||
</Typography>
|
||||
</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>
|
||||
|
||||
{permission.error ? (
|
||||
@@ -232,84 +258,84 @@ const PermissionRequestCard = ({
|
||||
useFlexGap
|
||||
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disableElevation
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "once")}
|
||||
startIcon={
|
||||
isSubmitting ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 94,
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
bgcolor: "#00838f",
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
|
||||
"&:hover": {
|
||||
bgcolor: "#006c78",
|
||||
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
允许一次
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "always")}
|
||||
startIcon={<PushPinRounded fontSize="small" />}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
color: "#00838f",
|
||||
borderColor: alpha("#00838f", 0.24),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
"&:hover": {
|
||||
borderColor: alpha("#00838f", 0.36),
|
||||
bgcolor: alpha("#00838f", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
始终允许
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "reject")}
|
||||
startIcon={<BlockRounded fontSize="small" />}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
borderColor: alpha(theme.palette.error.main, 0.22),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
"&:hover": {
|
||||
borderColor: alpha(theme.palette.error.main, 0.34),
|
||||
bgcolor: alpha(theme.palette.error.main, 0.07),
|
||||
},
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disableElevation
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "once")}
|
||||
startIcon={
|
||||
isSubmitting ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 94,
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
bgcolor: "#00838f",
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
|
||||
"&:hover": {
|
||||
bgcolor: "#006c78",
|
||||
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
允许一次
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "always")}
|
||||
startIcon={<GppGoodRounded fontSize="small" />}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
borderColor: alpha(theme.palette.success.main, 0.28),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
"&:hover": {
|
||||
borderColor: alpha(theme.palette.success.main, 0.42),
|
||||
bgcolor: alpha(theme.palette.success.main, 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
保存授权
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "reject")}
|
||||
startIcon={<BlockRounded fontSize="small" />}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
borderColor: alpha(theme.palette.error.main, 0.22),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
"&:hover": {
|
||||
borderColor: alpha(theme.palette.error.main, 0.34),
|
||||
bgcolor: alpha(theme.palette.error.main, 0.07),
|
||||
},
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -323,7 +349,7 @@ export const PermissionRequestGroup = ({
|
||||
}: {
|
||||
permissions: NonNullable<Message["permissions"]>;
|
||||
isRunning: boolean;
|
||||
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||
onReply: (requestId: string, reply: PermissionDecision) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
||||
@@ -348,7 +374,7 @@ export const PermissionRequestGroup = ({
|
||||
const summaryItems = [
|
||||
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
|
||||
{ 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: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
||||
];
|
||||
|
||||
@@ -112,4 +112,56 @@ describe("AgentTurn speech selection", () => {
|
||||
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",
|
||||
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.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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@mui/material";
|
||||
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||
import { TbArrowsSplit2 } from "react-icons/tb";
|
||||
import type { PermissionReply } from "@/lib/chatStream";
|
||||
import type { PermissionDecision } from "@/lib/chatStream";
|
||||
import {
|
||||
parseAssistantMessageSections,
|
||||
parseContentWithToolCalls,
|
||||
@@ -100,7 +100,7 @@ type AgentTurnProps = {
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { AgentWorkspace } from "./AgentWorkspace";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
@@ -85,6 +85,38 @@ describe("AgentWorkspace", () => {
|
||||
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", () => {
|
||||
const userMessage: Message = {
|
||||
id: "user-1",
|
||||
@@ -164,4 +196,70 @@ describe("AgentWorkspace", () => {
|
||||
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
|
||||
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 React from "react";
|
||||
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 SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||||
import MapRounded from "@mui/icons-material/MapRounded";
|
||||
import ReplayRounded from "@mui/icons-material/ReplayRounded";
|
||||
|
||||
import { AgentTurn } from "./AgentTurn";
|
||||
import type { PermissionReply } from "@/lib/chatStream";
|
||||
import type { AgentRuntimeState } from "@/lib/agentRuntime";
|
||||
import type { PermissionDecision } from "@/lib/chatStream";
|
||||
import type {
|
||||
Message,
|
||||
SpeechState,
|
||||
@@ -19,6 +21,8 @@ import type {
|
||||
type AgentWorkspaceProps = {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
onRetryRuntime?: () => void;
|
||||
isLoadingSession?: boolean;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -35,13 +39,15 @@ type AgentWorkspaceProps = {
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
type TurnListProps = {
|
||||
messages: Message[];
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
isAssistantStreaming: boolean;
|
||||
streamingMessageId: string | null;
|
||||
speakingMessageId: string | null;
|
||||
@@ -56,13 +62,18 @@ type TurnListProps = {
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||
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[]) =>
|
||||
left.length === right.length &&
|
||||
@@ -72,6 +83,8 @@ const TurnItem = React.memo(AgentTurn);
|
||||
|
||||
const TurnListInner = ({
|
||||
messages,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
isAssistantStreaming,
|
||||
streamingMessageId,
|
||||
speakingMessageId,
|
||||
@@ -86,33 +99,179 @@ const TurnListInner = ({
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: 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 (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<TurnItem
|
||||
{windowState.topSpacerHeight > 0 ? (
|
||||
<Box aria-hidden sx={{ height: windowState.topSpacerHeight, flexShrink: 0 }} />
|
||||
) : null}
|
||||
{visibleMessages.map((message) => (
|
||||
<MeasuredTurn
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
measure={isWindowed}
|
||||
onHeightChange={updateMeasuredHeight}
|
||||
>
|
||||
<TurnItem
|
||||
message={message}
|
||||
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
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(
|
||||
TurnListInner,
|
||||
(prevProps, nextProps) =>
|
||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||
prevProps.scrollTop === nextProps.scrollTop &&
|
||||
prevProps.viewportHeight === nextProps.viewportHeight &&
|
||||
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
||||
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||
@@ -130,8 +289,34 @@ const TurnList = React.memo(
|
||||
|
||||
TurnList.displayName = "TurnList";
|
||||
|
||||
const EmptyState = () => {
|
||||
const theme = useTheme();
|
||||
const EmptyState = ({
|
||||
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 = [
|
||||
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
|
||||
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
|
||||
@@ -147,6 +332,8 @@ const EmptyState = () => {
|
||||
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
|
||||
>
|
||||
<Paper
|
||||
role={isReady || isChecking ? "status" : "alert"}
|
||||
aria-live="polite"
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
@@ -170,9 +357,9 @@ const EmptyState = () => {
|
||||
}} />
|
||||
<motion.div
|
||||
animate={{
|
||||
y: [-6, 4, -6],
|
||||
scale: [1, 1.04, 1],
|
||||
rotate: [-3, 3, -3],
|
||||
y: isReady ? [-6, 4, -6] : 0,
|
||||
scale: isReady ? [1, 1.04, 1] : 1,
|
||||
rotate: isReady ? [-3, 3, -3] : 0,
|
||||
}}
|
||||
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{
|
||||
@@ -194,22 +381,37 @@ const EmptyState = () => {
|
||||
height={54}
|
||||
style={{
|
||||
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>
|
||||
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
|
||||
我已就绪,请描述任务
|
||||
{statusCopy.title}
|
||||
</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>
|
||||
|
||||
<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
|
||||
{isChecking ? (
|
||||
<CircularProgress size={28} thickness={4} aria-label="正在检测 Agent 服务" />
|
||||
) : !isReady ? (
|
||||
<Button
|
||||
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"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
@@ -234,11 +436,12 @@ const EmptyState = () => {
|
||||
<Typography variant="caption" fontWeight={700}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -309,6 +512,8 @@ const SessionLoadingSkeleton = () => (
|
||||
export const AgentWorkspace = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
runtimeState = "ready",
|
||||
onRetryRuntime,
|
||||
isLoadingSession = false,
|
||||
scrollContainerRef,
|
||||
bottomRef,
|
||||
@@ -325,14 +530,23 @@ export const AgentWorkspace = ({
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: AgentWorkspaceProps) => {
|
||||
const localScrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [scrollMetrics, setScrollMetrics] = React.useState({
|
||||
scrollTop: 0,
|
||||
viewportHeight: DEFAULT_VIEWPORT_HEIGHT_PX,
|
||||
});
|
||||
const streamingMessageId =
|
||||
isStreaming && messages.at(-1)?.role === "assistant"
|
||||
? messages.at(-1)?.id ?? null
|
||||
: null;
|
||||
const handleScroll = React.useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!onScrollStateChange) return;
|
||||
const target = event.currentTarget;
|
||||
setScrollMetrics({
|
||||
scrollTop: target.scrollTop,
|
||||
viewportHeight: target.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX,
|
||||
});
|
||||
if (!onScrollStateChange) return;
|
||||
const distanceToBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
onScrollStateChange(
|
||||
@@ -343,9 +557,37 @@ export const AgentWorkspace = ({
|
||||
[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 (
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
ref={setScrollContainer}
|
||||
onScroll={handleScroll}
|
||||
sx={{
|
||||
flex: 1,
|
||||
@@ -363,13 +605,30 @@ export const AgentWorkspace = ({
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length === 0 ? <EmptyState /> : null}
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState
|
||||
runtimeState={runtimeState}
|
||||
onRetryRuntime={onRetryRuntime}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{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
|
||||
messages={messages}
|
||||
scrollTop={
|
||||
messages.length > TURN_WINDOW_THRESHOLD
|
||||
? scrollMetrics.scrollTop
|
||||
: 0
|
||||
}
|
||||
viewportHeight={scrollMetrics.viewportHeight}
|
||||
isAssistantStreaming={isStreaming}
|
||||
streamingMessageId={streamingMessageId}
|
||||
speakingMessageId={speakingMessageId}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { act, render, screen } from "@testing-library/react";
|
||||
import { GlobalChatbox } from "./GlobalChatbox";
|
||||
|
||||
const createSession = jest.fn();
|
||||
const mockFetchAgentRuntimeHealth = jest.fn();
|
||||
const mockFetchAgentModels = jest.fn();
|
||||
let mockCurrentProjectId = "project-1";
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
@@ -12,7 +14,12 @@ jest.mock("@refinedev/core", () => ({
|
||||
}));
|
||||
|
||||
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", () => ({
|
||||
@@ -73,12 +80,14 @@ jest.mock("./AgentHistoryPanel", () => ({
|
||||
}));
|
||||
|
||||
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", () => ({
|
||||
AgentComposer: React.forwardRef(function MockAgentComposer() {
|
||||
return <div>Composer</div>;
|
||||
AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) {
|
||||
return <div>Composer mode: {props.approvalMode}</div>;
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -90,12 +99,17 @@ describe("GlobalChatbox lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
createSession.mockClear();
|
||||
mockFetchAgentRuntimeHealth.mockReset();
|
||||
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
|
||||
mockFetchAgentModels.mockReset();
|
||||
mockFetchAgentModels.mockImplementation(() => new Promise(() => {}));
|
||||
mockCurrentProjectId = "project-1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
||||
@@ -121,4 +135,60 @@ describe("GlobalChatbox lifecycle", () => {
|
||||
|
||||
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 { getAccessToken } from "@/lib/authToken";
|
||||
import {
|
||||
fetchAgentRuntimeHealth,
|
||||
type AgentRuntimeState,
|
||||
} from "@/lib/agentRuntime";
|
||||
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
@@ -26,6 +30,9 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||
|
||||
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||
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 }) => {
|
||||
const [width, setWidth] = useState(520);
|
||||
@@ -34,8 +41,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
||||
const [runtimeState, setRuntimeState] = useState<AgentRuntimeState>("checking");
|
||||
const [approvalMode, setApprovalMode] =
|
||||
useState<AgentApprovalMode>("request");
|
||||
useState<AgentApprovalMode>("auto");
|
||||
|
||||
const bottomRef = 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 composerRef = useRef<AgentComposerHandle | null>(null);
|
||||
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||
const runtimeRequestIdRef = useRef(0);
|
||||
const runtimeAbortRef = useRef<AbortController | null>(null);
|
||||
const theme = useTheme();
|
||||
const { open: openNotification } = useNotification();
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
@@ -68,35 +78,87 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
isSupported: isSttSupported,
|
||||
} = useSpeechRecognition(handleSpeechResult);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refreshAgentRuntime = useCallback(async (showChecking = true) => {
|
||||
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 () => {
|
||||
try {
|
||||
const modelConfig = await fetchAgentModels();
|
||||
if (cancelled) return;
|
||||
setModelOptions(modelConfig.models);
|
||||
setSelectedModel((current) => {
|
||||
if (current && modelConfig.models.some((model) => model.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return modelConfig.defaultModel;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to load agent models:", error);
|
||||
if (!cancelled) {
|
||||
setModelOptions([]);
|
||||
setSelectedModel(undefined);
|
||||
}
|
||||
if (showChecking) setRuntimeState("checking");
|
||||
|
||||
try {
|
||||
runtimeHealthy = await fetchAgentRuntimeHealth(controller.signal);
|
||||
if (requestId !== runtimeRequestIdRef.current) return;
|
||||
if (!runtimeHealthy) {
|
||||
setRuntimeState("unavailable");
|
||||
setModelOptions([]);
|
||||
setSelectedModel(undefined);
|
||||
return "unavailable" as const;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
if (pollTimerId !== undefined) window.clearTimeout(pollTimerId);
|
||||
runtimeRequestIdRef.current += 1;
|
||||
runtimeAbortRef.current?.abort();
|
||||
runtimeAbortRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [open, refreshAgentRuntime]);
|
||||
|
||||
const handleToolCall = useAgentToolActions();
|
||||
const {
|
||||
@@ -203,7 +265,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
||||
|
||||
const handleSend = useCallback(async (prompt: string) => {
|
||||
if (isStreaming || isCheckingAuth) return;
|
||||
if (isStreaming || isCheckingAuth || runtimeState !== "ready") return;
|
||||
|
||||
setIsCheckingAuth(true);
|
||||
try {
|
||||
@@ -229,7 +291,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
} finally {
|
||||
setIsCheckingAuth(false);
|
||||
}
|
||||
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]);
|
||||
}, [isCheckingAuth, isStreaming, openNotification, runtimeState, sendPrompt]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
handleStopSpeech();
|
||||
@@ -373,6 +435,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
canRenameSessionTitle={Boolean(activeSessionId)}
|
||||
isHydrating={isHydrating}
|
||||
isStreaming={isStreaming}
|
||||
runtimeState={runtimeState}
|
||||
isHistoryOpen={isHistoryOpen}
|
||||
onHistoryToggle={handleHistoryToggle}
|
||||
onRenameSessionTitle={handleRenameActiveSession}
|
||||
@@ -430,6 +493,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
<AgentWorkspace
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
runtimeState={runtimeState}
|
||||
onRetryRuntime={() => void refreshAgentRuntime(true)}
|
||||
isLoadingSession={Boolean(loadingSessionId)}
|
||||
scrollContainerRef={workspaceScrollRef}
|
||||
bottomRef={bottomRef}
|
||||
@@ -450,6 +515,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
<AgentComposer
|
||||
ref={composerRef}
|
||||
isHydrating={isHydrating || isCheckingAuth}
|
||||
runtimeState={runtimeState}
|
||||
isStreaming={isStreaming}
|
||||
isListening={isListening}
|
||||
isSttSupported={isSttSupported}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
replyAgentCredentialRefresh,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
resumeAgentChatStream,
|
||||
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
|
||||
jest.mock("@/lib/chatStream", () => ({
|
||||
abortAgentChat: jest.fn(async () => undefined),
|
||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||
replyAgentCredentialRefresh: jest.fn(async () => undefined),
|
||||
replyAgentPermission: jest.fn(async () => undefined),
|
||||
replyAgentQuestion: jest.fn(async () => undefined),
|
||||
resumeAgentChatStream: 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 deleteChatSession = jest.fn();
|
||||
const updateChatSessionTitle = jest.fn();
|
||||
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
|
||||
updateChatSessionTitle.mockReset();
|
||||
jest.mocked(abortAgentChat).mockReset();
|
||||
jest.mocked(forkAgentChat).mockReset();
|
||||
jest.mocked(replyAgentCredentialRefresh).mockReset();
|
||||
jest.mocked(replyAgentPermission).mockReset();
|
||||
jest.mocked(replyAgentQuestion).mockReset();
|
||||
jest.mocked(resumeAgentChatStream).mockReset();
|
||||
jest.mocked(streamAgentChat).mockReset();
|
||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
|
||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
replyAgentCredentialRefresh,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
resumeAgentChatStream,
|
||||
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
|
||||
jest.mock("@/lib/chatStream", () => ({
|
||||
abortAgentChat: jest.fn(async () => undefined),
|
||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||
replyAgentCredentialRefresh: jest.fn(async () => undefined),
|
||||
replyAgentPermission: jest.fn(async () => undefined),
|
||||
replyAgentQuestion: jest.fn(async () => undefined),
|
||||
resumeAgentChatStream: 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 deleteChatSession = jest.fn();
|
||||
const updateChatSessionTitle = jest.fn();
|
||||
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
|
||||
updateChatSessionTitle.mockReset();
|
||||
jest.mocked(abortAgentChat).mockReset();
|
||||
jest.mocked(forkAgentChat).mockReset();
|
||||
jest.mocked(replyAgentCredentialRefresh).mockReset();
|
||||
jest.mocked(replyAgentPermission).mockReset();
|
||||
jest.mocked(replyAgentQuestion).mockReset();
|
||||
jest.mocked(resumeAgentChatStream).mockReset();
|
||||
jest.mocked(streamAgentChat).mockReset();
|
||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
|
||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
"use client";
|
||||
|
||||
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 type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||||
import {
|
||||
abortAgentChat,
|
||||
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 { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||
import {
|
||||
createEmptyChatState,
|
||||
deleteChatSession,
|
||||
listChatSessions,
|
||||
loadChatSessionById,
|
||||
updateChatSessionTitle,
|
||||
} from "../chatStorage";
|
||||
import {
|
||||
applyQuestionResponse,
|
||||
cancelRunningTodos,
|
||||
completeRunningProgress,
|
||||
createAssistantMessage,
|
||||
createTodoUpdateFromEvent,
|
||||
createUserMessage,
|
||||
dedupeQuestionsAcrossMessages,
|
||||
finalizeAssistantMessageAfterAbort,
|
||||
normalizeSessionTodos,
|
||||
toPermissionStatus,
|
||||
upsertPermission,
|
||||
upsertProgress,
|
||||
upsertQuestionAcrossMessages,
|
||||
} from "./agentChatSessionState";
|
||||
import type {
|
||||
PromptRunOptions,
|
||||
UseAgentChatSessionOptions,
|
||||
} from "./useAgentChatSession.types";
|
||||
|
||||
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
||||
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
||||
@@ -80,6 +118,7 @@ export const useAgentChatSession = ({
|
||||
getModel,
|
||||
getApprovalMode,
|
||||
}: UseAgentChatSessionOptions) => {
|
||||
const { update: updateSession } = useSession();
|
||||
const hydrationNonceRef = useRef(0);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
@@ -102,6 +141,7 @@ export const useAgentChatSession = ({
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
||||
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = sessionId;
|
||||
@@ -289,6 +329,61 @@ export const useAgentChatSession = ({
|
||||
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(
|
||||
(
|
||||
event: StreamEvent,
|
||||
@@ -421,6 +516,68 @@ export const useAgentChatSession = ({
|
||||
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") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
@@ -457,6 +614,7 @@ export const useAgentChatSession = ({
|
||||
);
|
||||
setIsStreaming(false);
|
||||
} else if (event.type === "auth_required") {
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
@@ -477,6 +635,7 @@ export const useAgentChatSession = ({
|
||||
appendArtifact,
|
||||
flushPendingTokens,
|
||||
getLastAssistantMessageId,
|
||||
handleCredentialRefresh,
|
||||
onToolCall,
|
||||
queueTokenContent,
|
||||
],
|
||||
@@ -640,7 +799,7 @@ export const useAgentChatSession = ({
|
||||
}, [flushPendingTokens, getLastAssistantMessageId]);
|
||||
|
||||
const replyPermission = useCallback(
|
||||
async (requestId: string, reply: PermissionReply) => {
|
||||
async (requestId: string, reply: PermissionDecision) => {
|
||||
const target = messagesRef.current
|
||||
.flatMap((message) => message.permissions ?? [])
|
||||
.find((permission) => permission.requestId === requestId);
|
||||
|
||||
@@ -20,7 +20,16 @@ import "dayjs/locale/zh-cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { BurstDetectionResult } from "./types";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstDetectionResult) => void;
|
||||
@@ -48,7 +57,7 @@ const currentQuarterHour = () => {
|
||||
|
||||
export const createBurstDetectionAnalysisParametersState =
|
||||
(): BurstDetectionAnalysisParametersState => ({
|
||||
schemeName: `Burst_Detection_${Date.now()}`,
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection),
|
||||
detectionMode: "latest",
|
||||
targetTime: currentQuarterHour(),
|
||||
samplingIntervalMinutes: 15,
|
||||
@@ -93,7 +102,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||
export const buildBurstDetectionRequest = (
|
||||
parameters: BurstDetectionAnalysisParametersState,
|
||||
) => ({
|
||||
scheme_name: parameters.schemeName.trim(),
|
||||
scheme_name: normalizeSchemeName(parameters.schemeName),
|
||||
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
||||
...(parameters.detectionMode === "historical" && parameters.targetTime
|
||||
? { target_time: parameters.targetTime.toISOString() }
|
||||
@@ -121,6 +130,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
} = parametersState;
|
||||
const [running, setRunning] = useState(false);
|
||||
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
||||
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
|
||||
setParametersState({
|
||||
...draft,
|
||||
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (samplingIntervalSource !== "metadata") return;
|
||||
@@ -157,7 +172,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
|
||||
const isValid = useMemo(
|
||||
() =>
|
||||
schemeName.trim().length > 0 &&
|
||||
isSchemeNameValid(schemeName) &&
|
||||
samplingIntervalValid &&
|
||||
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
|
||||
[detectionMode, samplingIntervalValid, schemeName, targetTime],
|
||||
@@ -196,12 +211,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
? "目标时刻存在异常信号,请优先复核相关测点。"
|
||||
: "目标时刻未发现爆管异常。",
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
open?.({
|
||||
key: "burst-detection-analysis-error",
|
||||
type: "error",
|
||||
message: "侦测失败",
|
||||
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
|
||||
description: getApiErrorMessage(error, "爆管侦测请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -219,6 +234,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
value={schemeName}
|
||||
onChange={(event) => setFormField("schemeName", event.target.value)}
|
||||
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
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -26,9 +26,18 @@ import "dayjs/locale/zh-cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
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 { BurstLocationResult } from "./types";
|
||||
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstLocationResult) => void;
|
||||
@@ -66,7 +75,7 @@ export interface BurstLocationAnalysisParametersState {
|
||||
|
||||
export const createBurstLocationAnalysisParametersState =
|
||||
(): BurstLocationAnalysisParametersState => ({
|
||||
schemeName: `Burst_Locate_${Date.now()}`,
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstLocation),
|
||||
dataSource: "monitoring",
|
||||
schemes: [],
|
||||
selectedSchemeId: "",
|
||||
@@ -105,6 +114,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
} = parametersState;
|
||||
const [schemeLoading, setSchemeLoading] = 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 applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||||
@@ -155,12 +171,14 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "刷新方案失败",
|
||||
description:
|
||||
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
||||
description: getApiErrorMessage(
|
||||
error,
|
||||
"无法获取爆管分析方案列表",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setSchemeLoading(false);
|
||||
@@ -185,6 +203,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!isSchemeNameValid(schemeName)) return false;
|
||||
if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false;
|
||||
if (!burstStartTime || !burstEndTime) {
|
||||
return false;
|
||||
@@ -196,6 +215,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
return burstStartTime.isBefore(burstEndTime);
|
||||
}, [
|
||||
burstLeakage,
|
||||
schemeName,
|
||||
burstStartTime,
|
||||
burstEndTime,
|
||||
dataSource,
|
||||
@@ -226,7 +246,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
`${config.BACKEND_URL}/api/v1/burst-locations`,
|
||||
{
|
||||
data_source: dataSource,
|
||||
scheme_name: schemeName.trim() || undefined,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
||||
min_dpressure: minDpressure,
|
||||
basic_pressure: basicPressure,
|
||||
@@ -262,7 +282,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
message: "爆管定位成功",
|
||||
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
const notice = getBurstLocationErrorNotice(error);
|
||||
open?.({
|
||||
key: "burst-location-analysis-error",
|
||||
@@ -286,6 +306,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
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
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
|
||||
export interface BurstLocationErrorNotice {
|
||||
message: string;
|
||||
description: string;
|
||||
@@ -6,26 +8,10 @@ export interface BurstLocationErrorNotice {
|
||||
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 = (
|
||||
error: unknown,
|
||||
): BurstLocationErrorNotice => {
|
||||
const detail = extractErrorDetail(error);
|
||||
const detail = getApiErrorMessage(error);
|
||||
const match = detail.match(DATA_GAP_PATTERN);
|
||||
|
||||
if (!match) {
|
||||
|
||||
@@ -29,9 +29,18 @@ import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
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 { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
export interface PipePoint {
|
||||
id: string;
|
||||
@@ -51,7 +60,7 @@ export const createBurstAnalysisParametersState = (): BurstAnalysisParametersSta
|
||||
pipePoints: [],
|
||||
startTime: dayjs(new Date()),
|
||||
duration: 3600,
|
||||
schemeName: "FANGAN" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis),
|
||||
network: NETWORK_NAME,
|
||||
});
|
||||
|
||||
@@ -74,6 +83,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
);
|
||||
const { pipePoints, startTime, duration, schemeName, network } =
|
||||
parametersState;
|
||||
useSessionRecoveryDraft("burst-simulation", parametersState, (draft) =>
|
||||
setParametersState({
|
||||
...draft,
|
||||
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||
}),
|
||||
);
|
||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
@@ -97,7 +112,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
pipePoints.length > 0 &&
|
||||
startTime !== null &&
|
||||
duration > 0 &&
|
||||
schemeName.trim() !== "";
|
||||
isSchemeNameValid(schemeName);
|
||||
|
||||
// 地图点击选择要素事件处理函数
|
||||
const handleMapClickSelectFeatures = useCallback(
|
||||
@@ -326,7 +341,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
burst_id: burst_ID,
|
||||
burst_size: burst_size,
|
||||
modify_total_duration: modify_total_duration,
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -351,8 +366,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
key: "burst-analysis",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description:
|
||||
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
|
||||
description: getApiErrorMessage(error, "爆管模拟请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
@@ -518,6 +532,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setParameterField("schemeName", e.target.value)}
|
||||
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>
|
||||
|
||||
|
||||
@@ -29,6 +29,15 @@ import {
|
||||
queryFeaturesByIds,
|
||||
} from "@/utils/mapQueryService";
|
||||
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 {
|
||||
schemeName: string;
|
||||
@@ -41,7 +50,7 @@ export interface ContaminantAnalysisParametersState {
|
||||
|
||||
export const createContaminantAnalysisParametersState =
|
||||
(): ContaminantAnalysisParametersState => ({
|
||||
schemeName: "WQ_" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.contaminantAnalysis),
|
||||
startTime: dayjs(new Date()),
|
||||
sourceNode: "",
|
||||
concentration: 100,
|
||||
@@ -62,7 +71,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
const { open } = useNotification();
|
||||
|
||||
const network = NETWORK_NAME;
|
||||
const [parametersState, , setFormField] = useControllableObjectState(
|
||||
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createContaminantAnalysisParametersState(),
|
||||
@@ -75,6 +84,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
duration,
|
||||
pattern,
|
||||
} = parametersState;
|
||||
useSessionRecoveryDraft("contaminant-simulation", parametersState, (draft) =>
|
||||
setParametersState({
|
||||
...draft,
|
||||
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||
}),
|
||||
);
|
||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
@@ -91,7 +106,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
Boolean(sourceNode) &&
|
||||
concentration > 0 &&
|
||||
duration > 0 &&
|
||||
schemeName.trim() !== ""
|
||||
isSchemeNameValid(schemeName)
|
||||
);
|
||||
}, [network, startTime, sourceNode, concentration, duration, schemeName]);
|
||||
|
||||
@@ -233,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
concentration,
|
||||
duration,
|
||||
pattern: pattern || undefined,
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
};
|
||||
|
||||
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
|
||||
@@ -252,8 +267,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
key: "contaminant-analysis",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description:
|
||||
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
|
||||
description: getApiErrorMessage(error, "污染物模拟请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -384,6 +398,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
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>
|
||||
|
||||
|
||||
@@ -20,8 +20,17 @@ import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { config } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { LeakageResultDetail } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: LeakageResultDetail) => void;
|
||||
@@ -42,7 +51,7 @@ export interface DMALeakAnalysisParametersState {
|
||||
|
||||
export const createDMALeakAnalysisParametersState =
|
||||
(): DMALeakAnalysisParametersState => ({
|
||||
schemeName: `DMA_Leak_${Date.now()}`,
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.dmaLeakIdentification),
|
||||
dmaCount: 5,
|
||||
startTime: dayjs().subtract(2, "hour"),
|
||||
endTime: dayjs(),
|
||||
@@ -58,7 +67,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const [parametersState, , setFormField] = useControllableObjectState(
|
||||
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createDMALeakAnalysisParametersState(),
|
||||
@@ -73,6 +82,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
qSum,
|
||||
advancedOpen,
|
||||
} = 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 [qSumInput, setQSumInput] = useState(() => String(qSum));
|
||||
|
||||
@@ -85,7 +101,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0;
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!schemeName.trim() || !startTime || !endTime) return false;
|
||||
if (!isSchemeNameValid(schemeName) || !startTime || !endTime) return false;
|
||||
return startTime.isBefore(endTime) && qSumIsValid;
|
||||
}, [schemeName, startTime, endTime, qSumIsValid]);
|
||||
|
||||
@@ -110,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/leakage-identifications`,
|
||||
{
|
||||
scheme_name: schemeName.trim(),
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
dma_count: dmaCount,
|
||||
scada_start: startTime.toISOString(),
|
||||
scada_end: endTime.toISOString(),
|
||||
@@ -128,12 +144,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
message: "方案分析成功",
|
||||
description: "DMA 漏损识别完成,请在方案查询中查看结果。",
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
open?.({
|
||||
key: "dma-leak-analysis-error",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error?.response?.data?.detail ?? "请求失败",
|
||||
description: getApiErrorMessage(error, "DMA 漏损识别请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -155,6 +171,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
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
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
Typography,
|
||||
IconButton,
|
||||
Stack,
|
||||
Alert,
|
||||
Divider,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
AdjustOutlined,
|
||||
@@ -33,13 +33,34 @@ import {
|
||||
import Feature, { FeatureLike } from "ol/Feature";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
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 { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
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 {
|
||||
id: string;
|
||||
k: number;
|
||||
setting?: string | null;
|
||||
vType?: string | null;
|
||||
status?: LinkStatus | null;
|
||||
detailsLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface FlushingAnalysisParametersState {
|
||||
@@ -53,7 +74,7 @@ export interface FlushingAnalysisParametersState {
|
||||
|
||||
export const createFlushingAnalysisParametersState =
|
||||
(): FlushingAnalysisParametersState => ({
|
||||
schemeName: "Flushing_" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis),
|
||||
valves: [],
|
||||
drainageNode: null,
|
||||
startTime: dayjs(new Date()),
|
||||
@@ -80,11 +101,18 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
);
|
||||
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
|
||||
parametersState;
|
||||
useSessionRecoveryDraft("flushing-analysis", parametersState, (draft) =>
|
||||
setParametersState({
|
||||
...draft,
|
||||
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||
}),
|
||||
);
|
||||
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
|
||||
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none');
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||
const valveSettingRequests = useRef(new Set<string>());
|
||||
|
||||
const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null);
|
||||
|
||||
@@ -112,7 +140,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
if (!feature) return;
|
||||
|
||||
const layer = feature.getId()?.toString().split(".")[0];
|
||||
const featureId = feature.getProperties().id;
|
||||
const featureId = String(feature.getProperties().id);
|
||||
|
||||
if (selectionMode === 'valve') {
|
||||
if (layer !== 'geo_valves') {
|
||||
@@ -132,7 +160,16 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
return prev;
|
||||
}
|
||||
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') {
|
||||
@@ -246,6 +283,91 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
});
|
||||
}, [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(() => {
|
||||
if (!drainageNode) {
|
||||
setDrainageFeature(null);
|
||||
@@ -285,13 +407,25 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const handleValveKChange = (id: string, k: string) => {
|
||||
const numK = parseFloat(k);
|
||||
setValves(prev => prev.map(v => v.id === id ? { ...v, k: isNaN(numK) ? 0 : numK } : v));
|
||||
const handleValveStatusChange = (id: string, status: string) => {
|
||||
if (!isLinkStatus(status)) return;
|
||||
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 () => {
|
||||
if (!startTime || !drainageNode || !schemeName.trim()) {
|
||||
if (!startTime || !drainageNode || !isSchemeNameValid(schemeName)) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请填写完整参数",
|
||||
@@ -300,16 +434,48 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
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);
|
||||
|
||||
try {
|
||||
const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00
|
||||
|
||||
const params = {
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
start_time: formattedTime,
|
||||
valves: valves.map(v => v.id),
|
||||
valves_k: valves.map(v => v.k),
|
||||
...(valves.length > 0 && {
|
||||
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,
|
||||
flush_flow: flushFlow,
|
||||
duration: duration
|
||||
@@ -339,7 +505,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error instanceof Error ? error.message : "未知错误",
|
||||
description: getApiErrorMessage(error, "管道冲洗分析请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
@@ -348,62 +514,11 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col h-full gap-4 pb-4">
|
||||
{/* 1. Valve Selection */}
|
||||
{/* 1. Drainage Node 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="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>
|
||||
<Button
|
||||
variant={selectionMode === 'drainage' ? "contained" : "outlined"}
|
||||
@@ -437,6 +552,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
{!drainageNode && (
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
horizontalAlign="start"
|
||||
icon={<WaterDropOutlined />}
|
||||
title="尚未选择排水节点"
|
||||
description="点击“选择节点”,然后在地图上指定排水点。"
|
||||
@@ -447,6 +563,110 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
|
||||
<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 */}
|
||||
<Box className="flex flex-col gap-3">
|
||||
<Box>
|
||||
@@ -478,6 +698,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
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>
|
||||
|
||||
@@ -520,7 +747,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
onClick={handleAnalyze}
|
||||
disabled={
|
||||
analyzing ||
|
||||
!schemeName.trim() ||
|
||||
!isSchemeNameValid(schemeName) ||
|
||||
!drainageNode ||
|
||||
!startTime ||
|
||||
// !flushFlow ||
|
||||
|
||||
@@ -44,6 +44,7 @@ import { SchemeRecord, SchemaItem } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
import { formatValveControlSummary } from "@components/olmap/core/Controls/toolbarFeatureHelpers";
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: SchemeRecord[];
|
||||
@@ -589,10 +590,22 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
{/* 阀门列表 */}
|
||||
<Box className="col-span-2 pl-2">
|
||||
<Typography variant="caption" className="text-gray-600 block mb-1">
|
||||
参与阀门及开度:
|
||||
参与阀门及设置:
|
||||
</Typography>
|
||||
<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]) => (
|
||||
<Tooltip key={id} title="点击定位阀门">
|
||||
<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,5 +1,13 @@
|
||||
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;
|
||||
flushing_flow: number;
|
||||
duration: number;
|
||||
|
||||
+46
-3
@@ -1,5 +1,6 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import MonitoringPlaceOptimizationPanel, {
|
||||
getMonitoringLayerVisibility,
|
||||
getTabIndicatorSx,
|
||||
getTabIndicatorTransform,
|
||||
} from "./MonitoringPlaceOptimizationPanel";
|
||||
@@ -7,6 +8,7 @@ import { getSensorPlacementScheme } from "./schemeApi";
|
||||
import type { SensorPlacementScheme } from "./types";
|
||||
|
||||
const mockSchemeEditorRender = jest.fn();
|
||||
const mockSchemeQueryRender = jest.fn();
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useNotification: () => ({ open: jest.fn() }),
|
||||
@@ -20,9 +22,16 @@ jest.mock("./OptimizationParameters", () => ({
|
||||
|
||||
jest.mock("./SchemeQuery", () => ({
|
||||
__esModule: true,
|
||||
default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => (
|
||||
<button onClick={() => onEdit(7)}>打开测试方案</button>
|
||||
),
|
||||
default: ({
|
||||
onEdit,
|
||||
active,
|
||||
}: {
|
||||
onEdit: (schemeId: number) => void;
|
||||
active?: boolean;
|
||||
}) => {
|
||||
mockSchemeQueryRender(active);
|
||||
return <button onClick={() => onEdit(7)}>打开测试方案</button>;
|
||||
},
|
||||
createMonitoringSchemeQueryState: () => ({}),
|
||||
}));
|
||||
|
||||
@@ -66,6 +75,7 @@ const scheme: SensorPlacementScheme = {
|
||||
describe("MonitoringPlaceOptimizationPanel", () => {
|
||||
beforeEach(() => {
|
||||
mockSchemeEditorRender.mockClear();
|
||||
mockSchemeQueryRender.mockClear();
|
||||
});
|
||||
|
||||
it("applies equal-width positioning to the rendered tab indicator", () => {
|
||||
@@ -90,6 +100,21 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
||||
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 () => {
|
||||
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
|
||||
mockGetSensorPlacementScheme.mockReturnValue(
|
||||
@@ -119,5 +144,23 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
||||
false,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-1
@@ -69,6 +69,11 @@ export const getTabIndicatorSx = (tabIndex: number) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const getMonitoringLayerVisibility = (tabIndex: number) => ({
|
||||
editor: tabIndex === 1,
|
||||
query: tabIndex === 2,
|
||||
});
|
||||
|
||||
interface PreparedSchemeEditorProps
|
||||
extends React.ComponentProps<typeof SchemeEditor> {
|
||||
onReady?: () => void;
|
||||
@@ -126,6 +131,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
};
|
||||
|
||||
const drawerWidth = currentTab === 1 ? 820 : 520;
|
||||
const layerVisibility = getMonitoringLayerVisibility(currentTab);
|
||||
|
||||
const handleSchemeEditorReady = useCallback(() => {
|
||||
setCurrentTab(1);
|
||||
@@ -326,7 +332,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
<PreparedSchemeEditor
|
||||
scheme={activeScheme}
|
||||
network={NETWORK_NAME}
|
||||
active={isOpen && currentTab === 1}
|
||||
active={layerVisibility.editor}
|
||||
onSaved={handleSchemeSaved}
|
||||
onReady={
|
||||
pendingOpenSchemeId === activeScheme.id
|
||||
@@ -346,6 +352,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
<TabPanel value={currentTab} index={2}>
|
||||
<SchemeQuery
|
||||
schemes={schemes}
|
||||
active={layerVisibility.query}
|
||||
onSchemesChange={setSchemes}
|
||||
state={queryState}
|
||||
onStateChange={setQueryState}
|
||||
|
||||
@@ -12,8 +12,16 @@ import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { NETWORK_NAME } from "@/config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { optimizeSensorPlacement } from "./schemeApi";
|
||||
import type { SensorPlacementScheme } from "./types";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
export interface OptimizationParametersState {
|
||||
method: string;
|
||||
@@ -27,7 +35,7 @@ export const createOptimizationParametersState =
|
||||
method: "kmeans",
|
||||
sensorCount: 5,
|
||||
minDiameter: 5,
|
||||
schemeName: "Fangan" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
|
||||
});
|
||||
|
||||
interface OptimizationParametersProps {
|
||||
@@ -62,7 +70,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
// 创建方案
|
||||
const handleCreateScheme = async () => {
|
||||
// 验证输入
|
||||
if (!schemeName.trim()) {
|
||||
if (!isSchemeNameValid(schemeName)) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请输入方案名称",
|
||||
@@ -90,7 +98,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
|
||||
try {
|
||||
const created = await optimizeSensorPlacement({
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
sensor_type: "pressure",
|
||||
method: method as "sensitivity" | "kmeans",
|
||||
sensor_count: sensorCount,
|
||||
@@ -102,14 +110,16 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
description: `方案 "${schemeName}" 已完成优化分析`,
|
||||
});
|
||||
onSchemeCreated?.(created);
|
||||
setFormField("schemeName", "Fangan" + new Date().getTime());
|
||||
} catch (error: any) {
|
||||
setFormField(
|
||||
"schemeName",
|
||||
createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("创建方案失败:", error);
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "创建方案失败",
|
||||
description:
|
||||
error.response?.data?.message || error.message || "未知错误",
|
||||
description: getApiErrorMessage(error, "监测点优化请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
@@ -255,6 +265,13 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
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={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
"&:hover fieldset": {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import SchemeEditor from "./SchemeEditor";
|
||||
import { getSensorPlacementCandidate } from "./schemeApi";
|
||||
import type { SensorPlacementScheme } from "./types";
|
||||
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||
|
||||
const mockOpen = jest.fn();
|
||||
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
||||
let mockGridColumns: Array<{
|
||||
field: string;
|
||||
headerName?: string;
|
||||
valueFormatter?: (value: unknown) => string;
|
||||
}> = [];
|
||||
|
||||
const mockMap = {
|
||||
addLayer: jest.fn(),
|
||||
@@ -32,19 +39,23 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||
|
||||
jest.mock("@mui/x-data-grid", () => ({
|
||||
DataGrid: (props: {
|
||||
columns: typeof mockGridColumns;
|
||||
density?: string;
|
||||
initialState?: { density?: string };
|
||||
rowHeight?: number;
|
||||
columnHeaderHeight?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="scheme-grid"
|
||||
data-density={props.density ?? "uncontrolled"}
|
||||
data-initial-density={props.initialState?.density ?? ""}
|
||||
data-row-height={props.rowHeight ?? "automatic"}
|
||||
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
||||
/>
|
||||
),
|
||||
}) => {
|
||||
mockGridColumns = props.columns;
|
||||
return (
|
||||
<div
|
||||
data-testid="scheme-grid"
|
||||
data-density={props.density ?? "uncontrolled"}
|
||||
data-initial-density={props.initialState?.density ?? ""}
|
||||
data-row-height={props.rowHeight ?? "automatic"}
|
||||
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
GridToolbar: () => null,
|
||||
}));
|
||||
|
||||
@@ -119,11 +130,6 @@ jest.mock("ol/style", () => ({
|
||||
Text: class {},
|
||||
}));
|
||||
|
||||
jest.mock("ol/proj", () => ({
|
||||
fromLonLat: (coordinates: number[]) => coordinates,
|
||||
toLonLat: (coordinates: number[]) => coordinates,
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/mapQueryService", () => ({
|
||||
handleMapClickSelectFeatures: jest.fn(),
|
||||
}));
|
||||
@@ -135,6 +141,7 @@ jest.mock("./SchemeDrawingDialog", () => ({
|
||||
|
||||
jest.mock("./schemeApi", () => ({
|
||||
exportSensorPlacementExcel: jest.fn(),
|
||||
getSensorPlacementCandidate: jest.fn(),
|
||||
overwriteSensorPlacementScheme: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -149,6 +156,7 @@ const scheme: SensorPlacementScheme = {
|
||||
sensor_points: [
|
||||
{
|
||||
node_id: "J1",
|
||||
max_pipe_diameter: 400,
|
||||
project_x: 10,
|
||||
project_y: 20,
|
||||
map_x: 13500010,
|
||||
@@ -161,10 +169,18 @@ const scheme: SensorPlacementScheme = {
|
||||
can_edit: true,
|
||||
};
|
||||
|
||||
const mockGetSensorPlacementCandidate = jest.mocked(
|
||||
getSensorPlacementCandidate,
|
||||
);
|
||||
const mockHandleMapClickSelectFeatures = jest.mocked(
|
||||
handleMapClickSelectFeatures,
|
||||
);
|
||||
|
||||
describe("SchemeEditor notifications", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSingleClickHandler = undefined;
|
||||
mockGridColumns = [];
|
||||
});
|
||||
|
||||
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", () => {
|
||||
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||
|
||||
@@ -207,4 +255,22 @@ describe("SchemeEditor notifications", () => {
|
||||
"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 VectorSource from "ol/source/Vector";
|
||||
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
|
||||
import { fromLonLat, toLonLat } from "ol/proj";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { config } from "@/config/config";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||
import {
|
||||
@@ -65,6 +62,7 @@ import {
|
||||
} from "./schemeEditor";
|
||||
import {
|
||||
exportSensorPlacementExcel,
|
||||
getSensorPlacementCandidate,
|
||||
overwriteSensorPlacementScheme,
|
||||
} from "./schemeApi";
|
||||
import SchemeDrawingDialog from "./SchemeDrawingDialog";
|
||||
@@ -147,6 +145,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [drawingOpen, setDrawingOpen] = useState(false);
|
||||
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const activeRef = useRef(active);
|
||||
activeRef.current = active;
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(createSchemeEditorState(scheme));
|
||||
@@ -171,6 +171,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
},
|
||||
zIndex: 120,
|
||||
});
|
||||
layer.setVisible(activeRef.current);
|
||||
markerLayerRef.current = layer;
|
||||
map.addLayer(layer);
|
||||
return () => {
|
||||
@@ -206,47 +207,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
const feature = await handleMapClickSelectFeatures(event, map);
|
||||
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
|
||||
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 {
|
||||
const response = await api.get<{
|
||||
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),
|
||||
};
|
||||
return await getSensorPlacementCandidate(nodeId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -436,6 +398,70 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
|
||||
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",
|
||||
headerName: "序号",
|
||||
@@ -445,6 +471,21 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
sortable: false,
|
||||
},
|
||||
{ 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",
|
||||
headerName: "经度",
|
||||
@@ -507,70 +548,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
headerAlign: "right",
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -53,6 +53,7 @@ interface SchemaItem {
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: SchemeRecord[];
|
||||
active?: boolean;
|
||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||
onEdit?: (id: number) => void;
|
||||
network?: string;
|
||||
@@ -77,6 +78,7 @@ export const createMonitoringSchemeQueryState =
|
||||
|
||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
schemes: externalSchemes,
|
||||
active = true,
|
||||
onSchemesChange,
|
||||
onEdit,
|
||||
network = NETWORK_NAME,
|
||||
@@ -98,6 +100,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
useState<VectorLayer<VectorSource> | null>(null);
|
||||
const activeRef = useRef(active);
|
||||
activeRef.current = active;
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
// 使用外部提供的 schemes 或内部状态
|
||||
const schemes =
|
||||
@@ -142,6 +146,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
highlightLayer.setVisible(activeRef.current);
|
||||
|
||||
map.addLayer(highlightLayer);
|
||||
setHighlightLayer(highlightLayer);
|
||||
@@ -151,6 +156,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
highlightLayer?.setVisible(active);
|
||||
}, [active, highlightLayer]);
|
||||
|
||||
// 高亮要素的函数
|
||||
useEffect(() => {
|
||||
if (!highlightLayer) {
|
||||
|
||||
@@ -107,6 +107,7 @@ const point = (
|
||||
map_y: number,
|
||||
): SensorPointRow => ({
|
||||
node_id,
|
||||
max_pipe_diameter: 300,
|
||||
sequence,
|
||||
map_x,
|
||||
map_y,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { config } from "@/config/config";
|
||||
import type {
|
||||
AdjustmentStatus,
|
||||
SensorPlacementScheme,
|
||||
SensorPoint,
|
||||
} from "./types";
|
||||
|
||||
export interface OptimizeSchemeInput {
|
||||
@@ -32,6 +33,15 @@ export const getSensorPlacementScheme = async (
|
||||
return 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;
|
||||
};
|
||||
|
||||
export const overwriteSensorPlacementScheme = async (
|
||||
schemeId: number,
|
||||
expectedSensorLocation: string[],
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { SensorPlacementScheme, SensorPoint } from "./types";
|
||||
|
||||
const point = (node_id: string): SensorPoint => ({
|
||||
node_id,
|
||||
max_pipe_diameter: 300,
|
||||
project_x: Number(node_id.slice(1)) * 10,
|
||||
project_y: Number(node_id.slice(1)) * 20,
|
||||
map_x: 13500000 + Number(node_id.slice(1)) * 10,
|
||||
|
||||
@@ -2,6 +2,7 @@ export type AdjustmentStatus = "current" | "original" | "added" | "replaced";
|
||||
|
||||
export interface SensorPoint {
|
||||
node_id: string;
|
||||
max_pipe_diameter: number | null;
|
||||
project_x: number;
|
||||
project_y: number;
|
||||
map_x: number;
|
||||
|
||||
@@ -36,11 +36,15 @@ describe("PanelEmptyState", () => {
|
||||
render(
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
horizontalAlign="start"
|
||||
title="尚未选择阀门"
|
||||
description="点击“选择阀门”,然后在地图上添加。"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("尚未选择阀门")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveStyle({
|
||||
justifyContent: "flex-start",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PanelEmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: "panel" | "compact";
|
||||
horizontalAlign?: "center" | "start";
|
||||
}
|
||||
|
||||
const PanelEmptyState = ({
|
||||
@@ -17,6 +18,7 @@ const PanelEmptyState = ({
|
||||
title,
|
||||
description,
|
||||
variant = "panel",
|
||||
horizontalAlign = "center",
|
||||
}: PanelEmptyStateProps) => {
|
||||
const compact = variant === "compact";
|
||||
|
||||
@@ -32,7 +34,8 @@ const PanelEmptyState = ({
|
||||
display: "flex",
|
||||
flexDirection: compact ? "row" : { xs: "column", sm: "row" },
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
justifyContent:
|
||||
horizontalAlign === "start" ? "flex-start" : "center",
|
||||
gap: compact ? 1.5 : { xs: 1.75, sm: 2.5 },
|
||||
px: compact ? 1.5 : { xs: 2, sm: 4 },
|
||||
py: compact ? 1.25 : { xs: 4, sm: 5 },
|
||||
|
||||
@@ -22,9 +22,17 @@ import { useNotification } from "@refinedev/core";
|
||||
import ToolbarHistoryPanel from "./ToolbarHistoryPanel";
|
||||
import {
|
||||
buildFeatureProperties,
|
||||
getSimulationElementType,
|
||||
getValvePropertySource,
|
||||
} from "./toolbarFeatureHelpers";
|
||||
import { useToolbarChatActions } from "./useToolbarChatActions";
|
||||
import { useStyleEditor } from "./useStyleEditor";
|
||||
import {
|
||||
type LinkStatus,
|
||||
isLinkStatus,
|
||||
normalizeValveSetting,
|
||||
validateValveSetting,
|
||||
} from "./valveControl";
|
||||
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { useProject } from "@/contexts/ProjectContext";
|
||||
@@ -41,51 +49,25 @@ interface ToolbarProps {
|
||||
enableCompare?: boolean;
|
||||
}
|
||||
|
||||
type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE";
|
||||
type ValveProperties = {
|
||||
vType: string | null;
|
||||
setting: string | null;
|
||||
};
|
||||
|
||||
type SchemeValveControl = {
|
||||
status?: string;
|
||||
setting?: string | number;
|
||||
k?: number;
|
||||
};
|
||||
|
||||
type ActiveSchemeDetail = {
|
||||
valve_control?: Record<string, SchemeValveControl> | null;
|
||||
valve_opening?: Record<string, number> | null;
|
||||
};
|
||||
|
||||
const isValveLayer = (layerId: string | undefined) =>
|
||||
layerId === "geo_valves_mat" || layerId === "geo_valves";
|
||||
|
||||
const isLinkStatus = (value: unknown): value is LinkStatus =>
|
||||
value === "OPEN" || value === "CLOSED" || value === "ACTIVE";
|
||||
|
||||
const normalizeValveSetting = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const validateValveSetting = (
|
||||
valveType: string | null,
|
||||
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 : "阀门设置值不能为空。";
|
||||
};
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
hiddenButtons,
|
||||
queryType,
|
||||
@@ -400,6 +382,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const [isValvePropertiesLoading, setIsValvePropertiesLoading] =
|
||||
useState(false);
|
||||
const [isValveSettingSaving, setIsValveSettingSaving] = useState(false);
|
||||
const [activeSchemeDetail, setActiveSchemeDetail] =
|
||||
useState<ActiveSchemeDetail | null>(null);
|
||||
|
||||
const selectedFeature = highlightFeatures[0];
|
||||
const selectedFeatureLayer = selectedFeature
|
||||
@@ -415,9 +399,57 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId
|
||||
? String(selectedFeatureId)
|
||||
: null;
|
||||
const isSimulationDataActive =
|
||||
getValvePropertySource(queryType, schemeName) === "simulation";
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValveId) {
|
||||
if (
|
||||
queryType !== "scheme" ||
|
||||
schemeType !== "flushing_analysis" ||
|
||||
!schemeName ||
|
||||
!selectedValveId
|
||||
) {
|
||||
setActiveSchemeDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSchemeDetail(null);
|
||||
let cancelled = false;
|
||||
|
||||
const querySchemeDetail = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (schemeType) params.set("scheme_type", schemeType);
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}?${params.toString()}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`getschemedetail failed: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
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, schemeName, schemeType, selectedValveId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValveId || isSimulationDataActive) {
|
||||
setValveStatus(null);
|
||||
setIsValveStatusLoading(false);
|
||||
return;
|
||||
@@ -466,10 +498,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [networkName, open, selectedValveId]);
|
||||
}, [isSimulationDataActive, networkName, open, selectedValveId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValveId) {
|
||||
if (!selectedValveId || isSimulationDataActive) {
|
||||
setValveProperties({ vType: null, setting: null });
|
||||
setIsValvePropertiesLoading(false);
|
||||
return;
|
||||
@@ -521,7 +553,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [networkName, open, selectedValveId]);
|
||||
}, [isSimulationDataActive, networkName, open, selectedValveId]);
|
||||
|
||||
const handleValveStatusSave = useCallback(
|
||||
async (nextStatus: string) => {
|
||||
@@ -642,7 +674,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
// 添加 useEffect 来查询计算属性
|
||||
useEffect(() => {
|
||||
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
|
||||
const canQuerySimulation =
|
||||
getValvePropertySource(queryType, schemeName) === "simulation";
|
||||
if (
|
||||
highlightFeatures.length === 0 ||
|
||||
!selectedDate ||
|
||||
!showPropertyPanel ||
|
||||
!canQuerySimulation
|
||||
) {
|
||||
setComputedProperties({});
|
||||
return;
|
||||
}
|
||||
@@ -654,11 +693,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setComputedProperties({});
|
||||
|
||||
const queryComputedProperties = async () => {
|
||||
try {
|
||||
const properties = highlightFeature?.getProperties?.() || {};
|
||||
const type =
|
||||
properties.geometry?.getType?.() === "LineString" ? "link" : "node";
|
||||
const type = getSimulationElementType(highlightFeature);
|
||||
// selectedDate 格式化为 YYYY-MM-DD
|
||||
let dateObj: Date;
|
||||
if (selectedDate instanceof Date) {
|
||||
@@ -670,22 +710,33 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||
// 转为 UTC ISO 字符串
|
||||
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
||||
let response;
|
||||
let response: Response;
|
||||
if (queryType === "scheme") {
|
||||
const params = new URLSearchParams({
|
||||
scheme_type: schemeType ?? "",
|
||||
scheme_name: schemeName ?? "",
|
||||
id: String(id),
|
||||
type,
|
||||
query_time: querytime,
|
||||
});
|
||||
response = await apiFetch(
|
||||
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?${params.toString()}`,
|
||||
);
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
id: String(id),
|
||||
type,
|
||||
query_time: querytime,
|
||||
});
|
||||
response = await apiFetch(
|
||||
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?id=${id}&type=${type}&query_time=${querytime}`,
|
||||
`${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();
|
||||
if (cancelled) return;
|
||||
if (!data.result || data.result.length === 0) {
|
||||
setComputedProperties({});
|
||||
} else {
|
||||
@@ -694,32 +745,76 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error querying computed properties:", error);
|
||||
setComputedProperties({});
|
||||
if (!cancelled) {
|
||||
setComputedProperties({});
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "读取模拟属性失败,请检查方案、时间和数据源。",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// 仅当 currentTime 有效时查询
|
||||
if (currentTime !== -1 && queryType) queryComputedProperties();
|
||||
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
||||
if (currentTime !== -1 && queryType) void queryComputedProperties();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [highlightFeatures, currentTime, open, selectedDate, queryType, schemeName, schemeType, 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(
|
||||
() =>
|
||||
buildFeatureProperties(
|
||||
selectedFeature,
|
||||
computedProperties,
|
||||
canEditNetwork && selectedValveId
|
||||
displayedComputedProperties,
|
||||
!isSimulationDataActive && selectedValveId
|
||||
? {
|
||||
value: valveStatus,
|
||||
loading: isValveStatusLoading,
|
||||
saving: isValveStatusSaving,
|
||||
disabled: !canEditNetwork,
|
||||
onSave: handleValveStatusSave,
|
||||
}
|
||||
: undefined,
|
||||
canEditNetwork && selectedValveId
|
||||
!isSimulationDataActive && selectedValveId
|
||||
? {
|
||||
value: valveProperties.setting,
|
||||
vType: selectedValveType,
|
||||
loading: isValvePropertiesLoading,
|
||||
saving: isValveSettingSaving,
|
||||
disabled: !canEditNetwork,
|
||||
status: valveStatus,
|
||||
onSave: handleValveSettingSave,
|
||||
}
|
||||
@@ -727,7 +822,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
),
|
||||
[
|
||||
selectedFeature,
|
||||
computedProperties,
|
||||
displayedComputedProperties,
|
||||
isSimulationDataActive,
|
||||
canEditNetwork,
|
||||
selectedValveId,
|
||||
valveStatus,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
jest.mock("ol/Feature", () => ({
|
||||
__esModule: true,
|
||||
default: class Feature {},
|
||||
}));
|
||||
|
||||
import type Feature from "ol/Feature";
|
||||
|
||||
import {
|
||||
buildFeatureProperties,
|
||||
formatValveControlSummary,
|
||||
getSimulationElementType,
|
||||
getValvePropertySource,
|
||||
} from "./toolbarFeatureHelpers";
|
||||
|
||||
const createValveFeature = () => {
|
||||
const properties = {
|
||||
id: "V1",
|
||||
node1: "J1",
|
||||
node2: "J2",
|
||||
diameter: 200,
|
||||
v_type: "PRV",
|
||||
minor_loss: 0,
|
||||
};
|
||||
return {
|
||||
getId: () => "geo_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: () => "geo_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("geo_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([
|
||||
["geo_tanks", "T1", "水池"],
|
||||
["geo_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 { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import {
|
||||
getValveSettingHelperText,
|
||||
VALVE_STATUS_OPTIONS,
|
||||
} from "./valveControl";
|
||||
|
||||
type ToolbarBaseProperty = {
|
||||
label: string;
|
||||
@@ -54,6 +58,7 @@ export type ValveStatusPropertyOptions = {
|
||||
value: string | null;
|
||||
loading?: boolean;
|
||||
saving?: boolean;
|
||||
disabled?: boolean;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -62,32 +67,71 @@ export type ValveSettingPropertyOptions = {
|
||||
vType: string | null;
|
||||
loading?: boolean;
|
||||
saving?: boolean;
|
||||
disabled?: boolean;
|
||||
status?: string | null;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const getValveSettingHelperText = (
|
||||
vType: string | null,
|
||||
status?: string | null,
|
||||
) => {
|
||||
if (status === "OPEN" || status === "CLOSED") {
|
||||
return "开启/关闭状态下 EPANET 会忽略阀门设置值";
|
||||
export const getValvePropertySource = (
|
||||
queryType: string | undefined,
|
||||
schemeName: string | undefined,
|
||||
): "business" | "simulation" =>
|
||||
queryType === "realtime" || (queryType === "scheme" && Boolean(schemeName))
|
||||
? "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()) {
|
||||
case "PRV":
|
||||
case "PSV":
|
||||
case "PBV":
|
||||
return "压力设置值,需为 0 或正数";
|
||||
case "FCV":
|
||||
return "流量设置值,需为 0 或正数";
|
||||
case "TCV":
|
||||
return "损失系数,需为 0 或正数";
|
||||
case "GPV":
|
||||
return "水头损失曲线 ID";
|
||||
default:
|
||||
return "阀门类型相关设置值";
|
||||
const numericStatus = Number(value);
|
||||
if (Number.isFinite(numericStatus)) {
|
||||
if (numericStatus === 0) return "关闭";
|
||||
if (numericStatus === 1) return "开启";
|
||||
if (numericStatus === 2) 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 => {
|
||||
@@ -147,6 +191,51 @@ export const buildFeatureProperties = (
|
||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
||||
];
|
||||
|
||||
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 === "geo_pipes_mat" || layer === "geo_pipes") {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
@@ -166,30 +255,7 @@ export const buildFeatureProperties = (
|
||||
],
|
||||
};
|
||||
|
||||
pipeComputedFields.forEach(({ key, label, unit }) => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
appendLinkComputedProperties(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -230,25 +296,13 @@ export const buildFeatureProperties = (
|
||||
],
|
||||
};
|
||||
|
||||
nodeComputedFields.forEach(({ key, label, unit }) => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
appendNodeComputedProperties(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "水池",
|
||||
properties: [
|
||||
@@ -288,24 +342,28 @@ export const buildFeatureProperties = (
|
||||
},
|
||||
],
|
||||
};
|
||||
appendNodeComputedProperties(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "水库",
|
||||
properties: [
|
||||
{
|
||||
label: "水头",
|
||||
label: "基础水头",
|
||||
value: properties.head?.toFixed?.(1),
|
||||
unit: "m",
|
||||
},
|
||||
],
|
||||
};
|
||||
appendNodeComputedProperties(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "水泵",
|
||||
properties: [
|
||||
@@ -332,16 +390,22 @@ export const buildFeatureProperties = (
|
||||
},
|
||||
],
|
||||
};
|
||||
appendLinkComputedProperties(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_valves_mat" || layer === "geo_valves") {
|
||||
const valveType = valveSetting?.vType ?? properties.v_type;
|
||||
const hasSimulationValues =
|
||||
Object.hasOwn(computedProperties, "status") ||
|
||||
Object.hasOwn(computedProperties, "setting");
|
||||
const isValveSettingDisabled =
|
||||
valveSetting?.loading ||
|
||||
valveSetting?.disabled ||
|
||||
valveSetting?.status === "OPEN" ||
|
||||
valveSetting?.status === "CLOSED";
|
||||
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "阀门",
|
||||
properties: [
|
||||
@@ -360,25 +424,57 @@ export const buildFeatureProperties = (
|
||||
label: "局部损失",
|
||||
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,
|
||||
label: "开关状态",
|
||||
value: valveStatus.value ?? "",
|
||||
options: [
|
||||
{ label: "开启", value: "OPEN" },
|
||||
{ label: "关闭", value: "CLOSED" },
|
||||
{ label: "激活", value: "ACTIVE" },
|
||||
],
|
||||
options: VALVE_STATUS_OPTIONS,
|
||||
placeholder: valveStatus.loading ? "加载中" : "未设置",
|
||||
disabled: valveStatus.loading,
|
||||
disabled: valveStatus.loading || valveStatus.disabled,
|
||||
saving: valveStatus.saving,
|
||||
onSave: valveStatus.onSave,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(valveSetting
|
||||
...(!hasSimulationValues && valveSetting
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
@@ -397,6 +493,8 @@ export const buildFeatureProperties = (
|
||||
: []),
|
||||
],
|
||||
};
|
||||
appendLinkComputedProperties(result, ["setting", "status"]);
|
||||
return result;
|
||||
}
|
||||
|
||||
const getTransmissionFrequency = (transmissionFrequency: string) => {
|
||||
|
||||
@@ -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 "阀门类型相关设置值";
|
||||
}
|
||||
};
|
||||
+133
-2
@@ -109,6 +109,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agent/sessions/{session_id}/credential-refreshes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Resume a waiting agent tool call with refreshed credentials */
|
||||
post: operations["post_sessions_session_id_credential_refreshes"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -882,8 +899,11 @@ export interface operations {
|
||||
"application/json": {
|
||||
message: string;
|
||||
model?: string;
|
||||
/** @enum {string} */
|
||||
approval_mode?: "request" | "always";
|
||||
/**
|
||||
* @description request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.
|
||||
* @enum {string}
|
||||
*/
|
||||
approval_mode?: "request" | "auto" | "always";
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1195,6 +1215,117 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
post_sessions_session_id_credential_refreshes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
session_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": {
|
||||
request_id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful response */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Invalid request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Upstream dependency error */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_sessions_session_id_permission_responses: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
+110
-302
@@ -1121,7 +1121,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* 冲洗分析(高级)
|
||||
* @description 高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。
|
||||
* @description 高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。
|
||||
*/
|
||||
post: operations["post_flushing_analyses"];
|
||||
delete?: never;
|
||||
@@ -2438,26 +2438,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/network-schemas/user": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 获取用户模式
|
||||
* @description 获取指定网络的用户模式定义
|
||||
*/
|
||||
get: operations["get_network_schemas_user"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/network-schemas/valve": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3581,7 +3561,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* 检查项目是否被当前用户锁定
|
||||
* @description 检查指定项目是否被当前客户端 (IP) 锁定。
|
||||
* @description 检查指定项目是否被当前访问地址 (IP) 锁定。
|
||||
*/
|
||||
get: operations["get_projects_current_lock_ownership"];
|
||||
put?: never;
|
||||
@@ -4853,6 +4833,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 获取监测点候选节点详情 */
|
||||
get: operations["get_sensor_placement_candidates_node_id"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/sensor-placement-optimization-runs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -6581,46 +6578,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 获取所有用户
|
||||
* @description 获取指定网络的所有用户列表
|
||||
*/
|
||||
get: operations["get_users"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/users/detail": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 获取单个用户
|
||||
* @description 获取指定网络中的单个用户信息
|
||||
*/
|
||||
get: operations["get_users_detail"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/valve-closure-analyses": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8213,6 +8170,11 @@ export interface components {
|
||||
map_x: number;
|
||||
/** Map Y */
|
||||
map_y: number;
|
||||
/**
|
||||
* Max Pipe Diameter
|
||||
* @description 节点关联管道的最大管径,单位:毫米
|
||||
*/
|
||||
max_pipe_diameter: number | null;
|
||||
/** Node Id */
|
||||
node_id: string;
|
||||
/** Project X */
|
||||
@@ -14042,10 +14004,14 @@ export interface operations {
|
||||
query: {
|
||||
/** @description 冲洗开始时间(ISO 8601格式) */
|
||||
start_time: string;
|
||||
/** @description 要开启的阀门ID列表 */
|
||||
valves: string[];
|
||||
/** @description 对应各阀门的开度列表(0-1) */
|
||||
valves_k: number[];
|
||||
/** @description 参与控制的阀门ID列表(可选) */
|
||||
valves?: string[] | null;
|
||||
/** @description 对应各阀门的开度列表(0-1,可选,与valves同时提供) */
|
||||
valves_k?: number[] | null;
|
||||
/** @description 对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选) */
|
||||
valve_statuses?: ("OPEN" | "CLOSED" | "ACTIVE")[] | null;
|
||||
/** @description 对应各阀门的设置值列表(ACTIVE状态下必填) */
|
||||
valve_settings?: string[] | null;
|
||||
/** @description 排污节点ID */
|
||||
drainage_node_id: string;
|
||||
/** @description 冲洗流量(L/s),0表示自动计算 */
|
||||
@@ -20167,84 +20133,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_network_schemas_user: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: Record<string, never>;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_network_schemas_valve: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -31789,6 +31677,84 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_sensor_placement_candidates_node_id: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path: {
|
||||
node_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SensorPointResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_sensor_placement_optimization_runs: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -39792,164 +39758,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_users: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Page_dict_Any__Any__"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_users_detail: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description 用户名 */
|
||||
user_name: string;
|
||||
};
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": Record<string, never>;
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_valve_closure_analyses: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fetchAgentRuntimeHealth } from "./agentRuntime";
|
||||
|
||||
const apiFetch = jest.fn();
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||
}));
|
||||
|
||||
describe("fetchAgentRuntimeHealth", () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
});
|
||||
|
||||
it("reports ready only after runtime warmup is complete", async () => {
|
||||
apiFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
ready: true,
|
||||
warmed_up: true,
|
||||
runtime: { healthy: true },
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(fetchAgentRuntimeHealth()).resolves.toBe(true);
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/health"),
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports unavailable when runtime health is not ready", async () => {
|
||||
apiFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ ok: false, ready: false, warmed_up: true }),
|
||||
});
|
||||
|
||||
await expect(fetchAgentRuntimeHealth()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { config } from "@config/config";
|
||||
|
||||
export type AgentRuntimeState =
|
||||
| "checking"
|
||||
| "ready"
|
||||
| "unavailable"
|
||||
| "models_unavailable";
|
||||
|
||||
type AgentHealthPayload = {
|
||||
ok?: unknown;
|
||||
ready?: unknown;
|
||||
warmed_up?: unknown;
|
||||
runtime?: {
|
||||
healthy?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAgentRuntimeHealth = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/health`, {
|
||||
method: "GET",
|
||||
signal,
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as AgentHealthPayload | null;
|
||||
|
||||
return Boolean(
|
||||
response.ok &&
|
||||
payload?.ok === true &&
|
||||
payload.ready === true &&
|
||||
payload.warmed_up === true &&
|
||||
payload.runtime?.healthy === true,
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveRequestUrl } from "@/lib/api";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
describe("resolveRequestUrl", () => {
|
||||
it("does not prepend baseURL to an absolute request URL", () => {
|
||||
@@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => {
|
||||
).toBe("http://localhost:8000/api/v1/schemes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication lifecycle state", () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
accessToken: "access-token",
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears only the local access token when a request becomes unauthorized", () => {
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
accessToken: null,
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: "unauthorized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-8
@@ -1,6 +1,5 @@
|
||||
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
|
||||
import { config } from "@config/config";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import {
|
||||
applyAuthContextHeaders,
|
||||
@@ -13,8 +12,6 @@ export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
let isSigningOut = false;
|
||||
|
||||
export const resolveRequestUrl = (request: {
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
@@ -63,11 +60,7 @@ api.interceptors.response.use(
|
||||
},
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
||||
useAuthStore.getState().setAccessToken(null);
|
||||
if (!isSigningOut) {
|
||||
isSigningOut = true;
|
||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
||||
}
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { getApiErrorMessage } from "./apiError";
|
||||
|
||||
describe("getApiErrorMessage", () => {
|
||||
it("uses a business detail from Problem Details", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: "ACTIVE 状态的阀门 V1 必须提供设置值",
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("ACTIVE 状态的阀门 V1 必须提供设置值");
|
||||
});
|
||||
|
||||
it("formats field-level validation errors in Chinese", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: "Request validation failed",
|
||||
errors: [
|
||||
{
|
||||
type: "missing",
|
||||
loc: ["query", "drainage_node_id"],
|
||||
msg: "Field required",
|
||||
},
|
||||
{
|
||||
type: "literal_error",
|
||||
loc: ["query", "valve_statuses", 1],
|
||||
msg: "Input should be 'OPEN', 'CLOSED' or 'ACTIVE'",
|
||||
ctx: { expected: "'OPEN', 'CLOSED' or 'ACTIVE'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"排水节点:不能为空;阀门开关状态[2]:可选值为 'OPEN', 'CLOSED' 或 'ACTIVE'",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports legacy FastAPI validation details", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: [
|
||||
{
|
||||
type: "greater_than_equal",
|
||||
loc: ["body", "sensor_count"],
|
||||
msg: "Input should be greater than or equal to 1",
|
||||
ctx: { ge: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("监测点数量:必须大于或等于 1");
|
||||
});
|
||||
|
||||
it("limits long validation responses while preserving the remaining count", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: Array.from({ length: 5 }, (_, index) => ({
|
||||
type: "missing",
|
||||
loc: ["body", `field_${index}`],
|
||||
msg: "Field required",
|
||||
})),
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"field_0:不能为空;field_1:不能为空;field_2:不能为空;另有 2 项参数错误",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds hostile validation payloads before formatting", () => {
|
||||
const message = getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: Array.from({ length: 100_000 }, () => ({
|
||||
type: "custom_error",
|
||||
loc: ["body", "x".repeat(1_000)],
|
||||
msg: "y".repeat(10_000),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(message.length).toBeLessThan(600);
|
||||
expect(message).toContain("另有 99997 项参数错误");
|
||||
});
|
||||
|
||||
it("bounds validation constraint values", () => {
|
||||
const message = getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
type: "greater_than",
|
||||
loc: ["body", "duration"],
|
||||
ctx: { gt: "9".repeat(10_000) },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(message.length).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("distinguishes network and timeout failures", () => {
|
||||
expect(getApiErrorMessage({ code: "ERR_NETWORK" })).toContain("无法连接服务");
|
||||
expect(getApiErrorMessage({ code: "ECONNABORTED" })).toContain("请求超时");
|
||||
});
|
||||
|
||||
it("uses a localized message and trace id for server failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 503,
|
||||
data: {
|
||||
detail: "仿真服务暂时不可用",
|
||||
trace_id: "trace-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("服务暂时不可用,请稍后重试(追踪 ID:trace-123)");
|
||||
});
|
||||
|
||||
it("does not expose authentication details from the response", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { detail: "Not authenticated" },
|
||||
},
|
||||
}),
|
||||
).toBe("登录状态已失效,请重新登录");
|
||||
});
|
||||
|
||||
it("preserves a clear Chinese permission detail", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 403,
|
||||
data: { detail: "当前项目角色为只读,不能创建方案" },
|
||||
},
|
||||
}),
|
||||
).toBe("当前项目角色为只读,不能创建方案");
|
||||
});
|
||||
|
||||
it("localizes plain-text server failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 500,
|
||||
data: "Internal Server Error",
|
||||
},
|
||||
}),
|
||||
).toBe("服务处理失败,请稍后重试");
|
||||
});
|
||||
|
||||
it("does not misreport server error arrays as validation failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
errors: [{ loc: ["body", "scheme_name"], msg: "failed" }],
|
||||
trace_id: "trace-500",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("服务处理失败,请稍后重试(追踪 ID:trace-500)");
|
||||
});
|
||||
|
||||
it("does not display HTML gateway responses", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 502,
|
||||
data: "<!doctype html><html><body>Bad Gateway</body></html>",
|
||||
},
|
||||
}),
|
||||
).toBe("上游服务暂时不可用,请稍后重试");
|
||||
});
|
||||
|
||||
it("uses a localized fallback for an unknown HTTP status", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 418,
|
||||
},
|
||||
message: "Request failed with status code 418",
|
||||
}),
|
||||
).toBe("请求失败(HTTP 418)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
type ErrorResponse = {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
scheme_name: "方案名称",
|
||||
start_time: "开始时间",
|
||||
modify_pattern_start_time: "开始时间",
|
||||
target_time: "目标时间",
|
||||
sampling_interval_minutes: "采样间隔",
|
||||
duration: "持续时长",
|
||||
modify_total_duration: "持续时长",
|
||||
valves: "参与阀门",
|
||||
valve_statuses: "阀门开关状态",
|
||||
valve_settings: "阀门设置值",
|
||||
valves_k: "阀门开度",
|
||||
drainage_node_id: "排水节点",
|
||||
drainage_node_ID: "排水节点",
|
||||
burst_id: "爆管点",
|
||||
burst_ID: "爆管点",
|
||||
burst_size: "爆管流量",
|
||||
burst_leakage: "爆管流量",
|
||||
min_dpressure: "最小压降",
|
||||
basic_pressure: "基准压力",
|
||||
data_source: "数据来源",
|
||||
scada_burst_start: "爆管开始时间",
|
||||
scada_burst_end: "爆管结束时间",
|
||||
use_scada_flow: "使用流量监测数据",
|
||||
simulation_scheme_name: "模拟方案名称",
|
||||
simulation_scheme_type: "模拟方案类型",
|
||||
source: "污染源节点",
|
||||
concentration: "污染物浓度",
|
||||
pattern: "污染物注入模式",
|
||||
sensor_count: "监测点数量",
|
||||
sensor_type: "监测点类型",
|
||||
method: "优化方法",
|
||||
min_diameter: "最小管径",
|
||||
dma_count: "DMA 数量",
|
||||
scada_start: "监测开始时间",
|
||||
scada_end: "监测结束时间",
|
||||
q_sum: "总漏损流量",
|
||||
pop_size: "种群规模",
|
||||
max_gen: "最大迭代次数",
|
||||
flush_flow: "冲洗流量",
|
||||
};
|
||||
|
||||
const STATUS_MESSAGES: Record<number, string> = {
|
||||
400: "请求参数不正确",
|
||||
401: "登录状态已失效,请重新登录",
|
||||
403: "当前账号无权执行此操作",
|
||||
404: "请求的数据不存在",
|
||||
409: "当前数据已发生变化,请刷新后重试",
|
||||
422: "请求参数校验失败",
|
||||
429: "请求过于频繁,请稍后重试",
|
||||
500: "服务处理失败,请稍后重试",
|
||||
502: "上游服务暂时不可用,请稍后重试",
|
||||
503: "服务暂时不可用,请稍后重试",
|
||||
504: "服务响应超时,请稍后重试",
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): UnknownRecord | null =>
|
||||
value !== null && typeof value === "object"
|
||||
? (value as UnknownRecord)
|
||||
: null;
|
||||
|
||||
const nonEmptyString = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
|
||||
const safeServerMessage = (value: unknown): string | null => {
|
||||
const message = nonEmptyString(value);
|
||||
if (!message || message.length > 300) return null;
|
||||
if (/<!doctype|<html|<body|<script|<style|<[^>]+>/i.test(message)) {
|
||||
return null;
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
const boundedText = (value: unknown, maxLength: number): string | null => {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) return null;
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
};
|
||||
|
||||
const boundedConstraint = (value: unknown, fallback: string) =>
|
||||
value === null || value === undefined
|
||||
? fallback
|
||||
: (boundedText(String(value), 40) ?? fallback);
|
||||
|
||||
const getErrorResponse = (error: unknown): ErrorResponse | null => {
|
||||
const candidate = asRecord(error);
|
||||
const response = asRecord(candidate?.response);
|
||||
if (!response) return null;
|
||||
|
||||
return {
|
||||
status:
|
||||
typeof response.status === "number" ? response.status : undefined,
|
||||
data: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
const formatValidationLocation = (location: unknown): string => {
|
||||
if (!Array.isArray(location)) return "请求参数";
|
||||
|
||||
const segments = location.slice(0, 5).filter(
|
||||
(segment, index) =>
|
||||
!(
|
||||
index === 0 &&
|
||||
["body", "query", "path", "header"].includes(String(segment))
|
||||
),
|
||||
);
|
||||
if (segments.length === 0) return "请求参数";
|
||||
|
||||
return segments.reduce<string>((result, segment, index) => {
|
||||
if (typeof segment === "number") {
|
||||
return `${result}[${segment + 1}]`;
|
||||
}
|
||||
const rawSegment = String(segment);
|
||||
const label = FIELD_LABELS[rawSegment] ?? boundedText(rawSegment, 40) ?? "参数";
|
||||
return index === 0 ? label : `${result}.${label}`;
|
||||
}, "");
|
||||
};
|
||||
|
||||
const translateValidationMessage = (error: UnknownRecord): string => {
|
||||
const type = nonEmptyString(error.type);
|
||||
const message = nonEmptyString(error.msg);
|
||||
const context = asRecord(error.ctx);
|
||||
|
||||
switch (type) {
|
||||
case "missing":
|
||||
return "不能为空";
|
||||
case "literal_error": {
|
||||
const expected = boundedText(context?.expected, 100);
|
||||
return expected
|
||||
? `可选值为 ${expected.replace(/\s+or\s+/g, " 或 ")}`
|
||||
: "取值不在允许范围内";
|
||||
}
|
||||
case "greater_than":
|
||||
return `必须大于 ${boundedConstraint(context?.gt, "限定值")}`;
|
||||
case "greater_than_equal":
|
||||
return `必须大于或等于 ${boundedConstraint(context?.ge, "限定值")}`;
|
||||
case "less_than":
|
||||
return `必须小于 ${boundedConstraint(context?.lt, "限定值")}`;
|
||||
case "less_than_equal":
|
||||
return `必须小于或等于 ${boundedConstraint(context?.le, "限定值")}`;
|
||||
case "int_parsing":
|
||||
return "必须是整数";
|
||||
case "float_parsing":
|
||||
case "decimal_parsing":
|
||||
return "必须是数字";
|
||||
case "datetime_from_date_parsing":
|
||||
case "datetime_parsing":
|
||||
return "日期时间格式不正确";
|
||||
case "string_too_short":
|
||||
return `长度不能少于 ${boundedConstraint(context?.min_length, "要求的")} 个字符`;
|
||||
case "string_too_long":
|
||||
return `长度不能超过 ${boundedConstraint(context?.max_length, "允许的")} 个字符`;
|
||||
default:
|
||||
if (message === "Field required") return "不能为空";
|
||||
if (message?.startsWith("Input should be ")) {
|
||||
return boundedText(
|
||||
message.replace("Input should be ", "可选值为 "),
|
||||
120,
|
||||
)!;
|
||||
}
|
||||
return boundedText(message, 120) ?? "参数无效";
|
||||
}
|
||||
};
|
||||
|
||||
const formatValidationErrors = (value: unknown): string | null => {
|
||||
if (!Array.isArray(value) || value.length === 0) return null;
|
||||
|
||||
const messages = value
|
||||
.slice(0, 3)
|
||||
.map(asRecord)
|
||||
.filter((item): item is UnknownRecord => item !== null)
|
||||
.map(
|
||||
(item) =>
|
||||
`${formatValidationLocation(item.loc)}:${translateValidationMessage(item)}`,
|
||||
);
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
const visibleMessages = messages.join(";");
|
||||
const remainingCount = value.length - messages.length;
|
||||
return remainingCount > 0
|
||||
? `${visibleMessages};另有 ${remainingCount} 项参数错误`
|
||||
: visibleMessages;
|
||||
};
|
||||
|
||||
const appendTraceId = (message: string, traceId: string | null) =>
|
||||
traceId ? `${message}(追踪 ID:${traceId})` : message;
|
||||
|
||||
export const getApiErrorMessage = (
|
||||
error: unknown,
|
||||
fallback = "请求失败,请稍后重试",
|
||||
): string => {
|
||||
const candidate = asRecord(error);
|
||||
const response = getErrorResponse(error);
|
||||
const payload = asRecord(response?.data);
|
||||
const status = response?.status;
|
||||
const traceId = safeServerMessage(payload?.trace_id);
|
||||
const isValidationResponse =
|
||||
status === 400 ||
|
||||
status === 422 ||
|
||||
nonEmptyString(payload?.code) === "validation_error";
|
||||
const validationErrors = isValidationResponse
|
||||
? formatValidationErrors(
|
||||
payload?.errors ??
|
||||
(Array.isArray(payload?.detail) ? payload.detail : null),
|
||||
)
|
||||
: null;
|
||||
|
||||
if (validationErrors) {
|
||||
return validationErrors;
|
||||
}
|
||||
|
||||
const responseText = safeServerMessage(response?.data);
|
||||
const detail = safeServerMessage(payload?.detail);
|
||||
const responseMessage = safeServerMessage(payload?.message);
|
||||
const statusMessage = status ? STATUS_MESSAGES[status] : null;
|
||||
const unknownStatusMessage = status ? `请求失败(HTTP ${status})` : null;
|
||||
|
||||
if (status === 403) {
|
||||
const permissionDetail = detail ?? responseMessage;
|
||||
return permissionDetail && /[\u3400-\u9fff]/u.test(permissionDetail)
|
||||
? permissionDetail
|
||||
: STATUS_MESSAGES[403];
|
||||
}
|
||||
|
||||
if (status && (status === 401 || status >= 500)) {
|
||||
return appendTraceId(
|
||||
statusMessage ?? unknownStatusMessage ?? fallback,
|
||||
status >= 500 ? traceId : null,
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
detail ?? responseMessage ?? responseText ?? statusMessage ?? unknownStatusMessage;
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const code = nonEmptyString(candidate?.code);
|
||||
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
|
||||
return "请求超时,请稍后重试";
|
||||
}
|
||||
if (
|
||||
code === "ERR_NETWORK" ||
|
||||
(candidate?.isAxiosError === true && !response)
|
||||
) {
|
||||
return "无法连接服务,请检查网络连接或服务状态";
|
||||
}
|
||||
|
||||
return nonEmptyString(candidate?.message) ?? fallback;
|
||||
};
|
||||
+1
-8
@@ -1,12 +1,9 @@
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import {
|
||||
applyAuthContextHeaders,
|
||||
type AuthContextHeaderOptions,
|
||||
} from "@/lib/requestHeaders";
|
||||
|
||||
let isSigningOut = false;
|
||||
|
||||
const unwrapPage = async (response: Response) => {
|
||||
if (
|
||||
!response.headers.get("content-type")?.includes("application/json")
|
||||
@@ -58,11 +55,7 @@ export const apiFetch = async (
|
||||
const response = await fetch(input, requestInit);
|
||||
|
||||
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
||||
useAuthStore.getState().setAccessToken(null);
|
||||
if (!isSigningOut) {
|
||||
isSigningOut = true;
|
||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
||||
}
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
}
|
||||
|
||||
return unwrapPage(response);
|
||||
|
||||
@@ -42,6 +42,9 @@ export const getAccessToken = async () => {
|
||||
setAccessToken(null);
|
||||
}
|
||||
const session = await getSession();
|
||||
if (session?.error) {
|
||||
return null;
|
||||
}
|
||||
const token = typeof session?.accessToken === "string" ? session.accessToken : null;
|
||||
if (token && !isTokenExpired(token)) {
|
||||
setAccessToken(token);
|
||||
|
||||
@@ -46,9 +46,10 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => {
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
|
||||
export const fetchAgentModels = async (signal?: AbortSignal): Promise<AgentModelConfig> => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
|
||||
method: "GET",
|
||||
signal,
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
rejectAgentQuestion,
|
||||
replyAgentCredentialRefresh,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
type StreamEvent,
|
||||
@@ -171,6 +172,32 @@ describe("streamAgentChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses credential refresh lifecycle events", async () => {
|
||||
mockNewSessionStream({
|
||||
ok: true,
|
||||
body: makeStream([
|
||||
'event: credential_refresh_required\ndata: {"session_id":"s1","request_id":"credential-1","reason":"access_token_rejected","timeout_ms":30000}\n\n',
|
||||
'event: credential_refreshed\ndata: {"session_id":"s1","request_id":"credential-1"}\n\n',
|
||||
]),
|
||||
});
|
||||
const events: StreamEvent[] = [];
|
||||
await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event) });
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "credential_refresh_required",
|
||||
sessionId: "s1",
|
||||
requestId: "credential-1",
|
||||
reason: "access_token_rejected",
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
{
|
||||
type: "credential_refreshed",
|
||||
sessionId: "s1",
|
||||
requestId: "credential-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses tool_call arguments when params is empty", async () => {
|
||||
mockNewSessionStream({
|
||||
ok: true,
|
||||
@@ -363,6 +390,7 @@ describe("streamAgentChat", () => {
|
||||
skipAuthRedirect: true,
|
||||
}),
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
it("calls permission reply endpoint", async () => {
|
||||
@@ -386,6 +414,33 @@ describe("streamAgentChat", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await replyAgentPermission("s1", "perm-2", "always");
|
||||
|
||||
expect(apiFetch).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining("/api/v1/agent/sessions/s1/permission-responses"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
request_id: "perm-2",
|
||||
reply: "always",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("submits refreshed credentials through the authenticated context", async () => {
|
||||
apiFetch.mockResolvedValue({ ok: true, status: 202, text: async () => "" });
|
||||
await replyAgentCredentialRefresh("s1", "credential-1");
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/agent/sessions/s1/credential-refreshes"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ request_id: "credential-1" }),
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls question reply and reject endpoints", async () => {
|
||||
|
||||
+106
-26
@@ -1,10 +1,16 @@
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { config } from "@config/config";
|
||||
|
||||
const AGENT_SESSIONS_URL = `${config.AGENT_URL}/api/v1/agent/sessions`;
|
||||
|
||||
const getAgentSessionUrl = (sessionId: string, suffix = "") =>
|
||||
`${AGENT_SESSIONS_URL}/${encodeURIComponent(sessionId)}${suffix}`;
|
||||
|
||||
export type AgentModel = string;
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
export type AgentApprovalMode = "request" | "always";
|
||||
export type PermissionDecision = "once" | "always" | "reject";
|
||||
export type PermissionReply = PermissionDecision;
|
||||
export type AgentApprovalMode = "request" | "auto" | "always";
|
||||
|
||||
export type AgentQuestionStatus =
|
||||
| "pending"
|
||||
@@ -90,6 +96,24 @@ export type StreamEvent =
|
||||
reason?: string;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: "credential_refresh_required";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
reason?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
| {
|
||||
type: "credential_refreshed";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
}
|
||||
| {
|
||||
type: "credential_refresh_failed";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: "tool_call";
|
||||
sessionId: string;
|
||||
@@ -310,6 +334,7 @@ const emitParsedStreamEvent = (
|
||||
message_id?: string;
|
||||
todos?: unknown;
|
||||
reason?: string;
|
||||
timeout_ms?: number;
|
||||
};
|
||||
if (event === "state") {
|
||||
onEvent({
|
||||
@@ -366,6 +391,27 @@ const emitParsedStreamEvent = (
|
||||
reason: parsed.reason,
|
||||
message: parsed.message ?? "登录态已过期,请刷新登录后重试",
|
||||
});
|
||||
} else if (event === "credential_refresh_required") {
|
||||
onEvent({
|
||||
type: "credential_refresh_required",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
reason: parsed.reason,
|
||||
timeoutMs: parsed.timeout_ms,
|
||||
});
|
||||
} else if (event === "credential_refreshed") {
|
||||
onEvent({
|
||||
type: "credential_refreshed",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
});
|
||||
} else if (event === "credential_refresh_failed") {
|
||||
onEvent({
|
||||
type: "credential_refresh_failed",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
message: parsed.message ?? "登录凭据续期失败",
|
||||
});
|
||||
} else if (event === "tool_call") {
|
||||
onEvent({
|
||||
type: "tool_call",
|
||||
@@ -469,7 +515,7 @@ const readStreamEvents = async (
|
||||
const ensureAgentSession = async (sessionId?: string) => {
|
||||
if (sessionId) return sessionId;
|
||||
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||
const response = await apiFetch(AGENT_SESSIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -479,7 +525,10 @@ const ensureAgentSession = async (sessionId?: string) => {
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error((await response.text()) || `session creation failed: ${response.status}`);
|
||||
throw new Error(
|
||||
(await response.text()) ||
|
||||
`session creation failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as { session_id?: string };
|
||||
if (!payload.session_id) {
|
||||
@@ -500,7 +549,7 @@ export const streamAgentChat = async ({
|
||||
try {
|
||||
const effectiveSessionId = await ensureAgentSession(sessionId);
|
||||
response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(effectiveSessionId)}/runs`,
|
||||
getAgentSessionUrl(effectiveSessionId, "/runs"),
|
||||
{
|
||||
method: "POST",
|
||||
signal,
|
||||
@@ -557,7 +606,7 @@ export const resumeAgentChatStream = async ({
|
||||
let response: Response;
|
||||
try {
|
||||
response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current/events`,
|
||||
getAgentSessionUrl(sessionId, "/runs/current/events"),
|
||||
{
|
||||
method: "GET",
|
||||
signal,
|
||||
@@ -598,11 +647,14 @@ export const abortAgentChat = async (sessionId?: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current`, {
|
||||
method: "DELETE",
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
const response = await apiFetch(
|
||||
getAgentSessionUrl(sessionId, "/runs/current"),
|
||||
{
|
||||
method: "DELETE",
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
@@ -613,10 +665,10 @@ export const abortAgentChat = async (sessionId?: string) => {
|
||||
export const replyAgentPermission = async (
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
reply: PermissionReply,
|
||||
reply: PermissionDecision,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/permission-responses`,
|
||||
getAgentSessionUrl(sessionId, "/permission-responses"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -637,13 +689,35 @@ export const replyAgentPermission = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const replyAgentCredentialRefresh = async (
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
getAgentSessionUrl(sessionId, "/credential-refreshes"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_id: requestId }),
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(
|
||||
detail || `credential refresh reply failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const replyAgentQuestion = async (
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
answers: string[][],
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
|
||||
getAgentSessionUrl(sessionId, "/question-responses"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -670,7 +744,7 @@ export const rejectAgentQuestion = async (
|
||||
requestId: string,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
|
||||
getAgentSessionUrl(sessionId, "/question-responses"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -691,18 +765,24 @@ export const rejectAgentQuestion = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId ?? "")}/forks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
export const forkAgentChat = async (
|
||||
sessionId: string | undefined,
|
||||
keepMessageCount: number,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
getAgentSessionUrl(sessionId ?? "", "/forks"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keep_message_count: keepMessageCount,
|
||||
}),
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keep_message_count: keepMessageCount,
|
||||
}),
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
|
||||
|
||||
describe("reportLogoutAudit", () => {
|
||||
it("starts an authenticated keepalive request", async () => {
|
||||
const fetcher = jest.fn(async () => ({ ok: true }) as Response);
|
||||
|
||||
await reportLogoutAudit({
|
||||
endpoint: "https://server.example/api/v1/audit-events",
|
||||
accessToken: "access-token",
|
||||
fetcher,
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetcher.mock.calls[0];
|
||||
expect(url).toBe("https://server.example/api/v1/audit-events");
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ event: "logout" }),
|
||||
keepalive: true,
|
||||
});
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe(
|
||||
"Bearer access-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeLogout", () => {
|
||||
it("navigates immediately without waiting for audit reporting", async () => {
|
||||
const reportAudit = jest.fn(
|
||||
() => new Promise<unknown>(() => undefined),
|
||||
);
|
||||
const clearLocalState = jest.fn();
|
||||
const navigate = jest.fn();
|
||||
|
||||
const result = completeLogout({
|
||||
reportAudit,
|
||||
clearLocalState,
|
||||
navigate,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(reportAudit).toHaveBeenCalledTimes(1);
|
||||
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||
await expect(result).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("completes logout when audit reporting fails", async () => {
|
||||
const reportAudit = jest.fn(() => Promise.reject(new Error("offline")));
|
||||
const clearLocalState = jest.fn();
|
||||
const navigate = jest.fn();
|
||||
|
||||
await expect(
|
||||
completeLogout({ reportAudit, clearLocalState, navigate }),
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||
});
|
||||
|
||||
it("completes logout when audit reporting throws synchronously", async () => {
|
||||
const clearLocalState = jest.fn();
|
||||
const navigate = jest.fn();
|
||||
|
||||
await expect(
|
||||
completeLogout({
|
||||
reportAudit: () => {
|
||||
throw new Error("invalid request");
|
||||
},
|
||||
clearLocalState,
|
||||
navigate,
|
||||
}),
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
|
||||
|
||||
type CompleteLogoutOptions = {
|
||||
reportAudit: () => Promise<unknown>;
|
||||
clearLocalState: () => void;
|
||||
navigate: (path: string) => void;
|
||||
};
|
||||
|
||||
type LogoutAuditOptions = {
|
||||
endpoint: string;
|
||||
accessToken?: string | null;
|
||||
fetcher?: typeof fetch;
|
||||
};
|
||||
|
||||
export const reportLogoutAudit = ({
|
||||
endpoint,
|
||||
accessToken,
|
||||
fetcher = fetch,
|
||||
}: LogoutAuditOptions) => {
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
if (accessToken) {
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
return fetcher(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ event: "logout" }),
|
||||
keepalive: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const completeLogout = async ({
|
||||
reportAudit,
|
||||
clearLocalState,
|
||||
navigate,
|
||||
}: CompleteLogoutOptions) => {
|
||||
try {
|
||||
void reportAudit().catch(() => undefined);
|
||||
} catch {
|
||||
// Synchronous reporting errors must not block logout either.
|
||||
}
|
||||
|
||||
clearLocalState();
|
||||
navigate(KEYCLOAK_LOGOUT_PATH);
|
||||
return { success: true as const };
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useSessionRecoveryDraft } from "./sessionRecoveryDraft";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const DraftFixture = ({ initial }: { initial: string }) => {
|
||||
const [value, setValue] = useState(initial);
|
||||
useSessionRecoveryDraft("fixture", { value }, (draft) => setValue(draft.value));
|
||||
return <output>{value}</output>;
|
||||
};
|
||||
|
||||
describe("useSessionRecoveryDraft", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves an in-progress value when authentication expires and restores it once", () => {
|
||||
const first = render(<DraftFixture initial="in-progress" />);
|
||||
|
||||
act(() => {
|
||||
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBe(
|
||||
JSON.stringify({ value: "in-progress" }),
|
||||
);
|
||||
first.unmount();
|
||||
|
||||
useAuthStore.getState().clearSessionExpired();
|
||||
const restored = render(<DraftFixture initial="empty" />);
|
||||
|
||||
expect(restored.getByText("in-progress")).toBeInTheDocument();
|
||||
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const STORAGE_PREFIX = "tjwater-session-recovery:";
|
||||
|
||||
const storageKey = (key: string) => `${STORAGE_PREFIX}${key}`;
|
||||
|
||||
export const clearSessionRecoveryDrafts = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key?.startsWith(STORAGE_PREFIX)) sessionStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const useSessionRecoveryDraft = <T,>(
|
||||
key: string,
|
||||
value: T,
|
||||
restore: (value: T) => void,
|
||||
) => {
|
||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredRef.current || typeof window === "undefined") return;
|
||||
restoredRef.current = true;
|
||||
const raw = sessionStorage.getItem(storageKey(key));
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
restore(JSON.parse(raw) as T);
|
||||
sessionStorage.removeItem(storageKey(key));
|
||||
} catch {
|
||||
sessionStorage.removeItem(storageKey(key));
|
||||
}
|
||||
}, [key, restore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionExpired || typeof window === "undefined") return;
|
||||
try {
|
||||
sessionStorage.setItem(storageKey(key), JSON.stringify(value));
|
||||
} catch {
|
||||
// Recovery is best-effort. Do not block re-authentication when storage is unavailable.
|
||||
}
|
||||
}, [key, sessionExpired, value]);
|
||||
};
|
||||
@@ -2,10 +2,27 @@ import { create } from "zustand";
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
sessionExpired: boolean;
|
||||
sessionExpiryReason: "refresh_failed" | "session_max_age" | "unauthorized" | null;
|
||||
setAccessToken: (token: string | null) => void;
|
||||
markSessionExpired: (
|
||||
reason: Exclude<AuthState["sessionExpiryReason"], null>,
|
||||
) => void;
|
||||
clearSessionExpired: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
accessToken: null,
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
setAccessToken: (token) => set({ accessToken: token }),
|
||||
markSessionExpired: (reason) => set({
|
||||
accessToken: null,
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: reason,
|
||||
}),
|
||||
clearSessionExpired: () => set({
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
Vendored
+5
-2
@@ -4,7 +4,8 @@ import "next-auth/jwt";
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
accessToken?: string;
|
||||
error?: "RefreshAccessTokenError";
|
||||
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||
sessionExpiresAt?: number;
|
||||
user?: {
|
||||
id?: string;
|
||||
username?: string;
|
||||
@@ -26,7 +27,9 @@ declare module "next-auth/jwt" {
|
||||
username?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
accessTokenIssuedAt?: number;
|
||||
accessTokenExpires?: number;
|
||||
error?: "RefreshAccessTokenError";
|
||||
sessionExpiresAt?: number;
|
||||
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "./schemeName";
|
||||
|
||||
describe("createSchemeName", () => {
|
||||
it("combines the scheme type with a readable local timestamp", () => {
|
||||
const date = new Date(2026, 7, 17, 15, 30, 45, 123);
|
||||
|
||||
expect(createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, date)).toBe(
|
||||
"flush_260817_153045123",
|
||||
);
|
||||
});
|
||||
|
||||
it("pads single-digit date and time values", () => {
|
||||
const date = new Date(2026, 0, 2, 3, 4, 5, 6);
|
||||
|
||||
expect(createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis, date)).toBe(
|
||||
"burst_sim_260102_030405006",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps names distinct within the same second", () => {
|
||||
const first = new Date(2026, 7, 17, 15, 30, 45, 123);
|
||||
const second = new Date(2026, 7, 17, 15, 30, 45, 124);
|
||||
|
||||
expect(createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, first)).not.toBe(
|
||||
createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, second),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes and validates manually entered names", () => {
|
||||
expect(normalizeSchemeName(" cafe\u0301 ")).toBe("café");
|
||||
expect(normalizeSchemeName("方案\u200bA\u2060")).toBe("方案A");
|
||||
expect(isSchemeNameValid("x".repeat(32))).toBe(true);
|
||||
expect(isSchemeNameValid("x".repeat(33))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export const SCHEME_NAME_PREFIXES = {
|
||||
dmaLeakIdentification: "dma_leak",
|
||||
flushingAnalysis: "flush",
|
||||
burstDetection: "burst_detect",
|
||||
burstAnalysis: "burst_sim",
|
||||
burstLocation: "burst_locate",
|
||||
contaminantAnalysis: "water_quality",
|
||||
sensorPlacement: "sensor_place",
|
||||
} as const;
|
||||
|
||||
export type SchemeNamePrefix =
|
||||
(typeof SCHEME_NAME_PREFIXES)[keyof typeof SCHEME_NAME_PREFIXES];
|
||||
|
||||
export const SCHEME_NAME_MAX_LENGTH = 32;
|
||||
|
||||
export const normalizeSchemeName = (value: string) =>
|
||||
value
|
||||
.normalize("NFC")
|
||||
.replace(/\p{Default_Ignorable_Code_Point}/gu, "")
|
||||
.trim();
|
||||
|
||||
export const isSchemeNameValid = (value: string) => {
|
||||
const normalized = normalizeSchemeName(value);
|
||||
return normalized.length > 0 && normalized.length <= SCHEME_NAME_MAX_LENGTH;
|
||||
};
|
||||
|
||||
const padNumber = (value: number, length = 2) =>
|
||||
String(value).padStart(length, "0");
|
||||
|
||||
export const createSchemeName = (
|
||||
prefix: SchemeNamePrefix,
|
||||
date = new Date(),
|
||||
) => {
|
||||
const timestamp = [
|
||||
padNumber(date.getFullYear() % 100),
|
||||
padNumber(date.getMonth() + 1),
|
||||
padNumber(date.getDate()),
|
||||
].join("");
|
||||
const time = [
|
||||
padNumber(date.getHours()),
|
||||
padNumber(date.getMinutes()),
|
||||
padNumber(date.getSeconds()),
|
||||
padNumber(date.getMilliseconds(), 3),
|
||||
].join("");
|
||||
|
||||
return `${prefix}_${timestamp}_${time}`;
|
||||
};
|
||||
Reference in New Issue
Block a user