Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ac7372585 | ||
|
|
5d730ba1b8 | ||
|
|
caf18f706d | ||
|
|
0dd521d8c9 | ||
|
|
0a47534ddb | ||
|
|
9e75e2df8a | ||
|
|
8d7c947897 | ||
|
|
71bde7d9e4 | ||
|
|
5037089057 | ||
|
|
b4c96f8524 | ||
|
|
e496fbe4b7 | ||
|
|
5d9c40e454 | ||
|
|
5592c27386 | ||
|
|
94142d7031 | ||
|
|
0e638046ed | ||
|
|
65ed1fd212 | ||
|
|
81cc5dcae1 | ||
|
|
782363cfb6 | ||
|
|
b57e58ff87 | ||
|
|
38d246eb7b | ||
|
|
723931b6ae | ||
|
|
f355ddd002 | ||
|
|
6114e76735 | ||
|
|
d643f09fcf | ||
|
|
04557b9363 | ||
|
|
8836549697 | ||
|
|
1d7e07174f | ||
|
|
c4246cf25f | ||
|
|
1b2a7f4fb8 | ||
|
|
82e75c03d0 | ||
|
|
5894ee277a | ||
|
|
a3331691de | ||
|
|
7745333a58 |
+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 }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"contract_version": "1.0.0",
|
||||
"contracts": {
|
||||
"agent": {
|
||||
"file": "agent-v1.openapi.json",
|
||||
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+808
-410
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -14,6 +14,8 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
|
||||
"api:check": "node scripts/check-api-contracts.mjs",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -44,7 +46,8 @@
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^4.24.5",
|
||||
"ol": "^10.7.0",
|
||||
"postcss": "^8.5.6",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"postcss": "8.5.25",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-draggable": "^4.5.0",
|
||||
@@ -56,7 +59,9 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "5.5.9"
|
||||
"fast-xml-parser": "5.5.9",
|
||||
"postcss": "8.5.25",
|
||||
"sharp": "0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
@@ -75,6 +80,7 @@
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
|
||||
<title id="title">TJWater</title>
|
||||
<rect width="72" height="72" rx="18" fill="#1478d4"/>
|
||||
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
|
||||
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
|
||||
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
|
||||
<rect width="1440" height="900" fill="#edf5f5"/>
|
||||
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
|
||||
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
|
||||
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
|
||||
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
|
||||
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
|
||||
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
|
||||
</g>
|
||||
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
|
||||
<circle cx="178" cy="608" r="10"/><circle cx="203" cy="386" r="9"/>
|
||||
<circle cx="354" cy="294" r="9"/><circle cx="475" cy="469" r="11"/>
|
||||
<circle cx="659" cy="536" r="10"/><circle cx="747" cy="652" r="9"/>
|
||||
<circle cx="320" cy="659" r="8"/>
|
||||
</g>
|
||||
<g fill="#0b8f82">
|
||||
<circle cx="111" cy="291" r="6"/><circle cx="166" cy="130" r="6"/>
|
||||
<circle cx="446" cy="153" r="7"/><circle cx="598" cy="211" r="6"/>
|
||||
<circle cx="696" cy="105" r="6"/><circle cx="316" cy="791" r="6"/>
|
||||
<circle cx="487" cy="872" r="6"/><circle cx="835" cy="331" r="7"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
|
||||
<circle cx="615" cy="448" r="222"/><circle cx="615" cy="448" r="276"/>
|
||||
<circle cx="615" cy="448" r="334"/>
|
||||
</g>
|
||||
<g fill="#1478d4" opacity=".08">
|
||||
<rect x="40" y="40" width="118" height="10" rx="5"/>
|
||||
<rect x="40" y="62" width="72" height="6" rx="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,57 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await readFile(new URL("../contracts/manifest.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const run = promisify(execFile);
|
||||
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const generator = join(
|
||||
projectRoot,
|
||||
"node_modules",
|
||||
"openapi-typescript",
|
||||
"bin",
|
||||
"cli.js",
|
||||
);
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
join(tmpdir(), "tjwater-api-contracts-"),
|
||||
);
|
||||
|
||||
try {
|
||||
for (const [name, contract] of Object.entries(manifest.contracts)) {
|
||||
const contractPath = fileURLToPath(
|
||||
new URL(`../contracts/${contract.file}`, import.meta.url),
|
||||
);
|
||||
const payload = await readFile(contractPath);
|
||||
const actual = createHash("sha256").update(payload).digest("hex");
|
||||
if (actual !== contract.sha256) {
|
||||
throw new Error(
|
||||
`${name} contract hash mismatch: expected ${contract.sha256}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
const generatedName = `${name}Api.ts`;
|
||||
const temporaryOutput = join(temporaryDirectory, generatedName);
|
||||
await run(process.execPath, [generator, contractPath, "-o", temporaryOutput]);
|
||||
const expectedOutput = await readFile(
|
||||
new URL(`../src/generated/${generatedName}`, import.meta.url),
|
||||
);
|
||||
const generatedOutput = await readFile(temporaryOutput);
|
||||
if (!expectedOutput.equals(generatedOutput)) {
|
||||
throw new Error(
|
||||
`${basename(contract.file)} generated type is stale: run \`npm run api:generate\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`validated API contract mirrors and generated types for ${manifest.contract_version}`,
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
||||
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
|
||||
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
|
||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||
import {
|
||||
RAINBOW_COLORS,
|
||||
@@ -12,6 +12,26 @@ import {
|
||||
} from "@components/olmap/HealthRiskAnalysis/types";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
const Timeline = dynamic(
|
||||
() => import("@components/olmap/HealthRiskAnalysis/Timeline"),
|
||||
{
|
||||
loading: () => <MapTimelineSkeleton />,
|
||||
},
|
||||
);
|
||||
const HealthRiskStatistics = dynamic(
|
||||
() =>
|
||||
import("@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="health-risk-analysis" />,
|
||||
},
|
||||
);
|
||||
const PredictDataPanel = dynamic(
|
||||
() => import("@components/olmap/HealthRiskAnalysis/PredictDataPanel"),
|
||||
{
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<HealthRiskProvider>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
|
||||
|
||||
const BurstDetectionPanel = dynamic(
|
||||
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="burst-detection" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
|
||||
|
||||
const BurstLocationPanel = dynamic(
|
||||
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="burst-location" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
|
||||
|
||||
const BurstPipeAnalysisPanel = dynamic(
|
||||
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
|
||||
|
||||
const WaterQualityPanel = dynamic(
|
||||
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
|
||||
|
||||
const DMALeakDetectionPanel = dynamic(
|
||||
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
|
||||
|
||||
const FlushingAnalysisPanel = dynamic(
|
||||
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
||||
|
||||
const MonitoringPlaceOptimizationPanel = dynamic(
|
||||
() =>
|
||||
import(
|
||||
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<MapPanelSkeleton variant="monitoring-place-optimization" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,11 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useState } from "react";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
|
||||
import {
|
||||
MapPanelSkeleton,
|
||||
MapTimelineSkeleton,
|
||||
} from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||
const Timeline = dynamic(
|
||||
() => import("@components/olmap/core/Controls/Timeline"),
|
||||
{
|
||||
loading: () => <MapTimelineSkeleton />,
|
||||
},
|
||||
);
|
||||
const SCADADeviceList = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADeviceList"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="network-simulation" />,
|
||||
},
|
||||
);
|
||||
const SCADADataPanel = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADataPanel"),
|
||||
{
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||
const SCADADeviceList = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADeviceList"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="scada-data-cleaning" />,
|
||||
},
|
||||
);
|
||||
const SCADADataPanel = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADataPanel"),
|
||||
{
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import React, { Suspense } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||
import { Header } from "@components/header";
|
||||
import { Title } from "@components/title";
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
import { AppSider } from "@components/sider/AppSider";
|
||||
import { ThemedLayout } from "@refinedev/mui";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const theme = cookieStore.get("theme");
|
||||
@@ -49,9 +48,7 @@ export default async function MainLayout({
|
||||
sx: { height: "100vh", overflow: "hidden" },
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
{children}
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+278
-197
@@ -1,32 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Refine, type AuthProvider } from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import {
|
||||
RefineSnackbarProvider,
|
||||
} from "@refinedev/mui";
|
||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||
Refine,
|
||||
type AccessControlProvider,
|
||||
type AuthProvider,
|
||||
} from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import { RefineSnackbarProvider } from "@refinedev/mui";
|
||||
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import routerProvider from "@refinedev/nextjs-router";
|
||||
|
||||
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";
|
||||
|
||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
|
||||
import { LuReplace } from "react-icons/lu";
|
||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
||||
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
|
||||
import {
|
||||
ManageAccounts as ManageAccountsIcon,
|
||||
FactCheck as FactCheckIcon,
|
||||
ManageAccounts as ManageAccountsIcon,
|
||||
MyLocation as MyLocationIcon,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
@@ -36,16 +45,12 @@ type RefineContextProps = {
|
||||
};
|
||||
|
||||
export const RefineContext = (
|
||||
props: React.PropsWithChildren<RefineContextProps>
|
||||
) => {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ProjectProvider>
|
||||
<App {...props} />
|
||||
</ProjectProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
props: React.PropsWithChildren<RefineContextProps>,
|
||||
) => (
|
||||
<SessionProvider>
|
||||
<App {...props} />
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
type AppProps = {
|
||||
defaultMode?: string;
|
||||
@@ -55,40 +60,87 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
|
||||
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);
|
||||
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
||||
const resetAccess = useAccessStore((state) => state.reset);
|
||||
const can = (permission: string) => permissions.includes(permission);
|
||||
|
||||
useEffect(() => {
|
||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
||||
setAccessToken(
|
||||
typeof data?.accessToken === "string" ? data.accessToken : null,
|
||||
);
|
||||
}, [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") {
|
||||
setIsMetadataAdmin(false);
|
||||
resetAccess();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
||||
projectHeaderMode: "omit",
|
||||
setAccessLoading(true);
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||
skipAuthRedirect: true,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (cancelled) return;
|
||||
if (!response.ok) {
|
||||
setIsMetadataAdmin(false);
|
||||
resetAccess();
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
||||
setAccessContext(await response.json());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setIsMetadataAdmin(false);
|
||||
if (!cancelled) resetAccess();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [status]);
|
||||
}, [
|
||||
currentProjectId,
|
||||
resetAccess,
|
||||
setAccessContext,
|
||||
setAccessLoading,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated" || !data?.user?.id) return;
|
||||
const auditKey = `tjwater-login-audit:${data.user.id}`;
|
||||
if (sessionStorage.getItem(auditKey)) return;
|
||||
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "login" }),
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) sessionStorage.setItem(auditKey, "1");
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [data?.user?.id, status]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <span>loading...</span>;
|
||||
@@ -100,199 +152,228 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
callbackUrl: to ? to.toString() : "/",
|
||||
redirect: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
logout: async () => {
|
||||
signOut({
|
||||
redirect: true,
|
||||
callbackUrl: "/login",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
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 () => {
|
||||
if (status === "unauthenticated") {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/login",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
};
|
||||
},
|
||||
getPermissions: async () => {
|
||||
return null;
|
||||
return { error };
|
||||
},
|
||||
check: async () =>
|
||||
status === "unauthenticated"
|
||||
? { authenticated: false, redirectTo: "/login" }
|
||||
: { authenticated: true },
|
||||
getPermissions: async () => permissions,
|
||||
getIdentity: async () => {
|
||||
if (data?.user) {
|
||||
const { user } = data;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatar: user.image,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
if (!data?.user) return null;
|
||||
return {
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
name: data.user.name,
|
||||
avatar: data.user.image,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const defaultMode = props?.defaultMode;
|
||||
const accessControlProvider: AccessControlProvider = {
|
||||
can: async ({ resource }) => {
|
||||
const requiredPermission = resource
|
||||
? resourcePermissions[resource]
|
||||
: undefined;
|
||||
return {
|
||||
can: !requiredPermission || permissions.includes(requiredPermission),
|
||||
reason: requiredPermission
|
||||
? `需要权限:${requiredPermission}`
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const resources = [
|
||||
...(can(permissionCodes.simulationView)
|
||||
? [
|
||||
{
|
||||
name: "管网在线模拟",
|
||||
list: "/network-simulation",
|
||||
meta: {
|
||||
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.scadaClean)
|
||||
? [
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.optimizationRun)
|
||||
? [
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.riskRun)
|
||||
? [
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
label: "健康风险分析",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
|
||||
? [
|
||||
{
|
||||
name: "Hydraulic Simulation",
|
||||
meta: { label: "事件模拟" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.burstRun)
|
||||
? [
|
||||
{
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationRun)
|
||||
? [
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.environmentManage)
|
||||
? [
|
||||
{
|
||||
name: "系统管理",
|
||||
list: "/system-admin",
|
||||
meta: {
|
||||
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||
label: "系统管理",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.auditView)
|
||||
? [
|
||||
{
|
||||
name: "审计日志",
|
||||
list: "/audit-logs",
|
||||
meta: {
|
||||
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||
label: "审计日志",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectProvider>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
resources={[
|
||||
{
|
||||
name: "管网在线模拟",
|
||||
list: "/network-simulation",
|
||||
meta: {
|
||||
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
label: "健康风险分析",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Hydraulic Simulation",
|
||||
meta: {
|
||||
// icon: <MdWater className="w-6 h-6" />,
|
||||
label: "事件模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
...(isMetadataAdmin
|
||||
? [
|
||||
{
|
||||
name: "系统管理",
|
||||
list: "/system-admin",
|
||||
meta: {
|
||||
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||
label: "系统管理",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "审计日志",
|
||||
list: "/audit-logs",
|
||||
meta: {
|
||||
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||
label: "审计日志",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
accessControlProvider={accessControlProvider}
|
||||
resources={resources}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</>
|
||||
</ProjectProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
@@ -66,6 +88,7 @@ const authOptions: NextAuthOptions = {
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
username: profile.preferred_username,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
email: profile.email,
|
||||
image: Avatar.src,
|
||||
@@ -75,18 +98,29 @@ 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;
|
||||
}
|
||||
const preferredUsername = (
|
||||
profile as { preferred_username?: unknown } | undefined
|
||||
)?.preferred_username;
|
||||
if (typeof preferredUsername === "string") {
|
||||
token.username = preferredUsername;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -94,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;
|
||||
}
|
||||
|
||||
@@ -104,15 +157,28 @@ const authOptions: NextAuthOptions = {
|
||||
if (session.user && token.sub) {
|
||||
session.user.id = token.sub;
|
||||
}
|
||||
if (session.user && token.username) {
|
||||
session.user.username = token.username;
|
||||
}
|
||||
if (token.accessToken) {
|
||||
session.accessToken = token.accessToken;
|
||||
}
|
||||
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;
|
||||
};
|
||||
+1
-1
@@ -17,7 +17,7 @@ export default async function RootLayout({
|
||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
||||
<Suspense>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import Login from "./page";
|
||||
|
||||
const mockLogin = jest.fn();
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useLogin: () => ({ mutate: mockLogin }),
|
||||
}));
|
||||
|
||||
describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockLogin.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts Keycloak login once when the page opens", () => {
|
||||
const { rerender } = render(<Login />);
|
||||
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogin).toHaveBeenCalledWith({});
|
||||
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
|
||||
|
||||
rerender(<Login />);
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a manual fallback when automatic redirect does not complete", () => {
|
||||
render(<Login />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "继续登录" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
|
||||
expect(mockLogin).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+239
-34
@@ -1,54 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Container from "@mui/material/Container";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useLogin } from "@refinedev/core";
|
||||
import { Title } from "@components/title";
|
||||
import { PROJECT_TITLE } from "@config/config";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const FALLBACK_DELAY_MS = 4000;
|
||||
|
||||
export default function Login() {
|
||||
const { mutate: login } = useLogin();
|
||||
const loginStartedRef = useRef(false);
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginStartedRef.current) {
|
||||
loginStartedRef.current = true;
|
||||
login({});
|
||||
}
|
||||
|
||||
const fallbackTimer = window.setTimeout(
|
||||
() => setShowFallback(true),
|
||||
FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(fallbackTimer);
|
||||
}, [login]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
"@supports (height: 100svh)": {
|
||||
minHeight: "100svh",
|
||||
},
|
||||
color: "oklch(0.3 0.055 215)",
|
||||
bgcolor: "oklch(0.965 0.014 205)",
|
||||
backgroundImage: `
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 50%,
|
||||
oklch(0.975 0.01 205 / 62%) 68%,
|
||||
oklch(0.975 0.01 205 / 82%) 100%
|
||||
),
|
||||
url("/login-network-blueprint.svg")
|
||||
`,
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
"@keyframes tjwaterEnter": {
|
||||
from: { opacity: 0, transform: "translateY(12px)" },
|
||||
to: { opacity: 1, transform: "translateY(0)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
gap="36px"
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 1720,
|
||||
minHeight: "100vh",
|
||||
"@supports (height: 100svh)": {
|
||||
minHeight: "100svh",
|
||||
},
|
||||
mx: "auto",
|
||||
px: {
|
||||
xs: 1.75,
|
||||
sm: 3,
|
||||
md: "clamp(40px, 5vw, 88px)",
|
||||
},
|
||||
py: {
|
||||
xs: "max(28px, env(safe-area-inset-top))",
|
||||
md: "clamp(32px, 4.5vw, 76px)",
|
||||
},
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "minmax(0, 1fr)",
|
||||
md: "minmax(360px, 1fr) minmax(400px, 460px)",
|
||||
},
|
||||
gridTemplateAreas: {
|
||||
xs: '"brand" "status"',
|
||||
md: '"brand status"',
|
||||
},
|
||||
alignContent: { xs: "center", md: "stretch" },
|
||||
alignItems: "center",
|
||||
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
|
||||
}}
|
||||
>
|
||||
<Box display="flex" justifyContent="center">
|
||||
<Title collapsed={false} />
|
||||
</Box>
|
||||
<Button
|
||||
style={{ width: "240px" }}
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => login({})}
|
||||
<Stack
|
||||
spacing={{ xs: 0, md: 3 }}
|
||||
direction={{ xs: "row", md: "column" }}
|
||||
alignItems={{ xs: "center", md: "flex-start" }}
|
||||
sx={{
|
||||
position: "relative",
|
||||
isolation: "isolate",
|
||||
gridArea: "brand",
|
||||
width: "fit-content",
|
||||
maxWidth: "100%",
|
||||
animation:
|
||||
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
"&::before": {
|
||||
position: "absolute",
|
||||
zIndex: -1,
|
||||
inset: { xs: "-24px -20px", md: "-54px -72px" },
|
||||
background: {
|
||||
xs: `radial-gradient(
|
||||
ellipse at center,
|
||||
oklch(0.965 0.014 205 / 98%) 0%,
|
||||
oklch(0.965 0.014 205 / 88%) 58%,
|
||||
transparent 84%
|
||||
)`,
|
||||
md: `radial-gradient(
|
||||
ellipse at center,
|
||||
oklch(0.925 0.018 205 / 98%) 0%,
|
||||
oklch(0.925 0.018 205 / 94%) 48%,
|
||||
oklch(0.925 0.018 205 / 62%) 65%,
|
||||
transparent 82%
|
||||
)`,
|
||||
},
|
||||
pointerEvents: "none",
|
||||
content: '""',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
||||
Powered by
|
||||
<Image
|
||||
style={{ padding: "0 5px" }}
|
||||
alt="Keycloak"
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
||||
width={18}
|
||||
height={18}
|
||||
<Box
|
||||
component="img"
|
||||
src="/login-logo-mark.svg"
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
sx={{
|
||||
width: { xs: 42, md: 56 },
|
||||
height: { xs: 42, md: 56 },
|
||||
flex: { xs: "0 0 42px", md: "0 0 auto" },
|
||||
mr: { xs: 1.5, md: 0 },
|
||||
}}
|
||||
/>
|
||||
Keycloak
|
||||
</Typography>
|
||||
<Typography
|
||||
component="h1"
|
||||
sx={{
|
||||
maxWidth: 680,
|
||||
m: 0,
|
||||
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
|
||||
fontWeight: 720,
|
||||
lineHeight: { xs: 1.4, md: 1.28 },
|
||||
textWrap: "balance",
|
||||
}}
|
||||
>
|
||||
{PROJECT_TITLE}
|
||||
</Typography>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
width: 64,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
bgcolor: "oklch(0.58 0.12 180)",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
gridArea: "status",
|
||||
width: "100%",
|
||||
maxWidth: 460,
|
||||
justifySelf: "stretch",
|
||||
p: { xs: 2.75, sm: 4.5 },
|
||||
overflow: "hidden",
|
||||
borderRadius: { xs: 4, sm: 4.5 },
|
||||
bgcolor: "oklch(0.995 0.004 205 / 97%)",
|
||||
boxShadow:
|
||||
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
|
||||
animation:
|
||||
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CircularProgress
|
||||
size={30}
|
||||
thickness={4.5}
|
||||
aria-label="正在连接统一身份认证"
|
||||
sx={{
|
||||
flex: "0 0 auto",
|
||||
color: "oklch(0.57 0.16 242)",
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
animation: "none",
|
||||
"& .MuiCircularProgress-circle": {
|
||||
animation: "none",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
|
||||
>
|
||||
正在进入账号登录
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "oklch(0.52 0.035 215)",
|
||||
fontSize: { xs: 14, sm: 15 },
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
正在连接统一身份认证服务,请稍候。
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{showFallback ? (
|
||||
<Button
|
||||
fullWidth
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => login({})}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
borderRadius: 3,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
textTransform: "none",
|
||||
boxShadow:
|
||||
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
|
||||
transitionProperty:
|
||||
"transform, background-color, box-shadow",
|
||||
"&:active": { transform: "scale(0.96)" },
|
||||
}}
|
||||
>
|
||||
继续登录
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
color: "oklch(0.52 0.035 215)",
|
||||
fontSize: { xs: 12, sm: 13 },
|
||||
lineHeight: 1.7,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
账号与访问权限由统一身份认证服务管理
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -106,24 +107,30 @@ type DatabaseHealth = {
|
||||
|
||||
const businessRoleOptions = [
|
||||
{ value: "admin", label: "系统管理员" },
|
||||
{ value: "operator", label: "运行人员" },
|
||||
{ value: "user", label: "普通用户" },
|
||||
{ value: "viewer", label: "只读用户" },
|
||||
];
|
||||
|
||||
const projectRoleLabels: Record<string, string> = {
|
||||
owner: "项目负责人",
|
||||
admin: "项目管理员",
|
||||
member: "项目成员",
|
||||
viewer: "只读成员",
|
||||
};
|
||||
|
||||
const projectRoleOptions = [
|
||||
{ value: "admin", label: projectRoleLabels.admin },
|
||||
{ value: "member", label: projectRoleLabels.member },
|
||||
{ value: "viewer", label: projectRoleLabels.viewer },
|
||||
];
|
||||
|
||||
const projectRolePermissionSummary = [
|
||||
{
|
||||
role: "项目成员",
|
||||
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
|
||||
},
|
||||
{
|
||||
role: "只读成员",
|
||||
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
|
||||
},
|
||||
];
|
||||
|
||||
const projectStatusOptions = [
|
||||
{ value: "active", label: "启用" },
|
||||
{ value: "inactive", label: "停用" },
|
||||
@@ -473,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]);
|
||||
@@ -634,7 +655,7 @@ export const SystemAdminPanel = () => {
|
||||
|
||||
const loadInitialState = async () => {
|
||||
try {
|
||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
@@ -941,7 +962,7 @@ export const SystemAdminPanel = () => {
|
||||
const form = databaseForms[role];
|
||||
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`,
|
||||
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health-checks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1340,7 +1361,7 @@ export const SystemAdminPanel = () => {
|
||||
<Stack spacing={2}>
|
||||
<SectionHeader
|
||||
title="项目成员"
|
||||
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
|
||||
description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
|
||||
action={
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -1352,6 +1373,25 @@ export const SystemAdminPanel = () => {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1 }}>
|
||||
固定项目权限
|
||||
</Typography>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={1}
|
||||
useFlexGap
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{projectRolePermissionSummary.map((item) => (
|
||||
<Chip
|
||||
key={item.role}
|
||||
label={`${item.role}:${item.permissions}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
{!hasProjectId && (
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
当前未选择管理项目。
|
||||
@@ -1467,7 +1507,6 @@ export const SystemAdminPanel = () => {
|
||||
)}
|
||||
{members.map((member) => {
|
||||
const isSelf = member.user_id === currentAdmin?.id;
|
||||
const isOwner = member.project_role === "owner";
|
||||
|
||||
return (
|
||||
<TableRow key={member.id} hover>
|
||||
@@ -1486,54 +1525,38 @@ export const SystemAdminPanel = () => {
|
||||
</TableCell>
|
||||
<TableCell>{member.email}</TableCell>
|
||||
<TableCell>
|
||||
{isOwner ? (
|
||||
<Tooltip title="项目负责人不在系统管理页维护">
|
||||
<Chip
|
||||
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
||||
<span>
|
||||
<FormControl
|
||||
size="small"
|
||||
color="warning"
|
||||
label={getProjectRoleLabel(member.project_role)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
||||
<span>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ minWidth: 140 }}
|
||||
disabled={isSelf}
|
||||
sx={{ minWidth: 140 }}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<Select
|
||||
value={member.project_role}
|
||||
onChange={(e) =>
|
||||
updateMemberRole(member, e.target.value)
|
||||
}
|
||||
renderValue={(value) =>
|
||||
getProjectRoleLabel(String(value))
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={member.project_role}
|
||||
onChange={(e) =>
|
||||
updateMemberRole(member, e.target.value)
|
||||
}
|
||||
renderValue={(value) =>
|
||||
getProjectRoleLabel(String(value))
|
||||
}
|
||||
>
|
||||
{projectRoleOptions.map((role) => (
|
||||
<MenuItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{projectRoleOptions.map((role) => (
|
||||
<MenuItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip active={member.is_active} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip
|
||||
title={
|
||||
isSelf
|
||||
? "不能操作当前登录用户"
|
||||
: isOwner
|
||||
? "项目负责人不在系统管理页维护"
|
||||
: ""
|
||||
}
|
||||
title={isSelf ? "不能操作当前登录用户" : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
@@ -1542,7 +1565,7 @@ export const SystemAdminPanel = () => {
|
||||
variant="outlined"
|
||||
startIcon={<RemoveCircleOutlineIcon />}
|
||||
onClick={() => removeMember(member)}
|
||||
disabled={isSelf || isOwner}
|
||||
disabled={isSelf}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
} from "@mui/icons-material";
|
||||
import { config } from "@config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { permissionCodes } from "@/lib/permissions";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
|
||||
end_time: "",
|
||||
};
|
||||
|
||||
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
|
||||
|
||||
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "success", label: "成功 2xx" },
|
||||
@@ -338,8 +342,14 @@ const DetailSection = ({
|
||||
);
|
||||
|
||||
export const AuditLogPanel = () => {
|
||||
const [adminChecked, setAdminChecked] = useState(false);
|
||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||
const accessLoading = useAccessStore((state) => state.loading);
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const isSystemAdmin = useAccessStore(
|
||||
(state) => state.context?.is_system_admin === true,
|
||||
);
|
||||
const canViewAudit = permissions.includes(permissionCodes.auditView);
|
||||
const adminChecked = !accessLoading;
|
||||
const isAuthorized = adminChecked && canViewAudit && isSystemAdmin;
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [users, setUsers] = useState<MetadataUser[]>([]);
|
||||
const [projects, setProjects] = useState<AdminProject[]>([]);
|
||||
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
|
||||
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
|
||||
const countParams = buildCountParams(appliedFilters);
|
||||
const [logsResponse, countResponse] = await Promise.all([
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`),
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`),
|
||||
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
|
||||
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
|
||||
]);
|
||||
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
|
||||
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
|
||||
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
|
||||
setTotalCount(Number(countPayload.count ?? 0));
|
||||
} else {
|
||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
|
||||
if (!response.ok) throw new Error(await readErrorText(response));
|
||||
const payload = (await response.json()) as AuditLog[];
|
||||
setLogs(payload);
|
||||
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
|
||||
}, [appliedFilters, page, rowsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!response.ok) {
|
||||
setIsAuthorized(false);
|
||||
setAdminChecked(true);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
||||
setAdminChecked(true);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setIsAuthorized(false);
|
||||
setAdminChecked(true);
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkAdmin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminChecked || !isAuthorized) return;
|
||||
if (!isAuthorized) return;
|
||||
void loadOptions();
|
||||
}, [adminChecked, isAuthorized, loadOptions]);
|
||||
}, [isAuthorized, loadOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminChecked || !isAuthorized) return;
|
||||
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
|
||||
setError(null);
|
||||
try {
|
||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
|
||||
if (!response.ok) throw new Error(await readErrorText(response));
|
||||
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
|
||||
matchesStatusFilter(log, appliedFilters.status),
|
||||
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
|
||||
审计日志
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
管理员审计查询
|
||||
全局审计查询
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
|
||||
<Chip
|
||||
color="success"
|
||||
icon={<AdminPanelSettingsIcon />}
|
||||
label="管理员权限已验证"
|
||||
label="系统管理员权限已验证"
|
||||
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { permissionForPath } from "@/lib/permissions";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
|
||||
export const RoutePermissionGuard = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const loading = useAccessStore((state) => state.loading);
|
||||
const requiredPermission = permissionForPath(pathname);
|
||||
|
||||
if (requiredPermission && loading) {
|
||||
return (
|
||||
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (requiredPermission && !permissions.includes(requiredPermission)) {
|
||||
return (
|
||||
<Box sx={{ p: { xs: 2, md: 4 } }}>
|
||||
<Alert severity="error" icon={<LockOutlinedIcon />}>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography fontWeight={700}>无权访问此功能</Typography>
|
||||
<Typography variant="body2">
|
||||
当前项目角色缺少权限:{requiredPermission}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { SessionExpiryDialog } from "./SessionExpiryDialog";
|
||||
|
||||
jest.mock("next-auth/react", () => ({
|
||||
signIn: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("SessionExpiryDialog", () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: "unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => useAuthStore.getState().clearSessionExpired());
|
||||
});
|
||||
|
||||
it("renders above every regular application overlay", () => {
|
||||
const theme = createTheme();
|
||||
|
||||
render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<SessionExpiryDialog />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const dialogRoot = screen.getByRole("dialog").closest(".MuiModal-root");
|
||||
expect(dialogRoot).not.toBeNull();
|
||||
expect(dialogRoot).toHaveStyle({
|
||||
zIndex: theme.zIndex.tooltip + 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const WARNING_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
type SessionExpiryDialogProps = {
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export const SessionExpiryDialog = ({
|
||||
expiresAt,
|
||||
}: SessionExpiryDialogProps) => {
|
||||
const theme = useTheme();
|
||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||
const reason = useAuthStore((state) => state.sessionExpiryReason);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [warningDismissed, setWarningDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const isExpiringSoon = useMemo(
|
||||
() =>
|
||||
!sessionExpired &&
|
||||
!warningDismissed &&
|
||||
typeof expiresAt === "number" &&
|
||||
expiresAt > now &&
|
||||
expiresAt - now <= WARNING_WINDOW_MS,
|
||||
[expiresAt, now, sessionExpired, warningDismissed],
|
||||
);
|
||||
|
||||
const handleReauthenticate = () => {
|
||||
const callbackUrl = `${window.location.pathname}${window.location.search}`;
|
||||
void signIn("keycloak", { callbackUrl, redirect: true });
|
||||
};
|
||||
|
||||
const isOpen = sessionExpired || isExpiringSoon;
|
||||
const title = sessionExpired ? "登录已过期" : "登录即将到期";
|
||||
const detail = sessionExpired
|
||||
? reason === "session_max_age"
|
||||
? "已达到 12 小时的最长连续登录时间。请重新认证后继续。"
|
||||
: "无法续期当前登录。请重新认证后继续。"
|
||||
: "当前登录将在 15 分钟内到期。请先保存正在编辑的内容。";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
style={{ zIndex: theme.zIndex.tooltip + 1 }}
|
||||
disableEscapeKeyDown={sessionExpired}
|
||||
onClose={sessionExpired ? undefined : () => setWarningDismissed(true)}
|
||||
aria-labelledby="session-expiry-dialog-title"
|
||||
>
|
||||
<DialogTitle id="session-expiry-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ pt: 0.5 }}>
|
||||
<Alert icon={<AccessTimeOutlinedIcon />} severity={sessionExpired ? "warning" : "info"}>
|
||||
{detail}
|
||||
</Alert>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。
|
||||
</Typography>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!sessionExpired && (
|
||||
<Button onClick={() => setWarningDismissed(true)}>稍后处理</Button>
|
||||
)}
|
||||
<Button variant="contained" onClick={handleReauthenticate}>
|
||||
重新认证
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
|
||||
import { AgentComposer } from "./AgentComposer";
|
||||
|
||||
jest.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img {...props} alt={props.alt ?? ""} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({
|
||||
children,
|
||||
animate: _animate,
|
||||
exit: _exit,
|
||||
initial: _initial,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("AgentComposer", () => {
|
||||
it("places voice input immediately before send without an attachment action", () => {
|
||||
render(
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<AgentComposer
|
||||
isStreaming={false}
|
||||
isListening={false}
|
||||
isSttSupported
|
||||
presets={[]}
|
||||
onSend={jest.fn()}
|
||||
onAbort={jest.fn()}
|
||||
onStartListening={jest.fn()}
|
||||
onStopListening={jest.fn()}
|
||||
modelOptions={[{ id: "test-model", label: "测试模型" }]}
|
||||
selectedModel="test-model"
|
||||
onModelChange={jest.fn()}
|
||||
approvalMode="auto"
|
||||
onApprovalModeChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const voiceButton = screen.getByRole("button", { name: "语音输入" });
|
||||
const sendButton = screen.getByRole("button", { name: "发送" });
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,12 @@ import StopRounded from "@mui/icons-material/StopRounded";
|
||||
import MicRounded from "@mui/icons-material/MicRounded";
|
||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
||||
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";
|
||||
|
||||
@@ -41,6 +42,7 @@ export type AgentComposerHandle = {
|
||||
|
||||
type AgentComposerProps = {
|
||||
isHydrating?: boolean;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
isStreaming: boolean;
|
||||
isListening: boolean;
|
||||
isSttSupported: boolean;
|
||||
@@ -56,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>,
|
||||
@@ -68,6 +99,7 @@ const renderModelIcon = (
|
||||
|
||||
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||
isHydrating = false,
|
||||
runtimeState = "ready",
|
||||
isStreaming,
|
||||
isListening,
|
||||
isSttSupported,
|
||||
@@ -86,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,
|
||||
@@ -103,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 }}>
|
||||
@@ -125,16 +168,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Image
|
||||
src="/ai-agent.svg"
|
||||
alt="TJWater Agent"
|
||||
width={18}
|
||||
height={18}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<AutoAwesomeRounded
|
||||
titleAccess="快捷指令图标"
|
||||
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||
管网分析快捷指令
|
||||
</Typography>
|
||||
@@ -161,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);
|
||||
@@ -216,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" },
|
||||
@@ -230,61 +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">
|
||||
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
|
||||
<AttachFileRounded fontSize="small" />
|
||||
</IconButton>
|
||||
{isSttSupported ? (
|
||||
isListening ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.14, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onStopListening}
|
||||
aria-label="停止语音输入"
|
||||
size="small"
|
||||
sx={{
|
||||
color: "error.main",
|
||||
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||
width: 36,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={onStartListening}
|
||||
disabled={isStreaming || isHydrating}
|
||||
aria-label="语音输入"
|
||||
size="small"
|
||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
) : null}
|
||||
<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" },
|
||||
@@ -292,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)",
|
||||
@@ -311,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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -340,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>
|
||||
@@ -471,6 +509,39 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{isSttSupported ? (
|
||||
isListening ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.14, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onStopListening}
|
||||
aria-label="停止语音输入"
|
||||
size="small"
|
||||
sx={{
|
||||
color: "error.main",
|
||||
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||
width: 36,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={onStartListening}
|
||||
disabled={isStreaming || isHydrating || !isRuntimeReady}
|
||||
aria-label="语音输入"
|
||||
size="small"
|
||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isStreaming ? (
|
||||
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
|
||||
sessionId: "session-1",
|
||||
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||
});
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
|
||||
isTitleManuallyEdited: true,
|
||||
});
|
||||
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||
title: "新标题",
|
||||
|
||||
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
|
||||
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||
|
||||
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
|
||||
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
|
||||
isTitleManuallyEdited?: boolean,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
|
||||
|
||||
const deleteBackendChatSession = async (sessionId: string) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
projectHeaderMode: "include",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
import { Box, Skeleton } from "@mui/material";
|
||||
|
||||
import type { MapSkeletonVariant } from "./mapComponentSkeletonConfig";
|
||||
|
||||
const skeletonSx = {
|
||||
transform: "none",
|
||||
bgcolor: "rgba(37, 125, 212, 0.10)",
|
||||
"&::after": {
|
||||
background:
|
||||
"linear-gradient(90deg, transparent, rgba(37, 125, 212, 0.12), transparent)",
|
||||
},
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
animation: "none",
|
||||
"&::after": {
|
||||
animation: "none",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const Line = ({ width = "44%", height = 18 }: { width?: string; height?: number }) => (
|
||||
<Skeleton
|
||||
variant="text"
|
||||
animation="wave"
|
||||
width={width}
|
||||
height={height}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
);
|
||||
|
||||
const Field = () => (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Line width="34%" height={18} />
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={40}
|
||||
sx={{ ...skeletonSx, mt: 0.5, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const TwoColumns = ({ children }: { children: React.ReactNode }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const Action = ({
|
||||
width = "100%",
|
||||
height = 40,
|
||||
testId,
|
||||
}: {
|
||||
width?: string;
|
||||
height?: number;
|
||||
testId?: string;
|
||||
}) => (
|
||||
<Skeleton
|
||||
data-testid={testId}
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width={width}
|
||||
height={height}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const Notice = ({ testId }: { testId?: string }) => (
|
||||
<Box
|
||||
data-testid={testId}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gap: 0.5,
|
||||
p: 1.5,
|
||||
bgcolor: "rgba(37, 125, 212, 0.07)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Line width="58%" height={18} />
|
||||
<Line width="88%" height={16} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const SelectionHeader = ({ actionWidth = "34%" }: { actionWidth?: string }) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width="44%" height={20} />
|
||||
</Box>
|
||||
<Action width={actionWidth} height={32} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const EmptySelection = ({ height = 44 }: { height?: number }) => (
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={height}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const Divider = () => (
|
||||
<Box sx={{ height: 1, flex: "0 0 auto", bgcolor: "rgba(15, 23, 42, 0.08)" }} />
|
||||
);
|
||||
|
||||
const AdvancedRow = ({ testId }: { testId?: string }) => (
|
||||
<Box
|
||||
data-testid={testId}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 1.25,
|
||||
border: "1px solid",
|
||||
borderColor: "rgba(15, 23, 42, 0.10)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width="34%" height={18} />
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={24}
|
||||
height={24}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const SelectionList = ({ rows = 3 }: { rows?: number }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gap: 1,
|
||||
p: 1.25,
|
||||
bgcolor: "rgba(15, 23, 42, 0.025)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows }, (_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: "flex", alignItems: "center", gap: 1 }}
|
||||
>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={26}
|
||||
height={26}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width={`${72 - index * 8}%`} height={16} />
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width={48}
|
||||
height={26}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const AnalysisFormSkeleton = ({ variant }: { variant: MapSkeletonVariant }) => {
|
||||
switch (variant) {
|
||||
case "burst-detection":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="burst-detection-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
<Field />
|
||||
</Box>
|
||||
<Notice />
|
||||
<Line width="88%" height={16} />
|
||||
<Box sx={{ display: "flex", gap: 1.5, mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "burst-location":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="burst-location-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
</Box>
|
||||
<Notice />
|
||||
<TwoColumns>
|
||||
<Field />
|
||||
<Field />
|
||||
</TwoColumns>
|
||||
<Field />
|
||||
<AdvancedRow />
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "burst-simulation":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="burst-simulation-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
</Box>
|
||||
<Box
|
||||
data-testid="burst-simulation-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
<Field />
|
||||
</Box>
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "contaminant-simulation":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="contaminant-simulation-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
<EmptySelection />
|
||||
</Box>
|
||||
<Box
|
||||
data-testid="contaminant-simulation-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Field key={index} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "dma-leak-detection":
|
||||
return (
|
||||
<>
|
||||
<Notice testId="dma-notice-skeleton" />
|
||||
<Box
|
||||
data-testid="dma-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Field key={index} />
|
||||
))}
|
||||
</Box>
|
||||
<AdvancedRow testId="dma-advanced-skeleton" />
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action testId="dma-primary-action-skeleton" />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "flushing-analysis":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="flushing-valve-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
<EmptySelection height={192} />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
data-testid="flushing-node-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
<EmptySelection height={48} />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
data-testid="flushing-primary-fields"
|
||||
data-layout="vertical-vertical-two-columns"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
<TwoColumns>
|
||||
<Field />
|
||||
<Field />
|
||||
</TwoColumns>
|
||||
</Box>
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "monitoring-place-optimization":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="monitoring-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 2 }}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Field key={index} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||
<Action width="132px" />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const DeviceListSkeleton = ({ cleaning }: { cleaning: boolean }) => (
|
||||
<Box
|
||||
data-testid={cleaning ? "cleaning-device-content" : "network-device-content"}
|
||||
sx={{ display: "grid", gap: 2 }}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={40}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={38}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width="58%" height={16} />
|
||||
</Box>
|
||||
{cleaning && (
|
||||
<Skeleton
|
||||
data-testid="cleaning-action-skeleton"
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={32}
|
||||
height={32}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={32}
|
||||
height={32}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ height: 1, bgcolor: "rgba(15, 23, 42, 0.08)" }} />
|
||||
<SelectionList rows={6} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const RiskChartSkeleton = () => (
|
||||
<Box
|
||||
data-testid="health-risk-chart-content"
|
||||
sx={{ display: "grid", height: "100%", minHeight: 360, gap: 2 }}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Line width="32%" height={18} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Action width="22%" />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-around",
|
||||
gap: 2,
|
||||
minHeight: 300,
|
||||
px: 3,
|
||||
pt: 3,
|
||||
pb: 2,
|
||||
bgcolor: "rgba(15, 23, 42, 0.025)",
|
||||
borderRadius: 1,
|
||||
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.10)",
|
||||
}}
|
||||
>
|
||||
{[36, 58, 44, 76, 62, 84].map((height, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width="9%"
|
||||
height={`${height}%`}
|
||||
sx={{ ...skeletonSx, borderRadius: "6px 6px 2px 2px" }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export function MapPageSkeletonContent({
|
||||
variant,
|
||||
}: {
|
||||
variant: MapSkeletonVariant;
|
||||
}) {
|
||||
if (variant === "network-simulation") {
|
||||
return <DeviceListSkeleton cleaning={false} />;
|
||||
}
|
||||
|
||||
if (variant === "scada-data-cleaning") {
|
||||
return <DeviceListSkeleton cleaning />;
|
||||
}
|
||||
|
||||
if (variant === "health-risk-analysis") {
|
||||
return <RiskChartSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid={`${variant}-content`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AnalysisFormSkeleton variant={variant} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapTimelineSkeleton() {
|
||||
return (
|
||||
<Box
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="正在加载时间轴"
|
||||
data-component-skeleton="map-timeline"
|
||||
data-testid="map-timeline-skeleton"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
bottom: 16,
|
||||
width: "min(950px, calc(100% - 32px))",
|
||||
transform: "translateX(-50%)",
|
||||
display: "grid",
|
||||
gap: 1.5,
|
||||
p: 2,
|
||||
bgcolor: "rgba(255, 255, 255, 0.96)",
|
||||
borderRadius: 1.5,
|
||||
boxShadow:
|
||||
"0 14px 30px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={32}
|
||||
height={32}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
))}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width={132}
|
||||
height={32}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={8}
|
||||
sx={{ ...skeletonSx, borderRadius: 999 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MapPanelSkeleton,
|
||||
MapTimelineSkeleton,
|
||||
} from "./MapComponentSkeletons";
|
||||
import {
|
||||
MAP_SKELETON_CONFIGS,
|
||||
MAP_SKELETON_VARIANTS,
|
||||
type MapSkeletonVariant,
|
||||
} from "./mapComponentSkeletonConfig";
|
||||
|
||||
describe("map component skeletons", () => {
|
||||
it("keeps the loading state inside the analysis panel geometry", () => {
|
||||
render(<MapPanelSkeleton variant="burst-simulation" />);
|
||||
|
||||
const panel = screen.getByRole("status", {
|
||||
name: "正在加载爆管分析",
|
||||
});
|
||||
|
||||
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
|
||||
expect(panel).toHaveAttribute("data-panel-side", "right");
|
||||
expect(panel).toHaveAttribute("data-panel-width", "520");
|
||||
expect(panel).toHaveStyle({
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
expect(screen.getByTestId("burst-simulation-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an explicit panel configuration for every heavy map component", () => {
|
||||
expect(Object.keys(MAP_SKELETON_CONFIGS).sort()).toEqual(
|
||||
[...MAP_SKELETON_VARIANTS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps known panel chrome out of the skeleton layer", () => {
|
||||
render(<MapPanelSkeleton variant="burst-simulation" />);
|
||||
|
||||
const header = screen.getByTestId("map-panel-header");
|
||||
const tabs = screen.getByTestId("map-panel-tabs");
|
||||
|
||||
expect(header.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
|
||||
expect(tabs.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("正在加载组件")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(tabs).getByText("分析要件"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(MAP_SKELETON_VARIANTS)(
|
||||
"renders the component-specific panel for %s",
|
||||
(variant: MapSkeletonVariant) => {
|
||||
const config = MAP_SKELETON_CONFIGS[variant];
|
||||
|
||||
render(<MapPanelSkeleton variant={variant} />);
|
||||
|
||||
const panel = screen.getByRole("status", {
|
||||
name: `正在加载${config.title}`,
|
||||
});
|
||||
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
|
||||
expect(panel).toHaveAttribute("data-panel-side", config.side);
|
||||
expect(panel).toHaveAttribute(
|
||||
"data-panel-width",
|
||||
String(config.panelWidth),
|
||||
);
|
||||
expect(screen.getByText(config.panelTitle)).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("map-panel-tab-label")).toHaveLength(
|
||||
config.tabLabels.length,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("distinguishes cleaning controls from the simulation device list", () => {
|
||||
const { rerender } = render(
|
||||
<MapPanelSkeleton variant="network-simulation" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("network-device-content")).toBeInTheDocument();
|
||||
|
||||
rerender(<MapPanelSkeleton variant="scada-data-cleaning" />);
|
||||
|
||||
expect(screen.getByTestId("cleaning-device-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cleaning-action-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches the DMA parameter panel's vertical control distribution", () => {
|
||||
render(<MapPanelSkeleton variant="dma-leak-detection" />);
|
||||
|
||||
const fields = screen.getByTestId("dma-primary-fields");
|
||||
|
||||
expect(screen.getByTestId("dma-notice-skeleton")).toBeInTheDocument();
|
||||
expect(fields).toHaveAttribute("data-layout", "vertical");
|
||||
expect(fields.children).toHaveLength(5);
|
||||
expect(screen.getByTestId("dma-advanced-skeleton")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dma-primary-action-skeleton")).toHaveStyle({
|
||||
width: "100%",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["burst-detection", "burst-detection-primary-fields", "vertical", 3],
|
||||
["burst-location", "burst-location-primary-fields", "vertical", 2],
|
||||
["burst-simulation", "burst-simulation-primary-fields", "vertical", 3],
|
||||
[
|
||||
"contaminant-simulation",
|
||||
"contaminant-simulation-primary-fields",
|
||||
"vertical",
|
||||
4,
|
||||
],
|
||||
[
|
||||
"flushing-analysis",
|
||||
"flushing-primary-fields",
|
||||
"vertical-vertical-two-columns",
|
||||
3,
|
||||
],
|
||||
[
|
||||
"monitoring-place-optimization",
|
||||
"monitoring-primary-fields",
|
||||
"vertical",
|
||||
5,
|
||||
],
|
||||
] as const)(
|
||||
"matches the primary control distribution for %s",
|
||||
(variant, testId, layout, childCount) => {
|
||||
render(<MapPanelSkeleton variant={variant} />);
|
||||
|
||||
const fields = screen.getByTestId(testId);
|
||||
|
||||
expect(fields).toHaveAttribute("data-layout", layout);
|
||||
expect(fields.children).toHaveLength(childCount);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the two flushing selection regions separate", () => {
|
||||
render(<MapPanelSkeleton variant="flushing-analysis" />);
|
||||
|
||||
expect(screen.getByTestId("flushing-valve-selection")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("flushing-node-selection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the timeline as an independent loading boundary", () => {
|
||||
render(<MapTimelineSkeleton />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("status", { name: "正在加载时间轴" }),
|
||||
).toHaveAttribute("data-component-skeleton", "map-timeline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Box, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
import { MapPageSkeletonContent } from "./MapComponentSkeletonLayouts";
|
||||
import {
|
||||
MAP_SKELETON_CONFIGS,
|
||||
type MapSkeletonVariant,
|
||||
} from "./mapComponentSkeletonConfig";
|
||||
|
||||
export { MapTimelineSkeleton } from "./MapComponentSkeletonLayouts";
|
||||
|
||||
export interface MapPanelSkeletonProps {
|
||||
variant: MapSkeletonVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 地图业务面板的组件级加载占位。
|
||||
* 只占据最终面板区域,不替换地图、工具栏或其他已经完成加载的组件。
|
||||
*/
|
||||
export function MapPanelSkeleton({ variant }: MapPanelSkeletonProps) {
|
||||
const config = MAP_SKELETON_CONFIGS[variant];
|
||||
const horizontalPosition =
|
||||
config.side === "left"
|
||||
? { left: { xs: 12, md: 16 }, right: "auto" }
|
||||
: { right: { xs: 12, md: 16 }, left: "auto" };
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={`正在加载${config.title}`}
|
||||
data-component-skeleton="map-panel"
|
||||
data-testid="map-panel-skeleton"
|
||||
data-panel-side={config.side}
|
||||
data-panel-width={config.panelWidth}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: { xs: 12, md: config.panelTop },
|
||||
width: {
|
||||
xs: "calc(100% - 24px)",
|
||||
sm: `min(${Math.min(config.panelWidth, 640)}px, calc(100% - 32px))`,
|
||||
md: config.panelWidth,
|
||||
},
|
||||
height: {
|
||||
xs: "calc(100% - 24px)",
|
||||
md: `min(${config.panelMaxHeight}px, calc(100% - ${config.panelTop + 16}px))`,
|
||||
},
|
||||
zIndex: 1300,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
bgcolor: "rgba(255, 255, 255, 0.97)",
|
||||
borderRadius: 1.5,
|
||||
boxShadow:
|
||||
"0 20px 34px rgba(15, 23, 42, 0.18), 0 4px 10px rgba(15, 23, 42, 0.08)",
|
||||
pointerEvents: "none",
|
||||
...horizontalPosition,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
data-testid="map-panel-header"
|
||||
sx={{
|
||||
minHeight: 64,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.25,
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
bgcolor: "#257DD4",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
lang="zh-CN"
|
||||
sx={{
|
||||
display: "block",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{config.panelTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CircularProgress
|
||||
aria-label={`正在加载${config.title}`}
|
||||
size={20}
|
||||
thickness={4}
|
||||
sx={{ color: "rgba(255, 255, 255, 0.90)" }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{config.tabLabels.length > 0 && (
|
||||
<Box
|
||||
data-testid="map-panel-tabs"
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${config.tabLabels.length}, minmax(0, 1fr))`,
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
bgcolor: "#fff",
|
||||
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.08)",
|
||||
}}
|
||||
>
|
||||
{config.tabLabels.map((label, index) => (
|
||||
<Box
|
||||
key={label}
|
||||
data-testid="map-panel-tab-label"
|
||||
sx={{ display: "grid", justifyItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
lang="zh-CN"
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
color: index === 0 ? "#257DD4" : "text.secondary",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{index === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "78%",
|
||||
height: 2,
|
||||
bgcolor: "rgba(37, 125, 212, 0.34)",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
p: variant === "health-risk-analysis" ? 2 : 2.5,
|
||||
bgcolor:
|
||||
variant === "network-simulation" ||
|
||||
variant === "scada-data-cleaning"
|
||||
? "rgba(248, 250, 252, 0.98)"
|
||||
: "#fff",
|
||||
}}
|
||||
>
|
||||
<MapPageSkeletonContent variant={variant} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Box, Skeleton, CircularProgress } from "@mui/material";
|
||||
|
||||
/**
|
||||
* 地图页面骨架屏组件
|
||||
* 提供即时视觉反馈,模拟地图界面布局
|
||||
*/
|
||||
export function MapSkeleton() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
bgcolor: "background.default",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 主地图区域骨架 */}
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
animation="wave"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 中央加载指示器 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} thickness={4} color="primary" />
|
||||
</Box>
|
||||
|
||||
{/* 左侧工具栏骨架 (垂直) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 20,
|
||||
left: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.5,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="circular"
|
||||
width={40}
|
||||
height={40}
|
||||
animation="wave"
|
||||
sx={{ boxShadow: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 右侧控制面板骨架 (抽屉式) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: { xs: "100%", sm: 360 },
|
||||
height: "100%",
|
||||
bgcolor: "background.paper",
|
||||
borderLeft: 1,
|
||||
borderColor: "divider",
|
||||
p: 3,
|
||||
zIndex: 5,
|
||||
display: { xs: "none", md: "flex" },
|
||||
flexDirection: "column",
|
||||
boxShadow: -2,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="text" width="60%" height={40} sx={{ mb: 3 }} />
|
||||
|
||||
{/* 面板内容区块 */}
|
||||
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||||
<Skeleton variant="rectangular" width="100%" height={100} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="rectangular" width="100%" height={180} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Box key={i} sx={{ display: "flex", gap: 2, mb: 2 }}>
|
||||
<Skeleton variant="circular" width={36} height={36} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="80%" />
|
||||
<Skeleton variant="text" width="50%" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 底部时间轴/控制条骨架 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 30,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: { xs: "90%", md: "60%" },
|
||||
height: 64,
|
||||
bgcolor: "background.paper",
|
||||
borderRadius: 4,
|
||||
boxShadow: 3,
|
||||
p: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="circular" width={32} height={32} />
|
||||
<Skeleton variant="rectangular" width="100%" height={8} sx={{ borderRadius: 4 }} />
|
||||
<Skeleton variant="text" width={40} />
|
||||
</Box>
|
||||
|
||||
{/* 缩放控制骨架 (右下) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 110,
|
||||
right: { xs: 20, md: 380 }, // Adjust if drawer is open
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
zIndex: 4,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版骨架屏 - 用于非地图页面
|
||||
*/
|
||||
export function SimpleSkeleton() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
p: 3,
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<Skeleton width="40%" height={40} animation="wave" sx={{ mb: 3 }} />
|
||||
<Skeleton width="100%" height={60} animation="wave" sx={{ mb: 2 }} />
|
||||
<Skeleton width="100%" height={300} animation="wave" sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: "flex", gap: 2, mb: 2 }}>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="30%"
|
||||
height={150}
|
||||
animation="wave"
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="30%"
|
||||
height={150}
|
||||
animation="wave"
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="30%"
|
||||
height={150}
|
||||
animation="wave"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export const MAP_SKELETON_VARIANTS = [
|
||||
"network-simulation",
|
||||
"scada-data-cleaning",
|
||||
"health-risk-analysis",
|
||||
"monitoring-place-optimization",
|
||||
"burst-detection",
|
||||
"burst-location",
|
||||
"burst-simulation",
|
||||
"contaminant-simulation",
|
||||
"dma-leak-detection",
|
||||
"flushing-analysis",
|
||||
] as const;
|
||||
|
||||
export type MapSkeletonVariant = (typeof MAP_SKELETON_VARIANTS)[number];
|
||||
|
||||
export interface MapSkeletonConfig {
|
||||
title: string;
|
||||
panelTitle: string;
|
||||
side: "left" | "right";
|
||||
panelWidth: number;
|
||||
panelTop: number;
|
||||
panelMaxHeight: number;
|
||||
tabLabels: readonly string[];
|
||||
}
|
||||
|
||||
export const MAP_SKELETON_CONFIGS: Record<
|
||||
MapSkeletonVariant,
|
||||
MapSkeletonConfig
|
||||
> = {
|
||||
"network-simulation": {
|
||||
title: "管网模拟",
|
||||
panelTitle: "SCADA 设备列表",
|
||||
side: "left",
|
||||
panelWidth: 360,
|
||||
panelTop: 80,
|
||||
panelMaxHeight: 860,
|
||||
tabLabels: [],
|
||||
},
|
||||
"scada-data-cleaning": {
|
||||
title: "数据清洗",
|
||||
panelTitle: "SCADA 设备列表",
|
||||
side: "left",
|
||||
panelWidth: 360,
|
||||
panelTop: 80,
|
||||
panelMaxHeight: 860,
|
||||
tabLabels: [],
|
||||
},
|
||||
"health-risk-analysis": {
|
||||
title: "健康风险分析",
|
||||
panelTitle: "管道健康风险统计",
|
||||
side: "right",
|
||||
panelWidth: 640,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 614,
|
||||
tabLabels: [],
|
||||
},
|
||||
"monitoring-place-optimization": {
|
||||
title: "监测点优化",
|
||||
panelTitle: "监测点优化",
|
||||
side: "right",
|
||||
panelWidth: 520,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 850,
|
||||
tabLabels: ["优化要件", "结果编辑", "方案查询"],
|
||||
},
|
||||
"burst-detection": {
|
||||
title: "爆管侦测",
|
||||
panelTitle: "爆管侦测",
|
||||
side: "right",
|
||||
panelWidth: 450,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 850,
|
||||
tabLabels: ["侦测参数", "方案查询", "侦测结果"],
|
||||
},
|
||||
"burst-location": {
|
||||
title: "爆管定位",
|
||||
panelTitle: "爆管定位",
|
||||
side: "right",
|
||||
panelWidth: 450,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 850,
|
||||
tabLabels: ["定位参数", "方案查询", "定位结果"],
|
||||
},
|
||||
"burst-simulation": {
|
||||
title: "爆管分析",
|
||||
panelTitle: "爆管分析",
|
||||
side: "right",
|
||||
panelWidth: 520,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 850,
|
||||
tabLabels: ["分析要件", "方案查询", "分析报告", "关阀分析"],
|
||||
},
|
||||
"contaminant-simulation": {
|
||||
title: "水质模拟",
|
||||
panelTitle: "水质模拟",
|
||||
side: "right",
|
||||
panelWidth: 520,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 850,
|
||||
tabLabels: ["分析要件", "方案查询", "模拟结果"],
|
||||
},
|
||||
"dma-leak-detection": {
|
||||
title: "DMA 漏损识别",
|
||||
panelTitle: "DMA 漏损识别",
|
||||
side: "right",
|
||||
panelWidth: 450,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 850,
|
||||
tabLabels: ["识别参数", "方案查询", "识别结果"],
|
||||
},
|
||||
"flushing-analysis": {
|
||||
title: "管道冲洗分析",
|
||||
panelTitle: "管道冲洗分析",
|
||||
side: "right",
|
||||
panelWidth: 450,
|
||||
panelTop: 16,
|
||||
panelMaxHeight: 850,
|
||||
tabLabels: ["分析参数", "方案查询"],
|
||||
},
|
||||
};
|
||||
@@ -11,13 +11,13 @@ describe("burst detection request", () => {
|
||||
const state = createBurstDetectionAnalysisParametersState();
|
||||
state.schemeName = " latest-case ";
|
||||
|
||||
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
|
||||
network: "fengyang",
|
||||
expect(buildBurstDetectionRequest(state)).toEqual({
|
||||
scheme_name: "latest-case",
|
||||
sampling_interval_minutes: 15,
|
||||
});
|
||||
expect(state.detectionMode).toBe("latest");
|
||||
expect(state.targetTime?.minute() % 15).toBe(0);
|
||||
expect(state.targetTime).not.toBeNull();
|
||||
expect(state.targetTime!.minute() % 15).toBe(0);
|
||||
});
|
||||
|
||||
it("sends one target time for historical replay", () => {
|
||||
@@ -32,10 +32,8 @@ describe("burst detection request", () => {
|
||||
samplingIntervalMinutes: 30,
|
||||
samplingIntervalSource: "manual",
|
||||
},
|
||||
"fengyang",
|
||||
),
|
||||
).toEqual({
|
||||
network: "fengyang",
|
||||
scheme_name: "history-case",
|
||||
sampling_interval_minutes: 30,
|
||||
target_time: targetTime.toISOString(),
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 { BurstDetectionResult } from "./types";
|
||||
|
||||
interface Props {
|
||||
@@ -92,9 +93,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||
|
||||
export const buildBurstDetectionRequest = (
|
||||
parameters: BurstDetectionAnalysisParametersState,
|
||||
network: string,
|
||||
) => ({
|
||||
network,
|
||||
scheme_name: parameters.schemeName.trim(),
|
||||
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
||||
...(parameters.detectionMode === "historical" && parameters.targetTime
|
||||
@@ -123,13 +122,19 @@ 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;
|
||||
let active = true;
|
||||
setFrequencyLoading(true);
|
||||
api
|
||||
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
|
||||
.get("/api/v1/scada-info")
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const interval = resolvePressureSamplingInterval(
|
||||
@@ -186,8 +191,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
|
||||
try {
|
||||
const response = await api.post(
|
||||
"/api/v1/burst-detection/detect/",
|
||||
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
|
||||
"/api/v1/burst-detections",
|
||||
buildBurstDetectionRequest(parametersState),
|
||||
);
|
||||
onResult(response.data as BurstDetectionResult);
|
||||
open?.({
|
||||
|
||||
@@ -23,6 +23,7 @@ import { bbox, featureCollection } from "@turf/turf";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
export interface BurstDetectionResultsState {
|
||||
selectedDay: number | null;
|
||||
@@ -73,17 +74,11 @@ const formatDateTime = (value?: string) =>
|
||||
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
|
||||
|
||||
const EmptyState = () => (
|
||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
||||
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||
</Box>
|
||||
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
||||
等待侦测结果
|
||||
</Typography>
|
||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||
提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。
|
||||
</Typography>
|
||||
</Box>
|
||||
<PanelEmptyState
|
||||
icon={<ShowChartIcon />}
|
||||
title="尚未生成侦测结果"
|
||||
description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||
/>
|
||||
);
|
||||
|
||||
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
BurstDetectionResult,
|
||||
BurstDetectionSchemeDetail,
|
||||
@@ -42,6 +43,7 @@ export interface BurstDetectionSchemeQueryState {
|
||||
queryAll: boolean;
|
||||
queryDate: Dayjs | null;
|
||||
expandedId: number | null;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createBurstDetectionSchemeQueryState =
|
||||
@@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState =
|
||||
queryAll: true,
|
||||
queryDate: dayjs(),
|
||||
expandedId: null,
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({
|
||||
@@ -64,7 +67,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
onStateChange,
|
||||
createBurstDetectionSchemeQueryState(),
|
||||
);
|
||||
const { queryAll, queryDate, expandedId } = queryState;
|
||||
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
@@ -127,14 +130,17 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const handleQuery = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: "burst_detection",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
params.query_date = queryDate.startOf("day").toISOString();
|
||||
}
|
||||
|
||||
const response = await api.get("/api/v1/burst-detection/schemes/", { params });
|
||||
const response = await api.get("/api/v1/schemes", { params });
|
||||
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
|
||||
setSchemes(nextSchemes);
|
||||
setQueryField("hasQueried", true);
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
@@ -154,8 +160,8 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const handleViewSchemeResult = async (schemeName: string) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { network: NETWORK_NAME } },
|
||||
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { scheme_type: "burst_detection" } },
|
||||
);
|
||||
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
||||
result_payload?: BurstDetectionResult;
|
||||
@@ -200,7 +206,10 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={queryAll}
|
||||
onChange={(event) => setQueryField("queryAll", event.target.checked)}
|
||||
onChange={(event) => {
|
||||
setQueryField("queryAll", event.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">查询全部</Typography>}
|
||||
@@ -209,7 +218,10 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={(value) => setQueryField("queryDate", value)}
|
||||
onChange={(value) => {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
disabled={queryAll}
|
||||
format="YYYY-MM-DD"
|
||||
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
|
||||
@@ -231,12 +243,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{sortedSchemes.length === 0 ? (
|
||||
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
|
||||
<Typography variant="body2">暂无侦测方案</Typography>
|
||||
<Typography variant="caption" className="mt-1">
|
||||
运行一次展示版侦测后,可在这里回看历史结果。
|
||||
</Typography>
|
||||
</Box>
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="px-2 text-gray-500">
|
||||
|
||||
@@ -26,8 +26,10 @@ 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 { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||
import { BurstLocationResult } from "./types";
|
||||
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstLocationResult) => void;
|
||||
@@ -104,6 +106,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) => {
|
||||
@@ -125,7 +134,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
setSchemeLoading(true);
|
||||
try {
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params: { network: NETWORK_NAME },
|
||||
params: { scheme_type: "burst_analysis" },
|
||||
});
|
||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||
@@ -222,9 +231,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
: undefined;
|
||||
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
|
||||
`${config.BACKEND_URL}/api/v1/burst-locations`,
|
||||
{
|
||||
network: NETWORK_NAME,
|
||||
data_source: dataSource,
|
||||
scheme_name: schemeName.trim() || undefined,
|
||||
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
||||
@@ -263,11 +271,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const notice = getBurstLocationErrorNotice(error);
|
||||
open?.({
|
||||
key: "burst-location-analysis-error",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
|
||||
message: notice.message,
|
||||
description: notice.description,
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -304,22 +313,30 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert
|
||||
severity="info"
|
||||
severity={isSimulationMode ? "warning" : "info"}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mt: 1,
|
||||
alignItems: "flex-start",
|
||||
borderColor: "info.light",
|
||||
backgroundColor: "#eff6ff",
|
||||
borderColor: isSimulationMode ? "warning.light" : "info.light",
|
||||
backgroundColor: isSimulationMode ? "#fff8e1" : "#eff6ff",
|
||||
"& .MuiAlert-message": { width: "100%" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="block font-semibold text-blue-900">
|
||||
数据选择规则
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="block font-semibold"
|
||||
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
||||
>
|
||||
{isSimulationMode ? "模拟方案分析前置条件" : "数据选择规则"}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="block leading-5 text-blue-800">
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="block leading-5"
|
||||
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
||||
>
|
||||
{isSimulationMode
|
||||
? "当前为模拟方案:爆管数据取所选方案模拟结果,正常数据取实时模拟结果,两者使用同一爆管时间窗。"
|
||||
? "爆管数据取所选方案,正常数据取同一时间窗的实时模拟结果。所选时间窗必须已有实时模拟正常基线,否则无法分析;系统不会自动补数据。"
|
||||
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Stroke, Style, Circle, Fill } from "ol/style";
|
||||
import { bbox, featureCollection } from "@turf/turf";
|
||||
import { BurstCandidate, BurstLocationResult } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
result: BurstLocationResult | null;
|
||||
@@ -128,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
||||
};
|
||||
|
||||
const EmptyState = () => (
|
||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
||||
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||
</Box>
|
||||
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
||||
等待定位结果
|
||||
</Typography>
|
||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||
请先提交爆管定位分析,结果面板将展示定位摘要、时间窗、采样情况和候选管段。
|
||||
</Typography>
|
||||
</Box>
|
||||
<PanelEmptyState
|
||||
icon={<MapIcon />}
|
||||
title="尚未生成定位结果"
|
||||
description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||
/>
|
||||
);
|
||||
|
||||
const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
BurstSchemeRecord,
|
||||
} from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
onViewResult: (result: BurstLocationResult) => void;
|
||||
@@ -56,6 +58,7 @@ export interface BurstLocationSchemeQueryState {
|
||||
queryDate: Dayjs | null;
|
||||
expandedId: number | null;
|
||||
simulationBurstIdsByName: Record<string, string[]>;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createBurstLocationSchemeQueryState =
|
||||
@@ -64,6 +67,7 @@ export const createBurstLocationSchemeQueryState =
|
||||
queryDate: dayjs(),
|
||||
expandedId: null,
|
||||
simulationBurstIdsByName: {},
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({
|
||||
@@ -82,10 +86,11 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
onStateChange,
|
||||
createBurstLocationSchemeQueryState(),
|
||||
);
|
||||
const { queryAll, queryDate, expandedId } = queryState;
|
||||
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
|
||||
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const creatorName = useSchemeCreatorName();
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||
const sortedSchemes = useMemo(
|
||||
@@ -222,18 +227,17 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const handleQuery = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// API call to fetch schemes
|
||||
// Adjust URL as needed
|
||||
let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`;
|
||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: "burst_location",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
params.query_date = queryDate.startOf("day").toISOString();
|
||||
}
|
||||
|
||||
const [response, simulationResponse] = await Promise.all([
|
||||
api.get(url, { params }),
|
||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
|
||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params: { network: NETWORK_NAME },
|
||||
params: { scheme_type: "burst_analysis" },
|
||||
}),
|
||||
]);
|
||||
const nextSchemes = response.data as BurstSchemeRecord[];
|
||||
@@ -254,6 +258,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
),
|
||||
),
|
||||
);
|
||||
setQueryField("hasQueried", true);
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
@@ -274,8 +279,8 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const handleViewSchemeResult = async (schemeName: string) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { network: NETWORK_NAME } },
|
||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { scheme_type: "burst_location" } },
|
||||
);
|
||||
const schemeRecord = response.data as BurstSchemeRecord & {
|
||||
result_payload?: BurstLocationResult;
|
||||
@@ -318,7 +323,10 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={queryAll}
|
||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setQueryField("queryAll", e.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">查询全部</Typography>}
|
||||
@@ -327,7 +335,10 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={(value) => setQueryField("queryDate", value)}
|
||||
onChange={(value) => {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
disabled={queryAll}
|
||||
format="YYYY-MM-DD"
|
||||
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
|
||||
@@ -348,39 +359,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
</Box>
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{sortedSchemes.length === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">总共 0 条</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
No data
|
||||
</Typography>
|
||||
</Box>
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
@@ -526,7 +505,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
用户:
|
||||
</Typography>
|
||||
<Typography variant="caption" className="font-medium text-gray-900">
|
||||
{scheme.username || "-"}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||
|
||||
describe("getBurstLocationErrorNotice", () => {
|
||||
it("explains a missing realtime normal-pressure baseline", () => {
|
||||
const notice = getBurstLocationErrorNotice({
|
||||
response: {
|
||||
data: {
|
||||
detail: "正常压力数据 在时间窗内无有效模拟数据: 10186",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(notice.message).toBe("缺少正常模拟基线");
|
||||
expect(notice.description).toContain("实时模拟正常压力数据");
|
||||
expect(notice.description).toContain("10186");
|
||||
expect(notice.description).toContain("系统不会自动补数据");
|
||||
});
|
||||
|
||||
it("distinguishes incomplete burst-scheme data", () => {
|
||||
const notice = getBurstLocationErrorNotice({
|
||||
response: {
|
||||
data: {
|
||||
detail: "爆管压力数据 在时间窗内无有效模拟数据: 10186",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(notice).toEqual({
|
||||
message: "所选方案数据不完整",
|
||||
description:
|
||||
"所选方案时间窗内没有可用的爆管压力数据,缺失点位:10186。请选择数据完整的模拟方案。",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unrelated backend details", () => {
|
||||
expect(
|
||||
getBurstLocationErrorNotice({
|
||||
response: { data: { detail: "分析参数不合法" } },
|
||||
}),
|
||||
).toEqual({
|
||||
message: "提交分析失败",
|
||||
description: "分析参数不合法",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface BurstLocationErrorNotice {
|
||||
message: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
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 match = detail.match(DATA_GAP_PATTERN);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
message: "提交分析失败",
|
||||
description: detail,
|
||||
};
|
||||
}
|
||||
|
||||
const [, dataLabel, missingIds] = match;
|
||||
if (dataLabel.startsWith("正常")) {
|
||||
return {
|
||||
message: "缺少正常模拟基线",
|
||||
description: `所选方案时间窗内没有可用的实时模拟${dataLabel},缺失点位:${missingIds}。请选择已有正常基线覆盖的方案或时间窗,系统不会自动补数据。`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: "所选方案数据不完整",
|
||||
description: `所选方案时间窗内没有可用的${dataLabel},缺失点位:${missingIds}。请选择数据完整的模拟方案。`,
|
||||
};
|
||||
};
|
||||
@@ -29,6 +29,7 @@ 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 { along, lineString, length, toMercator } from "@turf/turf";
|
||||
import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
@@ -74,6 +75,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] =
|
||||
@@ -322,16 +329,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
: "";
|
||||
const modify_total_duration = duration;
|
||||
const params = {
|
||||
network: network,
|
||||
modify_pattern_start_time: modify_pattern_start_time,
|
||||
burst_ID: burst_ID,
|
||||
burst_id: burst_ID,
|
||||
burst_size: burst_size,
|
||||
modify_total_duration: modify_total_duration,
|
||||
scheme_name: schemeName,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
|
||||
await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import AnalysisReport, {
|
||||
clearAnalysisReportDiameterCache,
|
||||
matchesValveAnalysis,
|
||||
} from "./AnalysisReport";
|
||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||
import {
|
||||
isAllowedAccidentPipe,
|
||||
normalizeValveIsolationResult,
|
||||
} from "./valveIsolationScope";
|
||||
|
||||
jest.mock("@/utils/mapQueryService", () => ({
|
||||
queryFeaturesByIds: jest.fn(),
|
||||
}));
|
||||
|
||||
const scheme: SchemeRecord = {
|
||||
id: 17,
|
||||
schemeName: "burst-report-demo",
|
||||
type: "burst_analysis",
|
||||
username: "operator",
|
||||
create_time: "2026-07-30T08:00:00+08:00",
|
||||
startTime: "2026-07-30T09:00:00+08:00",
|
||||
schemeDetail: {
|
||||
burst_ID: ["P-1", "P-2"],
|
||||
burst_size: [120, 80],
|
||||
modify_total_duration: 5400,
|
||||
modify_fixed_pump_pattern: null,
|
||||
modify_valve_opening: null,
|
||||
modify_variable_pump_pattern: null,
|
||||
},
|
||||
};
|
||||
|
||||
const valveResult: ValveIsolationResult = {
|
||||
accident_elements: ["P-1"],
|
||||
affected_nodes: ["J-1", "J-2"],
|
||||
must_close_valves: ["V-1"],
|
||||
optional_valves: ["V-2"],
|
||||
isolatable: true,
|
||||
};
|
||||
|
||||
const feature = (id: string, diameter: number) => ({
|
||||
getProperties: () => ({ id, diameter }),
|
||||
});
|
||||
|
||||
describe("AnalysisReport", () => {
|
||||
beforeEach(() => {
|
||||
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
||||
async (_ids: string[], layerName: string) =>
|
||||
layerName === "geo_pipes_mat"
|
||||
? [feature("P-1", 315)]
|
||||
: [feature("P-2", 800)],
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAnalysisReportDiameterCache();
|
||||
jest.clearAllMocks();
|
||||
document.body.classList.remove("burst-analysis-report-printing");
|
||||
});
|
||||
|
||||
it("guides the user to choose a scheme when no report is active", () => {
|
||||
render(
|
||||
<AnalysisReport
|
||||
scheme={null}
|
||||
valveResult={null}
|
||||
disabledValves={[]}
|
||||
generatedAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"请在“方案查询”中打开一个方案,再查看分析报告。",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the selected scheme, pipe data, and matching valve result", async () => {
|
||||
render(
|
||||
<AnalysisReport
|
||||
scheme={scheme}
|
||||
valveResult={valveResult}
|
||||
disabledValves={["V-3"]}
|
||||
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = within(
|
||||
screen.getByTestId("burst-analysis-report-preview"),
|
||||
);
|
||||
expect(
|
||||
preview.getByRole("heading", { name: "爆管分析报告" }),
|
||||
).toBeInTheDocument();
|
||||
expect(preview.getByText("burst-report-demo")).toBeInTheDocument();
|
||||
expect(preview.getByText("可隔离")).toBeInTheDocument();
|
||||
expect(preview.getByText("V-1")).toBeInTheDocument();
|
||||
expect(preview.getByText("V-3")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(preview.getByText("315 mm")).toBeInTheDocument();
|
||||
expect(preview.getByText("800 mm")).toBeInTheDocument();
|
||||
});
|
||||
expect(queryFeaturesByIds).toHaveBeenCalledWith(
|
||||
["P-2"],
|
||||
"geo_pipes",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mix an unrelated valve analysis into the report", async () => {
|
||||
render(
|
||||
<AnalysisReport
|
||||
scheme={scheme}
|
||||
valveResult={{ ...valveResult, accident_elements: ["P-99"] }}
|
||||
disabledValves={[]}
|
||||
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = within(
|
||||
screen.getByTestId("burst-analysis-report-preview"),
|
||||
);
|
||||
expect(
|
||||
preview.getByText(/尚未对本方案执行匹配的关阀分析/),
|
||||
).toBeInTheDocument();
|
||||
expect(preview.queryByText("V-1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
matchesValveAnalysis(scheme, {
|
||||
...valveResult,
|
||||
accident_elements: ["P-99"],
|
||||
}),
|
||||
).toBe(false);
|
||||
await waitFor(() =>
|
||||
expect(preview.getByText("315 mm")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows only the affected count for a non-isolatable result", async () => {
|
||||
const legacyAffectedNodes = Array.from(
|
||||
{ length: 100 },
|
||||
(_, index) => `legacy-node-${index}`,
|
||||
);
|
||||
const nonIsolatableResult = {
|
||||
...valveResult,
|
||||
affected_nodes: legacyAffectedNodes,
|
||||
affected_node_count: 85747,
|
||||
must_close_valves: [],
|
||||
isolatable: false,
|
||||
};
|
||||
|
||||
render(
|
||||
<AnalysisReport
|
||||
scheme={scheme}
|
||||
valveResult={nonIsolatableResult}
|
||||
disabledValves={[]}
|
||||
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = within(
|
||||
screen.getByTestId("burst-analysis-report-preview"),
|
||||
);
|
||||
expect(preview.getByText("85747 个")).toBeInTheDocument();
|
||||
expect(
|
||||
preview.getByText("不可隔离,未生成受影响节点清单。"),
|
||||
).toBeInTheDocument();
|
||||
expect(preview.queryByText("legacy-node-0")).not.toBeInTheDocument();
|
||||
expect(preview.queryByText("legacy-node-99")).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(preview.getByText("315 mm")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("limits scheme-bound valve analysis to the scheme accident pipes", () => {
|
||||
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
|
||||
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
|
||||
expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes legacy valve results without changing isolatable lists", () => {
|
||||
expect(
|
||||
normalizeValveIsolationResult({
|
||||
...valveResult,
|
||||
affected_nodes: ["J-1", "J-2", "J-3"],
|
||||
must_close_valves: [],
|
||||
isolatable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
affected_nodes: [],
|
||||
affected_node_count: 3,
|
||||
isolatable: false,
|
||||
});
|
||||
|
||||
expect(normalizeValveIsolationResult(valveResult)).toMatchObject({
|
||||
affected_nodes: ["J-1", "J-2"],
|
||||
affected_node_count: 2,
|
||||
isolatable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prints only after assigning the report print state", async () => {
|
||||
const originalTitle = document.title;
|
||||
const print = jest
|
||||
.spyOn(window, "print")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<AnalysisReport
|
||||
scheme={scheme}
|
||||
valveResult={null}
|
||||
disabledValves={[]}
|
||||
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = within(
|
||||
screen.getByTestId("burst-analysis-report-preview"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(preview.getByText("315 mm")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".burst-analysis-report-print-root"),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "打印/保存 PDF" }),
|
||||
);
|
||||
expect(print).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.querySelector(".burst-analysis-report-print-root"),
|
||||
).toBeInTheDocument();
|
||||
expect(document.body).toHaveClass("burst-analysis-report-printing");
|
||||
expect(document.title).toBe("爆管分析报告-burst-report-demo");
|
||||
|
||||
fireEvent(window, new Event("afterprint"));
|
||||
expect(document.body).not.toHaveClass(
|
||||
"burst-analysis-report-printing",
|
||||
);
|
||||
expect(document.title).toBe(originalTitle);
|
||||
print.mockRestore();
|
||||
});
|
||||
|
||||
it("reuses pipe diameters after the report is remounted", async () => {
|
||||
const props = {
|
||||
scheme,
|
||||
valveResult: null,
|
||||
disabledValves: [],
|
||||
generatedAt: new Date("2026-07-30T10:00:00+08:00"),
|
||||
};
|
||||
const firstRender = render(<AnalysisReport {...props} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
|
||||
"800 mm",
|
||||
),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
|
||||
|
||||
firstRender.unmount();
|
||||
render(<AnalysisReport {...props} />);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
|
||||
"800 mm",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
GlobalStyles,
|
||||
Paper,
|
||||
Portal,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DescriptionOutlined,
|
||||
PrintOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { NETWORK_NAME } from "@config/config";
|
||||
import {
|
||||
clearPipeDiameterCache,
|
||||
getCachedPipeDiameters,
|
||||
getPipeDiameterDisplay,
|
||||
getPipeDiameterQueryKey,
|
||||
loadPipeDiameters,
|
||||
type PipeDiameterMap,
|
||||
} from "./schemePipeDiameters";
|
||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||
import { getAffectedNodeCount } from "./valveIsolationScope";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface AnalysisReportProps {
|
||||
scheme: SchemeRecord | null;
|
||||
valveResult: ValveIsolationResult | null;
|
||||
disabledValves: string[];
|
||||
generatedAt: Date | null;
|
||||
}
|
||||
|
||||
interface ReportDocumentProps extends AnalysisReportProps {
|
||||
diameters: PipeDiameterMap;
|
||||
loadingDiameters: boolean;
|
||||
}
|
||||
|
||||
const REPORT_BLUE = "#0b4f87";
|
||||
const REPORT_INK = "#172435";
|
||||
const REPORT_MUTED = "#5f6f80";
|
||||
const REPORT_LINE = "#d8e0e8";
|
||||
export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
|
||||
|
||||
const formatDateTime = (value: Date | string | null | undefined) => {
|
||||
if (!value) return "未记录";
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "未记录";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | undefined) => {
|
||||
if (!Number.isFinite(seconds) || seconds === undefined || seconds < 0) {
|
||||
return "未记录";
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return [
|
||||
hours ? `${hours} 小时` : "",
|
||||
minutes ? `${minutes} 分钟` : "",
|
||||
remainingSeconds || (!hours && !minutes) ? `${remainingSeconds} 秒` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
export const matchesValveAnalysis = (
|
||||
scheme: SchemeRecord | null,
|
||||
valveResult: ValveIsolationResult | null,
|
||||
) => {
|
||||
const schemePipeIds = new Set(scheme?.schemeDetail?.burst_ID ?? []);
|
||||
const accidentElements = valveResult?.accident_elements ?? [];
|
||||
return (
|
||||
valveResult !== null &&
|
||||
accidentElements.length > 0 &&
|
||||
accidentElements.every((pipeId) => schemePipeIds.has(pipeId))
|
||||
);
|
||||
};
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }} />
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{ color: REPORT_INK, fontSize: 17, fontWeight: 700 }}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const IdList = ({
|
||||
values,
|
||||
emptyText = "无",
|
||||
}: {
|
||||
values: string[] | undefined;
|
||||
emptyText?: string;
|
||||
}) =>
|
||||
values?.length ? (
|
||||
<Box className="flex flex-wrap gap-1.5">
|
||||
{values.map((value) => (
|
||||
<Chip
|
||||
key={value}
|
||||
label={value}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: REPORT_LINE, color: REPORT_INK }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: REPORT_MUTED }}>
|
||||
{emptyText}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const ReportDocument: React.FC<ReportDocumentProps> = ({
|
||||
scheme,
|
||||
valveResult,
|
||||
disabledValves,
|
||||
generatedAt,
|
||||
diameters,
|
||||
loadingDiameters,
|
||||
}) => {
|
||||
if (!scheme) return null;
|
||||
|
||||
const pipeIds = scheme.schemeDetail?.burst_ID ?? [];
|
||||
const burstSizes = scheme.schemeDetail?.burst_size ?? [];
|
||||
const matchedValveResult = matchesValveAnalysis(scheme, valveResult)
|
||||
? valveResult
|
||||
: null;
|
||||
const duration = scheme.schemeDetail?.modify_total_duration;
|
||||
const valveDetailRows: Array<[string, string[] | undefined]> =
|
||||
matchedValveResult
|
||||
? [
|
||||
["已分析事故管段", matchedValveResult.accident_elements],
|
||||
["必关阀门", matchedValveResult.must_close_valves],
|
||||
["可选阀门", matchedValveResult.optional_valves],
|
||||
["不可用阀门", disabledValves],
|
||||
]
|
||||
: [];
|
||||
if (matchedValveResult?.isolatable) {
|
||||
valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
minHeight: "100%",
|
||||
bgcolor: "#fff",
|
||||
color: REPORT_INK,
|
||||
p: { xs: 2, sm: 3 },
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: `3px solid ${REPORT_BLUE}`,
|
||||
pb: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="h1"
|
||||
sx={{ color: REPORT_BLUE, fontSize: 26, fontWeight: 800 }}
|
||||
>
|
||||
爆管分析报告
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 0.75, color: REPORT_MUTED, fontSize: 13 }}>
|
||||
报告编号:BA-{scheme.id} 生成时间:{formatDateTime(generatedAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3, breakInside: "avoid" }}>
|
||||
<SectionTitle>方案概况</SectionTitle>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
borderTop: `1px solid ${REPORT_LINE}`,
|
||||
borderLeft: `1px solid ${REPORT_LINE}`,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
["管网", NETWORK_NAME],
|
||||
["方案名称", scheme.schemeName],
|
||||
["方案创建人", scheme.username || "未记录"],
|
||||
["方案创建时间", formatDateTime(scheme.create_time)],
|
||||
["模拟开始时间", formatDateTime(scheme.startTime)],
|
||||
["模拟持续时间", formatDuration(duration)],
|
||||
].map(([label, value]) => (
|
||||
<Box
|
||||
key={label}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRight: `1px solid ${REPORT_LINE}`,
|
||||
borderBottom: `1px solid ${REPORT_LINE}`,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 0.3, fontSize: 14, fontWeight: 600 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3, breakInside: "avoid" }}>
|
||||
<SectionTitle>爆管模拟参数</SectionTitle>
|
||||
<TableContainer component={Paper} variant="outlined" elevation={0}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: "#f3f7fa" }}>
|
||||
<TableCell>序号</TableCell>
|
||||
<TableCell>爆管管段</TableCell>
|
||||
<TableCell>管径</TableCell>
|
||||
<TableCell align="right">爆管面积</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{pipeIds.length ? (
|
||||
pipeIds.map((pipeId, index) => (
|
||||
<TableRow key={`${pipeId}-${index}`}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>{pipeId}</TableCell>
|
||||
<TableCell>
|
||||
{getPipeDiameterDisplay(
|
||||
[pipeId],
|
||||
diameters,
|
||||
loadingDiameters,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{Number.isFinite(burstSizes[index])
|
||||
? `${burstSizes[index]} cm²`
|
||||
: "未记录"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">
|
||||
未记录爆管管段
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3, breakInside: "avoid" }}>
|
||||
<SectionTitle>分析结论</SectionTitle>
|
||||
<Alert
|
||||
severity={matchedValveResult?.isolatable === false ? "warning" : "info"}
|
||||
variant="outlined"
|
||||
>
|
||||
本方案记录了 {pipeIds.length} 条爆管管段,模拟持续时间为
|
||||
{formatDuration(duration)}。
|
||||
{matchedValveResult
|
||||
? ` 当前关阀分析判定事故管段${
|
||||
matchedValveResult.isolatable ? "可以" : "无法"
|
||||
}有效隔离。`
|
||||
: " 当前会话尚未对本方案执行匹配的关阀分析。"}
|
||||
</Alert>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<SectionTitle>关阀分析</SectionTitle>
|
||||
{matchedValveResult ? (
|
||||
<Stack spacing={2}>
|
||||
<Box className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
|
||||
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`],
|
||||
["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`],
|
||||
].map(([label, value]) => (
|
||||
<Paper
|
||||
key={label}
|
||||
variant="outlined"
|
||||
sx={{ p: 1.5, textAlign: "center", breakInside: "avoid" }}
|
||||
>
|
||||
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 0.5, fontWeight: 700 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
{valveDetailRows.map(([label, values]) => (
|
||||
<Box key={label} sx={{ breakInside: "avoid" }}>
|
||||
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<IdList values={values} />
|
||||
</Box>
|
||||
))}
|
||||
{!matchedValveResult.isolatable && (
|
||||
<Alert severity="info" variant="outlined">
|
||||
不可隔离,未生成受影响节点清单。
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert severity="info" variant="outlined">
|
||||
本方案尚未执行关阀分析。可在“关阀分析”页签完成分析后重新查看报告。
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const AnalysisReport: React.FC<AnalysisReportProps> = ({
|
||||
scheme,
|
||||
valveResult,
|
||||
disabledValves,
|
||||
generatedAt,
|
||||
}) => {
|
||||
const pipeIds = useMemo(
|
||||
() => scheme?.schemeDetail?.burst_ID ?? [],
|
||||
[scheme],
|
||||
);
|
||||
const diameterQueryKey = getPipeDiameterQueryKey(pipeIds);
|
||||
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||
const [diameterState, setDiameterState] = useState<{
|
||||
queryKey: string | null;
|
||||
values: PipeDiameterMap;
|
||||
}>({ queryKey: null, values: {} });
|
||||
const [printReady, setPrintReady] = useState(false);
|
||||
const printStateRef = useRef<{ title: string } | null>(null);
|
||||
const diameters = useMemo(
|
||||
() =>
|
||||
diameterState.queryKey === diameterQueryKey
|
||||
? diameterState.values
|
||||
: cachedDiameters ?? {},
|
||||
[cachedDiameters, diameterQueryKey, diameterState],
|
||||
);
|
||||
const loadingDiameters =
|
||||
pipeIds.length > 0 &&
|
||||
diameterState.queryKey !== diameterQueryKey &&
|
||||
!cachedDiameters;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipeIds.length) {
|
||||
return;
|
||||
}
|
||||
if (getCachedPipeDiameters(pipeIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
loadPipeDiameters(pipeIds)
|
||||
.then((nextDiameters) => {
|
||||
if (!cancelled) {
|
||||
setDiameterState({
|
||||
queryKey: diameterQueryKey,
|
||||
values: nextDiameters,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("查询分析报告管径失败:", error);
|
||||
if (!cancelled) {
|
||||
setDiameterState({
|
||||
queryKey: diameterQueryKey,
|
||||
values: Object.fromEntries(
|
||||
pipeIds.map((pipeId) => [pipeId, null]),
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [diameterQueryKey, pipeIds]);
|
||||
|
||||
const restoreAfterPrint = useCallback(() => {
|
||||
if (printStateRef.current) {
|
||||
document.title = printStateRef.current.title;
|
||||
printStateRef.current = null;
|
||||
document.body.classList.remove("burst-analysis-report-printing");
|
||||
}
|
||||
setPrintReady(false);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
restoreAfterPrint();
|
||||
},
|
||||
[restoreAfterPrint],
|
||||
);
|
||||
|
||||
const handlePrint = () => {
|
||||
if (!scheme || printStateRef.current) return;
|
||||
printStateRef.current = { title: document.title };
|
||||
document.title = `爆管分析报告-${scheme.schemeName}`;
|
||||
document.body.classList.add("burst-analysis-report-printing");
|
||||
setPrintReady(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!printReady) return;
|
||||
|
||||
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
|
||||
try {
|
||||
window.print();
|
||||
} catch (error) {
|
||||
console.error("打印爆管分析报告失败:", error);
|
||||
window.setTimeout(restoreAfterPrint, 0);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("afterprint", restoreAfterPrint);
|
||||
};
|
||||
}, [printReady, restoreAfterPrint]);
|
||||
|
||||
const reportDocument = useMemo(
|
||||
() => (
|
||||
<ReportDocument
|
||||
scheme={scheme}
|
||||
valveResult={valveResult}
|
||||
disabledValves={disabledValves}
|
||||
generatedAt={generatedAt}
|
||||
diameters={diameters}
|
||||
loadingDiameters={loadingDiameters}
|
||||
/>
|
||||
),
|
||||
[
|
||||
diameters,
|
||||
disabledValves,
|
||||
generatedAt,
|
||||
loadingDiameters,
|
||||
scheme,
|
||||
valveResult,
|
||||
],
|
||||
);
|
||||
|
||||
if (!scheme) {
|
||||
return (
|
||||
<PanelEmptyState
|
||||
icon={<DescriptionOutlined />}
|
||||
title="尚未选择分析方案"
|
||||
description="请在“方案查询”中打开一个方案,再查看分析报告。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
".burst-analysis-report-print-root": { display: "none" },
|
||||
"@page": { size: "A4 portrait", margin: 0 },
|
||||
"@media print": {
|
||||
"html, body": {
|
||||
width: "210mm",
|
||||
minHeight: "297mm",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"body.burst-analysis-report-printing > *:not(.burst-analysis-report-print-root)":
|
||||
{ display: "none !important" },
|
||||
".burst-analysis-report-print-root": {
|
||||
display: "block !important",
|
||||
width: "210mm !important",
|
||||
minHeight: "297mm !important",
|
||||
backgroundColor: "#fff !important",
|
||||
},
|
||||
".burst-analysis-report-print-root > div": {
|
||||
padding: "14mm 16mm !important",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box className="space-y-3">
|
||||
<Box className="flex justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<PrintOutlined />}
|
||||
onClick={handlePrint}
|
||||
>
|
||||
打印/保存 PDF
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper
|
||||
data-testid="burst-analysis-report-preview"
|
||||
variant="outlined"
|
||||
sx={{ overflow: "hidden" }}
|
||||
>
|
||||
{reportDocument}
|
||||
</Paper>
|
||||
</Box>
|
||||
{printReady && (
|
||||
<Portal>
|
||||
<Box
|
||||
className="burst-analysis-report-print-root"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{reportDocument}
|
||||
</Box>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisReport;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import BurstPipeAnalysisPanel from "./BurstPipeAnalysisPanel";
|
||||
|
||||
jest.mock("./AnalysisParameters", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>analysis parameters</div>,
|
||||
createBurstAnalysisParametersState: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock("./SchemeQuery", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onViewReport,
|
||||
}: {
|
||||
onViewReport: (scheme: Record<string, unknown>) => void;
|
||||
}) => {
|
||||
const createScheme = (id: number, schemeName: string) => ({
|
||||
id,
|
||||
schemeName,
|
||||
type: "burst_analysis",
|
||||
username: "operator",
|
||||
create_time: "2026-07-30T08:00:00+08:00",
|
||||
startTime: "2026-07-30T09:00:00+08:00",
|
||||
schemeDetail: {
|
||||
burst_ID: ["P-1", "P-2"],
|
||||
burst_size: [120, 80],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => onViewReport(createScheme(1, "selected-scheme"))}>
|
||||
mock report action
|
||||
</button>
|
||||
<button onClick={() => onViewReport(createScheme(2, "other-scheme"))}>
|
||||
mock other report action
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
createBurstSchemeQueryState: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock("./AnalysisReport", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
scheme,
|
||||
valveResult,
|
||||
}: {
|
||||
scheme: { schemeName: string } | null;
|
||||
valveResult: { accident_elements: string[] } | null;
|
||||
}) => (
|
||||
<div>
|
||||
report:{scheme?.schemeName ?? "empty"}:valve:
|
||||
{valveResult?.accident_elements.join(",") ?? "none"}
|
||||
</div>
|
||||
),
|
||||
matchesValveAnalysis: (
|
||||
scheme: { schemeDetail: { burst_ID: string[] } },
|
||||
valveResult: { accident_elements: string[] } | null,
|
||||
) =>
|
||||
valveResult !== null &&
|
||||
valveResult.accident_elements.every((pipeId) =>
|
||||
scheme.schemeDetail.burst_ID.includes(pipeId),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./ValveIsolation", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
allowedPipeIds,
|
||||
sourceSchemeName,
|
||||
onResultChange,
|
||||
}: {
|
||||
allowedPipeIds?: string[];
|
||||
sourceSchemeName?: string;
|
||||
onResultChange: (result: Record<string, unknown>) => void;
|
||||
}) => (
|
||||
<>
|
||||
<div data-testid="valve-scope">
|
||||
valve scope:{sourceSchemeName ?? "independent"}:
|
||||
{allowedPipeIds?.join(",") ?? "any"}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
onResultChange({
|
||||
accident_elements: ["P-1"],
|
||||
affected_nodes: ["J-1"],
|
||||
must_close_valves: ["V-1"],
|
||||
optional_valves: [],
|
||||
isolatable: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
mock valve result
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
createValveIsolationState: () => ({
|
||||
selectedPipeId: null,
|
||||
activeStep: 0,
|
||||
expandedResult: true,
|
||||
disabledValves: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("BurstPipeAnalysisPanel", () => {
|
||||
it("replaces the obsolete location tab and opens the selected report", () => {
|
||||
render(<BurstPipeAnalysisPanel />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /定位结果/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: /分析报告/ }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "mock report action" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("report:selected-scheme:valve:none"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
|
||||
expect(screen.getByTestId("valve-scope")).toHaveTextContent(
|
||||
"valve scope:selected-scheme:P-1,P-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reuse valve results across schemes with the same pipe", () => {
|
||||
render(<BurstPipeAnalysisPanel />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "mock report action" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "mock valve result" }),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "mock other report action" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("report:other-scheme:valve:none"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
ChevronLeft,
|
||||
Analytics as AnalyticsIcon,
|
||||
Search as SearchIcon,
|
||||
MyLocation as MyLocationIcon,
|
||||
DescriptionOutlined as ReportIcon,
|
||||
Handyman as HandymanIcon,
|
||||
} from "@mui/icons-material";
|
||||
import AnalysisParameters, {
|
||||
@@ -26,15 +26,12 @@ import SchemeQuery, {
|
||||
createBurstSchemeQueryState,
|
||||
type BurstSchemeQueryState,
|
||||
} from "./SchemeQuery";
|
||||
import LocationResults from "./LocationResults";
|
||||
import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
|
||||
import ValveIsolation, {
|
||||
createValveIsolationState,
|
||||
type ValveIsolationState,
|
||||
} from "./ValveIsolation";
|
||||
import { api } from "@/lib/api";
|
||||
import { config } from "@config/config";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
|
||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
|
||||
// 持久化方案查询结果
|
||||
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||
// 定位结果数据
|
||||
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
|
||||
const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
|
||||
const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
|
||||
// 关阀分析结果和加载状态
|
||||
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
||||
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
||||
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
|
||||
useState<string | null>(null);
|
||||
const [valveIsolationState, setValveIsolationState] =
|
||||
useState<ValveIsolationState>(createValveIsolationState);
|
||||
|
||||
const { open } = useNotification();
|
||||
|
||||
// 使用受控或非受控状态
|
||||
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const handleToggle = () => {
|
||||
@@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
setCurrentTab(newValue);
|
||||
};
|
||||
|
||||
const handleLocateScheme = async (scheme: SchemeRecord) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
|
||||
);
|
||||
setLocationResults(response.data);
|
||||
setCurrentTab(2); // 切换到定位结果标签页
|
||||
} catch (error) {
|
||||
console.error("获取定位结果失败:", error);
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "获取定位结果失败",
|
||||
description: "无法从服务器获取该方案的定位结果",
|
||||
});
|
||||
const handleViewReport = (scheme: SchemeRecord) => {
|
||||
if (
|
||||
valveAnalysisSchemeName !== scheme.schemeName ||
|
||||
!matchesValveAnalysis(scheme, valveAnalysisResult)
|
||||
) {
|
||||
setValveAnalysisResult(null);
|
||||
setValveAnalysisSchemeName(null);
|
||||
setValveIsolationState(createValveIsolationState());
|
||||
}
|
||||
setReportScheme(scheme);
|
||||
setReportGeneratedAt(new Date());
|
||||
setCurrentTab(2);
|
||||
};
|
||||
|
||||
const handleValveAnalysisResultChange = (
|
||||
result: ValveIsolationResult | null,
|
||||
) => {
|
||||
setValveAnalysisResult(result);
|
||||
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
|
||||
};
|
||||
|
||||
const drawerWidth = 520;
|
||||
@@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
label="方案查询"
|
||||
/>
|
||||
<Tab
|
||||
icon={<MyLocationIcon fontSize="small" />}
|
||||
icon={<ReportIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label="定位结果"
|
||||
label="分析报告"
|
||||
/>
|
||||
<Tab
|
||||
icon={<HandymanIcon fontSize="small" />}
|
||||
@@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
<SchemeQuery
|
||||
schemes={schemes}
|
||||
onSchemesChange={setSchemes}
|
||||
onLocate={handleLocateScheme}
|
||||
onViewReport={handleViewReport}
|
||||
state={queryState}
|
||||
onStateChange={setQueryState}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={currentTab} index={2}>
|
||||
<LocationResults
|
||||
results={locationResults}
|
||||
<AnalysisReport
|
||||
scheme={reportScheme}
|
||||
valveResult={valveAnalysisResult}
|
||||
disabledValves={valveIsolationState.disabledValves}
|
||||
generatedAt={reportGeneratedAt}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
@@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
loading={valveAnalysisLoading}
|
||||
result={valveAnalysisResult}
|
||||
onLoadingChange={setValveAnalysisLoading}
|
||||
onResultChange={setValveAnalysisResult}
|
||||
onResultChange={handleValveAnalysisResultChange}
|
||||
state={valveIsolationState}
|
||||
onStateChange={setValveIsolationState}
|
||||
allowedPipeIds={
|
||||
reportScheme?.schemeDetail?.burst_ID
|
||||
}
|
||||
sourceSchemeName={reportScheme?.schemeName}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Link,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
LocationOn as LocationIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { Stroke, Style, Icon } from "ol/style";
|
||||
import Feature, { FeatureLike } from "ol/Feature";
|
||||
import {
|
||||
along,
|
||||
lineString,
|
||||
length,
|
||||
toMercator,
|
||||
bbox,
|
||||
featureCollection,
|
||||
} from "@turf/turf";
|
||||
import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import moment from "moment";
|
||||
import "moment-timezone";
|
||||
import { LocationResult } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
||||
|
||||
interface LocationResultsProps {
|
||||
results?: LocationResult[];
|
||||
}
|
||||
|
||||
const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
results = [],
|
||||
}) => {
|
||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const map = useMap();
|
||||
|
||||
// 格式化时间为 UTC+8
|
||||
const formatTime = (timeStr: string) => {
|
||||
return moment(timeStr).utcOffset(8).format("YYYY-MM-DD HH:mm:ss");
|
||||
};
|
||||
|
||||
const handleLocatePipes = (pipeIds: string[]) => {
|
||||
if (pipeIds.length > 0) {
|
||||
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
|
||||
if (features.length > 0) {
|
||||
// 设置高亮要素
|
||||
setHighlightFeatures(features);
|
||||
// 将 OpenLayers Feature 转换为 GeoJSON Feature
|
||||
const geojsonFormat = new GeoJSON();
|
||||
const geojsonFeatures = features.map((feature) =>
|
||||
geojsonFormat.writeFeatureObject(feature),
|
||||
);
|
||||
|
||||
const extent = bbox(featureCollection(geojsonFeatures as any));
|
||||
|
||||
if (extent) {
|
||||
map?.getView().fit(extent, { maxZoom: 18, duration: 1000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化管道图层和高亮图层
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const burstPipeStyle = function (feature: FeatureLike) {
|
||||
const styles = [];
|
||||
// 线条样式(底层发光,主线条,内层高亮线)
|
||||
styles.push(
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(255, 0, 0, 0.3)",
|
||||
width: 12,
|
||||
}),
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(255, 0, 0, 1)",
|
||||
width: 6,
|
||||
lineDash: [15, 10],
|
||||
}),
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(255, 102, 102, 1)",
|
||||
width: 3,
|
||||
lineDash: [15, 10],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const geometry = feature.getGeometry();
|
||||
const lineCoords =
|
||||
geometry?.getType() === "LineString"
|
||||
? (geometry as any).getCoordinates()
|
||||
: null;
|
||||
if (geometry && lineCoords) {
|
||||
const lineCoordsWGS84 = lineCoords.map((coord: []) => {
|
||||
const [lon, lat] = toLonLat(coord);
|
||||
return [lon, lat];
|
||||
});
|
||||
// 计算中点
|
||||
const lineStringFeature = lineString(lineCoordsWGS84);
|
||||
const lineLength = length(lineStringFeature);
|
||||
const midPoint = along(lineStringFeature, lineLength / 2).geometry
|
||||
.coordinates;
|
||||
// 在中点添加 icon 样式
|
||||
const midPointMercator = toMercator(midPoint);
|
||||
styles.push(
|
||||
new Style({
|
||||
geometry: new Point(midPointMercator),
|
||||
image: new Icon({
|
||||
src: "/icons/burst_pipe.svg",
|
||||
scale: 0.2,
|
||||
anchor: [0.5, 1],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return styles;
|
||||
};
|
||||
// 创建高亮图层
|
||||
const highlightLayer = new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
style: burstPipeStyle,
|
||||
maxZoom: 24,
|
||||
minZoom: 12,
|
||||
properties: {
|
||||
name: "爆管管段高亮",
|
||||
value: "burst_pipe_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer(highlightLayer);
|
||||
highlightLayerRef.current = highlightLayer;
|
||||
|
||||
return () => {
|
||||
highlightLayerRef.current = null;
|
||||
map.removeLayer(highlightLayer);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// 高亮要素的函数
|
||||
useEffect(() => {
|
||||
const source = highlightLayerRef.current?.getSource();
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
// 清除之前的高亮
|
||||
source.clear();
|
||||
// 添加新的高亮要素
|
||||
highlightFeatures.forEach((feature) => {
|
||||
if (feature instanceof Feature) {
|
||||
source.addFeature(feature);
|
||||
}
|
||||
});
|
||||
}, [highlightFeatures]);
|
||||
|
||||
// 取第一条记录或空对象
|
||||
const result = results.length > 0 ? results[0] : null;
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col h-full">
|
||||
{/* 结果展示 */}
|
||||
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200">
|
||||
{!result ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle cx="40" cy="40" r="5" fill="currentColor" />
|
||||
<line
|
||||
x1="40"
|
||||
y1="15"
|
||||
x2="40"
|
||||
y2="25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="55"
|
||||
x2="40"
|
||||
y2="65"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="15"
|
||||
y1="40"
|
||||
x2="25"
|
||||
y2="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="55"
|
||||
y1="40"
|
||||
x2="65"
|
||||
y2="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">暂无定位结果</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
请先执行方案分析
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="p-5 h-full overflow-auto">
|
||||
{/* 头部:标识信息 */}
|
||||
<Box className="mb-5">
|
||||
<Box className="flex items-center gap-2 mb-1">
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="font-bold text-gray-900"
|
||||
title={result.burst_incident}
|
||||
>
|
||||
{result.burst_incident}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={
|
||||
result.type === "burst_analysis" ? "爆管模拟" : result.type
|
||||
}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
height: "24px",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
ID: {result.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 主要信息:三栏卡片布局 */}
|
||||
<Box className="grid grid-cols-3 gap-3 mb-5">
|
||||
{/* 检测时间卡片 */}
|
||||
<Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<Box className="flex items-center gap-1.5 mb-2">
|
||||
<Box className="w-1.5 h-1.5 rounded-full bg-blue-600"></Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="text-blue-700 font-semibold uppercase tracking-wide"
|
||||
sx={{ fontSize: "0.7rem" }}
|
||||
>
|
||||
检测时间
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="font-bold text-blue-900 leading-tight"
|
||||
sx={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
{formatTime(result.detect_time)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 漏损量卡片 */}
|
||||
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<Box className="flex items-center gap-1.5 mb-2">
|
||||
<Box className="w-1.5 h-1.5 rounded-full bg-orange-600"></Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="text-orange-700 font-semibold uppercase tracking-wide"
|
||||
sx={{ fontSize: "0.7rem" }}
|
||||
>
|
||||
漏损量
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="font-bold text-orange-900"
|
||||
sx={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
{result.leakage !== null
|
||||
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 定位管段数量卡片 */}
|
||||
<Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<Box className="flex items-center gap-1.5 mb-2">
|
||||
<Box className="w-1.5 h-1.5 rounded-full bg-green-600"></Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="text-green-700 font-semibold uppercase tracking-wide"
|
||||
sx={{ fontSize: "0.7rem" }}
|
||||
>
|
||||
定位管段
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="font-bold text-green-900"
|
||||
sx={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
{result.locate_result ? result.locate_result.length : 0}{" "}
|
||||
个管段
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 定位管段详细列表 */}
|
||||
{result.locate_result && result.locate_result.length > 0 && (
|
||||
<Box className="bg-white rounded-lg p-4 border-2 border-blue-200 shadow-sm">
|
||||
<Box className="flex items-center justify-between mb-3">
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-gray-900 font-bold"
|
||||
sx={{ fontSize: "0.95rem" }}
|
||||
>
|
||||
管段列表
|
||||
</Typography>
|
||||
<Box className="flex items-center gap-2">
|
||||
<Tooltip title="定位所有管道">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleLocatePipes(result.locate_result!)}
|
||||
color="primary"
|
||||
sx={{
|
||||
backgroundColor: "rgba(37, 125, 212, 0.1)",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(37, 125, 212, 0.2)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LocationIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="grid grid-cols-2 gap-2">
|
||||
{result.locate_result.map((pipeId, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
className="bg-gradient-to-r from-blue-50 to-white rounded-lg px-3 py-2 border border-blue-200 hover:border-blue-400 hover:shadow-md transition-all cursor-pointer group"
|
||||
onClick={() => handleLocatePipes([pipeId])}
|
||||
sx={{
|
||||
"&:active": {
|
||||
transform: "scale(0.98)",
|
||||
boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box className="flex items-center justify-between">
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="font-semibold text-blue-700 group-hover:text-blue-900"
|
||||
>
|
||||
{pipeId}
|
||||
</Typography>
|
||||
<Box className="flex items-center gap-1">
|
||||
{/* <Tooltip title="定位管段">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLocatePipes([pipeId]);
|
||||
}}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(37, 125, 212, 0.1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LocationIcon sx={{ fontSize: "1rem" }} />
|
||||
</IconButton>
|
||||
</Tooltip> */}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationResults;
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Link,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DescriptionOutlined as ReportIcon,
|
||||
Info as InfoIcon,
|
||||
LocationOn as LocationIcon,
|
||||
} from "@mui/icons-material";
|
||||
@@ -52,14 +53,18 @@ import { toLonLat } from "ol/proj";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
import { SchemaItem, SchemeRecord } from "./types";
|
||||
import {
|
||||
getCachedPipeDiameters,
|
||||
getPipeDiameterDisplay,
|
||||
loadPipeDiameters,
|
||||
type PipeDiameterMap,
|
||||
} from "./schemePipeDiameters";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: SchemeRecord[];
|
||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||
onLocate?: (scheme: SchemeRecord) => void;
|
||||
onViewReport?: (scheme: SchemeRecord) => void;
|
||||
network?: string;
|
||||
state?: BurstSchemeQueryState;
|
||||
onStateChange?: (state: BurstSchemeQueryState) => void;
|
||||
@@ -74,6 +79,7 @@ export interface BurstSchemeQueryState {
|
||||
selectedDate: Date | undefined;
|
||||
timeRange: { start: Date; end: Date } | undefined;
|
||||
expandedId: number | null;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
|
||||
@@ -83,12 +89,13 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
|
||||
selectedDate: undefined,
|
||||
timeRange: undefined,
|
||||
expandedId: null,
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
schemes: externalSchemes,
|
||||
onSchemesChange,
|
||||
onLocate,
|
||||
onViewReport,
|
||||
network = NETWORK_NAME,
|
||||
state,
|
||||
onStateChange,
|
||||
@@ -105,6 +112,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
selectedDate,
|
||||
timeRange,
|
||||
expandedId,
|
||||
hasQueried,
|
||||
} = queryState;
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
useState<VectorLayer<VectorSource> | null>(null);
|
||||
@@ -112,6 +120,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const creatorName = useSchemeCreatorName();
|
||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
||||
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
||||
Record<number, PipeDiameterMap>
|
||||
@@ -152,9 +161,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
||||
);
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: SCHEME_TYPE,
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
params.query_date = queryDate.startOf("day").toISOString();
|
||||
}
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params,
|
||||
});
|
||||
let filteredResults = response.data;
|
||||
|
||||
if (!queryAll) {
|
||||
@@ -169,16 +184,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
id: item.scheme_id,
|
||||
schemeName: item.scheme_name,
|
||||
type: item.scheme_type,
|
||||
user: item.username,
|
||||
username: item.username,
|
||||
create_time: item.create_time,
|
||||
startTime: item.scheme_start_time,
|
||||
schemeDetail: item.scheme_detail,
|
||||
}));
|
||||
setSchemes(nextSchemes);
|
||||
setQueryField("hasQueried", true);
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
open?.({
|
||||
type: "error",
|
||||
type: "success",
|
||||
message: "查询结果",
|
||||
description: queryAll
|
||||
? "没有找到任何方案"
|
||||
@@ -236,40 +252,23 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||
if (cachedDiameters) {
|
||||
setPipeDiametersByScheme((previous) => ({
|
||||
...previous,
|
||||
[expandedId]: cachedDiameters,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingDiameterByScheme((previous) => ({
|
||||
...previous,
|
||||
[expandedId]: true,
|
||||
}));
|
||||
|
||||
const loadPipeDiameters = async () => {
|
||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
||||
const foundPipeIds = new Set(
|
||||
features.map((feature) => String(feature.getProperties().id)),
|
||||
);
|
||||
const missingPipeIds = pipeIds.filter(
|
||||
(pipeId) => !foundPipeIds.has(pipeId),
|
||||
);
|
||||
|
||||
if (missingPipeIds.length > 0) {
|
||||
const fallbackFeatures = await queryFeaturesByIds(
|
||||
missingPipeIds,
|
||||
"geo_pipes",
|
||||
);
|
||||
features = [...features, ...fallbackFeatures];
|
||||
}
|
||||
|
||||
const nextDiameters: PipeDiameterMap = Object.fromEntries(
|
||||
pipeIds.map((pipeId) => [pipeId, null]),
|
||||
);
|
||||
|
||||
features.forEach((feature) => {
|
||||
const properties = feature.getProperties();
|
||||
const pipeId = String(properties.id);
|
||||
const diameter = Number(properties.diameter);
|
||||
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
||||
});
|
||||
|
||||
const applyPipeDiameters = async () => {
|
||||
const nextDiameters = await loadPipeDiameters(pipeIds);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
}));
|
||||
};
|
||||
|
||||
loadPipeDiameters().catch((error) => {
|
||||
applyPipeDiameters().catch((error) => {
|
||||
console.error("查询管径失败:", error);
|
||||
if (!cancelled) {
|
||||
setLoadingDiameterByScheme((previous) => ({
|
||||
@@ -452,7 +451,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
control={
|
||||
<Checkbox
|
||||
checked={queryAll}
|
||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setQueryField("queryAll", e.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
@@ -465,9 +467,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
>
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value)) {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
disabled={queryAll}
|
||||
slotProps={{
|
||||
@@ -495,39 +500,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
{/* 结果列表 */}
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{filteredSchemes.length === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">总共 0 条</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
No data
|
||||
</Typography>
|
||||
</Box>
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
@@ -592,14 +565,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="查看定位结果">
|
||||
<Tooltip title="查看分析报告">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onLocate?.(scheme)}
|
||||
onClick={() => onViewReport?.(scheme)}
|
||||
color="primary"
|
||||
className="p-1"
|
||||
>
|
||||
<LocationIcon fontSize="small" />
|
||||
<ReportIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
@@ -715,7 +688,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="font-medium text-gray-900"
|
||||
>
|
||||
{scheme.user}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-2">
|
||||
|
||||
@@ -52,9 +52,16 @@ import {
|
||||
import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import {
|
||||
getAffectedNodeCount,
|
||||
isAllowedAccidentPipe,
|
||||
normalizeValveIsolationResult,
|
||||
} from "./valveIsolationScope";
|
||||
|
||||
interface ValveIsolationProps {
|
||||
initialPipeIds?: string[];
|
||||
allowedPipeIds?: string[];
|
||||
sourceSchemeName?: string;
|
||||
shouldFetch?: boolean;
|
||||
onFetchComplete?: () => void;
|
||||
loading?: boolean;
|
||||
@@ -81,6 +88,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({
|
||||
|
||||
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
initialPipeIds = [],
|
||||
allowedPipeIds,
|
||||
sourceSchemeName,
|
||||
shouldFetch = false,
|
||||
onFetchComplete,
|
||||
loading: externalLoading,
|
||||
@@ -160,14 +169,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPipeId(pipeId);
|
||||
const normalizedPipeId = String(pipeId);
|
||||
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请选择当前爆管方案中的事故管段",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPipeId(normalizedPipeId);
|
||||
setHighlightFeature(feature);
|
||||
setIsSelecting(false);
|
||||
setResult(null); // 清除旧结果
|
||||
}
|
||||
}
|
||||
},
|
||||
[isSelecting, map, open, setResult, setSelectedPipeId],
|
||||
[
|
||||
allowedPipeIds,
|
||||
isSelecting,
|
||||
map,
|
||||
open,
|
||||
setResult,
|
||||
setSelectedPipeId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -207,6 +232,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
"must_close" | "optional" | "affected_node" | "pipe"
|
||||
>("affected_node");
|
||||
|
||||
const selectSchemeAccidentPipe = (pipeId: string) => {
|
||||
if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return;
|
||||
setSelectedPipeId(pipeId);
|
||||
setHighlightFeature(null);
|
||||
setHighlightFeatures([]);
|
||||
setResult(null);
|
||||
setActiveStep(0);
|
||||
setExpandedResult(true);
|
||||
setDisabledValves([]);
|
||||
};
|
||||
|
||||
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
||||
if (pipeIds.length > 0) {
|
||||
@@ -311,6 +346,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
|
||||
return;
|
||||
}
|
||||
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "关阀分析仅限当前爆管方案中的事故管段",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const isExpandSearch = disabled.length > 0;
|
||||
if (!isExpandSearch) {
|
||||
@@ -319,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
}
|
||||
try {
|
||||
const params: any = {
|
||||
network: NETWORK_NAME,
|
||||
accident_element: ids,
|
||||
};
|
||||
if (disabled.length > 0) {
|
||||
params.disabled_valves = disabled;
|
||||
}
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
||||
const response = await api.post<ValveIsolationResult>(
|
||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
|
||||
undefined,
|
||||
{
|
||||
params,
|
||||
paramsSerializer: {
|
||||
@@ -334,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
},
|
||||
},
|
||||
);
|
||||
setResult(response.data);
|
||||
setResult(normalizeValveIsolationResult(response.data));
|
||||
if (!isExpandSearch) {
|
||||
setActiveStep(1);
|
||||
} else {
|
||||
@@ -352,7 +394,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[open, setActiveStep, setDisabledValves, setLoading, setResult],
|
||||
[
|
||||
allowedPipeIds,
|
||||
open,
|
||||
setActiveStep,
|
||||
setDisabledValves,
|
||||
setLoading,
|
||||
setResult,
|
||||
],
|
||||
);
|
||||
|
||||
// 监听外部传入的分析请求
|
||||
@@ -666,7 +715,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
{[
|
||||
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
|
||||
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
|
||||
{ label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
|
||||
{ label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
|
||||
].map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
@@ -832,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!result.isolatable && (
|
||||
<Alert severity="info" variant="outlined">
|
||||
不可隔离,未生成受影响节点清单。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 受影响节点 */}
|
||||
{result.affected_nodes && result.affected_nodes.length > 0 && (
|
||||
{result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && (
|
||||
<Box>
|
||||
<Box className="flex items-center justify-between mb-2">
|
||||
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
|
||||
@@ -912,6 +967,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
</StepLabel>
|
||||
<StepContent>
|
||||
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
||||
{allowedPipeIds !== undefined ? (
|
||||
<Alert severity="info" variant="outlined">
|
||||
已绑定爆管方案“{sourceSchemeName || "未命名方案"}”。关阀分析仅限该方案中的事故管段,分析结果将写入对应报告。
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="warning" variant="outlined">
|
||||
当前为独立关阀分析,结果不会自动写入爆管分析报告。请先从“方案查询”打开对应分析报告以建立关联。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 选择管段 */}
|
||||
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
|
||||
<Box className="flex items-center justify-between mb-2">
|
||||
@@ -955,6 +1020,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{allowedPipeIds !== undefined && (
|
||||
<Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2">
|
||||
<Box className="mb-2 flex flex-wrap gap-1.5">
|
||||
{allowedPipeIds.map((pipeId) => (
|
||||
<Chip
|
||||
key={pipeId}
|
||||
label={pipeId}
|
||||
size="small"
|
||||
color={selectedPipeId === pipeId ? "primary" : "default"}
|
||||
variant={selectedPipeId === pipeId ? "filled" : "outlined"}
|
||||
onClick={() => selectSchemeAccidentPipe(pipeId)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={allowedPipeIds.length === 0}
|
||||
onClick={() =>
|
||||
allowedPipeIds[0] &&
|
||||
selectSchemeAccidentPipe(allowedPipeIds[0])
|
||||
}
|
||||
startIcon={<CheckCircleIcon />}
|
||||
>
|
||||
选择当前爆管管段
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isSelecting && (
|
||||
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||||
💡 点击地图上的管道添加爆管点
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import {
|
||||
clearPipeDiameterCache,
|
||||
getPipeDiameterDisplay,
|
||||
loadPipeDiameters,
|
||||
} from "./schemePipeDiameters";
|
||||
|
||||
jest.mock("@/utils/mapQueryService", () => ({
|
||||
queryFeaturesByIds: jest.fn(),
|
||||
}));
|
||||
|
||||
const feature = (id: string, diameter: number) => ({
|
||||
getProperties: () => ({ id, diameter }),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearPipeDiameterCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("loadPipeDiameters", () => {
|
||||
it("shares an in-flight WFS request and reuses its resolved result", async () => {
|
||||
let resolveFeatures: ((features: ReturnType<typeof feature>[]) => void) | undefined;
|
||||
const pendingFeatures = new Promise<ReturnType<typeof feature>[]>((resolve) => {
|
||||
resolveFeatures = resolve;
|
||||
});
|
||||
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
||||
async () => pendingFeatures,
|
||||
);
|
||||
|
||||
const firstRequest = loadPipeDiameters(["P-1"]);
|
||||
const duplicateRequest = loadPipeDiameters(["P-1"]);
|
||||
|
||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFeatures?.([feature("P-1", 315)]);
|
||||
await expect(firstRequest).resolves.toEqual({ "P-1": 315 });
|
||||
await expect(duplicateRequest).resolves.toEqual({ "P-1": 315 });
|
||||
|
||||
await expect(loadPipeDiameters(["P-1"])).resolves.toEqual({
|
||||
"P-1": 315,
|
||||
});
|
||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPipeDiameterDisplay", () => {
|
||||
it("shows the actual diameter for one burst pipe", () => {
|
||||
|
||||
@@ -1,5 +1,94 @@
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import config from "@config/config";
|
||||
|
||||
export type PipeDiameterMap = Record<string, number | null | undefined>;
|
||||
|
||||
const diameterCache = new Map<string, PipeDiameterMap>();
|
||||
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
|
||||
|
||||
const normalizePipeIds = (pipeIds: string[]) =>
|
||||
[...new Set(pipeIds.map((pipeId) => String(pipeId).trim()).filter(Boolean))];
|
||||
|
||||
export const getPipeDiameterQueryKey = (pipeIds: string[]) =>
|
||||
[
|
||||
config.MAP_URL,
|
||||
config.MAP_WORKSPACE,
|
||||
...normalizePipeIds(pipeIds).sort(),
|
||||
].join("\u0000");
|
||||
|
||||
export const getCachedPipeDiameters = (pipeIds: string[]) =>
|
||||
diameterCache.get(getPipeDiameterQueryKey(pipeIds));
|
||||
|
||||
export const clearPipeDiameterCache = () => {
|
||||
diameterCache.clear();
|
||||
diameterRequestCache.clear();
|
||||
};
|
||||
|
||||
export const loadPipeDiameters = async (
|
||||
pipeIds: string[],
|
||||
): Promise<PipeDiameterMap> => {
|
||||
const normalizedPipeIds = normalizePipeIds(pipeIds);
|
||||
if (normalizedPipeIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const queryKey = getPipeDiameterQueryKey(normalizedPipeIds);
|
||||
const cachedDiameters = diameterCache.get(queryKey);
|
||||
if (cachedDiameters) {
|
||||
return cachedDiameters;
|
||||
}
|
||||
|
||||
const cachedRequest = diameterRequestCache.get(queryKey);
|
||||
if (cachedRequest) {
|
||||
return cachedRequest;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
let features = await queryFeaturesByIds(
|
||||
normalizedPipeIds,
|
||||
"geo_pipes_mat",
|
||||
);
|
||||
const foundPipeIds = new Set(
|
||||
features.map((feature) => String(feature.getProperties().id)),
|
||||
);
|
||||
const missingPipeIds = normalizedPipeIds.filter(
|
||||
(pipeId) => !foundPipeIds.has(pipeId),
|
||||
);
|
||||
|
||||
if (missingPipeIds.length > 0) {
|
||||
const fallbackFeatures = await queryFeaturesByIds(
|
||||
missingPipeIds,
|
||||
"geo_pipes",
|
||||
);
|
||||
features = [...features, ...fallbackFeatures];
|
||||
}
|
||||
|
||||
const diameters: PipeDiameterMap = Object.fromEntries(
|
||||
normalizedPipeIds.map((pipeId) => [pipeId, null]),
|
||||
);
|
||||
features.forEach((feature) => {
|
||||
const properties = feature.getProperties();
|
||||
const pipeId = String(properties.id);
|
||||
const diameter = Number(properties.diameter);
|
||||
if (pipeId in diameters) {
|
||||
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
||||
}
|
||||
});
|
||||
|
||||
diameterCache.set(queryKey, diameters);
|
||||
return diameters;
|
||||
})();
|
||||
|
||||
diameterRequestCache.set(queryKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (diameterRequestCache.get(queryKey) === request) {
|
||||
diameterRequestCache.delete(queryKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getPipeDiameterDisplay = (
|
||||
pipeIds: string[] | undefined,
|
||||
diameters: PipeDiameterMap | undefined,
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface SchemeRecord {
|
||||
id: number;
|
||||
schemeName: string;
|
||||
type: string;
|
||||
user: string;
|
||||
username: string;
|
||||
create_time: string;
|
||||
startTime: string;
|
||||
// 详情信息
|
||||
@@ -28,18 +28,10 @@ export interface SchemaItem {
|
||||
scheme_detail?: SchemeDetail;
|
||||
}
|
||||
|
||||
export interface LocationResult {
|
||||
id: number;
|
||||
type: string;
|
||||
burst_incident: string;
|
||||
leakage: number | null;
|
||||
detect_time: string;
|
||||
locate_result: string[] | null;
|
||||
}
|
||||
|
||||
export interface ValveIsolationResult {
|
||||
accident_elements: string[];
|
||||
affected_nodes: string[];
|
||||
affected_node_count?: number;
|
||||
must_close_valves: string[];
|
||||
optional_valves: string[];
|
||||
isolatable: boolean;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ValveIsolationResult } from "./types";
|
||||
|
||||
export const isAllowedAccidentPipe = (
|
||||
pipeId: string,
|
||||
allowedPipeIds: string[] | undefined,
|
||||
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);
|
||||
|
||||
export const getAffectedNodeCount = (result: ValveIsolationResult) =>
|
||||
result.affected_node_count ?? result.affected_nodes?.length ?? 0;
|
||||
|
||||
export const normalizeValveIsolationResult = (
|
||||
result: ValveIsolationResult,
|
||||
): ValveIsolationResult => ({
|
||||
...result,
|
||||
affected_nodes: result.isolatable ? result.affected_nodes ?? [] : [],
|
||||
affected_node_count: getAffectedNodeCount(result),
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
queryFeaturesByIds,
|
||||
} from "@/utils/mapQueryService";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
|
||||
export interface ContaminantAnalysisParametersState {
|
||||
schemeName: string;
|
||||
@@ -62,7 +63,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 +76,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);
|
||||
|
||||
@@ -228,7 +235,6 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
console.log("默认设置 pattern 为 CONSTANT");
|
||||
}
|
||||
const params = {
|
||||
network,
|
||||
start_time: start_time,
|
||||
source: sourceNode,
|
||||
concentration,
|
||||
@@ -237,7 +243,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
scheme_name: schemeName,
|
||||
};
|
||||
|
||||
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, {
|
||||
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
|
||||
params,
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ import Feature from "ol/Feature";
|
||||
import { bbox, featureCollection } from "@turf/turf";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: ContaminantSchemeRecord[];
|
||||
@@ -59,6 +61,7 @@ export interface ContaminantSchemeQueryState {
|
||||
selectedDate: Date | undefined;
|
||||
timeRange: { start: Date; end: Date } | undefined;
|
||||
expandedId: number | null;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createContaminantSchemeQueryState =
|
||||
@@ -69,6 +72,7 @@ export const createContaminantSchemeQueryState =
|
||||
selectedDate: undefined,
|
||||
timeRange: undefined,
|
||||
expandedId: null,
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
@@ -91,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
selectedDate,
|
||||
timeRange,
|
||||
expandedId,
|
||||
hasQueried,
|
||||
} = queryState;
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
useState<VectorLayer<VectorSource> | null>(null);
|
||||
@@ -101,6 +106,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
|
||||
const creatorName = useSchemeCreatorName();
|
||||
|
||||
const { open } = useNotification();
|
||||
const map = useMap();
|
||||
@@ -216,9 +222,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
if (!queryAll && !queryDate) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
||||
);
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: SCHEME_TYPE,
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
params.query_date = queryDate.startOf("day").toISOString();
|
||||
}
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params,
|
||||
});
|
||||
let filteredResults = response.data;
|
||||
|
||||
if (!queryAll) {
|
||||
@@ -235,16 +247,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
id: item.scheme_id,
|
||||
schemeName: item.scheme_name,
|
||||
type: item.scheme_type,
|
||||
user: item.username,
|
||||
username: item.username,
|
||||
create_time: item.create_time,
|
||||
startTime: item.scheme_start_time,
|
||||
schemeDetail: item.scheme_detail,
|
||||
}));
|
||||
setSchemes(nextSchemes);
|
||||
setQueryField("hasQueried", true);
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
open?.({
|
||||
type: "error",
|
||||
type: "success",
|
||||
message: "查询结果",
|
||||
description: queryAll
|
||||
? "没有找到任何方案"
|
||||
@@ -342,7 +355,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
control={
|
||||
<Checkbox
|
||||
checked={queryAll}
|
||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setQueryField("queryAll", e.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
@@ -355,9 +371,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
>
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value)) {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
disabled={queryAll}
|
||||
slotProps={{
|
||||
@@ -384,39 +403,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{filteredSchemes.length === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">总共 0 条</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
No data
|
||||
</Typography>
|
||||
</Box>
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
@@ -591,7 +578,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="font-medium text-gray-900"
|
||||
>
|
||||
{scheme.user}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-2">
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ContaminantSchemeRecord {
|
||||
id: number;
|
||||
schemeName: string;
|
||||
type: string;
|
||||
user: string;
|
||||
username: string;
|
||||
create_time: string;
|
||||
startTime: string;
|
||||
schemeDetail?: ContaminantSchemeDetail;
|
||||
|
||||
@@ -18,8 +18,9 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
import { config } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { LeakageResultDetail } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||
|
||||
@@ -58,7 +59,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const [parametersState, , setFormField] = useControllableObjectState(
|
||||
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createDMALeakAnalysisParametersState(),
|
||||
@@ -73,6 +74,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));
|
||||
|
||||
@@ -82,7 +90,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
|
||||
const parsedQSum = Number(qSumInput);
|
||||
const qSumIsValid =
|
||||
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 360;
|
||||
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0;
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!schemeName.trim() || !startTime || !endTime) return false;
|
||||
@@ -94,7 +102,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请完善参数并确认时间范围合法",
|
||||
description: !qSumIsValid ? `总漏损流量需不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined,
|
||||
description: !qSumIsValid ? `总漏损流量不能小于 0 ${FLOW_DISPLAY_UNIT}` : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -108,9 +116,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
});
|
||||
try {
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/leakage/identify/`,
|
||||
`${config.BACKEND_URL}/api/v1/leakage-identifications`,
|
||||
{
|
||||
network: NETWORK_NAME,
|
||||
scheme_name: schemeName.trim(),
|
||||
dma_count: dmaCount,
|
||||
scada_start: startTime.toISOString(),
|
||||
@@ -235,11 +242,11 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
setFormField("qSum", value);
|
||||
}
|
||||
}}
|
||||
inputProps={{ min: 360, step: 10 }}
|
||||
inputProps={{ min: 0, step: 10 }}
|
||||
error={qSumInput.trim() !== "" && !qSumIsValid}
|
||||
helperText={
|
||||
qSumInput.trim() !== "" && !qSumIsValid
|
||||
? `需不小于 360 ${FLOW_DISPLAY_UNIT}`
|
||||
? `不能小于 0 ${FLOW_DISPLAY_UNIT}`
|
||||
: " "
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
import { FormatListBulleted } from "@mui/icons-material";
|
||||
import {
|
||||
FormatListBulleted,
|
||||
TroubleshootOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import dayjs from "dayjs";
|
||||
import { getAreaColor } from "./utils";
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import { LeakageResultDetail } from "./types";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
result: LeakageResultDetail | null;
|
||||
@@ -31,39 +35,11 @@ const RecognitionResults: React.FC<Props> = ({ result }) => {
|
||||
|
||||
if (!result || !sortedRows.length) {
|
||||
return (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">暂无识别结果</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
请先加载方案或执行识别分析
|
||||
</Typography>
|
||||
</Box>
|
||||
<PanelEmptyState
|
||||
icon={<TroubleshootOutlined />}
|
||||
title="尚未生成识别结果"
|
||||
description="请在“识别参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import { NETWORK_NAME, config } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
onViewResult: (result: LeakageResultDetail) => void;
|
||||
@@ -39,12 +41,14 @@ export interface DMALeakSchemeQueryState {
|
||||
queryAll: boolean;
|
||||
queryDate: Dayjs | null;
|
||||
expandedId: number | null;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({
|
||||
queryAll: true,
|
||||
queryDate: dayjs(),
|
||||
expandedId: null,
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({
|
||||
@@ -60,9 +64,10 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
onStateChange,
|
||||
createDMALeakSchemeQueryState(),
|
||||
);
|
||||
const { queryAll, queryDate, expandedId } = queryState;
|
||||
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const creatorName = useSchemeCreatorName();
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||
const sortedSchemes = useMemo(
|
||||
@@ -79,18 +84,21 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const handleQuery = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: "dma_leak_identification",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
params.query_date = queryDate.startOf("day").toISOString();
|
||||
}
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, {
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params,
|
||||
});
|
||||
const nextSchemes = response.data as LeakageSchemeRecord[];
|
||||
setSchemes(nextSchemes);
|
||||
setQueryField("hasQueried", true);
|
||||
if (nextSchemes.length === 0) {
|
||||
open?.({
|
||||
type: "error",
|
||||
type: "success",
|
||||
message: "查询结果",
|
||||
description: queryAll
|
||||
? "没有找到任何方案"
|
||||
@@ -117,8 +125,12 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const handleViewSchemeResult = async (schemeName: string) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/leakage/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { network: NETWORK_NAME } },
|
||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{
|
||||
params: {
|
||||
scheme_type: "dma_leak_identification",
|
||||
},
|
||||
},
|
||||
);
|
||||
onViewResult(response.data as LeakageResultDetail);
|
||||
} catch (error: any) {
|
||||
@@ -140,7 +152,10 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={queryAll}
|
||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setQueryField("queryAll", e.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">查询全部</Typography>}
|
||||
@@ -149,7 +164,10 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={(value) => setQueryField("queryDate", value)}
|
||||
onChange={(value) => {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
disabled={queryAll}
|
||||
format="YYYY-MM-DD"
|
||||
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
|
||||
@@ -170,39 +188,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
</Box>
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{sortedSchemes.length === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">总共 0 条</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
No data
|
||||
</Typography>
|
||||
</Box>
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
@@ -257,7 +243,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
用户:
|
||||
</Typography>
|
||||
<Typography variant="caption" className="font-medium text-gray-900">
|
||||
{scheme.username || "-"}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
Alert,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { Close as CloseIcon } from "@mui/icons-material";
|
||||
import {
|
||||
AdjustOutlined,
|
||||
Close as CloseIcon,
|
||||
WaterDropOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||
@@ -31,6 +35,8 @@ 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 PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
export interface ValveItem {
|
||||
id: string;
|
||||
@@ -75,6 +81,12 @@ 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);
|
||||
|
||||
@@ -302,11 +314,10 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
|
||||
const params = {
|
||||
scheme_name: schemeName,
|
||||
network: NETWORK_NAME,
|
||||
start_time: formattedTime,
|
||||
valves: valves.map(v => v.id),
|
||||
valves_k: valves.map(v => v.k),
|
||||
drainage_node_ID: drainageNode,
|
||||
drainage_node_id: drainageNode,
|
||||
flush_flow: flushFlow,
|
||||
duration: duration
|
||||
};
|
||||
@@ -315,7 +326,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
// but axios usually handles array as valves[]=1&valves[]=2
|
||||
// FastAPI default expects repeated query params.
|
||||
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, {
|
||||
const response = await api.post(`${config.BACKEND_URL}/api/v1/flushing-analyses`, undefined, {
|
||||
params,
|
||||
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
||||
paramsSerializer: {
|
||||
@@ -383,9 +394,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
</Box>
|
||||
))}
|
||||
{valves.length === 0 && (
|
||||
<Typography variant="caption" className="text-gray-400 text-center py-20">
|
||||
暂无选中阀门
|
||||
</Typography>
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
icon={<AdjustOutlined />}
|
||||
title="尚未选择阀门"
|
||||
description="点击“选择阀门”,然后在地图上添加。"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -412,7 +426,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
💡 点击地图上的节点作为排水点
|
||||
</Box>
|
||||
)}
|
||||
<Stack spacing={1} className="h-12 overflow-auto">
|
||||
<Stack spacing={1} className="min-h-16 overflow-auto">
|
||||
{drainageNode && (
|
||||
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||
<Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography>
|
||||
@@ -428,9 +442,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
{!drainageNode && (
|
||||
<Typography variant="caption" className="text-gray-400 text-center py-2">
|
||||
暂无选中排水节点
|
||||
</Typography>
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
icon={<WaterDropOutlined />}
|
||||
title="尚未选择排水节点"
|
||||
description="点击“选择节点”,然后在地图上指定排水点。"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -42,6 +42,8 @@ import { bbox, featureCollection } from "@turf/turf";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
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";
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: SchemeRecord[];
|
||||
@@ -60,6 +62,7 @@ export interface FlushingSchemeQueryState {
|
||||
showTimeline: boolean;
|
||||
selectedDate: Date | undefined;
|
||||
timeRange: { start: Date; end: Date } | undefined;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createFlushingSchemeQueryState =
|
||||
@@ -70,6 +73,7 @@ export const createFlushingSchemeQueryState =
|
||||
showTimeline: false,
|
||||
selectedDate: undefined,
|
||||
timeRange: undefined,
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
@@ -91,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
showTimeline,
|
||||
selectedDate,
|
||||
timeRange,
|
||||
hasQueried,
|
||||
} = queryState;
|
||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@@ -100,6 +105,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
|
||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
|
||||
const creatorName = useSchemeCreatorName();
|
||||
|
||||
const { open } = useNotification();
|
||||
const map = useMap();
|
||||
@@ -262,9 +268,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
||||
);
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: SCHEME_TYPE,
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
params.query_date = queryDate.startOf("day").toISOString();
|
||||
}
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params,
|
||||
});
|
||||
|
||||
let filteredResults = response.data;
|
||||
|
||||
@@ -283,16 +295,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
id: item.scheme_id,
|
||||
schemeName: item.scheme_name,
|
||||
type: item.scheme_type,
|
||||
user: item.username,
|
||||
username: item.username,
|
||||
create_time: item.create_time,
|
||||
startTime: item.scheme_start_time,
|
||||
schemeDetail: item.scheme_detail,
|
||||
}));
|
||||
setSchemes(nextSchemes);
|
||||
setQueryField("hasQueried", true);
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
open?.({
|
||||
type: "error",
|
||||
type: "success",
|
||||
message: "未找到相关方案",
|
||||
description: "请尝试更改查询条件",
|
||||
});
|
||||
@@ -362,7 +375,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
control={
|
||||
<Checkbox
|
||||
checked={queryAll}
|
||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setQueryField("queryAll", e.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
@@ -375,9 +391,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
>
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value)) {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
disabled={queryAll}
|
||||
slotProps={{
|
||||
@@ -405,39 +424,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
{/* Results List */}
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{sortedSchemes.length === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">总共 0 条</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
No data
|
||||
</Typography>
|
||||
</Box>
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
@@ -472,7 +459,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="text-gray-500 block"
|
||||
>
|
||||
用户: {scheme.user} · 时间: {formatTime(scheme.create_time)}
|
||||
用户: {creatorName(scheme.username)} · 时间:{" "}
|
||||
{formatTime(scheme.create_time)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex gap-1 ml-2">
|
||||
@@ -572,7 +560,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
用户:
|
||||
</Typography>
|
||||
<Typography variant="caption" className="font-medium text-gray-900">
|
||||
{scheme.user}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface SchemeRecord {
|
||||
id: number;
|
||||
schemeName: string;
|
||||
type: string;
|
||||
user: string;
|
||||
username: string;
|
||||
create_time: string;
|
||||
startTime: string;
|
||||
// 详情信息
|
||||
|
||||
@@ -8,6 +8,8 @@ interface HealthRiskContextType {
|
||||
setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>;
|
||||
currentYear: number;
|
||||
setCurrentYear: Dispatch<SetStateAction<number>>;
|
||||
analysisQueryTime: string | null;
|
||||
setAnalysisQueryTime: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined);
|
||||
@@ -15,6 +17,7 @@ const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undef
|
||||
export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]);
|
||||
const [currentYear, setCurrentYear] = useState<number>(4);
|
||||
const [analysisQueryTime, setAnalysisQueryTime] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<HealthRiskContext.Provider
|
||||
@@ -23,6 +26,8 @@ export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
setPredictionResults,
|
||||
currentYear,
|
||||
setCurrentYear,
|
||||
analysisQueryTime,
|
||||
setAnalysisQueryTime,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import HealthRiskReport from "./HealthRiskReport";
|
||||
import { PredictionResult } from "./types";
|
||||
|
||||
jest.mock("echarts-for-react", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="report-chart" />,
|
||||
}));
|
||||
|
||||
const predictionResults: PredictionResult[] = [
|
||||
{
|
||||
link_id: "PIPE-007",
|
||||
survival_function: {
|
||||
x: [4, 5],
|
||||
y: [0.2, 0.15],
|
||||
a: 0,
|
||||
b: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
link_id: "PIPE-021",
|
||||
survival_function: {
|
||||
x: [4, 5],
|
||||
y: [0.8, 0.7],
|
||||
a: 0,
|
||||
b: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("HealthRiskReport", () => {
|
||||
it("renders automatic report identity and priority results", () => {
|
||||
render(
|
||||
<HealthRiskReport
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
predictionResults={predictionResults}
|
||||
currentYear={4}
|
||||
analysisQueryTime="2026-07-30T01:02:03.000Z"
|
||||
networkName="fengyang"
|
||||
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "管网健康风险体检报告" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("fengyang")).toBeInTheDocument();
|
||||
expect(screen.getByText("第 4 年")).toBeInTheDocument();
|
||||
expect(screen.getByText("PIPE-007")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("report-chart")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prints the report and restores document state after printing", () => {
|
||||
const originalTitle = document.title;
|
||||
const print = jest
|
||||
.spyOn(window, "print")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<HealthRiskReport
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
predictionResults={predictionResults}
|
||||
currentYear={4}
|
||||
analysisQueryTime="2026-07-30T01:02:03.000Z"
|
||||
networkName="fengyang"
|
||||
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "打印" }));
|
||||
|
||||
expect(print).toHaveBeenCalledTimes(1);
|
||||
expect(document.body).toHaveClass("health-risk-report-printing");
|
||||
expect(document.title).toContain("管网健康风险体检报告-fengyang");
|
||||
|
||||
window.dispatchEvent(new Event("afterprint"));
|
||||
|
||||
expect(document.body).not.toHaveClass("health-risk-report-printing");
|
||||
expect(document.title).toBe(originalTitle);
|
||||
print.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
GlobalStyles,
|
||||
IconButton,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Close,
|
||||
PrintOutlined,
|
||||
WaterDropOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { PredictionResult } from "./types";
|
||||
import { buildHealthRiskReportData } from "./healthRiskReport";
|
||||
|
||||
interface HealthRiskReportProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
predictionResults: PredictionResult[];
|
||||
currentYear: number;
|
||||
analysisQueryTime: string | null;
|
||||
networkName: string;
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
const REPORT_BLUE = "#0b4f87";
|
||||
const REPORT_INK = "#172435";
|
||||
const REPORT_MUTED = "#5f6f80";
|
||||
const REPORT_LINE = "#d8e0e8";
|
||||
|
||||
const reportFontFamily =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||
|
||||
const formatDateTime = (value: Date | string | null) => {
|
||||
if (!value) return "未记录";
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "未记录";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatPercent = (value: number | null, fractionDigits = 1) =>
|
||||
value === null ? "无有效数据" : `${(value * 100).toFixed(fractionDigits)}%`;
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1.25}
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }}
|
||||
/>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{
|
||||
color: REPORT_INK,
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const HealthRiskReport: React.FC<HealthRiskReportProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
predictionResults,
|
||||
currentYear,
|
||||
analysisQueryTime,
|
||||
networkName,
|
||||
generatedAt,
|
||||
}) => {
|
||||
const printStateRef = useRef<{ title: string } | null>(null);
|
||||
const reportData = useMemo(
|
||||
() => buildHealthRiskReportData(predictionResults, currentYear),
|
||||
[currentYear, predictionResults],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (printStateRef.current) {
|
||||
document.title = printStateRef.current.title;
|
||||
document.body.classList.remove("health-risk-report-printing");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const restoreAfterPrint = () => {
|
||||
if (!printStateRef.current) return;
|
||||
document.title = printStateRef.current.title;
|
||||
printStateRef.current = null;
|
||||
document.body.classList.remove("health-risk-report-printing");
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
if (printStateRef.current) return;
|
||||
printStateRef.current = { title: document.title };
|
||||
document.title = `管网健康风险体检报告-${networkName || "未命名管网"}-${new Intl.DateTimeFormat(
|
||||
"zh-CN",
|
||||
{ year: "numeric", month: "2-digit", day: "2-digit" },
|
||||
)
|
||||
.format(generatedAt)
|
||||
.replaceAll("/", "-")}`;
|
||||
document.body.classList.add("health-risk-report-printing");
|
||||
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
|
||||
window.print();
|
||||
};
|
||||
|
||||
const distributionOption = {
|
||||
animation: false,
|
||||
grid: { top: 4, right: 38, bottom: 28, left: 148 },
|
||||
xAxis: {
|
||||
type: "value",
|
||||
name: "管段数",
|
||||
minInterval: 1,
|
||||
axisLine: { lineStyle: { color: "#8b99a8" } },
|
||||
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: "#e7edf2" } },
|
||||
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "category",
|
||||
inverse: true,
|
||||
data: reportData.distribution.map((item) => item.label),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: REPORT_INK, fontSize: 10 },
|
||||
},
|
||||
tooltip: { show: false },
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
barMaxWidth: 14,
|
||||
data: reportData.distribution.map((item) => ({
|
||||
value: item.count,
|
||||
itemStyle: { color: item.color },
|
||||
})),
|
||||
label: {
|
||||
show: true,
|
||||
position: "right",
|
||||
color: REPORT_INK,
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const trendOption = {
|
||||
animation: false,
|
||||
grid: { top: 24, right: 24, bottom: 38, left: 48 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
name: "预测年份",
|
||||
boundaryGap: false,
|
||||
data: reportData.trend.map((item) => item.year),
|
||||
axisLine: { lineStyle: { color: "#8b99a8" } },
|
||||
axisLabel: {
|
||||
color: REPORT_MUTED,
|
||||
fontSize: 10,
|
||||
interval: Math.max(0, Math.ceil(reportData.trend.length / 9) - 1),
|
||||
},
|
||||
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "较高及以上风险管段数",
|
||||
minInterval: 1,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: "#e7edf2" } },
|
||||
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
|
||||
},
|
||||
tooltip: { show: false },
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "none",
|
||||
lineStyle: { color: "#c83d3d", width: 2.5 },
|
||||
areaStyle: { color: "rgba(200, 61, 61, 0.08)" },
|
||||
data: reportData.trend.map((item) => item.count),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const metricItems = [
|
||||
{ label: "分析管段", value: reportData.totalCount.toLocaleString("zh-CN") },
|
||||
{ label: "有效结果", value: reportData.validCount.toLocaleString("zh-CN") },
|
||||
{
|
||||
label: "平均生存概率",
|
||||
value: formatPercent(reportData.averageProbability),
|
||||
},
|
||||
{
|
||||
label: "较高及以上风险",
|
||||
value: `${reportData.highRiskCount.toLocaleString("zh-CN")} 条`,
|
||||
detail: formatPercent(reportData.highRiskRatio),
|
||||
danger: reportData.highRiskCount > 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
"@page": { size: "A4 portrait", margin: 0 },
|
||||
"@media print": {
|
||||
"html, body": {
|
||||
width: "210mm",
|
||||
minHeight: "297mm",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"body.health-risk-report-printing > *:not(.health-risk-report-dialog)":
|
||||
{
|
||||
display: "none !important",
|
||||
},
|
||||
".health-risk-report-dialog": {
|
||||
position: "static !important",
|
||||
display: "block !important",
|
||||
},
|
||||
".health-risk-report-dialog .MuiBackdrop-root, .health-risk-report-actions":
|
||||
{
|
||||
display: "none !important",
|
||||
},
|
||||
".health-risk-report-dialog .MuiDialog-container": {
|
||||
display: "block !important",
|
||||
height: "auto !important",
|
||||
},
|
||||
".health-risk-report-dialog .MuiDialog-paper": {
|
||||
width: "210mm !important",
|
||||
maxWidth: "none !important",
|
||||
minHeight: "297mm !important",
|
||||
maxHeight: "none !important",
|
||||
margin: "0 !important",
|
||||
overflow: "visible !important",
|
||||
boxShadow: "none !important",
|
||||
},
|
||||
".health-risk-report-preview": {
|
||||
padding: "0 !important",
|
||||
overflow: "visible !important",
|
||||
backgroundColor: "#fff !important",
|
||||
},
|
||||
"#health-risk-print-root": {
|
||||
width: "210mm !important",
|
||||
minHeight: "297mm !important",
|
||||
padding: "12mm !important",
|
||||
boxShadow: "none !important",
|
||||
printColorAdjust: "exact",
|
||||
WebkitPrintColorAdjust: "exact",
|
||||
},
|
||||
".health-risk-report-section, .health-risk-report-metrics": {
|
||||
breakInside: "avoid",
|
||||
pageBreakInside: "avoid",
|
||||
},
|
||||
".health-risk-report-table-row": {
|
||||
breakInside: "avoid",
|
||||
pageBreakInside: "avoid",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
fullWidth
|
||||
className="health-risk-report-dialog"
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
m: 0,
|
||||
borderRadius: 0,
|
||||
bgcolor: "#e8edf2",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
className="health-risk-report-actions"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
minHeight: 64,
|
||||
px: { xs: 1.5, sm: 3 },
|
||||
bgcolor: "#fff",
|
||||
boxShadow: "0 1px 4px rgba(23, 36, 53, 0.16)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
aria-label="关闭报告预览"
|
||||
sx={{ width: 40, height: 40 }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700, color: REPORT_INK }}>
|
||||
报告预览
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: REPORT_MUTED }}>
|
||||
A4 纵向,可在打印窗口另存为 PDF
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PrintOutlined />}
|
||||
onClick={handlePrint}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: REPORT_BLUE,
|
||||
"&:hover": { bgcolor: "#083f6d" },
|
||||
"&:active": { transform: "scale(0.96)" },
|
||||
}}
|
||||
>
|
||||
打印
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
className="health-risk-report-preview"
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
px: { xs: 0, sm: 2 },
|
||||
py: { xs: 0, sm: 3 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
id="health-risk-print-root"
|
||||
lang="zh-CN"
|
||||
sx={{
|
||||
boxSizing: "border-box",
|
||||
width: { xs: "100%", sm: "min(210mm, 100%)" },
|
||||
minHeight: "297mm",
|
||||
mx: "auto",
|
||||
p: { xs: 2, sm: "12mm" },
|
||||
bgcolor: "#fff",
|
||||
color: REPORT_INK,
|
||||
boxShadow: { xs: "none", sm: "0 12px 32px rgba(23,36,53,0.16)" },
|
||||
fontFamily: reportFontFamily,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
bgcolor: REPORT_BLUE,
|
||||
color: "#f4f8fb",
|
||||
px: { xs: 2.5, sm: 4 },
|
||||
py: { xs: 2.5, sm: 3.5 },
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: "flex-start", sm: "center" }}
|
||||
spacing={2}
|
||||
>
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<WaterDropOutlined aria-hidden="true" />
|
||||
<Typography
|
||||
lang="en"
|
||||
sx={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.08em" }}
|
||||
>
|
||||
TJWATER
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
component="h1"
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
fontSize: { xs: 24, sm: 30 },
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.25,
|
||||
textWrap: "balance",
|
||||
}}
|
||||
>
|
||||
管网健康风险体检报告
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.75,
|
||||
color: "#cfe1ef",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
Pipeline health risk assessment
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: { sm: 168 },
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
pt: { xs: 2, sm: 0 },
|
||||
pl: { xs: 0, sm: 3 },
|
||||
borderTop: {
|
||||
xs: "1px solid rgba(224, 238, 248, 0.28)",
|
||||
sm: "none",
|
||||
},
|
||||
borderLeft: {
|
||||
xs: "none",
|
||||
sm: "1px solid rgba(224, 238, 248, 0.28)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={0.8}>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
bgcolor:
|
||||
reportData.highRiskCount > 0 ? "#ffb3a7" : "#9dd7bd",
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ color: "#cfe1ef", fontSize: 11 }}>
|
||||
本期提示
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.75,
|
||||
fontWeight: 700,
|
||||
fontSize: 20,
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{reportData.highRiskCount > 0 ? "需重点关注" : "持续监测"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="dl"
|
||||
sx={{
|
||||
m: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" },
|
||||
borderBottom: `1px solid ${REPORT_LINE}`,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
["管网项目", networkName || "未命名管网"],
|
||||
["预测时点", `第 ${currentYear} 年`],
|
||||
["分析数据时间", formatDateTime(analysisQueryTime)],
|
||||
["报告生成时间", formatDateTime(generatedAt)],
|
||||
].map(([label, value]) => (
|
||||
<Box
|
||||
key={label}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "96px 1fr",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderBottom: { xs: `1px solid ${REPORT_LINE}`, sm: "none" },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="dt"
|
||||
sx={{ color: REPORT_MUTED, fontSize: 12 }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="dd"
|
||||
sx={{
|
||||
m: 0,
|
||||
color: REPORT_INK,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="section"
|
||||
className="health-risk-report-section"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
<SectionTitle>总体结论</SectionTitle>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor:
|
||||
reportData.highRiskCount > 0 ? "#fff4f1" : "#eff8f4",
|
||||
px: 2.25,
|
||||
py: 1.75,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: 13, lineHeight: 1.8, color: REPORT_INK }}>
|
||||
{reportData.conclusion}
|
||||
</Typography>
|
||||
{reportData.invalidCount > 0 && (
|
||||
<Typography sx={{ mt: 0.5, fontSize: 11, color: REPORT_MUTED }}>
|
||||
另有 {reportData.invalidCount} 条管段在当前预测年份缺少有效结果,未纳入本期统计。
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
className="health-risk-report-metrics"
|
||||
sx={{
|
||||
mt: 2.25,
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "repeat(2, 1fr)", sm: "repeat(4, 1fr)" },
|
||||
borderTop: `1px solid ${REPORT_LINE}`,
|
||||
borderBottom: `1px solid ${REPORT_LINE}`,
|
||||
}}
|
||||
>
|
||||
{metricItems.map((item, index) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.75,
|
||||
borderRight:
|
||||
index < metricItems.length - 1
|
||||
? `1px solid ${REPORT_LINE}`
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: REPORT_MUTED, fontSize: 11 }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
color: item.danger ? "#a62f2f" : REPORT_INK,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</Typography>
|
||||
{item.detail && (
|
||||
<Typography sx={{ mt: 0.25, color: REPORT_MUTED, fontSize: 10 }}>
|
||||
占有效结果 {item.detail}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="section"
|
||||
className="health-risk-report-section"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
<SectionTitle>当前年份风险分布</SectionTitle>
|
||||
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
|
||||
生存概率越低,表示模型预测风险越高。统计口径与地图风险图例一致。
|
||||
</Typography>
|
||||
<ReactECharts
|
||||
option={distributionOption}
|
||||
style={{ width: "100%", height: 300 }}
|
||||
opts={{ renderer: "svg" }}
|
||||
notMerge
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="section"
|
||||
className="health-risk-report-section"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
<SectionTitle>重点风险趋势</SectionTitle>
|
||||
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
|
||||
展示各预测年份中生存概率不高于 0.3 的管段数量。
|
||||
</Typography>
|
||||
<ReactECharts
|
||||
option={trendOption}
|
||||
style={{ width: "100%", height: 240 }}
|
||||
opts={{ renderer: "svg" }}
|
||||
notMerge
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box component="section" sx={{ mt: 3 }}>
|
||||
<SectionTitle>重点风险管段</SectionTitle>
|
||||
<Typography sx={{ mb: 1.25, color: REPORT_MUTED, fontSize: 11 }}>
|
||||
按当前预测年份的生存概率从低到高排列,最多展示 20 条。
|
||||
</Typography>
|
||||
{reportData.priorityPipes.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table
|
||||
size="small"
|
||||
aria-label="重点风险管段"
|
||||
sx={{ tableLayout: "fixed" }}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: "#edf3f7" }}>
|
||||
<TableCell sx={{ width: 52, fontWeight: 700 }}>序号</TableCell>
|
||||
<TableCell sx={{ width: "34%", fontWeight: 700 }}>
|
||||
管段编号
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ width: 110, fontWeight: 700 }}>
|
||||
生存概率
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>风险等级</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{reportData.priorityPipes.map((pipe, index) => (
|
||||
<TableRow
|
||||
key={`${pipe.linkId}-${index}`}
|
||||
className="health-risk-report-table-row"
|
||||
>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, overflowWrap: "anywhere" }}
|
||||
>
|
||||
{pipe.linkId}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatPercent(pipe.probability, 2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
flexShrink: 0,
|
||||
bgcolor: pipe.color,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: 12 }}>
|
||||
{pipe.riskLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Box sx={{ bgcolor: "#f4f7f9", px: 2, py: 2 }}>
|
||||
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
|
||||
当前预测年份没有较高及以上风险管段。
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="section"
|
||||
className="health-risk-report-section"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
<SectionTitle>处置建议</SectionTitle>
|
||||
<Box component="ol" sx={{ m: 0, pl: 3 }}>
|
||||
{reportData.recommendations.map((recommendation) => (
|
||||
<Typography
|
||||
component="li"
|
||||
key={recommendation}
|
||||
sx={{
|
||||
pl: 0.5,
|
||||
mb: 0.75,
|
||||
color: REPORT_INK,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.75,
|
||||
}}
|
||||
>
|
||||
{recommendation}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
mt: 3.5,
|
||||
pt: 1.5,
|
||||
borderTop: `1px solid ${REPORT_LINE}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
color: REPORT_MUTED,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: 10 }}>TJWater 管网分析平台</Typography>
|
||||
<Typography sx={{ fontSize: 10, textAlign: "right" }}>
|
||||
风险阈值:生存概率 ≤ 0.3
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthRiskReport;
|
||||
@@ -11,10 +11,19 @@ import {
|
||||
Stack,
|
||||
Slide,
|
||||
Fade,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import { ChevronLeft, ChevronRight, BarChart } from "@mui/icons-material";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
BarChart,
|
||||
DescriptionOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types";
|
||||
import { useHealthRisk } from "./HealthRiskContext";
|
||||
import { useProject } from "@/contexts/ProjectContext";
|
||||
import HealthRiskReport from "./HealthRiskReport";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
const SIMPLE_LABELS = [
|
||||
"0.0 - 0.1",
|
||||
@@ -30,8 +39,13 @@ const SIMPLE_LABELS = [
|
||||
];
|
||||
|
||||
const HealthRiskStatistics: React.FC = () => {
|
||||
const { predictionResults, currentYear } = useHealthRisk();
|
||||
const { predictionResults, currentYear, analysisQueryTime } = useHealthRisk();
|
||||
const project = useProject();
|
||||
const [isExpanded, setIsExpanded] = React.useState<boolean>(true);
|
||||
const [isReportOpen, setIsReportOpen] = React.useState<boolean>(false);
|
||||
const [reportGeneratedAt, setReportGeneratedAt] = React.useState(
|
||||
() => new Date(),
|
||||
);
|
||||
const [hoveredYearIndex, setHoveredYearIndex] = React.useState<number | null>(
|
||||
null
|
||||
);
|
||||
@@ -216,24 +230,11 @@ const HealthRiskStatistics: React.FC = () => {
|
||||
};
|
||||
|
||||
const renderEmpty = () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<BarChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
|
||||
暂无预测数据
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
请先进行健康风险分析预测,以生成风险分布数据
|
||||
</Typography>
|
||||
</Box>
|
||||
<PanelEmptyState
|
||||
icon={<BarChart />}
|
||||
title="暂无风险预测结果"
|
||||
description="请先运行健康风险预测,完成后将在这里展示风险分布统计。"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -278,6 +279,41 @@ const HealthRiskStatistics: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip
|
||||
title={
|
||||
predictionResults.length > 0
|
||||
? "生成健康风险体检报告"
|
||||
: "请先完成健康风险分析"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<DescriptionOutlined fontSize="small" />}
|
||||
disabled={predictionResults.length === 0}
|
||||
onClick={() => {
|
||||
setReportGeneratedAt(new Date());
|
||||
setIsReportOpen(true);
|
||||
}}
|
||||
sx={{
|
||||
minHeight: 32,
|
||||
color: "white",
|
||||
borderColor: "rgba(255,255,255,0.62)",
|
||||
"&:hover": {
|
||||
borderColor: "white",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
},
|
||||
"&.Mui-disabled": {
|
||||
color: "rgba(255,255,255,0.48)",
|
||||
borderColor: "rgba(255,255,255,0.24)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
生成报告
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="收起">
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -308,6 +344,15 @@ const HealthRiskStatistics: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Slide>
|
||||
<HealthRiskReport
|
||||
open={isReportOpen}
|
||||
onClose={() => setIsReportOpen(false)}
|
||||
predictionResults={predictionResults}
|
||||
currentYear={currentYear}
|
||||
analysisQueryTime={analysisQueryTime}
|
||||
networkName={project?.networkName || "未命名管网"}
|
||||
generatedAt={reportGeneratedAt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ShowChart } from "@mui/icons-material";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { useHealthRisk } from "./HealthRiskContext";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
export interface PredictDataPanelProps {
|
||||
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
||||
@@ -58,26 +59,11 @@ const PredictDataPanel: React.FC<PredictDataPanelProps> = ({
|
||||
}, [filteredResults]);
|
||||
|
||||
const renderEmpty = () => (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
py: 8,
|
||||
color: "text.secondary",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
|
||||
暂无预测数据
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
请在地图上选择已分析的管道
|
||||
</Typography>
|
||||
</Box>
|
||||
<PanelEmptyState
|
||||
icon={<ShowChart />}
|
||||
title="暂无可展示的管道预测"
|
||||
description="请先在地图上选择已完成健康风险分析的管道。"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderChart = () => {
|
||||
|
||||
@@ -137,6 +137,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
setPredictionResults,
|
||||
currentYear,
|
||||
setCurrentYear,
|
||||
setAnalysisQueryTime,
|
||||
} = useHealthRisk();
|
||||
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
@@ -391,12 +392,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
});
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
||||
`${config.BACKEND_URL}/api/v1/pipeline-health-predictions?query_time=${query_time}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const results: PredictionResult[] = await response.json();
|
||||
setPredictionResults(results);
|
||||
setAnalysisQueryTime(calculationDateTime.toISOString());
|
||||
open?.({
|
||||
type: "success",
|
||||
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
|
||||
@@ -404,7 +406,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
} else {
|
||||
// 读取后端 HTTPException 返回的 detail 信息
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = errorData.detail || "模拟预测失败";
|
||||
const errorMessage =
|
||||
typeof errorData.detail === "string" &&
|
||||
errorData.detail.includes("未找到流速数据")
|
||||
? "所选时间暂无可用的管网模拟数据,请确认该时刻已完成模拟计算并生成流速结果后重试。"
|
||||
: errorData.detail || "模拟预测失败";
|
||||
open?.({
|
||||
type: "error",
|
||||
message: errorMessage,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
buildHealthRiskReportData,
|
||||
getProbabilityAtYear,
|
||||
getRiskBandIndex,
|
||||
PRIORITY_PIPE_LIMIT,
|
||||
} from "./healthRiskReport";
|
||||
import { PredictionResult } from "./types";
|
||||
|
||||
const createResult = (
|
||||
linkId: string,
|
||||
years: number[],
|
||||
probabilities: number[],
|
||||
): PredictionResult => ({
|
||||
link_id: linkId,
|
||||
survival_function: {
|
||||
x: years,
|
||||
y: probabilities,
|
||||
a: 0,
|
||||
b: 0,
|
||||
},
|
||||
});
|
||||
|
||||
describe("healthRiskReport", () => {
|
||||
it("matches probability by the exact forecast year", () => {
|
||||
const result = createResult("P-1", [4, 6], [0.8, 0.2]);
|
||||
|
||||
expect(getProbabilityAtYear(result, 6)).toBe(0.2);
|
||||
expect(getProbabilityAtYear(result, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing and out-of-range probabilities", () => {
|
||||
expect(
|
||||
getProbabilityAtYear(createResult("P-1", [4], [Number.NaN]), 4),
|
||||
).toBeNull();
|
||||
expect(getProbabilityAtYear(createResult("P-2", [4], [1.1]), 4)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps risk break boundaries consistent with the existing legend", () => {
|
||||
expect(getRiskBandIndex(0)).toBe(0);
|
||||
expect(getRiskBandIndex(0.1)).toBe(0);
|
||||
expect(getRiskBandIndex(0.10001)).toBe(1);
|
||||
expect(getRiskBandIndex(1)).toBe(9);
|
||||
});
|
||||
|
||||
it("builds the current snapshot and high-risk trend", () => {
|
||||
const report = buildHealthRiskReportData(
|
||||
[
|
||||
createResult("P-1", [4, 5], [0.2, 0.1]),
|
||||
createResult("P-2", [4, 5], [0.5, 0.25]),
|
||||
createResult("P-3", [4, 5], [0.9, 0.8]),
|
||||
createResult("P-4", [5], [0.4]),
|
||||
],
|
||||
4,
|
||||
);
|
||||
|
||||
expect(report.totalCount).toBe(4);
|
||||
expect(report.validCount).toBe(3);
|
||||
expect(report.invalidCount).toBe(1);
|
||||
expect(report.highRiskCount).toBe(1);
|
||||
expect(report.highRiskRatio).toBeCloseTo(1 / 3);
|
||||
expect(report.averageProbability).toBeCloseTo(0.533333);
|
||||
expect(report.distribution.reduce((sum, item) => sum + item.count, 0)).toBe(
|
||||
3,
|
||||
);
|
||||
expect(report.trend).toEqual([
|
||||
{ year: 4, count: 1, validCount: 3, ratio: 1 / 3 },
|
||||
{ year: 5, count: 2, validCount: 4, ratio: 0.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts priority pipes and limits the printed detail", () => {
|
||||
const results = Array.from({ length: PRIORITY_PIPE_LIMIT + 5 }, (_, index) =>
|
||||
createResult(`P-${PRIORITY_PIPE_LIMIT + 5 - index}`, [4], [0.2]),
|
||||
);
|
||||
|
||||
const report = buildHealthRiskReportData(results, 4);
|
||||
|
||||
expect(report.priorityPipes).toHaveLength(PRIORITY_PIPE_LIMIT);
|
||||
expect(report.priorityPipes[0].linkId).toBe("P-1");
|
||||
expect(report.priorityPipes.at(-1)?.linkId).toBe(
|
||||
`P-${PRIORITY_PIPE_LIMIT}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user