Compare commits
259
Commits
master
..
65ed1fd212
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ed1fd212 | ||
|
|
81cc5dcae1 | ||
|
|
782363cfb6 | ||
|
|
b57e58ff87 | ||
|
|
38d246eb7b | ||
|
|
723931b6ae | ||
|
|
f355ddd002 | ||
|
|
6114e76735 | ||
|
|
d643f09fcf | ||
|
|
04557b9363 | ||
|
|
8836549697 | ||
|
|
1d7e07174f | ||
|
|
c4246cf25f | ||
|
|
1b2a7f4fb8 | ||
|
|
82e75c03d0 | ||
|
|
5894ee277a | ||
|
|
a3331691de | ||
|
|
7745333a58 | ||
|
|
4596af7c12 | ||
|
|
5332f8f0c5 | ||
|
|
1993cadba8 | ||
|
|
7a3677ee9f | ||
|
|
0d559f6130 | ||
|
|
7af90e495d | ||
|
|
a52c04204d | ||
|
|
08152ff978 | ||
|
|
589cf45aa7 | ||
|
|
59447a100c | ||
|
|
4adbcc1c4c | ||
|
|
202f18332f | ||
|
|
acf13639ef | ||
|
|
d986e563a6 | ||
|
|
d8ee2e1f0c | ||
|
|
fddb0ceb34 | ||
|
|
3d7b594682 | ||
|
|
eb8950c89a | ||
|
|
ef2b045306 | ||
|
|
209da0d295 | ||
|
|
dfaee645ff | ||
|
|
d90ca7c951 | ||
|
|
041b4ef89d | ||
|
|
f5e7312e3b | ||
|
|
c6e6e24aab | ||
|
|
adb53d9a13 | ||
|
|
14c76231d5 | ||
|
|
694f7629ee | ||
|
|
701c5a949d | ||
|
|
435a4172e4 | ||
|
|
600a8703ed | ||
|
|
b13fbe7dca | ||
|
|
e2e296dc06 | ||
|
|
9c2a6a386a | ||
|
|
a4e7ab263a | ||
|
|
67c1a88afe | ||
|
|
76e62a2d3d | ||
|
|
758100b345 | ||
|
|
cf6386d209 | ||
|
|
0dea655f68 | ||
|
|
6ff8886524 | ||
|
|
7cd0c61181 | ||
|
|
f6d2e19397 | ||
|
|
181871e0cf | ||
|
|
8934844bd9 | ||
|
|
24cddc18a6 | ||
|
|
7f07f0449d | ||
|
|
757eea49de | ||
|
|
bb7311589c | ||
|
|
877b79ada8 | ||
|
|
a6ea97142a | ||
|
|
4374c89a63 | ||
|
|
224d53a04d | ||
|
|
7d2ae87e39 | ||
|
|
1e872ca873 | ||
|
|
e2a6bb0e7d | ||
|
|
9c0a7a2864 | ||
|
|
ab9e2a0420 | ||
|
|
eee165c812 | ||
|
|
213a01ff7d | ||
|
|
0501afaced | ||
|
|
d80a071987 | ||
|
|
216c7b1ab9 | ||
|
|
7d966a5e91 | ||
|
|
22afdbf2e8 | ||
|
|
ed9828befe | ||
|
|
968d798a2a | ||
|
|
7da0ed0e39 | ||
|
|
166b45e529 | ||
|
|
e5f13c3d46 | ||
|
|
36cdb1df8d | ||
|
|
865e425748 | ||
|
|
3a36c693cd | ||
|
|
b23cb6acdd | ||
|
|
2691f42581 | ||
|
|
34fd5bfb1a | ||
|
|
40cc355fff | ||
|
|
f7cd5ebfa7 | ||
|
|
d31565d52c | ||
|
|
e32823e4b5 | ||
|
|
5fc1812d53 | ||
|
|
709b029c4e | ||
|
|
57369772c7 | ||
|
|
7764e25398 | ||
|
|
e60e1f6453 | ||
|
|
20ca410e0a | ||
|
|
06a3f32d2d | ||
|
|
fa3e6b6e84 | ||
|
|
888132a60f | ||
|
|
9761ade8d8 | ||
|
|
0e82c080df | ||
|
|
a4f0ffcd32 | ||
|
|
9dc8549f31 | ||
|
|
6b447eb398 | ||
|
|
54fbf15be8 | ||
|
|
4bf99e8069 | ||
|
|
e4d45300b1 | ||
|
|
477350a2a1 | ||
|
|
424555aae2 | ||
|
|
98635e5247 | ||
|
|
adf8ea5ca8 | ||
|
|
91a57123a4 | ||
|
|
4f54da64d0 | ||
|
|
2fbfba118f | ||
|
|
9106b8d4a9 | ||
|
|
3800d73e85 | ||
|
|
e4424b87d1 | ||
|
|
39ee9a02e5 | ||
|
|
45274955c6 | ||
|
|
03ca56d2a7 | ||
|
|
570d2c7de1 | ||
|
|
8058b7b859 | ||
|
|
a4486e3d89 | ||
|
|
536cd6a5d1 | ||
|
|
133f5d417f | ||
|
|
cf43700459 | ||
|
|
5cfb7cc38f | ||
|
|
d4050a841b | ||
|
|
ba66abb4ee | ||
|
|
e0e78cd95a | ||
|
|
c5b0f43a0d | ||
|
|
8f3c288823 | ||
|
|
24d81e04e0 | ||
|
|
85b4f45d4a | ||
|
|
36d1a8d6ea | ||
|
|
e5ca9e24aa | ||
|
|
2c1afdc97c | ||
|
|
30d85173ee | ||
|
|
3b5a493cda | ||
|
|
49fd4f5eb1 | ||
|
|
3db2af0271 | ||
|
|
07861bee03 | ||
|
|
60181dba54 | ||
|
|
a1442fc062 | ||
|
|
260c493fc8 | ||
|
|
46a4d7157d | ||
|
|
3ba252462d | ||
|
|
5ca9a55a7b | ||
|
|
9206c480b2 | ||
|
|
23bd2f47c3 | ||
|
|
c4269f40e3 | ||
|
|
3afe885cc0 | ||
|
|
b99fe66704 | ||
|
|
c2785f0746 | ||
|
|
1ed09c9594 | ||
|
|
baa5d41bec | ||
|
|
05868c6af6 | ||
|
|
e81305d046 | ||
|
|
b963562a5f | ||
|
|
bfd41b58e3 | ||
|
|
333d0d3353 | ||
|
|
f207e2b192 | ||
|
|
4f195b0e06 | ||
|
|
0f110ce0c6 | ||
|
|
a23626614f | ||
|
|
1debaed7ea | ||
|
|
74b4a4157c | ||
|
|
efd04fd651 | ||
|
|
5aa28c8409 | ||
|
|
8b6dda08e6 | ||
|
|
427cbe70b3 | ||
|
|
6410df0cb7 | ||
|
|
ff5cbfde9c | ||
|
|
5cbf1e82f8 | ||
|
|
259202ca8f | ||
|
|
bfa4020239 | ||
|
|
5dab6464c3 | ||
|
|
b752be498a | ||
|
|
781711943a | ||
|
|
7d05ad4920 | ||
|
|
f0fad61bb2 | ||
|
|
d763876f86 | ||
|
|
56b4777dbd | ||
|
|
c484aad1d3 | ||
|
|
d610a09c14 | ||
|
|
a1c8041b11 | ||
|
|
295c959b52 | ||
|
|
adc12c13f9 | ||
|
|
6559d0c062 | ||
|
|
a101e79750 | ||
|
|
8713e5a468 | ||
|
|
03a77f7368 | ||
|
|
825acbf29c | ||
|
|
045391d036 | ||
|
|
accf6ad254 | ||
|
|
55362bef8f | ||
|
|
d232104aa4 | ||
|
|
e1e4664dec | ||
|
|
e0ab4bf60d | ||
|
|
abfc8770a4 | ||
|
|
71be47b956 | ||
|
|
081e4c4c13 | ||
|
|
a7106a7289 | ||
|
|
76aa28c701 | ||
|
|
a7f4867afe | ||
|
|
e2ea1853f1 | ||
|
|
f0f9d3f4f9 | ||
|
|
73201ae44e | ||
|
|
62914f80c3 | ||
|
|
64dcf9cbdb | ||
|
|
520e1cb3f1 | ||
|
|
7f25bd34d5 | ||
|
|
47e47fc605 | ||
|
|
b4ab3e287b | ||
|
|
ddb02cc688 | ||
|
|
6b68b7d081 | ||
|
|
2f24ab5d66 | ||
|
|
133880f7fc | ||
|
|
5ed6740a24 | ||
|
|
9beba1cf6f | ||
|
|
bf6edf2662 | ||
|
|
5430a9d885 | ||
|
|
377fc32f4c | ||
|
|
b73481d604 | ||
|
|
cd34e511ac | ||
|
|
6c5862f7e4 | ||
|
|
2d27e803a3 | ||
|
|
f9dc4b74d0 | ||
|
|
66f2390078 | ||
|
|
9d06226cb4 | ||
|
|
a2e6c1f416 | ||
|
|
2911b87fac | ||
|
|
8b6198a2ac | ||
|
|
03e5f1456c | ||
|
|
25bde02b43 | ||
|
|
1e8af75b88 | ||
|
|
8ea70d04ad | ||
|
|
1d15eeb172 | ||
|
|
ae1f9b284f | ||
|
|
409057cef2 | ||
|
|
2c51785157 | ||
|
|
6be4a0de14 | ||
|
|
9d12b1960c | ||
|
|
cbfce9164e | ||
|
|
62a97459d0 | ||
|
|
4fbe845015 | ||
|
|
f89e43eee2 | ||
|
|
4bd7b48bcf | ||
|
|
5b52afcc53 | ||
|
|
9bb0f8dcd7 | ||
|
|
bc73db66de |
+10
-5
@@ -1,7 +1,12 @@
|
|||||||
**/node_modules/
|
node_modules
|
||||||
**/dist
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
.git
|
.git
|
||||||
npm-debug.log
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.env*.local
|
||||||
|
README.md
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
KEYCLOAK_CLIENT_ID="tjwater"
|
||||||
|
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
|
||||||
|
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
|
||||||
|
NEXTAUTH_SECRET="replace-with-nextauth-secret"
|
||||||
|
NEXTAUTH_URL="https://frontend.example.com/"
|
||||||
|
|
||||||
|
BACKEND_URL="https://server.example.com"
|
||||||
|
AGENT_URL="https://agent.example.com"
|
||||||
|
MAP_URL="https://geoserver.example.com/geoserver"
|
||||||
|
MAP_WORKSPACE="tjwater"
|
||||||
|
MAP_EXTENT="13490131,3630016,13525879,3666968.25"
|
||||||
|
NETWORK_NAME="tjwater"
|
||||||
|
MAPBOX_TOKEN="replace-with-public-mapbox-token"
|
||||||
|
TIANDITU_TOKEN="replace-with-public-tianditu-token"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
name: Build Push and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
- "latest"
|
||||||
|
|
||||||
|
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."
|
||||||
+7
-2
@@ -19,6 +19,7 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
/public/runtime-config.js
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -26,11 +27,15 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
memery.md
|
||||||
|
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This repository is the TJWater web frontend built with Refine, Next.js, React, and MUI. Application source lives under the existing Next.js project folders. Reuse established page, component, provider, map, and chat patterns instead of adding parallel structures. Static assets and public files should remain in the existing asset/public locations. Build output (`.next/`), dependency folders, and local caches are generated and must not be edited by hand.
|
||||||
|
|
||||||
|
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use npm and Node 20 or newer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run test:coverage
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Tests use Jest with React Testing Library. Name tests `*.test.ts` or `*.test.tsx` near the related code when possible. Add tests for user-visible behavior, state transitions, route guards, data transforms, and map/chat interactions. Run `npm test` or `npm run test:coverage` before larger PRs.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
History uses Conventional Commit messages such as `feat(map): add coordinate zoom action` and `fix(chat): hide raw permission metadata`, with occasional Chinese summaries. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`.
|
||||||
|
|
||||||
|
PRs should include a UI/behavior summary, verification commands, screenshots for visual changes, and notes for changed environment variables or backend API expectations.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit `.env`, `.next/`, `node_modules/`, local caches, or private map/API tokens. Public build-time variables should be documented; sensitive values belong in Gitea secrets.
|
||||||
+18
-12
@@ -1,14 +1,17 @@
|
|||||||
FROM refinedev/node:18 AS base
|
FROM node:22.23.1-bookworm-slim AS base
|
||||||
|
|
||||||
|
WORKDIR /app/refine
|
||||||
|
|
||||||
|
ARG NPM_CONFIG_REGISTRY
|
||||||
|
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
|
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
elif [ -f package-lock.json ]; then npm ci; \
|
elif [ -f package-lock.json ]; then npm ci || (echo "===== npm debug logs =====" && find /root/.npm/_logs -maxdepth 1 -type f -name "*-debug-0.log" -print -exec cat {} \; && exit 1); \
|
||||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
fi
|
fi
|
||||||
@@ -23,21 +26,24 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY --from=builder /app/refine/public ./public
|
COPY --from=builder --chown=node:node /app/refine/public ./public
|
||||||
|
COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir .next
|
RUN mkdir .next
|
||||||
RUN chown refine:nodejs .next
|
RUN chown node:node .next
|
||||||
|
|
||||||
COPY --from=builder --chown=refine:nodejs /app/refine/.next/standalone ./
|
COPY --from=builder --chown=node:node /app/refine/.next/standalone ./
|
||||||
COPY --from=builder --chown=refine:nodejs /app/refine/.next/static ./.next/static
|
COPY --from=builder --chown=node:node /app/refine/.next/static ./.next/static
|
||||||
|
|
||||||
USER refine
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Frontend Naming Audit
|
||||||
|
|
||||||
|
DOC-004 audit for the internal `TJWaterFrontend_Refine` application.
|
||||||
|
|
||||||
|
## Local frontend naming
|
||||||
|
|
||||||
|
- Next.js route directories under `src/app` are already `kebab-case`: `audit-logs`, `health-risk-analysis`, `hydraulic-simulation`, `monitoring-place-optimization`, `network-simulation`, `scada-data-cleaning`, and `system-admin`.
|
||||||
|
- Route groups and dynamic segments keep Next.js syntax: `(main)` and `[...nextauth]`.
|
||||||
|
- Local ordinary module filenames now use `camelCase`, including `src/utils/breaksClassification.ts`, `src/components/chat/globalChatboxUtils.ts`, and `src/components/chat/globalChatboxVoice.ts`.
|
||||||
|
- React component files remain `PascalCase.tsx`, including `src/app/RefineContext.tsx`, `src/components/chat/GlobalChatboxParts.tsx`, and domain directories under `src/components/olmap`.
|
||||||
|
|
||||||
|
## API boundary naming
|
||||||
|
|
||||||
|
Frontend request and response boundary fields intentionally keep backend/Agent wire names. Examples include `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `session_id`, `request_id`, and `keep_message_count`.
|
||||||
|
|
||||||
|
Current direct API calls mostly use new `kebab-case` URL paths, including:
|
||||||
|
|
||||||
|
- `/api/v1/admin/projects`
|
||||||
|
- `/api/v1/audit/logs`
|
||||||
|
- `/api/v1/projects/open`
|
||||||
|
- `/api/v1/project-info`
|
||||||
|
- `/api/v1/schemes`
|
||||||
|
- `/api/v1/sensor-placement-schemes`
|
||||||
|
- `/api/v1/burst-analysis`
|
||||||
|
- `/api/v1/valve-isolation-analysis`
|
||||||
|
- `/api/v1/flushing-analysis`
|
||||||
|
- `/api/v1/contaminant-simulation`
|
||||||
|
- `/api/v1/simulations/run-by-date`
|
||||||
|
- `/api/v1/burst-detection/detect`
|
||||||
|
- `/api/v1/burst-location/locate`
|
||||||
|
- `/api/v1/scada/by-ids-field-time-range`
|
||||||
|
- `/api/v1/composite/clean-scada`
|
||||||
|
- `/api/v1/agent/chat/render-ref/{render_ref}`
|
||||||
|
|
||||||
|
## Legacy URL inventory
|
||||||
|
|
||||||
|
The frontend no longer calls these active legacy URLs directly. The backend still exposes them as deprecated compatibility aliases:
|
||||||
|
|
||||||
|
| Current frontend URL | Files | Suggested target |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/api/v1/openproject/` | `src/contexts/ProjectContext.tsx` | `/api/v1/projects/open` or `/api/v1/project/open` |
|
||||||
|
| `/api/v1/project_info/` | `src/contexts/ProjectContext.tsx` | `/api/v1/project-info` |
|
||||||
|
| `/api/v1/getallschemes/` | burst, burst simulation, contaminant, flushing scheme query components | `/api/v1/schemes` |
|
||||||
|
| `/api/v1/getallsensorplacements/` | `src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/sensorplacementscheme/create` | `src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/burst_analysis/` | `src/components/olmap/BurstSimulation/AnalysisParameters.tsx` | `/api/v1/burst-analysis` |
|
||||||
|
| `/api/v1/valve_isolation_analysis/` | `src/components/olmap/BurstSimulation/ValveIsolation.tsx` | `/api/v1/valve-isolation-analysis` |
|
||||||
|
| `/api/v1/flushing_analysis/` | `src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx` | `/api/v1/flushing-analysis` |
|
||||||
|
| `/api/v1/contaminant_simulation/` | `src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx` | `/api/v1/contaminant-simulation` |
|
||||||
|
| `/api/v1/runsimulationmanuallybydate/` | `src/components/olmap/core/Controls/Timeline.tsx` | `/api/v1/simulations/run-by-date` |
|
||||||
|
|
||||||
|
Already migrated frontend code leaves comments showing older pre-`/api/v1` URLs in `src/components/olmap/core/Controls/Toolbar.tsx`; those comments are historical only and are not active calls.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
Continue tracking broad passive legacy backend routes under the shared legacy API compatibility strategy.
|
||||||
@@ -1,48 +1,91 @@
|
|||||||
# my-refine-app
|
# TJWaterFrontend_Refine 内部前端
|
||||||
|
|
||||||
<div align="center" style="margin: 30px;">
|
`TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
|
||||||
<a href="https://refine.dev">
|
|
||||||
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app).
|
## 技术栈
|
||||||
|
|
||||||
## Getting Started
|
- Next.js 16
|
||||||
|
- React 19
|
||||||
|
- Refine 5
|
||||||
|
- MUI 6 / MUI X
|
||||||
|
- OpenLayers、deck.gl、Turf
|
||||||
|
- Zustand、NextAuth、Jest
|
||||||
|
|
||||||
A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility ✨
|
## 目录结构
|
||||||
|
|
||||||
Refine's hooks and components simplifies the development process and eliminates the repetitive tasks by providing industry-standard solutions for crucial aspects of a project, including authentication, access control, routing, networking, state management, and i18n.
|
```text
|
||||||
|
src/app/ Next.js App Router 页面
|
||||||
## Available Scripts
|
src/components/ 复用 UI 组件
|
||||||
|
src/providers/ Refine、认证、数据和主题 provider
|
||||||
### Running the development server.
|
src/hooks/ 业务 hooks
|
||||||
|
src/utils/ 通用工具
|
||||||
```bash
|
public/ 静态资源
|
||||||
npm run dev
|
scripts/ 运行时配置和辅助脚本
|
||||||
|
Dockerfile 镜像构建文件
|
||||||
|
docker-compose.yml 本地编排参考
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building for production.
|
新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
要求 Node.js 20 或更高版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the production server.
|
`npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run start
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run test:coverage
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
docker build -t tjwater-frontend:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Learn More
|
- `npm run lint`:运行 ESLint。
|
||||||
|
- `npm test`:运行 Jest。
|
||||||
|
- `npm run test:coverage`:生成测试覆盖率。
|
||||||
|
- `npm run build`:生成生产构建。
|
||||||
|
- `npm run start`:启动生产模式服务。
|
||||||
|
|
||||||
To learn more about **Refine**, please check out the [Documentation](https://refine.dev/docs)
|
## 配置说明
|
||||||
|
|
||||||
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview)
|
运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
|
||||||
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
|
|
||||||
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
|
|
||||||
|
|
||||||
## License
|
只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。
|
||||||
|
|
||||||
MIT
|
## 开发规范
|
||||||
|
|
||||||
|
- React 组件文件使用 `PascalCase.tsx`。
|
||||||
|
- 普通 TypeScript 模块、hooks、store、provider 和工具使用 `camelCase.ts`。
|
||||||
|
- `src/app` 路由目录使用 `kebab-case`,保留 Next.js 路由组和动态段语法。
|
||||||
|
- UI 优先沿用 MUI、Refine 和既有地图/聊天界面模式。
|
||||||
|
- 与后端或 Agent 通信的字段保持接口原始格式,通常为 `snake_case`。
|
||||||
|
|
||||||
|
## 测试与发布
|
||||||
|
|
||||||
|
提交前建议运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
发布镜像前运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
|
||||||
|
|
||||||
|
## 安全规则
|
||||||
|
|
||||||
|
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"contract_version": "1.0.0",
|
||||||
|
"contracts": {
|
||||||
|
"agent": {
|
||||||
|
"file": "agent-v1.openapi.json",
|
||||||
|
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"file": "server-v1.openapi.json",
|
||||||
|
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: ${IMAGE_NAME:-refinedev/tjwater-frontend:latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: ${BACKEND_URL}
|
||||||
|
AGENT_URL: ${AGENT_URL}
|
||||||
|
MAP_URL: ${MAP_URL}
|
||||||
|
MAP_WORKSPACE: ${MAP_WORKSPACE}
|
||||||
|
MAP_EXTENT: ${MAP_EXTENT}
|
||||||
|
NETWORK_NAME: ${NETWORK_NAME}
|
||||||
|
MAPBOX_TOKEN: ${MAPBOX_TOKEN}
|
||||||
|
TIANDITU_TOKEN: ${TIANDITU_TOKEN}
|
||||||
|
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
|
||||||
|
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
||||||
|
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
|
NODE_ENV: production
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
PORT: 3000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
pull_policy: always
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const parseExtent = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return [13508849, 3608036, 13555781, 3633813];
|
||||||
|
}
|
||||||
|
|
||||||
|
const extent = value.split(",").map(Number);
|
||||||
|
return extent.length === 4 && extent.every(Number.isFinite)
|
||||||
|
? extent
|
||||||
|
: [13508849, 3608036, 13555781, 3633813];
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
||||||
|
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
||||||
|
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
|
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
|
||||||
|
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
||||||
|
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
|
||||||
|
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
||||||
|
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
"/app/refine/public/runtime-config.js",
|
||||||
|
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
|
||||||
|
);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Chat 流式生成动画改造经验
|
||||||
|
|
||||||
|
本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。
|
||||||
|
- 已生成内容必须稳定,不能反复淡入、重排或闪烁。
|
||||||
|
- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。
|
||||||
|
- 底部自动滚动要跟随,但不能每个 token 都强制贴底。
|
||||||
|
- 动画应辅助理解,不能比内容本身更抢眼。
|
||||||
|
|
||||||
|
## 文本流式生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。
|
||||||
|
|
||||||
|
更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路:
|
||||||
|
|
||||||
|
- token 先进入缓冲区。
|
||||||
|
- 前端按固定节奏释放 chunk。
|
||||||
|
- chunk 尽量按词、短语、标点边界切分。
|
||||||
|
- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。
|
||||||
|
|
||||||
|
当前实现采用:
|
||||||
|
|
||||||
|
- `TOKEN_PLAYBACK_INTERVAL_MS = 16`
|
||||||
|
- 小缓冲按较短 chunk 输出。
|
||||||
|
- 大缓冲最多每帧释放 `160` 字符。
|
||||||
|
- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`。
|
||||||
|
- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。
|
||||||
|
- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。
|
||||||
|
- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。
|
||||||
|
- `done`、`error`、`tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。
|
||||||
|
|
||||||
|
## Markdown 动画
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。
|
||||||
|
|
||||||
|
典型例子:
|
||||||
|
|
||||||
|
- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。
|
||||||
|
- 列表符号、链接语法、代码块围栏都可能影响原始索引。
|
||||||
|
- 新增文本可能落在 `p`、`li`、`strong`、`code` 等不同节点里。
|
||||||
|
|
||||||
|
因此不要简单用原始 `text.length` 或 `fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- Markdown 仍完整解析,保证格式正确。
|
||||||
|
- 在 rehype 阶段处理 AST。
|
||||||
|
- 从 AST 尾部反向找最后的可见 text node。
|
||||||
|
- 只给最后一段尾部文本加动画。
|
||||||
|
- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 用 `text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。
|
||||||
|
- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。
|
||||||
|
- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。
|
||||||
|
- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。
|
||||||
|
|
||||||
|
## 当前文字动画建议
|
||||||
|
|
||||||
|
推荐保留轻量动画:
|
||||||
|
|
||||||
|
- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。
|
||||||
|
- 使用 `clip-path` 做左到右 reveal。
|
||||||
|
- 叠加轻微 opacity:当前约 `0.46 -> 1`。
|
||||||
|
- 时长控制在 `120ms - 260ms`。
|
||||||
|
|
||||||
|
不要做:
|
||||||
|
|
||||||
|
- 外层整段 `motion.div` 淡入。
|
||||||
|
- 每次流式更新都改变 key。
|
||||||
|
- 对整个 Markdown AST 的新增范围大面积包 span。
|
||||||
|
- 在生成中对已有文本重复动画。
|
||||||
|
|
||||||
|
## 滚动和边框稳定
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight` 或 `scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 生成中不再每个 token 精确贴底。
|
||||||
|
- 底部保留生成缓冲区,当前约 `180px`。
|
||||||
|
- 只有缓冲被消耗到阈值后才恢复滚动。
|
||||||
|
- 用户离开底部附近后,不再强制自动跟随。
|
||||||
|
- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。
|
||||||
|
- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。
|
||||||
|
- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。
|
||||||
|
|
||||||
|
## 图表生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`。
|
||||||
|
- 图表真实数据到达后,继续短暂保留 skeleton overlay。
|
||||||
|
- ECharts 在 skeleton 下方淡入。
|
||||||
|
- 容器尺寸保持一致,避免边框高度突变。
|
||||||
|
- ECharts 内部使用 enter/update 动画,而不是外层布局动画。
|
||||||
|
|
||||||
|
图表类型动画建议:
|
||||||
|
|
||||||
|
- 折线图:平滑 enter,面积渐显。
|
||||||
|
- 柱状图:柱子从基线增长,并对数据点轻微 stagger。
|
||||||
|
- 饼图:使用 expansion/sweep 类进入动画。
|
||||||
|
- update 动画要短于 enter 动画。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。
|
||||||
|
- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。
|
||||||
|
- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。
|
||||||
|
|
||||||
|
## 状态提示
|
||||||
|
|
||||||
|
“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 状态放在“分析结果”标题行右侧。
|
||||||
|
- 使用小尺寸、低干扰的 pulsing dots。
|
||||||
|
- 不使用末尾光标,避免和业务文本混在一起。
|
||||||
|
|
||||||
|
## 验证建议
|
||||||
|
|
||||||
|
每次调整流式动画后至少跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
人工验证重点:
|
||||||
|
|
||||||
|
- 长中文回答是否明显落后后端真实速度。
|
||||||
|
- Markdown 加粗、列表、代码块是否乱序。
|
||||||
|
- 底部自动滚动时“分析结果”边框是否抖动。
|
||||||
|
- 工具调用 pending 到图表出现时是否有高度跳变。
|
||||||
|
- 用户手动上滚后是否停止强制跟随。
|
||||||
|
|
||||||
|
## 后续调整原则
|
||||||
|
|
||||||
|
1. 先调节 token playback,再调动画。
|
||||||
|
2. 动画只作用于新增内容,已有内容不能重播。
|
||||||
|
3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。
|
||||||
|
4. 图表和工具调用先稳定布局,再考虑视觉效果。
|
||||||
|
5. 滚动跟随要有缓冲,不能逐 token 贴底。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
|
||||||
|
const config = [...nextCoreWebVitals];
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# CI build notes
|
||||||
|
|
||||||
|
## 2026-04-24
|
||||||
|
|
||||||
|
- **Observed failure while reproducing workflow checkout locally:** the `Checkout code` step ran `git remote add origin ...` unconditionally. In a workspace that already had an `origin` remote, the job failed with `error: remote origin already exists.` and exited before `docker build`.
|
||||||
|
- **Why this matters for act_runner:** self-hosted Gitea runners can reuse working directories or start from repositories that already contain Git metadata, so checkout logic must be idempotent.
|
||||||
|
- **Applied fix:** changed `.gitea/workflows/package.yml` to initialize Git only when needed, use `git remote set-url origin ...` when `origin` already exists, and force-clean the workspace after checking out `FETCH_HEAD`.
|
||||||
|
- **Safety improvement for remote validation:** tags ending with `-test` now run the build verification path only. They skip registry login, image push, `latest` updates, and the deploy webhook so act_runner can be tested without deployment side effects.
|
||||||
|
- **Root cause found on the real act_runner:** although the runner was registered with `ubuntu:docker://gitea/runner-images:ubuntu-22.04`, the workflow used `runs-on: ubuntu`, and the job log showed `Start image=ubuntu:latest`. That default image does not include the expected toolset, which explains the remote `git: not found` failure.
|
||||||
|
- **Applied fix for label selection:** changed both jobs to `runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04"` so Gitea resolves the exact runner image instead of falling back to `ubuntu:latest`.
|
||||||
|
- **Follow-up from server validation:** Gitea then reported `No matching online runner with label: ubuntu:docker://gitea/runner-images:ubuntu-22.04`. The runner advertises the short label `ubuntu-22.04`, so the workflow was updated again to use `runs-on: ubuntu-22.04`, which should map to `docker://gitea/runner-images:ubuntu-22.04` on the runner side.
|
||||||
|
- **Next remote failure on act_runner:** Docker rejected the tag `gitea.waternetwork.cn/OrgTJWater/TJWaterFrontend_Refine:v2026.04.24-test3` with `repository name must be lowercase`. The workflow had normalized the registry host but not the repository path from `github.repository`.
|
||||||
|
- **Applied fix for image naming:** lowercased `REPOSITORY_PATH` during image metadata normalization so image tags remain valid even when the Gitea owner or repository name contains uppercase letters.
|
||||||
|
- **Latest remote failure on act_runner:** a `*-test` run still reached `Notify Deploy Server` and failed with `curl: (3) URL using bad/illegal format or missing URL`. That showed the shell-level `IS_TEST_TAG` guard was not reliable enough for cross-step skip control on this runner.
|
||||||
|
- **Applied fix for test-tag skipping:** moved registry login and deploy webhook skipping to workflow-level `if:` conditions based on `endsWith(github.ref_name, '-test')`, and made the image-push branch check the tag name directly instead of relying on `IS_TEST_TAG` from a previous step.
|
||||||
|
- **Follow-up from server validation:** the runner still executed `Notify Deploy Server` for `v2026.04.24-test5`, so Gitea step-level `if:` with `endsWith(...)` was not sufficient in this environment.
|
||||||
|
- **Applied hardening:** replaced those step-level conditions with direct shell `case "${{ github.ref_name }}" in *-test)` guards inside the login, push, and deploy steps. This avoids relying on Gitea expression behavior for test-tag skipping.
|
||||||
|
- **Workflow mode changed for full CD verification:** per latest request, all `*-test` bypass logic was removed again so the workflow always runs registry login, image push, and deploy webhook. Full deployment validation now depends on using a normal `v*` tag and observing the real CD result instead of synthetic skip branches.
|
||||||
|
- **Next full-CD failure on act_runner:** image build completed, but pushing to the Gitea registry failed on blob upload commit with `failed to do request: Put ... EOF`. This is past the workflow logic stage and points to a transient or infrastructure-side registry upload failure.
|
||||||
|
- **Applied push hardening:** wrapped both `docker push "${IMAGE_NAME}:${IMAGE_TAG}"` and `docker push "${IMAGE_NAME}:latest"` in a 3-attempt retry helper with a short backoff to absorb transient registry EOF failures.
|
||||||
|
- **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment.
|
||||||
|
- **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow.
|
||||||
@@ -1,6 +1,23 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "refine.ams3.cdn.digitaloceanspaces.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
turbopack: {
|
||||||
|
rules: {
|
||||||
|
"*.svg": {
|
||||||
|
loaders: ["@svgr/webpack"],
|
||||||
|
as: "*.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
|
|||||||
Generated
+2782
-3720
File diff suppressed because it is too large
Load Diff
+27
-18
@@ -6,14 +6,17 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
"dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
|
||||||
"build": "refine build",
|
"runtime:config": "node scripts/generate-runtime-config.mjs",
|
||||||
"start": "refine start",
|
"build": "next build",
|
||||||
"lint": "next lint",
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
"refine": "refine"
|
"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": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.8.2",
|
||||||
@@ -24,12 +27,10 @@
|
|||||||
"@mui/x-charts": "^7.29.1",
|
"@mui/x-charts": "^7.29.1",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
"@mui/x-date-pickers": "^8.12.0",
|
"@mui/x-date-pickers": "^8.12.0",
|
||||||
"@refinedev/cli": "^2.16.50",
|
"@refinedev/core": "^5.0.12",
|
||||||
"@refinedev/core": "^5.0.8",
|
|
||||||
"@refinedev/devtools": "^2.0.3",
|
|
||||||
"@refinedev/kbar": "^2.0.1",
|
"@refinedev/kbar": "^2.0.1",
|
||||||
"@refinedev/mui": "^8.0.0",
|
"@refinedev/mui": "^8.0.2",
|
||||||
"@refinedev/nextjs-router": "^7.0.4",
|
"@refinedev/nextjs-router": "^7.0.5",
|
||||||
"@refinedev/react-hook-form": "^5.0.4",
|
"@refinedev/react-hook-form": "^5.0.4",
|
||||||
"@refinedev/simple-rest": "^6.0.1",
|
"@refinedev/simple-rest": "^6.0.1",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
@@ -39,17 +40,26 @@
|
|||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
|
"edge-tts-ts": "^1.0.0",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^15.5.11",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-draggable": "^4.5.0",
|
"react-draggable": "^4.5.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-window": "^1.8.10",
|
"react-window": "^1.8.10",
|
||||||
"tailwindcss": "^4.1.13"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"fast-xml-parser": "5.5.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
@@ -62,15 +72,14 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^15.0.3",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
|
||||||
"refine": {
|
|
||||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tjwater-app",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
|
||||||
"build": "refine build",
|
|
||||||
"start": "refine start",
|
|
||||||
"lint": "next lint",
|
|
||||||
"refine": "refine"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@emotion/react": "^11.8.2",
|
|
||||||
"@emotion/styled": "^11.8.1",
|
|
||||||
"@mui/icons-material": "^6.1.6",
|
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
|
||||||
"@mui/material": "^6.1.7",
|
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
|
||||||
"@refinedev/cli": "^2.16.48",
|
|
||||||
"@refinedev/core": "^5.0.0",
|
|
||||||
"@refinedev/devtools": "^2.0.1",
|
|
||||||
"@refinedev/kbar": "^2.0.0",
|
|
||||||
"@refinedev/mui": "^7.0.0",
|
|
||||||
"@refinedev/nextjs-router": "^7.0.0",
|
|
||||||
"@refinedev/react-hook-form": "^5.0.0",
|
|
||||||
"@refinedev/simple-rest": "^6.0.0",
|
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
|
||||||
"@turf/turf": "^7.2.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"deck.gl": "^9.1.14",
|
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"next": "^15.2.4",
|
|
||||||
"next-auth": "^4.24.5",
|
|
||||||
"ol": "^10.6.1",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"tailwindcss": "^4.1.13"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@svgr/webpack": "^8.1.0",
|
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19.1.0",
|
|
||||||
"@types/react-dom": "^19.1.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-next": "^15.0.3",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
},
|
|
||||||
"refine": {
|
|
||||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777523623582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11701" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" fill="#CE75FF" p-id="11702"></path><path d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" fill="#F3E2FF" p-id="11703"></path><path d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#F62E76" p-id="11704"></path><path d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#CD88FF" p-id="11705"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777457471585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5556" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M550.4 486.4c0-8.533333 4.266667-12.8 12.8-12.8h4.266667c4.266667 0 4.266667 4.266667 4.266666 4.266667s4.266667 4.266667 4.266667 8.533333v4.266667s0 4.266667-4.266667 4.266666c0 0-4.266667 0-4.266666 4.266667h-4.266667-4.266667s-4.266667 0-4.266666-4.266667c0 0 0-4.266667-4.266667-4.266666v-4.266667z" fill="#4D6BFE" p-id="5557"></path><path d="M994.133333 196.266667c-8.533333-4.266667-12.8 4.266667-21.333333 8.533333l-4.266667 4.266667c-12.8 17.066667-34.133333 25.6-55.466666 25.6-34.133333 0-59.733333 8.533333-85.333334 34.133333-4.266667-29.866667-21.333333-51.2-51.2-64-12.8-4.266667-29.866667-12.8-38.4-25.6-8.533333-8.533333-8.533333-21.333333-12.8-29.866667 0-4.266667 0-12.8-8.533333-12.8s-12.8 4.266667-12.8 12.8c-12.8 21.333333-21.333333 46.933333-17.066667 72.533334 0 59.733333 25.6 106.666667 72.533334 136.533333 4.266667 4.266667 8.533333 8.533333 4.266666 12.8-4.266667 12.8-8.533333 21.333333-8.533333 34.133333-4.266667 8.533333-4.266667 8.533333-12.8 4.266667-25.6-12.8-51.2-29.866667-68.266667-46.933333-34.133333-34.133333-64-72.533333-102.4-102.4-8.533333-8.533333-17.066667-12.8-25.6-21.333334-46.933333-34.133333 0-64 8.533334-68.266666 12.8-4.266667 4.266667-17.066667-29.866667-17.066667-34.133333 0-68.266667 12.8-106.666667 29.866667-8.533333 0-12.8 0-21.333333 4.266666-38.4-8.533333-76.8-8.533333-115.2-4.266666-76.8 8.533333-136.533333 42.666667-179.2 106.666666-51.2 76.8-64 157.866667-51.2 247.466667 17.066667 93.866667 64 170.666667 132.266667 230.4 72.533333 64 157.866667 93.866667 256 85.333333 59.733333-4.266667 123.733333-12.8 200.533333-76.8 17.066667 8.533333 38.4 12.8 72.533333 17.066667 25.6 4.266667 51.2 0 68.266667-4.266667 29.866667-4.266667 25.6-34.133333 17.066667-38.4-85.333333-42.666667-68.266667-25.6-85.333334-38.4 42.666667-51.2 110.933333-106.666667 136.533334-285.866666v-34.133334c0-8.533333 4.266667-8.533333 12.8-8.533333 21.333333-4.266667 42.666667-8.533333 59.733333-21.333333 55.466667-29.866667 76.8-81.066667 85.333333-145.066667 0-8.533333 0-17.066667-12.8-21.333333zM507.733333 746.666667c-85.333333-68.266667-123.733333-89.6-140.8-89.6-17.066667 0-12.8 21.333333-8.533333 29.866666 4.266667 12.8 8.533333 21.333333 12.8 29.866667 4.266667 8.533333 8.533333 17.066667-4.266667 25.6-25.6 17.066667-72.533333-4.266667-76.8-8.533333-55.466667-34.133333-98.133333-76.8-132.266666-136.533334-29.866667-51.2-46.933333-110.933333-46.933334-174.933333 0-17.066667 4.266667-21.333333 17.066667-25.6 21.333333-4.266667 42.666667-4.266667 59.733333 0 85.333333 12.8 157.866667 51.2 217.6 115.2 34.133333 34.133333 59.733333 76.8 89.6 119.466667 29.866667 42.666667 59.733333 85.333333 98.133334 119.466666 12.8 12.8 25.6 21.333333 34.133333 25.6-29.866667 0-81.066667 0-119.466667-29.866666z m166.4-196.266667c-8.533333 4.266667-17.066667 4.266667-25.6 4.266667-12.8 0-25.6-4.266667-29.866666-8.533334-12.8-8.533333-17.066667-12.8-21.333334-29.866666v-25.6c4.266667-12.8 0-21.333333-8.533333-29.866667-8.533333-4.266667-17.066667-8.533333-25.6-8.533333-4.266667 0-8.533333 0-8.533333-4.266667 0 0-4.266667 0-4.266667-4.266667v-4.266666-4.266667-4.266667c0-4.266667 8.533333-8.533333 8.533333-8.533333 12.8-8.533333 29.866667-4.266667 46.933334 0 12.8 4.266667 25.6 17.066667 38.4 29.866667 17.066667 17.066667 17.066667 25.6 25.6 38.4 8.533333 12.8 12.8 21.333333 17.066666 34.133333 0 12.8-4.266667 21.333333-12.8 25.6z" fill="#4D6BFE" p-id="5558"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -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}`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import nextEnv from "@next/env";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectDir = process.cwd();
|
||||||
|
const { loadEnvConfig } = nextEnv;
|
||||||
|
|
||||||
|
loadEnvConfig(projectDir, process.env.NODE_ENV !== "production");
|
||||||
|
|
||||||
|
const parseExtent = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return [13508849, 3608036, 13555781, 3633813];
|
||||||
|
}
|
||||||
|
|
||||||
|
const extent = value.split(",").map(Number);
|
||||||
|
return extent.length === 4 && extent.every(Number.isFinite)
|
||||||
|
? extent
|
||||||
|
: [13508849, 3608036, 13555781, 3633813];
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
||||||
|
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
||||||
|
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
|
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
|
||||||
|
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
||||||
|
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
|
||||||
|
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
||||||
|
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputPath = path.join(projectDir, "public", "runtime-config.js");
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
outputPath,
|
||||||
|
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Generated ${path.relative(projectDir, outputPath)}`);
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh gitea v2026.05.15.1"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE="${1:-gitea}"
|
||||||
|
TAG="${2:-latest}"
|
||||||
|
|
||||||
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Current directory is not a git repository."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Remote '$REMOTE' does not exist."
|
||||||
|
echo "Available remotes:"
|
||||||
|
git remote -v
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
|
||||||
|
|
||||||
|
echo "[INFO] HEAD: ${HEAD_SHA}"
|
||||||
|
echo "[INFO] Recreate annotated tag '${TAG}'"
|
||||||
|
git tag -fa "$TAG" -m "$MESSAGE"
|
||||||
|
|
||||||
|
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
|
||||||
|
git push "$REMOTE" "refs/tags/${TAG}" --force
|
||||||
|
|
||||||
|
echo "[INFO] Verify remote tag reference"
|
||||||
|
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
|
||||||
|
|
||||||
|
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
|
import {
|
||||||
|
RAINBOW_COLORS,
|
||||||
|
RISK_BREAKS,
|
||||||
|
} 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>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="realtime"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
HistoryPanel={PredictDataPanel}
|
||||||
|
/>
|
||||||
|
<Timeline />
|
||||||
|
<HealthRiskStatistics />
|
||||||
|
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
||||||
|
<StyleLegend
|
||||||
|
layerName="管道"
|
||||||
|
layerId="health-risk"
|
||||||
|
property="健康风险"
|
||||||
|
colors={RAINBOW_COLORS}
|
||||||
|
type="line"
|
||||||
|
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
|
||||||
|
breaks={[0, ...RISK_BREAKS]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</HealthRiskProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const BurstDetectionPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-detection" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_detection"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<BurstDetectionPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const BurstLocationPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-location" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_location"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<BurstLocationPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const BurstPipeAnalysisPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<BurstPipeAnalysisPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const WaterQualityPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="contaminant_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<WaterQualityPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const DMALeakDetectionPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="dma_leak_identification"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<DMALeakDetectionPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const FlushingAnalysisPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
|
||||||
|
<FlushingAnalysisPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
|
|
||||||
|
export default function MapLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <MapComponent>{children}</MapComponent>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const MonitoringPlaceOptimizationPanel = dynamic(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<MapPanelSkeleton variant="monitoring-place-optimization" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar hiddenButtons={["style"]} />
|
||||||
|
<MonitoringPlaceOptimizationPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MapPanelSkeleton,
|
||||||
|
MapTimelineSkeleton,
|
||||||
|
} from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
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[]>([]);
|
||||||
|
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||||
|
setSelectedDeviceIds(ids);
|
||||||
|
setPanelVisible(ids.length > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeviceClick = useCallback(() => {
|
||||||
|
setPanelVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar queryType="realtime" />
|
||||||
|
<Timeline />
|
||||||
|
<SCADADeviceList
|
||||||
|
onDeviceClick={handleDeviceClick}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
selectedDeviceIds={selectedDeviceIds}
|
||||||
|
/>
|
||||||
|
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
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[]>([]);
|
||||||
|
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||||
|
setSelectedDeviceIds(ids);
|
||||||
|
setPanelVisible(ids.length > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeviceClick = useCallback(() => {
|
||||||
|
setPanelVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar hiddenButtons={["style"]} />
|
||||||
|
<SCADADeviceList
|
||||||
|
onDeviceClick={handleDeviceClick}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
selectedDeviceIds={selectedDeviceIds}
|
||||||
|
showCleaning={true}
|
||||||
|
/>
|
||||||
|
<SCADADataPanel
|
||||||
|
deviceIds={selectedDeviceIds}
|
||||||
|
visible={panelVisible}
|
||||||
|
showCleaning={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AuditLogPanel } from "@/components/audit/AuditLogPanel";
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
return <AuditLogPanel />;
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
|
||||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
|
||||||
import MapToolbar from "@app/OlMap/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 "@app/OlMap/Controls/StyleLegend";
|
|
||||||
import {
|
|
||||||
RAINBOW_COLORS,
|
|
||||||
RISK_BREAKS,
|
|
||||||
} from "@components/olmap/HealthRiskAnalysis/types";
|
|
||||||
import { Box } from "@mui/material";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<HealthRiskProvider>
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar
|
|
||||||
queryType="realtime"
|
|
||||||
hiddenButtons={["style"]}
|
|
||||||
HistoryPanel={PredictDataPanel}
|
|
||||||
/>
|
|
||||||
<Timeline />
|
|
||||||
<HealthRiskStatistics />
|
|
||||||
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
|
||||||
<StyleLegend
|
|
||||||
layerName="管道"
|
|
||||||
layerId="health-risk"
|
|
||||||
property="健康风险"
|
|
||||||
colors={RAINBOW_COLORS}
|
|
||||||
type="line"
|
|
||||||
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
|
|
||||||
breaks={[0, ...RISK_BREAKS]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</MapComponent>
|
|
||||||
</HealthRiskProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+21
-20
@@ -1,12 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import React, { Suspense } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { RefineContext } from "../_refine_context";
|
|
||||||
|
|
||||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||||
import { Header } from "@components/header";
|
import { Header } from "@components/header";
|
||||||
import { Title } from "@components/title";
|
import { Title } from "@components/title";
|
||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
import { AppSider } from "@components/sider/AppSider";
|
||||||
import { ThemedLayout } from "@refinedev/mui";
|
import { ThemedLayout } from "@refinedev/mui";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
|
|||||||
export default async function MainLayout({
|
export default async function MainLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const theme = cookieStore.get("theme");
|
const theme = cookieStore.get("theme");
|
||||||
@@ -33,22 +32,24 @@ export default async function MainLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RefineContext defaultMode={defaultMode}>
|
<ThemedLayout
|
||||||
<ThemedLayout
|
Header={Header}
|
||||||
Header={Header}
|
Title={Title}
|
||||||
Title={Title}
|
Sider={AppSider}
|
||||||
childrenBoxProps={{
|
childrenBoxProps={{
|
||||||
sx: { height: "100vh", p: 0 },
|
sx: {
|
||||||
}}
|
flex: 1,
|
||||||
containerBoxProps={{
|
minHeight: 0,
|
||||||
sx: { height: "100%" },
|
overflow: "auto",
|
||||||
}}
|
p: 0,
|
||||||
>
|
},
|
||||||
<Suspense fallback={<MapSkeleton />}>
|
}}
|
||||||
{children}
|
containerBoxProps={{
|
||||||
</Suspense>
|
sx: { height: "100vh", overflow: "hidden" },
|
||||||
</ThemedLayout>
|
}}
|
||||||
</RefineContext>
|
>
|
||||||
|
{children}
|
||||||
|
</ThemedLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
|
||||||
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar hiddenButtons={["style"]} />
|
|
||||||
<MonitoringPlaceOptimizationPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
|
||||||
import ZonePropsPanel from "@components/olmap/NetworkPartitionOptimization/ZonePropsPanel";
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<ZonePropsPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
|
||||||
import Timeline from "@app/OlMap/Controls/Timeline";
|
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
|
||||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
|
||||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
|
||||||
setSelectedDeviceIds(ids);
|
|
||||||
setPanelVisible(ids.length > 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDeviceClick = useCallback(() => {
|
|
||||||
setPanelVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar queryType="realtime" />
|
|
||||||
<Timeline />
|
|
||||||
<SCADADeviceList
|
|
||||||
onDeviceClick={handleDeviceClick}
|
|
||||||
onSelectionChange={handleSelectionChange}
|
|
||||||
selectedDeviceIds={selectedDeviceIds}
|
|
||||||
/>
|
|
||||||
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
|
||||||
import BurstPipeAnalysisPanel from "@/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar queryType="scheme" />
|
|
||||||
<BurstPipeAnalysisPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
|
||||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
|
||||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
|
||||||
setSelectedDeviceIds(ids);
|
|
||||||
setPanelVisible(ids.length > 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDeviceClick = useCallback(() => {
|
|
||||||
setPanelVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
|
||||||
<MapComponent>
|
|
||||||
<MapToolbar hiddenButtons={["style"]} />
|
|
||||||
<SCADADeviceList
|
|
||||||
onDeviceClick={handleDeviceClick}
|
|
||||||
onSelectionChange={handleSelectionChange}
|
|
||||||
selectedDeviceIds={selectedDeviceIds}
|
|
||||||
showCleaning={true}
|
|
||||||
/>
|
|
||||||
<SCADADataPanel
|
|
||||||
deviceIds={selectedDeviceIds}
|
|
||||||
visible={panelVisible}
|
|
||||||
showCleaning={true}
|
|
||||||
/>
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SystemAdminPanel } from "@/components/admin/SystemAdminPanel";
|
||||||
|
|
||||||
|
export default function SystemAdminPage() {
|
||||||
|
return <SystemAdminPanel />;
|
||||||
|
}
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useMap } from "../MapComponent";
|
|
||||||
import TileLayer from "ol/layer/Tile.js";
|
|
||||||
import XYZ from "ol/source/XYZ.js";
|
|
||||||
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
|
|
||||||
import mapboxLight from "@assets/map/layers/mapbox-light.png";
|
|
||||||
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
|
|
||||||
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
|
|
||||||
import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Group from "ol/layer/Group";
|
|
||||||
import { MAPBOX_TOKEN } from "@config/config";
|
|
||||||
import { TIANDITU_TOKEN } from "@config/config";
|
|
||||||
const INITIAL_LAYER = "mapbox-light";
|
|
||||||
|
|
||||||
const streetsLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const lightMapLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const satelliteLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const satelliteStreetsLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tiandituVectorLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituVectorAnnotationLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituImageLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituImageAnnotationLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituVectorLayerGroup = new Group({
|
|
||||||
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
|
||||||
});
|
|
||||||
const tiandituImageLayerGroup = new Group({
|
|
||||||
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
|
||||||
});
|
|
||||||
const baseLayers = [
|
|
||||||
{
|
|
||||||
id: "mapbox-light",
|
|
||||||
name: "默认地图",
|
|
||||||
layer: lightMapLayer,
|
|
||||||
// layer: tiandituVectorLayerGroup,
|
|
||||||
img: mapboxLight.src,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mapbox-satellite",
|
|
||||||
name: "卫星地图",
|
|
||||||
layer: satelliteLayer,
|
|
||||||
// layer: tiandituImageLayerGroup,
|
|
||||||
img: mapboxSatellite.src,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mapbox-satellite-streets",
|
|
||||||
name: "卫星街道地图",
|
|
||||||
layer: satelliteStreetsLayer,
|
|
||||||
img: mapboxSatelliteStreet.src,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mapbox-streets",
|
|
||||||
name: "街道地图",
|
|
||||||
layer: streetsLayer,
|
|
||||||
img: mapboxStreets.src,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BaseLayers: React.FC = () => {
|
|
||||||
const map = useMap();
|
|
||||||
// 切换底图选项展开,控制显示和卸载
|
|
||||||
const [isShow, setShow] = useState(false);
|
|
||||||
const [isExpanded, setExpanded] = useState(false);
|
|
||||||
// 快速切换底图
|
|
||||||
const [activeId, setActiveId] = useState(INITIAL_LAYER);
|
|
||||||
|
|
||||||
// 初始化默认底图
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
// 添加所有底图至地图并根据 activeId 控制可见性
|
|
||||||
baseLayers.forEach((layerInfo) => {
|
|
||||||
const layers = map.getLayers().getArray();
|
|
||||||
if (!layers.includes(layerInfo.layer)) {
|
|
||||||
map.getLayers().insertAt(0, layerInfo.layer);
|
|
||||||
}
|
|
||||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
|
||||||
});
|
|
||||||
}, [map]);
|
|
||||||
|
|
||||||
const changeMapLayers = (id: string) => {
|
|
||||||
if (map) {
|
|
||||||
// 根据 id 设置每个图层的可见性
|
|
||||||
baseLayers.forEach(({ id: lid, layer }) => {
|
|
||||||
layer.setVisible(lid === id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickSwitch = () => {
|
|
||||||
const nextId =
|
|
||||||
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
|
|
||||||
setActiveId(nextId);
|
|
||||||
handleMapLayers(nextId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMapLayers = (id: string) => {
|
|
||||||
setActiveId(id);
|
|
||||||
changeMapLayers(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 记录定时器,避免多次触发
|
|
||||||
const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const handleEnter = () => {
|
|
||||||
if (hideTimer.current) {
|
|
||||||
clearTimeout(hideTimer.current);
|
|
||||||
hideTimer.current = null;
|
|
||||||
}
|
|
||||||
setShow(true);
|
|
||||||
setExpanded(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLeave = () => {
|
|
||||||
setShow(false);
|
|
||||||
hideTimer.current = setTimeout(() => {
|
|
||||||
setExpanded(false);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute right-17 bottom-8 z-1300">
|
|
||||||
<div
|
|
||||||
className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black"
|
|
||||||
onMouseEnter={handleEnter}
|
|
||||||
onMouseLeave={handleLeave}
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 p-1">
|
|
||||||
<button onClick={() => handleQuickSwitch()}>
|
|
||||||
<img
|
|
||||||
width={240}
|
|
||||||
height={100}
|
|
||||||
src={
|
|
||||||
activeId === baseLayers[0].id
|
|
||||||
? baseLayers[1].img
|
|
||||||
: baseLayers[0].img
|
|
||||||
}
|
|
||||||
alt={
|
|
||||||
activeId === baseLayers[0].id
|
|
||||||
? baseLayers[1].name
|
|
||||||
: baseLayers[0].name
|
|
||||||
}
|
|
||||||
className="object-cover object-left w-18 h-18 rounded-xl"
|
|
||||||
/>
|
|
||||||
<div className=" absolute left-1 bottom-1 flex w-18 h-auto items-center justify-center rounded-b-xl text-xs text-white bg-black opacity-80">
|
|
||||||
<span>
|
|
||||||
{activeId === baseLayers[0].id
|
|
||||||
? baseLayers[1].name
|
|
||||||
: baseLayers[0].name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"absolute flex right-24 bottom-0 w-90 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
|
|
||||||
isShow ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
onMouseEnter={handleEnter}
|
|
||||||
onMouseLeave={handleLeave}
|
|
||||||
>
|
|
||||||
{baseLayers.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
|
|
||||||
onClick={() => handleMapLayers(item.id)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
width={240}
|
|
||||||
height={100}
|
|
||||||
src={item.img}
|
|
||||||
alt={item.name}
|
|
||||||
className={clsx(
|
|
||||||
"object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300",
|
|
||||||
{
|
|
||||||
"ring-1 ring-blue-300": activeId === item.id,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="pt-1">{item.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BaseLayers;
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { useData, useMap } from "../MapComponent";
|
|
||||||
import { Checkbox, FormControlLabel } from "@mui/material";
|
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
|
||||||
import VectorLayer from "ol/layer/Vector";
|
|
||||||
import VectorTileLayer from "ol/layer/VectorTile";
|
|
||||||
import { DeckLayer } from "@utils/layers";
|
|
||||||
|
|
||||||
// 定义统一的图层项接口
|
|
||||||
interface LayerItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
visible: boolean;
|
|
||||||
type: "ol" | "deck";
|
|
||||||
layerRef: any; // OpenLayers Layer 实例或 deck.gl layer 对象
|
|
||||||
}
|
|
||||||
|
|
||||||
const LayerControl: React.FC = () => {
|
|
||||||
const map = useMap();
|
|
||||||
const data = useData();
|
|
||||||
if (!data) return;
|
|
||||||
const {
|
|
||||||
deckLayer,
|
|
||||||
isContourLayerAvailable,
|
|
||||||
isWaterflowLayerAvailable,
|
|
||||||
setShowWaterflowLayer,
|
|
||||||
setShowContourLayer,
|
|
||||||
} = data;
|
|
||||||
const [layerItems, setLayerItems] = useState<LayerItem[]>([]);
|
|
||||||
|
|
||||||
const layerOrder = [
|
|
||||||
"junctions",
|
|
||||||
"reservoirs",
|
|
||||||
"tanks",
|
|
||||||
"pipes",
|
|
||||||
"pumps",
|
|
||||||
"valves",
|
|
||||||
"scada",
|
|
||||||
"waterflowLayer",
|
|
||||||
"junctionContourLayer",
|
|
||||||
];
|
|
||||||
|
|
||||||
// 更新图层列表
|
|
||||||
const updateLayers = useCallback(() => {
|
|
||||||
if (!map || !data) return;
|
|
||||||
|
|
||||||
const items: LayerItem[] = [];
|
|
||||||
|
|
||||||
// 1. 获取 OpenLayers 图层
|
|
||||||
const mapLayers = map.getLayers().getArray();
|
|
||||||
mapLayers.forEach((layer) => {
|
|
||||||
// 筛选特定类型的 OpenLayers 图层
|
|
||||||
if (
|
|
||||||
layer instanceof WebGLVectorTileLayer ||
|
|
||||||
layer instanceof VectorTileLayer ||
|
|
||||||
layer instanceof VectorLayer
|
|
||||||
) {
|
|
||||||
const value = layer.get("value");
|
|
||||||
const name = layer.get("name");
|
|
||||||
// 只有设置了 value (作为 ID) 的图层才会被纳入控制
|
|
||||||
if (value) {
|
|
||||||
items.push({
|
|
||||||
id: value,
|
|
||||||
name: name || value,
|
|
||||||
visible: layer.getVisible(),
|
|
||||||
type: "ol",
|
|
||||||
layerRef: layer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 获取 DeckLayer 中的子图层
|
|
||||||
if (deckLayer && deckLayer instanceof DeckLayer) {
|
|
||||||
const deckLayers = deckLayer.getDeckLayers();
|
|
||||||
deckLayers.forEach((layer: any) => {
|
|
||||||
if (layer && layer.id) {
|
|
||||||
// 仅处理 junctionContourLayer 和 waterflowLayer
|
|
||||||
if (
|
|
||||||
layer.id !== "junctionContourLayer" &&
|
|
||||||
layer.id !== "waterflowLayer"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 检查可用性
|
|
||||||
if (
|
|
||||||
(layer.id === "junctionContourLayer" && !isContourLayerAvailable) ||
|
|
||||||
(layer.id === "waterflowLayer" && !isWaterflowLayerAvailable)
|
|
||||||
) {
|
|
||||||
return; // 跳过不可用图层
|
|
||||||
}
|
|
||||||
const visible =
|
|
||||||
deckLayer.getDeckLayerVisible(layer.id) ??
|
|
||||||
layer.props?.visible ??
|
|
||||||
true;
|
|
||||||
items.push({
|
|
||||||
id: layer.props.id,
|
|
||||||
name: layer.props.name, // 使用 name 属性作为显示名称
|
|
||||||
visible: visible,
|
|
||||||
type: "deck",
|
|
||||||
layerRef: layer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤并排序
|
|
||||||
const sortedItems = items
|
|
||||||
.filter((item) => layerOrder.includes(item.id))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const indexA = layerOrder.indexOf(a.id);
|
|
||||||
const indexB = layerOrder.indexOf(b.id);
|
|
||||||
return indexA - indexB;
|
|
||||||
});
|
|
||||||
|
|
||||||
setLayerItems(sortedItems);
|
|
||||||
}, [map, deckLayer, isWaterflowLayerAvailable, isContourLayerAvailable]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateLayers();
|
|
||||||
|
|
||||||
if (map) {
|
|
||||||
const layerCollection = map.getLayers();
|
|
||||||
layerCollection.on("change:length", updateLayers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (map) {
|
|
||||||
map.getLayers().un("change:length", updateLayers);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [map, updateLayers]);
|
|
||||||
|
|
||||||
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
|
|
||||||
if (item.type === "ol") {
|
|
||||||
item.layerRef.setVisible(checked);
|
|
||||||
} else if (item.type === "deck" && deckLayer) {
|
|
||||||
if (item.id === "junctionContourLayer") {
|
|
||||||
setShowContourLayer && setShowContourLayer(checked);
|
|
||||||
}
|
|
||||||
if (item.id === "waterflowLayer") {
|
|
||||||
setShowWaterflowLayer && setShowWaterflowLayer(checked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLayerItems((prev) =>
|
|
||||||
prev.map((i) => (i.id === item.id ? { ...i, visible: checked } : i)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg z-1300 opacity-85 hover:opacity-100 transition-opacity max-w-xs">
|
|
||||||
<div className="ml-3 grid grid-cols-3">
|
|
||||||
{layerItems.map((item) => (
|
|
||||||
<FormControlLabel
|
|
||||||
key={item.id}
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={item.visible}
|
|
||||||
onChange={(e) => handleVisibilityChange(item, e.target.checked)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={item.name}
|
|
||||||
sx={{
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
"& .MuiFormControlLabel-label": { fontSize: "0.7rem" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LayerControl;
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface BaseProperty {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
unit?: string;
|
|
||||||
formatter?: (value: string | number) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增:表格型属性(用于二级数据)
|
|
||||||
interface TableProperty {
|
|
||||||
type: "table";
|
|
||||||
label: string;
|
|
||||||
columns: string[]; // 表头
|
|
||||||
rows: (string | number)[][]; // 每行的数据
|
|
||||||
}
|
|
||||||
|
|
||||||
type PropertyItem = BaseProperty | TableProperty;
|
|
||||||
|
|
||||||
interface PropertyPanelProps {
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
properties?: PropertyItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|
||||||
id,
|
|
||||||
type = "未知类型",
|
|
||||||
properties = [],
|
|
||||||
}) => {
|
|
||||||
const formatValue = (property: BaseProperty) => {
|
|
||||||
if (property.formatter) {
|
|
||||||
return property.formatter(property.value);
|
|
||||||
}
|
|
||||||
if (property.unit) {
|
|
||||||
return `${property.value} ${property.unit}`;
|
|
||||||
}
|
|
||||||
return property.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"];
|
|
||||||
|
|
||||||
// 统计属性数量(表格型按行数计入)
|
|
||||||
const totalProps = id
|
|
||||||
? 2 +
|
|
||||||
properties.reduce((sum, p) => {
|
|
||||||
if ("type" in p && p.type === "table") return sum + p.rows.length;
|
|
||||||
return sum + 1;
|
|
||||||
}, 0)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 ">
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<h3 className="text-lg font-semibold">属性面板</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
|
||||||
{!id ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
|
||||||
<svg
|
|
||||||
className="w-16 h-16 mb-3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm">暂无属性信息</p>
|
|
||||||
<p className="text-xs mt-1">请选择一个要素以查看其属性</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* ID 属性 */}
|
|
||||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
|
||||||
ID
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
|
||||||
{id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 类型属性 */}
|
|
||||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
|
||||||
类型
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
|
||||||
{type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 其他属性(包含二级表格) */}
|
|
||||||
{properties.map((property, index) => {
|
|
||||||
// 二级表格
|
|
||||||
if ("type" in property && property.type === "table") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`table-${index}`}
|
|
||||||
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
|
|
||||||
{property.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-gray-200 text-gray-700">
|
|
||||||
<tr>
|
|
||||||
{property.columns.map((col, ci) => (
|
|
||||||
<th
|
|
||||||
key={ci}
|
|
||||||
className="px-3 py-2 text-left font-semibold"
|
|
||||||
>
|
|
||||||
{col}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-300">
|
|
||||||
{property.rows.map((row, ri) => (
|
|
||||||
<tr key={ri} className="bg-white hover:bg-gray-50">
|
|
||||||
{row.map((cell, cci) => (
|
|
||||||
<td
|
|
||||||
key={cci}
|
|
||||||
className="px-3 py-2 text-gray-800"
|
|
||||||
>
|
|
||||||
{cell}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 普通属性
|
|
||||||
const base = property as BaseProperty;
|
|
||||||
const isImportant = isImportantKeys.includes(base.label);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`prop-${index}`}
|
|
||||||
className={`group rounded-lg p-3 transition-all duration-200 ${
|
|
||||||
isImportant
|
|
||||||
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
|
|
||||||
: "bg-gray-50 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span
|
|
||||||
className={`font-medium text-xs uppercase tracking-wide ${
|
|
||||||
isImportant ? "text-blue-700" : "text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{base.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-sm font-semibold text-right flex-1 ${
|
|
||||||
isImportant ? "text-blue-900" : "text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatValue(base)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部统计区域 */}
|
|
||||||
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-gray-600 flex items-center gap-1">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
共 {totalProps} 个属性
|
|
||||||
</span>
|
|
||||||
{id && (
|
|
||||||
<span className="text-green-600 flex items-center gap-1 font-medium">
|
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
|
||||||
已选中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PropertyPanel;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useMap } from "../MapComponent";
|
|
||||||
|
|
||||||
const Scale: React.FC = () => {
|
|
||||||
const map = useMap();
|
|
||||||
const [zoomLevel, setZoomLevel] = useState(0);
|
|
||||||
const [coordinates, setCoordinates] = useState<[number, number]>([0, 0]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const updateZoomLevel = () => {
|
|
||||||
const zoom = map.getView().getZoom();
|
|
||||||
setZoomLevel(zoom ?? 0); // 如果 zoom 是 undefined,则使用默认值 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCoordinates = (event: any) => {
|
|
||||||
const coords = event.coordinate;
|
|
||||||
const transformedCoords = coords.map((c: number) =>
|
|
||||||
parseFloat(c.toFixed(4))
|
|
||||||
);
|
|
||||||
setCoordinates(transformedCoords);
|
|
||||||
};
|
|
||||||
|
|
||||||
map.on("moveend", updateZoomLevel);
|
|
||||||
map.on("pointermove", updateCoordinates);
|
|
||||||
|
|
||||||
// Initialize values
|
|
||||||
updateZoomLevel();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.un("moveend", updateZoomLevel);
|
|
||||||
map.un("pointermove", updateCoordinates);
|
|
||||||
};
|
|
||||||
}, [map]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-0 right-0 flex col-auto px-2 bg-white bg-opacity-70 text-black rounded-tl shadow-md text-sm z-1300">
|
|
||||||
<div className="px-1">缩放: {zoomLevel.toFixed(1)}</div>
|
|
||||||
<div className="px-1">
|
|
||||||
坐标: {coordinates[0]}, {coordinates[1]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Scale;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
|
|
||||||
interface LegendStyleConfig {
|
|
||||||
layerName: string;
|
|
||||||
layerId: string;
|
|
||||||
property: string;
|
|
||||||
colors: string[];
|
|
||||||
type: string; // 图例类型
|
|
||||||
dimensions: number[]; // 尺寸大小
|
|
||||||
breaks: number[]; // 分段值
|
|
||||||
}
|
|
||||||
// 图例组件
|
|
||||||
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
|
||||||
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
|
|
||||||
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
|
|
||||||
|
|
||||||
const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|
||||||
layerName,
|
|
||||||
layerId,
|
|
||||||
property,
|
|
||||||
colors,
|
|
||||||
type, // 图例类型
|
|
||||||
dimensions,
|
|
||||||
breaks,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={layerId}
|
|
||||||
className="bg-white p-3 rounded-xl max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
{layerName} - {property}
|
|
||||||
</Typography>
|
|
||||||
{[...Array(breaks.length)].map((_, index) => {
|
|
||||||
const color = colors[index]; // 默认颜色为黑色
|
|
||||||
const dimension = dimensions[index]; // 默认尺寸为16
|
|
||||||
|
|
||||||
// // 处理第一个区间(小于 breaks[0])
|
|
||||||
// if (index === 0) {
|
|
||||||
// return (
|
|
||||||
// <Box key={index} className="flex items-center gap-2 mb-1">
|
|
||||||
// <Box
|
|
||||||
// sx={
|
|
||||||
// type === "point"
|
|
||||||
// ? {
|
|
||||||
// width: dimension,
|
|
||||||
// height: dimension,
|
|
||||||
// borderRadius: "50%",
|
|
||||||
// backgroundColor: color,
|
|
||||||
// }
|
|
||||||
// : {
|
|
||||||
// width: 16,
|
|
||||||
// height: dimension,
|
|
||||||
// backgroundColor: color,
|
|
||||||
// border: `1px solid ${color}`,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// />
|
|
||||||
// <Typography variant="caption" className="text-xs">
|
|
||||||
// {"<"} {breaks[0]?.toFixed(1)}
|
|
||||||
// </Typography>
|
|
||||||
// </Box>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 处理中间区间(breaks[index] - breaks[index + 1])
|
|
||||||
if (index + 1 < breaks.length) {
|
|
||||||
const prevValue = breaks[index];
|
|
||||||
const currentValue = breaks[index + 1];
|
|
||||||
return (
|
|
||||||
<Box key={index} className="flex items-center gap-2 mb-1">
|
|
||||||
<Box
|
|
||||||
sx={
|
|
||||||
type === "point"
|
|
||||||
? {
|
|
||||||
width: dimension,
|
|
||||||
height: dimension,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: color,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
width: 16,
|
|
||||||
height: dimension,
|
|
||||||
backgroundColor: color,
|
|
||||||
border: `1px solid ${color}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" className="text-xs">
|
|
||||||
{prevValue?.toFixed(1)} - {currentValue?.toFixed(1)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StyleLegend;
|
|
||||||
export type { LegendStyleConfig };
|
|
||||||
@@ -1,843 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
|
||||||
import Draggable from "react-draggable";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Slider,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
IconButton,
|
|
||||||
Stack,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
|
|
||||||
import { TbRewindBackward15, TbRewindForward15 } from "react-icons/tb";
|
|
||||||
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
|
||||||
import { useData } from "../MapComponent";
|
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
|
||||||
import { useMap } from "../MapComponent";
|
|
||||||
|
|
||||||
interface TimelineProps {
|
|
||||||
schemeDate?: Date;
|
|
||||||
timeRange?: { start: Date; end: Date };
|
|
||||||
disableDateSelection?: boolean;
|
|
||||||
schemeName?: string;
|
|
||||||
schemeType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Timeline: React.FC<TimelineProps> = ({
|
|
||||||
schemeDate,
|
|
||||||
timeRange,
|
|
||||||
disableDateSelection = false,
|
|
||||||
schemeName = "",
|
|
||||||
schemeType = "burst_Analysis",
|
|
||||||
}) => {
|
|
||||||
const data = useData();
|
|
||||||
if (!data) {
|
|
||||||
return <div>Loading...</div>; // 或其他占位符
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
currentTime,
|
|
||||||
setCurrentTime,
|
|
||||||
selectedDate,
|
|
||||||
setSelectedDate,
|
|
||||||
setCurrentJunctionCalData,
|
|
||||||
setCurrentPipeCalData,
|
|
||||||
junctionText,
|
|
||||||
pipeText,
|
|
||||||
} = data;
|
|
||||||
if (
|
|
||||||
setCurrentTime === undefined ||
|
|
||||||
currentTime === undefined ||
|
|
||||||
selectedDate === undefined ||
|
|
||||||
setSelectedDate === undefined
|
|
||||||
) {
|
|
||||||
return <div>Loading...</div>; // 或其他占位符
|
|
||||||
}
|
|
||||||
const { open } = useNotification();
|
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
|
||||||
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
|
||||||
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟
|
|
||||||
const [isCalculating, setIsCalculating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// 计算时间轴范围
|
|
||||||
const minTime = timeRange
|
|
||||||
? timeRange.start.getHours() * 60 + timeRange.start.getMinutes()
|
|
||||||
: 0;
|
|
||||||
const maxTime = timeRange
|
|
||||||
? timeRange.end.getHours() * 60 + timeRange.end.getMinutes()
|
|
||||||
: 1440;
|
|
||||||
useEffect(() => {
|
|
||||||
if (schemeDate) {
|
|
||||||
setSelectedDate(schemeDate);
|
|
||||||
}
|
|
||||||
}, [schemeDate]);
|
|
||||||
// 新增:用于 Draggable 的 nodeRef
|
|
||||||
const draggableRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
|
||||||
// 添加缓存引用
|
|
||||||
const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
|
|
||||||
const linkCacheRef = useRef<Map<string, any[]>>(new Map());
|
|
||||||
// 添加防抖引用
|
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const fetchFrameData = async (
|
|
||||||
queryTime: Date,
|
|
||||||
junctionProperties: string,
|
|
||||||
pipeProperties: string,
|
|
||||||
schemeName: string,
|
|
||||||
schemeType: string,
|
|
||||||
) => {
|
|
||||||
const query_time = queryTime.toISOString();
|
|
||||||
let nodeRecords: any = { results: [] };
|
|
||||||
let linkRecords: any = { results: [] };
|
|
||||||
const requests: Promise<Response>[] = [];
|
|
||||||
let nodePromise: Promise<any> | null = null;
|
|
||||||
let linkPromise: Promise<any> | null = null;
|
|
||||||
// 检查node缓存
|
|
||||||
if (junctionProperties !== "" && junctionProperties !== "elevation") {
|
|
||||||
const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`;
|
|
||||||
if (nodeCacheRef.current.has(nodeCacheKey)) {
|
|
||||||
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
|
||||||
} else {
|
|
||||||
disableDateSelection && schemeName
|
|
||||||
? (nodePromise = fetch(
|
|
||||||
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}`
|
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
|
||||||
))
|
|
||||||
: (nodePromise = fetch(
|
|
||||||
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
|
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
|
||||||
));
|
|
||||||
requests.push(nodePromise);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 处理特殊属性名称
|
|
||||||
if (pipeProperties === "unit_headloss") pipeProperties = "headloss";
|
|
||||||
|
|
||||||
// 检查link缓存
|
|
||||||
if (pipeProperties !== "" && pipeProperties !== "diameter") {
|
|
||||||
const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`;
|
|
||||||
if (linkCacheRef.current.has(linkCacheKey)) {
|
|
||||||
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
|
||||||
} else {
|
|
||||||
disableDateSelection && schemeName
|
|
||||||
? (linkPromise = fetch(
|
|
||||||
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}`
|
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`,
|
|
||||||
))
|
|
||||||
: (linkPromise = fetch(
|
|
||||||
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
|
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
|
|
||||||
));
|
|
||||||
requests.push(linkPromise);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待所有有效请求
|
|
||||||
const responses = await Promise.all(requests);
|
|
||||||
|
|
||||||
if (nodePromise) {
|
|
||||||
const nodeResponse = responses.shift()!;
|
|
||||||
if (!nodeResponse.ok)
|
|
||||||
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
|
|
||||||
nodeRecords = await nodeResponse.json();
|
|
||||||
// 缓存数据(修复键以包含 schemeName)
|
|
||||||
nodeCacheRef.current.set(
|
|
||||||
`${query_time}_${junctionProperties}_${schemeName}_${schemeType}`,
|
|
||||||
nodeRecords || [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (linkPromise) {
|
|
||||||
const linkResponse = responses.shift()!;
|
|
||||||
if (!linkResponse.ok)
|
|
||||||
throw new Error(`Link fetch failed: ${linkResponse.status}`);
|
|
||||||
linkRecords = await linkResponse.json();
|
|
||||||
// 缓存数据(修复键以包含 schemeName)
|
|
||||||
linkCacheRef.current.set(
|
|
||||||
`${query_time}_${pipeProperties}_${schemeName}_${schemeType}`,
|
|
||||||
linkRecords || [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 更新状态
|
|
||||||
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 提取更新状态的逻辑
|
|
||||||
const updateDataStates = (nodeResults: any[], linkResults: any[]) => {
|
|
||||||
if (setCurrentJunctionCalData) {
|
|
||||||
setCurrentJunctionCalData(nodeResults);
|
|
||||||
} else {
|
|
||||||
console.log("setCurrentJunctionCalData is undefined");
|
|
||||||
}
|
|
||||||
if (setCurrentPipeCalData) {
|
|
||||||
setCurrentPipeCalData(linkResults);
|
|
||||||
} else {
|
|
||||||
console.log("setCurrentPipeCalData is undefined");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 时间刻度数组 (每5分钟一个刻度)
|
|
||||||
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
|
||||||
value: i * 5,
|
|
||||||
label: i % 24 === 0 ? formatTime(i * 5) : "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 格式化时间显示
|
|
||||||
function formatTime(minutes: number): string {
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const mins = minutes % 60;
|
|
||||||
return `${hours.toString().padStart(2, "0")}:${mins
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentTimeToDate(selectedDate: Date, minutes: number): Date {
|
|
||||||
const date = new Date(selectedDate);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const mins = minutes % 60;
|
|
||||||
date.setHours(hours, mins, 0, 0);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 播放时间间隔选项
|
|
||||||
const intervalOptions = [
|
|
||||||
{ value: 5000, label: "5秒" },
|
|
||||||
{ value: 10000, label: "10秒" },
|
|
||||||
{ value: 15000, label: "15秒" },
|
|
||||||
{ value: 20000, label: "20秒" },
|
|
||||||
];
|
|
||||||
// 强制计算时间段选项
|
|
||||||
const calculatedIntervalOptions = [
|
|
||||||
{ value: 1440, label: "1 天" },
|
|
||||||
{ value: 60, label: "1 小时" },
|
|
||||||
{ value: 30, label: "30 分钟" },
|
|
||||||
{ value: 15, label: "15 分钟" },
|
|
||||||
{ value: 5, label: "5 分钟" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 处理时间轴滑动
|
|
||||||
const handleSliderChange = useCallback(
|
|
||||||
(event: Event, newValue: number | number[]) => {
|
|
||||||
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
|
||||||
// 如果有时间范围限制,只允许在范围内拖动
|
|
||||||
if (timeRange && (value < minTime || value > maxTime)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 防抖设置currentTime,避免频繁触发数据获取
|
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
setCurrentTime(value);
|
|
||||||
}, 500); // 500ms 防抖延迟
|
|
||||||
},
|
|
||||||
[timeRange, minTime, maxTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 播放控制
|
|
||||||
const handlePlay = useCallback(() => {
|
|
||||||
if (!isPlaying) {
|
|
||||||
// if (junctionText === "" && pipeText === "") {
|
|
||||||
// open?.({
|
|
||||||
// type: "error",
|
|
||||||
// message: "请至少设定并应用一个图层的样式。",
|
|
||||||
// });
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
setIsPlaying(true);
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
setCurrentTime((prev) => {
|
|
||||||
let next = prev + 15;
|
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, playInterval);
|
|
||||||
}
|
|
||||||
}, [isPlaying, playInterval]);
|
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
// 设置为当前时间
|
|
||||||
const currentTime = new Date();
|
|
||||||
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
|
||||||
setCurrentTime(minutes); // 组件卸载时重置时间
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 步进控制
|
|
||||||
const handleDayStepBackward = useCallback(() => {
|
|
||||||
setSelectedDate((prev) => {
|
|
||||||
const newDate = new Date(prev);
|
|
||||||
newDate.setDate(newDate.getDate() - 1);
|
|
||||||
return newDate;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
const handleDayStepForward = useCallback(() => {
|
|
||||||
setSelectedDate((prev) => {
|
|
||||||
const newDate = new Date(prev);
|
|
||||||
newDate.setDate(newDate.getDate() + 1);
|
|
||||||
return newDate;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
const handleStepBackward = useCallback(() => {
|
|
||||||
setCurrentTime((prev) => {
|
|
||||||
let next = prev - 15;
|
|
||||||
if (timeRange) {
|
|
||||||
if (next < minTime) next = maxTime;
|
|
||||||
} else {
|
|
||||||
if (next < 0) next += 1440;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [timeRange, minTime, maxTime]);
|
|
||||||
|
|
||||||
const handleStepForward = useCallback(() => {
|
|
||||||
setCurrentTime((prev) => {
|
|
||||||
let next = prev + 15;
|
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [timeRange, minTime, maxTime]);
|
|
||||||
|
|
||||||
// 日期选择处理
|
|
||||||
const handleDateChange = useCallback((newDate: Date | null) => {
|
|
||||||
if (newDate) {
|
|
||||||
setSelectedDate(newDate);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 播放间隔改变处理
|
|
||||||
const handleIntervalChange = useCallback(
|
|
||||||
(event: any) => {
|
|
||||||
const newInterval = event.target.value;
|
|
||||||
setPlayInterval(newInterval);
|
|
||||||
|
|
||||||
// 如果正在播放,重新启动定时器
|
|
||||||
if (isPlaying && intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
setCurrentTime((prev) => {
|
|
||||||
let next = prev + 15;
|
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, newInterval);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isPlaying],
|
|
||||||
);
|
|
||||||
// 计算时间段改变处理
|
|
||||||
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
|
||||||
const newInterval = event.target.value;
|
|
||||||
setCalculatedInterval(newInterval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
|
|
||||||
useEffect(() => {
|
|
||||||
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
|
|
||||||
if (selectedDate && currentTime !== undefined && currentTime !== -1) {
|
|
||||||
// 检查至少一个属性有值
|
|
||||||
// const junctionProperties = junctionText;
|
|
||||||
// const pipeProperties = pipeText;
|
|
||||||
// if (junctionProperties === "" && pipeProperties === "") {
|
|
||||||
// open?.({
|
|
||||||
// type: "error",
|
|
||||||
// message: "请至少设定并应用一个图层的样式。",
|
|
||||||
// });
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
fetchFrameData(
|
|
||||||
currentTimeToDate(selectedDate, currentTime),
|
|
||||||
junctionText,
|
|
||||||
pipeText,
|
|
||||||
schemeName,
|
|
||||||
schemeType,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
junctionText,
|
|
||||||
pipeText,
|
|
||||||
currentTime,
|
|
||||||
selectedDate,
|
|
||||||
schemeName,
|
|
||||||
schemeType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 组件卸载时清理定时器和防抖
|
|
||||||
useEffect(() => {
|
|
||||||
// 设置为当前时间
|
|
||||||
const currentTime = new Date();
|
|
||||||
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
|
||||||
// 找到最近的前15分钟刻度
|
|
||||||
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
|
||||||
setCurrentTime(roundedMinutes); // 组件卸载时重置时间
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
|
||||||
useEffect(() => {
|
|
||||||
if (timeRange) {
|
|
||||||
setCurrentTime(minTime);
|
|
||||||
}
|
|
||||||
}, [timeRange, minTime]);
|
|
||||||
// 获取地图实例
|
|
||||||
const map = useMap();
|
|
||||||
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
|
||||||
useEffect(() => {
|
|
||||||
// 监听地图缩放事件,缩放时停止播放
|
|
||||||
if (map) {
|
|
||||||
const onZoom = () => {
|
|
||||||
handlePause();
|
|
||||||
};
|
|
||||||
map.getView().on("change:resolution", onZoom);
|
|
||||||
|
|
||||||
// 清理事件监听
|
|
||||||
return () => {
|
|
||||||
map.getView().un("change:resolution", onZoom);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [map, handlePause]);
|
|
||||||
// 清除当天当前时间点后的缓存并重新获取数据
|
|
||||||
const clearCacheAndRefetch = (date: Date, timeInMinutes: number) => {
|
|
||||||
const dateStr = date.toISOString().split("T")[0];
|
|
||||||
|
|
||||||
const clearCache = (
|
|
||||||
cacheRef: ReturnType<typeof useRef<Map<string, any[]>>>,
|
|
||||||
) => {
|
|
||||||
if (!cacheRef.current) return;
|
|
||||||
const cacheKeys = Array.from(cacheRef.current.keys());
|
|
||||||
cacheKeys.forEach((key) => {
|
|
||||||
const keyParts = key.split("_");
|
|
||||||
const cacheDate = keyParts[0].split("T")[0];
|
|
||||||
const cacheTimeStr = keyParts[0].split("T")[1];
|
|
||||||
|
|
||||||
if (cacheDate === dateStr && cacheTimeStr) {
|
|
||||||
const [hours, minutes] = cacheTimeStr.split(":");
|
|
||||||
const cacheTimeInMinutes =
|
|
||||||
(parseInt(hours) + 8) * 60 + parseInt(minutes);
|
|
||||||
|
|
||||||
if (cacheTimeInMinutes >= timeInMinutes && cacheRef.current) {
|
|
||||||
cacheRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
clearCache(nodeCacheRef);
|
|
||||||
clearCache(linkCacheRef);
|
|
||||||
// 重新获取当前时刻的新数据
|
|
||||||
fetchFrameData(
|
|
||||||
currentTimeToDate(selectedDate, currentTime),
|
|
||||||
junctionText,
|
|
||||||
pipeText,
|
|
||||||
schemeName,
|
|
||||||
schemeType,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleForceCalculate = async () => {
|
|
||||||
if (!NETWORK_NAME) {
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "管网名称缺失,无法进行强制计算。",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
|
||||||
const calculationDate = selectedDate;
|
|
||||||
const calculationTime = currentTime;
|
|
||||||
const calculationDateStr = calculationDate.toISOString().split("T")[0];
|
|
||||||
|
|
||||||
setIsCalculating(true);
|
|
||||||
// 显示处理中的通知
|
|
||||||
open?.({
|
|
||||||
type: "progress",
|
|
||||||
message: "正在强制计算,请稍候...",
|
|
||||||
undoableTimeout: 3,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
name: NETWORK_NAME,
|
|
||||||
simulation_date: calculationDateStr, // YYYY-MM-DD
|
|
||||||
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
|
|
||||||
duration: calculatedInterval,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
open?.({
|
|
||||||
type: "success",
|
|
||||||
message: "重新计算成功",
|
|
||||||
});
|
|
||||||
// 清空当天当前时刻及之后的缓存并重新获取数据
|
|
||||||
clearCacheAndRefetch(calculationDate, calculationTime);
|
|
||||||
} else {
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "重新计算失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Recalculation failed:", error);
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "重新计算时发生错误",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsCalculating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
|
||||||
<div
|
|
||||||
ref={draggableRef}
|
|
||||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300"
|
|
||||||
>
|
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
|
||||||
<Paper
|
|
||||||
elevation={3}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
p: 2,
|
|
||||||
pt: 1, // 减小顶部内边距,为拖拽柄留出空间
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
|
||||||
backdropFilter: "blur(10px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 拖拽柄区域 */}
|
|
||||||
<Box
|
|
||||||
className="drag-handle"
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
height: "16px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
cursor: "move",
|
|
||||||
mb: 1,
|
|
||||||
borderRadius: "4px 4px 0 0",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "40px",
|
|
||||||
height: "4px",
|
|
||||||
backgroundColor: "grey.400",
|
|
||||||
borderRadius: "2px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ width: "100%" }}>
|
|
||||||
{/* 控制按钮栏 */}
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
alignItems="center"
|
|
||||||
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
|
|
||||||
>
|
|
||||||
<Tooltip title="后退一天">
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={handleDayStepBackward}
|
|
||||||
size="small"
|
|
||||||
disabled={disableDateSelection}
|
|
||||||
>
|
|
||||||
<FiSkipBack />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{/* 日期选择器 */}
|
|
||||||
<DatePicker
|
|
||||||
label="模拟数据日期选择"
|
|
||||||
value={dayjs(selectedDate)}
|
|
||||||
onChange={(value) =>
|
|
||||||
value && handleDateChange(value.toDate())
|
|
||||||
}
|
|
||||||
enableAccessibleFieldDOMStructure={false}
|
|
||||||
format="YYYY-MM-DD"
|
|
||||||
sx={{ width: 180 }}
|
|
||||||
slotProps={{
|
|
||||||
textField: {
|
|
||||||
size: "small",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
maxDate={dayjs(new Date())} // 禁止选取未来的日期
|
|
||||||
disabled={disableDateSelection}
|
|
||||||
/>
|
|
||||||
<Tooltip title="前进一天">
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={handleDayStepForward}
|
|
||||||
size="small"
|
|
||||||
disabled={
|
|
||||||
disableDateSelection ||
|
|
||||||
selectedDate.toDateString() === new Date().toDateString()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FiSkipForward />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{/* 播放控制按钮 */}
|
|
||||||
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
|
||||||
{/* 播放间隔选择 */}
|
|
||||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
|
||||||
<InputLabel>播放间隔</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={playInterval}
|
|
||||||
label="播放间隔"
|
|
||||||
onChange={handleIntervalChange}
|
|
||||||
>
|
|
||||||
{intervalOptions.map((option) => (
|
|
||||||
<MenuItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Tooltip title="后退一步">
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={handleStepBackward}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<TbRewindBackward15 />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title={isPlaying ? "暂停" : "播放"}>
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={isPlaying ? handlePause : handlePlay}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="前进一步">
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={handleStepForward}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<TbRewindForward15 />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="停止">
|
|
||||||
<IconButton
|
|
||||||
color="secondary"
|
|
||||||
onClick={handleStop}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<Stop />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
|
||||||
{/* 强制计算时间段 */}
|
|
||||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
|
||||||
<InputLabel>计算时间段</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={calculatedInterval}
|
|
||||||
label="强制计算时间段"
|
|
||||||
onChange={handleCalculatedIntervalChange}
|
|
||||||
>
|
|
||||||
{calculatedIntervalOptions.map((option) => (
|
|
||||||
<MenuItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{/* 功能按钮 */}
|
|
||||||
<Tooltip title="强制计算">
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<Refresh />}
|
|
||||||
onClick={handleForceCalculate}
|
|
||||||
disabled={isCalculating}
|
|
||||||
>
|
|
||||||
强制计算
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 当前时间显示 */}
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{
|
|
||||||
ml: "auto",
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "primary.main",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTime(currentTime)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
min={0}
|
|
||||||
max={1440} // 24:00 = 1440分钟
|
|
||||||
step={15} // 每15分钟一个步进
|
|
||||||
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记
|
|
||||||
onChange={handleSliderChange}
|
|
||||||
valueLabelDisplay="auto"
|
|
||||||
valueLabelFormat={formatTime}
|
|
||||||
sx={{
|
|
||||||
zIndex: 10,
|
|
||||||
height: 8,
|
|
||||||
"& .MuiSlider-track": {
|
|
||||||
backgroundColor: "primary.main",
|
|
||||||
height: 6,
|
|
||||||
display: timeRange ? "none" : "block",
|
|
||||||
},
|
|
||||||
"& .MuiSlider-rail": {
|
|
||||||
backgroundColor: "grey.300",
|
|
||||||
height: 6,
|
|
||||||
},
|
|
||||||
"& .MuiSlider-thumb": {
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
backgroundColor: "primary.main",
|
|
||||||
border: "2px solid #fff",
|
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
|
||||||
"&:hover": {
|
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"& .MuiSlider-mark": {
|
|
||||||
backgroundColor: "grey.400",
|
|
||||||
height: 4,
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
"& .MuiSlider-markActive": {
|
|
||||||
backgroundColor: "primary.main",
|
|
||||||
},
|
|
||||||
"& .MuiSlider-markLabel": {
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "grey.600",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 禁用区域遮罩 */}
|
|
||||||
{timeRange && (
|
|
||||||
<>
|
|
||||||
{/* 左侧禁用区域 */}
|
|
||||||
{minTime > 0 && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "14px",
|
|
||||||
top: "30%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
width: `${(minTime / 1440) * 856 + 2}px`,
|
|
||||||
height: "20px",
|
|
||||||
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
|
||||||
pointerEvents: "none",
|
|
||||||
backdropFilter: "blur(1px)",
|
|
||||||
borderRadius: "2.5px",
|
|
||||||
rounded: "true",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* 右侧禁用区域 */}
|
|
||||||
{maxTime < 1440 && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${16 + (maxTime / 1440) * 856}px`,
|
|
||||||
top: "30%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
width: `${((1440 - maxTime) / 1440) * 856}px`,
|
|
||||||
height: "20px",
|
|
||||||
backgroundColor: "rgba(189, 189, 189, 0.4)",
|
|
||||||
pointerEvents: "none",
|
|
||||||
backdropFilter: "blur(1px)",
|
|
||||||
borderRadius: "2.5px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</LocalizationProvider>
|
|
||||||
</div>
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Timeline;
|
|
||||||
@@ -1,849 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { useData, useMap } from "../MapComponent";
|
|
||||||
import ToolbarButton from "@/components/olmap/common/ToolbarButton";
|
|
||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
||||||
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
|
|
||||||
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
|
|
||||||
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
|
||||||
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
|
||||||
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
|
||||||
|
|
||||||
import VectorSource from "ol/source/Vector";
|
|
||||||
import VectorLayer from "ol/layer/Vector";
|
|
||||||
import { Style, Stroke, Fill, Circle } from "ol/style";
|
|
||||||
import Feature from "ol/Feature";
|
|
||||||
import StyleEditorPanel from "./StyleEditorPanel";
|
|
||||||
import { LayerStyleState } from "./StyleEditorPanel";
|
|
||||||
import StyleLegend from "./StyleLegend"; // 引入图例组件
|
|
||||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
|
||||||
|
|
||||||
// 添加接口定义隐藏按钮的props
|
|
||||||
interface ToolbarProps {
|
|
||||||
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
|
|
||||||
queryType?: string; // 可选的查询类型参数
|
|
||||||
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
|
||||||
}
|
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
|
||||||
hiddenButtons,
|
|
||||||
queryType,
|
|
||||||
HistoryPanel,
|
|
||||||
}) => {
|
|
||||||
const map = useMap();
|
|
||||||
const data = useData();
|
|
||||||
const { open } = useNotification();
|
|
||||||
if (!data) return null;
|
|
||||||
const { currentTime, selectedDate, schemeName } = data;
|
|
||||||
const [activeTools, setActiveTools] = useState<string[]>([]);
|
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
|
||||||
const [showPropertyPanel, setShowPropertyPanel] = useState<boolean>(false);
|
|
||||||
const [showDrawPanel, setShowDrawPanel] = useState<boolean>(false);
|
|
||||||
const [showStyleEditor, setShowStyleEditor] = useState<boolean>(false);
|
|
||||||
const [showHistoryPanel, setShowHistoryPanel] = useState<boolean>(false);
|
|
||||||
const [highlightLayer, setHighlightLayer] =
|
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
|
||||||
|
|
||||||
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
|
|
||||||
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
|
|
||||||
{
|
|
||||||
isActive: false, // 默认不激活,不显示图例
|
|
||||||
layerId: "junctions",
|
|
||||||
layerName: "节点",
|
|
||||||
styleConfig: {
|
|
||||||
property: "pressure",
|
|
||||||
classificationMethod: "custom_breaks",
|
|
||||||
customBreaks: [16, 18, 20, 22, 24, 26],
|
|
||||||
customColors: [
|
|
||||||
"rgba(255, 0, 0, 1)",
|
|
||||||
"rgba(255, 127, 0, 1)",
|
|
||||||
"rgba(255, 215, 0, 1)",
|
|
||||||
"rgba(199, 224, 0, 1)",
|
|
||||||
"rgba(76, 175, 80, 1)",
|
|
||||||
"rgba(0, 158, 115, 1)",
|
|
||||||
],
|
|
||||||
segments: 6,
|
|
||||||
minSize: 4,
|
|
||||||
maxSize: 12,
|
|
||||||
minStrokeWidth: 2,
|
|
||||||
maxStrokeWidth: 8,
|
|
||||||
fixedStrokeWidth: 3,
|
|
||||||
colorType: "rainbow",
|
|
||||||
singlePaletteIndex: 0,
|
|
||||||
gradientPaletteIndex: 0,
|
|
||||||
rainbowPaletteIndex: 0,
|
|
||||||
showLabels: false,
|
|
||||||
showId: false,
|
|
||||||
opacity: 0.9,
|
|
||||||
adjustWidthByProperty: true,
|
|
||||||
},
|
|
||||||
legendConfig: {
|
|
||||||
layerId: "junctions",
|
|
||||||
layerName: "节点",
|
|
||||||
property: "压力", // 暂时为空,等计算后更新
|
|
||||||
colors: [],
|
|
||||||
type: "point",
|
|
||||||
dimensions: [],
|
|
||||||
breaks: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isActive: false, // 默认不激活,不显示图例
|
|
||||||
layerId: "pipes",
|
|
||||||
layerName: "管道",
|
|
||||||
styleConfig: {
|
|
||||||
property: "flow",
|
|
||||||
classificationMethod: "pretty_breaks",
|
|
||||||
segments: 6,
|
|
||||||
minSize: 4,
|
|
||||||
maxSize: 12,
|
|
||||||
minStrokeWidth: 2,
|
|
||||||
maxStrokeWidth: 8,
|
|
||||||
fixedStrokeWidth: 3,
|
|
||||||
colorType: "gradient",
|
|
||||||
singlePaletteIndex: 0,
|
|
||||||
gradientPaletteIndex: 0,
|
|
||||||
rainbowPaletteIndex: 0,
|
|
||||||
showLabels: false,
|
|
||||||
showId: false,
|
|
||||||
opacity: 0.9,
|
|
||||||
adjustWidthByProperty: true,
|
|
||||||
},
|
|
||||||
legendConfig: {
|
|
||||||
layerId: "pipes",
|
|
||||||
layerName: "管道",
|
|
||||||
property: "流量", // 暂时为空,等计算后更新
|
|
||||||
colors: [],
|
|
||||||
type: "linestring",
|
|
||||||
dimensions: [],
|
|
||||||
breaks: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 计算激活的图例配置
|
|
||||||
const activeLegendConfigs = layerStyleStates
|
|
||||||
.filter((state) => state.isActive && state.legendConfig.property)
|
|
||||||
.map((state) => ({
|
|
||||||
...state.legendConfig,
|
|
||||||
layerName: state.layerName,
|
|
||||||
layerId: state.layerId,
|
|
||||||
}));
|
|
||||||
// 创建高亮图层
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const highLightSource = new VectorSource();
|
|
||||||
const highLightLayer = new VectorLayer({
|
|
||||||
source: highLightSource,
|
|
||||||
style: new Style({
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: `rgba(255, 0, 0, 1)`,
|
|
||||||
width: 5,
|
|
||||||
}),
|
|
||||||
fill: new Fill({
|
|
||||||
color: `rgba(255, 0, 0, 0.2)`,
|
|
||||||
}),
|
|
||||||
image: new Circle({
|
|
||||||
radius: 7,
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: `rgba(255, 0, 0, 1)`,
|
|
||||||
width: 3,
|
|
||||||
}),
|
|
||||||
fill: new Fill({
|
|
||||||
color: `rgba(255, 0, 0, 0.2)`,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
properties: {
|
|
||||||
name: "属性查询高亮图层", // 设置图层名称
|
|
||||||
value: "info_highlight_layer",
|
|
||||||
type: "multigeometry",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
map.addLayer(highLightLayer);
|
|
||||||
setHighlightLayer(highLightLayer);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.removeLayer(highLightLayer);
|
|
||||||
};
|
|
||||||
}, [map]);
|
|
||||||
// 高亮要素的函数
|
|
||||||
useEffect(() => {
|
|
||||||
if (!highlightLayer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const source = highlightLayer.getSource();
|
|
||||||
if (!source) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 清除之前的高亮
|
|
||||||
source.clear();
|
|
||||||
// 添加新的高亮要素
|
|
||||||
highlightFeatures.forEach((feature) => {
|
|
||||||
if (feature instanceof Feature) {
|
|
||||||
source.addFeature(feature);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [highlightFeatures, highlightLayer]);
|
|
||||||
// 地图点击选择要素事件处理函数
|
|
||||||
const handleMapClickSelectFeatures = useCallback(
|
|
||||||
async (event: { coordinate: number[] }) => {
|
|
||||||
if (!map) return;
|
|
||||||
const feature = await mapClickSelectFeatures(event, map); // 调用导入的函数
|
|
||||||
|
|
||||||
if (!feature || !(feature instanceof Feature)) {
|
|
||||||
// 如果没有点击到要素,且当前是 info 模式,则清除高亮
|
|
||||||
if (activeTools.includes("info")) {
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTools.includes("history")) {
|
|
||||||
// 历史查询模式:支持同类型多选
|
|
||||||
const featureId = feature.getProperties().id;
|
|
||||||
const layerId = feature.getId()?.toString().split(".")[0] || "";
|
|
||||||
console.log("点击选择要素", feature, "图层:", layerId);
|
|
||||||
// 简单的类型检查函数
|
|
||||||
const getBaseType = (lid: string) => {
|
|
||||||
if (lid.includes("pipe")) return "pipe";
|
|
||||||
if (lid.includes("junction")) return "junction";
|
|
||||||
if (lid.includes("tank")) return "tank";
|
|
||||||
if (lid.includes("reservoir")) return "reservoir";
|
|
||||||
if (lid.includes("pump")) return "pump";
|
|
||||||
if (lid.includes("valve")) return "valve";
|
|
||||||
return lid;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否与已选要素类型一致
|
|
||||||
if (highlightFeatures.length > 0) {
|
|
||||||
const firstLayerId =
|
|
||||||
highlightFeatures[0].getId()?.toString().split(".")[0] || "";
|
|
||||||
|
|
||||||
if (getBaseType(layerId) !== getBaseType(firstLayerId)) {
|
|
||||||
// 如果点击的是已选中的要素(为了取消选中),则不报错
|
|
||||||
const isAlreadySelected = highlightFeatures.some(
|
|
||||||
(f) => f.getProperties().id === featureId,
|
|
||||||
);
|
|
||||||
if (!isAlreadySelected) {
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "请选择相同类型的要素进行多选查询。",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHighlightFeatures((prev) => {
|
|
||||||
const existingIndex = prev.findIndex(
|
|
||||||
(f) => f.getProperties().id === featureId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
|
||||||
// 如果已存在,移除
|
|
||||||
return prev.filter((_, i) => i !== existingIndex);
|
|
||||||
} else {
|
|
||||||
// 如果不存在,添加
|
|
||||||
return [...prev, feature];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 其他模式(如 info):单选
|
|
||||||
setHighlightFeatures([feature]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[map, activeTools, highlightFeatures, open],
|
|
||||||
);
|
|
||||||
// 添加矢量属性查询事件监听器
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
// 监听 info 或 history 工具激活时添加
|
|
||||||
if (activeTools.includes("info") || activeTools.includes("history")) {
|
|
||||||
map.on("click", handleMapClickSelectFeatures);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.un("click", handleMapClickSelectFeatures);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [activeTools, map, handleMapClickSelectFeatures]);
|
|
||||||
|
|
||||||
// 处理工具栏按钮点击事件
|
|
||||||
const handleToolClick = (tool: string) => {
|
|
||||||
// 样式工具的特殊处理 - 只有再次点击时才会取消激活和关闭
|
|
||||||
if (tool === "style") {
|
|
||||||
if (activeTools.includes("style")) {
|
|
||||||
// 如果样式工具已激活,点击时关闭
|
|
||||||
setShowStyleEditor(false);
|
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== "style"));
|
|
||||||
} else {
|
|
||||||
// 激活样式工具,打开样式面板
|
|
||||||
setActiveTools((prev) => [...prev, "style"]);
|
|
||||||
setShowStyleEditor(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他工具的处理逻辑
|
|
||||||
if (activeTools.includes(tool)) {
|
|
||||||
// 如果当前工具已激活,再次点击时取消激活并关闭面板
|
|
||||||
deactivateTool(tool);
|
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== tool));
|
|
||||||
} else {
|
|
||||||
// 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具
|
|
||||||
// 关闭所有面板(但保持样式编辑器状态)
|
|
||||||
closeAllPanelsExceptStyle();
|
|
||||||
|
|
||||||
// 取消激活所有非样式工具
|
|
||||||
setActiveTools((prev) => {
|
|
||||||
const styleActive = prev.includes("style");
|
|
||||||
return styleActive ? ["style", tool] : [tool];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 激活当前工具并打开对应面板
|
|
||||||
activateTool(tool);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 取消激活指定工具并关闭对应面板
|
|
||||||
const deactivateTool = (tool: string) => {
|
|
||||||
switch (tool) {
|
|
||||||
case "info":
|
|
||||||
setShowPropertyPanel(false);
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
break;
|
|
||||||
case "draw":
|
|
||||||
setShowDrawPanel(false);
|
|
||||||
break;
|
|
||||||
case "history":
|
|
||||||
setShowHistoryPanel(false);
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 激活指定工具并打开对应面板
|
|
||||||
const activateTool = (tool: string) => {
|
|
||||||
switch (tool) {
|
|
||||||
case "info":
|
|
||||||
setShowPropertyPanel(true);
|
|
||||||
break;
|
|
||||||
case "draw":
|
|
||||||
setShowDrawPanel(true);
|
|
||||||
break;
|
|
||||||
case "history":
|
|
||||||
setShowHistoryPanel(true);
|
|
||||||
// 激活历史查询后:HistoryDataPanel 自行负责根据传入的 props 拉取数据。
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 关闭所有面板(除了样式编辑器)
|
|
||||||
const closeAllPanelsExceptStyle = () => {
|
|
||||||
setShowPropertyPanel(false);
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
setShowDrawPanel(false);
|
|
||||||
setShowHistoryPanel(false);
|
|
||||||
// 样式编辑器保持其当前状态,不自动关闭
|
|
||||||
};
|
|
||||||
const [computedProperties, setComputedProperties] = useState<
|
|
||||||
Record<string, any>
|
|
||||||
>({});
|
|
||||||
// 添加 useEffect 来查询计算属性
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
|
|
||||||
setComputedProperties({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightFeature = highlightFeatures[0];
|
|
||||||
const id = highlightFeature.getProperties().id;
|
|
||||||
if (!id) {
|
|
||||||
setComputedProperties({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryComputedProperties = async () => {
|
|
||||||
try {
|
|
||||||
const properties = highlightFeature?.getProperties?.() || {};
|
|
||||||
const type =
|
|
||||||
properties.geometry?.getType?.() === "LineString" ? "link" : "node";
|
|
||||||
// selectedDate 格式化为 YYYY-MM-DD
|
|
||||||
let dateObj: Date;
|
|
||||||
if (selectedDate instanceof Date) {
|
|
||||||
dateObj = new Date(selectedDate);
|
|
||||||
} else {
|
|
||||||
dateObj = new Date(selectedDate);
|
|
||||||
}
|
|
||||||
const minutes = Number(currentTime) || 0;
|
|
||||||
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
||||||
// 转为 UTC ISO 字符串
|
|
||||||
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
|
||||||
let response;
|
|
||||||
if (queryType === "scheme") {
|
|
||||||
response = await fetch(
|
|
||||||
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
response = await fetch(
|
|
||||||
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
|
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("API request failed");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setComputedProperties(data.results[0] || {});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error querying computed properties:", error);
|
|
||||||
setComputedProperties({});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 仅当 currentTime 有效时查询
|
|
||||||
if (currentTime !== -1 && queryType) queryComputedProperties();
|
|
||||||
}, [highlightFeatures, currentTime, selectedDate]);
|
|
||||||
|
|
||||||
// 从要素属性中提取属性面板需要的数据
|
|
||||||
const getFeatureProperties = useCallback(() => {
|
|
||||||
if (highlightFeatures.length === 0) return {};
|
|
||||||
const highlightFeature = highlightFeatures[0];
|
|
||||||
const layer = highlightFeature?.getId()?.toString().split(".")[0];
|
|
||||||
const properties = highlightFeature.getProperties();
|
|
||||||
// 计算属性字段,增加 key 字段
|
|
||||||
const pipeComputedFields = [
|
|
||||||
{ key: "flow", label: "流量", unit: "m³/h" },
|
|
||||||
{ key: "friction", label: "摩阻", unit: "" },
|
|
||||||
{ key: "headloss", label: "水头损失", unit: "m" },
|
|
||||||
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
|
|
||||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
|
||||||
{ key: "reaction", label: "反应", unit: "1/d" },
|
|
||||||
{ key: "setting", label: "设置", unit: "" },
|
|
||||||
{ key: "status", label: "状态", unit: "" },
|
|
||||||
{ key: "velocity", label: "流速", unit: "m/s" },
|
|
||||||
];
|
|
||||||
const nodeComputedFields = [
|
|
||||||
{ key: "actual_demand", label: "实际需水量", unit: "m³/h" },
|
|
||||||
{ key: "total_head", label: "水头", unit: "m" },
|
|
||||||
{ key: "pressure", label: "压力", unit: "m" },
|
|
||||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
|
|
||||||
let result = {
|
|
||||||
id: properties.id,
|
|
||||||
type: "管道",
|
|
||||||
properties: [
|
|
||||||
{ label: "起始节点ID", value: properties.node1 },
|
|
||||||
{ label: "终点节点ID", value: properties.node2 },
|
|
||||||
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
|
|
||||||
{
|
|
||||||
label: "管径",
|
|
||||||
value: properties.diameter?.toFixed?.(1),
|
|
||||||
unit: "mm",
|
|
||||||
},
|
|
||||||
{ label: "粗糙度", value: properties.roughness },
|
|
||||||
{ label: "局部损失", value: properties.minor_loss },
|
|
||||||
{ label: "初始状态", value: "开" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
// 追加计算属性
|
|
||||||
if (computedProperties) {
|
|
||||||
pipeComputedFields.forEach(({ key, label, unit }) => {
|
|
||||||
let value = computedProperties[key];
|
|
||||||
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
|
|
||||||
if (
|
|
||||||
key === "unit_headloss" &&
|
|
||||||
value === undefined &&
|
|
||||||
computedProperties.headloss !== undefined &&
|
|
||||||
properties.length
|
|
||||||
) {
|
|
||||||
value = (computedProperties.headloss / properties.length) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined) {
|
|
||||||
result.properties.push({
|
|
||||||
label,
|
|
||||||
value: typeof value === "number" ? value.toFixed(3) : value,
|
|
||||||
unit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
|
|
||||||
let result = {
|
|
||||||
id: properties.id,
|
|
||||||
type: "节点",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "高程",
|
|
||||||
value: properties.elevation?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
// 将 demand1~demand5 与 pattern1~pattern5 作为二级表格展示
|
|
||||||
{
|
|
||||||
type: "table",
|
|
||||||
label: "基本需水量",
|
|
||||||
columns: ["demand", "pattern"],
|
|
||||||
rows: Array.from({ length: 5 }, (_, i) => i + 1)
|
|
||||||
.map((idx) => {
|
|
||||||
const d = properties?.[`demand${idx}`]?.toFixed?.(3);
|
|
||||||
const p = properties?.[`pattern${idx}`];
|
|
||||||
// 仅当 demand 有效时展示该行
|
|
||||||
if (d !== undefined && d !== null && d !== "") {
|
|
||||||
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean) as (string | number)[][],
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
// 追加计算属性
|
|
||||||
if (computedProperties) {
|
|
||||||
nodeComputedFields.forEach(({ key, label, unit }) => {
|
|
||||||
if (computedProperties[key] !== undefined) {
|
|
||||||
result.properties.push({
|
|
||||||
label,
|
|
||||||
value:
|
|
||||||
computedProperties[key].toFixed?.(3) || computedProperties[key],
|
|
||||||
unit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "水池",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "高程",
|
|
||||||
value: properties.elevation?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "初始水位",
|
|
||||||
value: properties.init_level?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "最低水位",
|
|
||||||
value: properties.min_level?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "最高水位",
|
|
||||||
value: properties.max_level?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "直径",
|
|
||||||
value: properties.diameter?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "最小容积",
|
|
||||||
value: properties.min_vol?.toFixed?.(1),
|
|
||||||
unit: "m³",
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// label: "容积曲线",
|
|
||||||
// value: properties.vol_curve,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
label: "溢出",
|
|
||||||
value: properties.overflow ? "是" : "否",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "水库",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "水头",
|
|
||||||
value: properties.head?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// label: "模式",
|
|
||||||
// value: properties.pattern,
|
|
||||||
// },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "水泵",
|
|
||||||
properties: [
|
|
||||||
{ label: "起始节点 ID", value: properties.node1 },
|
|
||||||
{ label: "终点节点 ID", value: properties.node2 },
|
|
||||||
{
|
|
||||||
label: "功率",
|
|
||||||
value: properties.power?.toFixed?.(1),
|
|
||||||
unit: "kW",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "扬程",
|
|
||||||
value: properties.head?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "转速",
|
|
||||||
value: properties.speed?.toFixed?.(1),
|
|
||||||
unit: "rpm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "模式",
|
|
||||||
value: properties.pattern,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (layer === "geo_valves_mat" || layer === "geo_valves") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "阀门",
|
|
||||||
properties: [
|
|
||||||
{ label: "起始节点 ID", value: properties.node1 },
|
|
||||||
{ label: "终点节点 ID", value: properties.node2 },
|
|
||||||
{
|
|
||||||
label: "直径",
|
|
||||||
value: properties.diameter?.toFixed?.(1),
|
|
||||||
unit: "mm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "阀门类型",
|
|
||||||
value: properties.v_type,
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// label: "设置",
|
|
||||||
// value: properties.setting?.toFixed?.(2),
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
label: "局部损失",
|
|
||||||
value: properties.minor_loss?.toFixed?.(2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 传输频率文字对应
|
|
||||||
const getTransmissionFrequency = (transmission_frequency: string) => {
|
|
||||||
// 传输频率文本:00:01:00,00:05:00,00:10:00,00:30:00,01:00:00,转换为分钟数
|
|
||||||
const parts = transmission_frequency.split(":");
|
|
||||||
if (parts.length !== 3) return transmission_frequency;
|
|
||||||
const hours = parseInt(parts[0], 10);
|
|
||||||
const minutes = parseInt(parts[1], 10);
|
|
||||||
const seconds = parseInt(parts[2], 10);
|
|
||||||
const totalMinutes = hours * 60 + minutes + (seconds >= 30 ? 1 : 0);
|
|
||||||
return totalMinutes;
|
|
||||||
};
|
|
||||||
// 可靠度文字映射
|
|
||||||
const getReliability = (reliability: number) => {
|
|
||||||
switch (reliability) {
|
|
||||||
case 1:
|
|
||||||
return "高";
|
|
||||||
case 2:
|
|
||||||
return "中";
|
|
||||||
case 3:
|
|
||||||
return "低";
|
|
||||||
default:
|
|
||||||
return "未知";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (layer === "geo_scada_mat" || layer === "geo_scada") {
|
|
||||||
let result = {
|
|
||||||
id: properties.id,
|
|
||||||
type: "SCADA设备",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "类型",
|
|
||||||
value:
|
|
||||||
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "关联节点 ID",
|
|
||||||
value: properties.associated_element_id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "传输模式",
|
|
||||||
value:
|
|
||||||
properties.transmission_mode === "non_realtime"
|
|
||||||
? "定时传输"
|
|
||||||
: "实时传输",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "传输频率",
|
|
||||||
value: getTransmissionFrequency(properties.transmission_frequency),
|
|
||||||
unit: "分钟",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "可靠性",
|
|
||||||
value: getReliability(properties.reliability),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}, [highlightFeatures, computedProperties]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity">
|
|
||||||
{!hiddenButtons?.includes("info") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<InfoOutlinedIcon />}
|
|
||||||
name="查看属性"
|
|
||||||
isActive={activeTools.includes("info")}
|
|
||||||
onClick={() => handleToolClick("info")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hiddenButtons?.includes("history") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<QueryStatsOutlinedIcon />}
|
|
||||||
name="查询历史数据"
|
|
||||||
isActive={activeTools.includes("history")}
|
|
||||||
onClick={() => handleToolClick("history")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hiddenButtons?.includes("draw") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<EditOutlinedIcon />}
|
|
||||||
name="标记绘制"
|
|
||||||
isActive={activeTools.includes("draw")}
|
|
||||||
onClick={() => handleToolClick("draw")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hiddenButtons?.includes("style") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<PaletteOutlinedIcon />}
|
|
||||||
name="图层样式"
|
|
||||||
isActive={activeTools.includes("style")}
|
|
||||||
onClick={() => handleToolClick("style")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
|
|
||||||
{showDrawPanel && map && <DrawPanel />}
|
|
||||||
<div style={{ display: showStyleEditor ? "block" : "none" }}>
|
|
||||||
<StyleEditorPanel
|
|
||||||
layerStyleStates={layerStyleStates}
|
|
||||||
setLayerStyleStates={setLayerStyleStates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showHistoryPanel &&
|
|
||||||
(HistoryPanel ? (
|
|
||||||
<HistoryPanel
|
|
||||||
featureInfos={(() => {
|
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
return highlightFeatures
|
|
||||||
.map((feature) => {
|
|
||||||
const properties = feature.getProperties();
|
|
||||||
const id = properties.id;
|
|
||||||
if (!id) return null;
|
|
||||||
|
|
||||||
// 从图层名称推断类型
|
|
||||||
const layerId =
|
|
||||||
feature.getId()?.toString().split(".")[0] || "";
|
|
||||||
let type = "unknown";
|
|
||||||
|
|
||||||
if (layerId.includes("pipe")) {
|
|
||||||
type = "pipe";
|
|
||||||
} else if (layerId.includes("junction")) {
|
|
||||||
type = "junction";
|
|
||||||
} else if (layerId.includes("tank")) {
|
|
||||||
type = "tank";
|
|
||||||
} else if (layerId.includes("reservoir")) {
|
|
||||||
type = "reservoir";
|
|
||||||
} else if (layerId.includes("pump")) {
|
|
||||||
type = "pump";
|
|
||||||
} else if (layerId.includes("valve")) {
|
|
||||||
type = "valve";
|
|
||||||
}
|
|
||||||
// 仅处理 type 为 pipe 或 junction 的情况
|
|
||||||
if (type !== "pipe" && type !== "junction") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return [id, type];
|
|
||||||
})
|
|
||||||
.filter(Boolean) as [string, string][];
|
|
||||||
})()}
|
|
||||||
scheme_type="burst_Analysis"
|
|
||||||
scheme_name={schemeName}
|
|
||||||
type={queryType as "realtime" | "scheme" | "none"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<HistoryDataPanel
|
|
||||||
featureInfos={(() => {
|
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
return highlightFeatures
|
|
||||||
.map((feature) => {
|
|
||||||
const properties = feature.getProperties();
|
|
||||||
const id = properties.id;
|
|
||||||
if (!id) return null;
|
|
||||||
|
|
||||||
// 从图层名称推断类型
|
|
||||||
const layerId =
|
|
||||||
feature.getId()?.toString().split(".")[0] || "";
|
|
||||||
let type = "unknown";
|
|
||||||
|
|
||||||
if (layerId.includes("pipe")) {
|
|
||||||
type = "pipe";
|
|
||||||
} else if (layerId.includes("junction")) {
|
|
||||||
type = "junction";
|
|
||||||
} else if (layerId.includes("tank")) {
|
|
||||||
type = "tank";
|
|
||||||
} else if (layerId.includes("reservoir")) {
|
|
||||||
type = "reservoir";
|
|
||||||
} else if (layerId.includes("pump")) {
|
|
||||||
type = "pump";
|
|
||||||
} else if (layerId.includes("valve")) {
|
|
||||||
type = "valve";
|
|
||||||
}
|
|
||||||
// 仅处理 type 为 pipe 或 junction 的情况
|
|
||||||
if (type !== "pipe" && type !== "junction") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return [id, type];
|
|
||||||
})
|
|
||||||
.filter(Boolean) as [string, string][];
|
|
||||||
})()}
|
|
||||||
scheme_type="burst_Analysis"
|
|
||||||
scheme_name={schemeName}
|
|
||||||
type={queryType as "realtime" | "scheme" | "none"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 图例显示 */}
|
|
||||||
{activeLegendConfigs.length > 0 && (
|
|
||||||
<div className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
|
||||||
<div className="flex flex-row gap-3">
|
|
||||||
{activeLegendConfigs.map((config, index) => (
|
|
||||||
<StyleLegend key={`${config.layerId}-${index}`} {...config} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Toolbar;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Refine,
|
||||||
|
type AccessControlProvider,
|
||||||
|
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";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
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 { useAuthStore } from "@/store/authStore";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
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 { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
|
||||||
|
import { LuReplace } from "react-icons/lu";
|
||||||
|
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||||
|
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
|
||||||
|
import {
|
||||||
|
FactCheck as FactCheckIcon,
|
||||||
|
ManageAccounts as ManageAccountsIcon,
|
||||||
|
MyLocation as MyLocationIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
|
type RefineContextProps = {
|
||||||
|
defaultMode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RefineContext = (
|
||||||
|
props: React.PropsWithChildren<RefineContextProps>,
|
||||||
|
) => (
|
||||||
|
<SessionProvider>
|
||||||
|
<App {...props} />
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
type AppProps = {
|
||||||
|
defaultMode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||||
|
const { data, status } = useSession();
|
||||||
|
const to = usePathname();
|
||||||
|
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}, [data?.accessToken, setAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "authenticated") {
|
||||||
|
resetAccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
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) {
|
||||||
|
resetAccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAccessContext(await response.json());
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) resetAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authProvider: AuthProvider = {
|
||||||
|
login: async () => {
|
||||||
|
signIn("keycloak", {
|
||||||
|
callbackUrl: to ? to.toString() : "/",
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Logout must still complete when audit storage is unavailable.
|
||||||
|
}
|
||||||
|
if (data?.user?.id) {
|
||||||
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
|
}
|
||||||
|
signOut({ redirect: true, callbackUrl: "/login" });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
return { logout: true };
|
||||||
|
}
|
||||||
|
return { error };
|
||||||
|
},
|
||||||
|
check: async () =>
|
||||||
|
status === "unauthenticated"
|
||||||
|
? { authenticated: false, redirectTo: "/login" }
|
||||||
|
: { authenticated: true },
|
||||||
|
getPermissions: async () => permissions,
|
||||||
|
getIdentity: async () => {
|
||||||
|
if (!data?.user) return null;
|
||||||
|
return {
|
||||||
|
id: data.user.id,
|
||||||
|
username: data.user.username,
|
||||||
|
name: data.user.name,
|
||||||
|
avatar: data.user.image,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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={props.defaultMode}>
|
||||||
|
<RefineSnackbarProvider>
|
||||||
|
<Refine
|
||||||
|
routerProvider={routerProvider}
|
||||||
|
dataProvider={dataProvider}
|
||||||
|
notificationProvider={useAppNotificationProvider}
|
||||||
|
authProvider={authProvider}
|
||||||
|
accessControlProvider={accessControlProvider}
|
||||||
|
resources={resources}
|
||||||
|
options={{
|
||||||
|
syncWithLocation: true,
|
||||||
|
warnWhenUnsavedChanges: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||||
|
<RefineKbar />
|
||||||
|
</Refine>
|
||||||
|
</RefineSnackbarProvider>
|
||||||
|
</ColorModeContextProvider>
|
||||||
|
</RefineKbarProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Refine, type AuthProvider } from "@refinedev/core";
|
|
||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
|
||||||
import {
|
|
||||||
RefineSnackbarProvider,
|
|
||||||
useNotificationProvider,
|
|
||||||
} from "@refinedev/mui";
|
|
||||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
|
||||||
|
|
||||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
|
||||||
import { dataProvider } from "@providers/data-provider";
|
|
||||||
|
|
||||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
|
||||||
import { TbDatabaseEdit } from "react-icons/tb";
|
|
||||||
import { LuReplace } from "react-icons/lu";
|
|
||||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
|
||||||
import { TbLocationPin } from "react-icons/tb";
|
|
||||||
import { AiOutlinePartition } from "react-icons/ai";
|
|
||||||
|
|
||||||
type RefineContextProps = {
|
|
||||||
defaultMode?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RefineContext = (
|
|
||||||
props: React.PropsWithChildren<RefineContextProps>
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<SessionProvider>
|
|
||||||
<App {...props} />
|
|
||||||
</SessionProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type AppProps = {
|
|
||||||
defaultMode?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
|
||||||
const { data, status } = useSession();
|
|
||||||
const to = usePathname();
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return <span>loading...</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProvider: AuthProvider = {
|
|
||||||
login: async () => {
|
|
||||||
signIn("keycloak", {
|
|
||||||
callbackUrl: to ? to.toString() : "/",
|
|
||||||
redirect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
logout: async () => {
|
|
||||||
signOut({
|
|
||||||
redirect: true,
|
|
||||||
callbackUrl: "/login",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
getIdentity: async () => {
|
|
||||||
if (data?.user) {
|
|
||||||
const { user } = data;
|
|
||||||
return {
|
|
||||||
name: user.name,
|
|
||||||
avatar: user.image,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultMode = props?.defaultMode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<RefineKbarProvider>
|
|
||||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
|
||||||
<RefineSnackbarProvider>
|
|
||||||
<Refine
|
|
||||||
routerProvider={routerProvider}
|
|
||||||
dataProvider={dataProvider}
|
|
||||||
notificationProvider={useNotificationProvider}
|
|
||||||
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: "风险分析定位",
|
|
||||||
list: "/risk-analysis-location",
|
|
||||||
meta: {
|
|
||||||
icon: <TbLocationPin className="w-6 h-6" />,
|
|
||||||
label: "风险分析定位",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "管网优化分区",
|
|
||||||
list: "/network-partition-optimization",
|
|
||||||
meta: {
|
|
||||||
icon: <AiOutlinePartition className="w-6 h-6" />,
|
|
||||||
label: "管网优化分区",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
options={{
|
|
||||||
syncWithLocation: true,
|
|
||||||
warnWhenUnsavedChanges: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
<RefineKbar />
|
|
||||||
</Refine>
|
|
||||||
</RefineSnackbarProvider>
|
|
||||||
</ColorModeContextProvider>
|
|
||||||
</RefineKbarProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,72 @@
|
|||||||
|
import { NextAuthOptions } from "next-auth";
|
||||||
|
import { JWT } from "next-auth/jwt";
|
||||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
||||||
|
|
||||||
const authOptions = {
|
type KeycloakTokenResponse = {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeycloakTokenEndpoint = () => {
|
||||||
|
const issuer = process.env.KEYCLOAK_ISSUER;
|
||||||
|
return issuer
|
||||||
|
? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||||
|
if (!token.refreshToken) {
|
||||||
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||||
|
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||||
|
const keycloakTokenEndpoint = getKeycloakTokenEndpoint();
|
||||||
|
|
||||||
|
if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) {
|
||||||
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: keycloakClientId,
|
||||||
|
client_secret: keycloakClientSecret,
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") {
|
||||||
|
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 = {
|
||||||
// Configure one or more authentication providers
|
// Configure one or more authentication providers
|
||||||
providers: [
|
providers: [
|
||||||
KeycloakProvider({
|
KeycloakProvider({
|
||||||
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
|
||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
||||||
issuer: process.env.KEYCLOAK_ISSUER!,
|
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
||||||
profile(profile) {
|
profile(profile) {
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
|
username: profile.preferred_username,
|
||||||
name: profile.name ?? profile.preferred_username,
|
name: profile.name ?? profile.preferred_username,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
image: Avatar.src,
|
image: Avatar.src,
|
||||||
@@ -19,6 +75,54 @@ const authOptions = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
callbacks: {
|
||||||
|
jwt: async ({ token, profile, account }) => {
|
||||||
|
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) {
|
||||||
|
if (account.access_token) {
|
||||||
|
token.accessToken = account.access_token;
|
||||||
|
}
|
||||||
|
if (account.refresh_token) {
|
||||||
|
token.refreshToken = account.refresh_token;
|
||||||
|
}
|
||||||
|
if (typeof account.expires_at === "number") {
|
||||||
|
token.accessTokenExpires = account.expires_at * 1000;
|
||||||
|
}
|
||||||
|
token.error = undefined;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshAccessToken(token);
|
||||||
|
},
|
||||||
|
session: async ({ session, token }) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default authOptions;
|
export default authOptions;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
const streamMock = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("edge-tts-ts", () => ({
|
||||||
|
Communicate: jest.fn().mockImplementation(() => ({
|
||||||
|
stream: streamMock,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("POST /api/tts/edge", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
streamMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns synthesized mp3 audio", async () => {
|
||||||
|
streamMock.mockImplementation(async function* () {
|
||||||
|
yield { type: "audio", data: new Uint8Array([1, 2]) };
|
||||||
|
yield { type: "SentenceBoundary", offset: 0, duration: 1, text: "测试" };
|
||||||
|
yield { type: "audio", data: new Uint8Array([3]) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/tts/edge", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text: "测试文本" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("audio/mpeg");
|
||||||
|
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty text", async () => {
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/tts/edge", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text: " " }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ error: "text is required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Communicate } from "edge-tts-ts";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural";
|
||||||
|
const MAX_TEXT_LENGTH = 12000;
|
||||||
|
|
||||||
|
type EdgeTtsRequest = {
|
||||||
|
text?: unknown;
|
||||||
|
voice?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonError = (message: string, status: number) =>
|
||||||
|
NextResponse.json({ error: message }, { status });
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let payload: EdgeTtsRequest;
|
||||||
|
try {
|
||||||
|
payload = (await request.json()) as EdgeTtsRequest;
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON body", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||||
|
if (!text) {
|
||||||
|
return jsonError("text is required", 400);
|
||||||
|
}
|
||||||
|
if (text.length > MAX_TEXT_LENGTH) {
|
||||||
|
return jsonError(`text must be ${MAX_TEXT_LENGTH} characters or fewer`, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
typeof payload.voice === "string" && payload.voice.trim()
|
||||||
|
? payload.voice.trim()
|
||||||
|
: DEFAULT_VOICE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const communicate = new Communicate(text, { voice });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let byteLength = 0;
|
||||||
|
|
||||||
|
for await (const chunk of communicate.stream()) {
|
||||||
|
if (chunk.type !== "audio") continue;
|
||||||
|
chunks.push(chunk.data);
|
||||||
|
byteLength += chunk.data.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byteLength === 0) {
|
||||||
|
return jsonError("Edge TTS returned empty audio", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = new Uint8Array(byteLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
audio.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = audio.buffer.slice(
|
||||||
|
audio.byteOffset,
|
||||||
|
audio.byteOffset + audio.byteLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(audioBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "audio/mpeg",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[EdgeTTS] Failed to synthesize speech:", error);
|
||||||
|
return jsonError("Failed to synthesize speech", 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import Script from "next/script";
|
||||||
import React, { Suspense } from "react";
|
import React, { Suspense } from "react";
|
||||||
import { RefineContext } from "./_refine_context";
|
import { RefineContext } from "./RefineContext";
|
||||||
import { META_DATA } from "@config/config";
|
import { META_DATA } from "@config/config";
|
||||||
|
|
||||||
export const metadata: Metadata = META_DATA;
|
export const metadata: Metadata = META_DATA;
|
||||||
@@ -16,8 +17,9 @@ export default async function RootLayout({
|
|||||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body>
|
<body>
|
||||||
|
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<RefineContext defaultMode={defaultMode}>{children}</RefineContext>
|
<RefineContext defaultMode={defaultMode}>{children}</RefineContext>
|
||||||
</Suspense>
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+238
-34
@@ -2,54 +2,258 @@
|
|||||||
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
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 Typography from "@mui/material/Typography";
|
||||||
import { useLogin } from "@refinedev/core";
|
import { useLogin } from "@refinedev/core";
|
||||||
import { ThemedTitle } from "@refinedev/mui";
|
import { PROJECT_TITLE } from "@config/config";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const FALLBACK_DELAY_MS = 4000;
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { mutate: login } = useLogin();
|
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 (
|
return (
|
||||||
<Container
|
<Box
|
||||||
style={{
|
component="main"
|
||||||
height: "100vh",
|
sx={{
|
||||||
display: "flex",
|
minHeight: "100vh",
|
||||||
justifyContent: "center",
|
"@supports (height: 100svh)": {
|
||||||
alignItems: "center",
|
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
|
<Box
|
||||||
display="flex"
|
sx={{
|
||||||
gap="36px"
|
width: "100%",
|
||||||
justifyContent="center"
|
maxWidth: 1720,
|
||||||
flexDirection="column"
|
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)" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ThemedTitle
|
<Stack
|
||||||
collapsed={false}
|
spacing={{ xs: 0, md: 3 }}
|
||||||
wrapperStyles={{
|
direction={{ xs: "row", md: "column" }}
|
||||||
fontSize: "22px",
|
alignItems={{ xs: "center", md: "flex-start" }}
|
||||||
justifyContent: "center",
|
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: '""',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
style={{ width: "240px" }}
|
|
||||||
size="large"
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => login({})}
|
|
||||||
>
|
>
|
||||||
Sign in
|
<Box
|
||||||
</Button>
|
component="img"
|
||||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
src="/login-logo-mark.svg"
|
||||||
Powered by
|
alt=""
|
||||||
<img
|
width={56}
|
||||||
style={{ padding: "0 5px" }}
|
height={56}
|
||||||
alt="Keycloak"
|
sx={{
|
||||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
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>
|
</Box>
|
||||||
</Container>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import BarChartRounded from "@mui/icons-material/BarChartRounded";
|
||||||
|
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
|
||||||
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
|
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
||||||
|
|
||||||
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
|
import type { AgentArtifact } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const artifactIcon = (kind: AgentArtifact["kind"]) => {
|
||||||
|
if (kind === "chart") return <BarChartRounded sx={{ fontSize: 18 }} />;
|
||||||
|
if (kind === "map") return <LocationOnRounded sx={{ fontSize: 18 }} />;
|
||||||
|
if (kind === "panel") return <SensorsRounded sx={{ fontSize: 18 }} />;
|
||||||
|
return <BuildCircleRounded sx={{ fontSize: 18 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const artifactColor = (kind: AgentArtifact["kind"], theme: Theme) => {
|
||||||
|
if (kind === "chart") return theme.palette.info.main;
|
||||||
|
if (kind === "map") return theme.palette.success.main;
|
||||||
|
if (kind === "panel") return theme.palette.warning.main;
|
||||||
|
return theme.palette.primary.main;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
if (!artifacts.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1.25}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="caption" fontWeight={800} color="text.primary">
|
||||||
|
结果与动作
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${artifacts.length} 项`}
|
||||||
|
sx={{ height: 20, fontSize: "0.68rem" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const color = artifactColor(artifact.kind, theme);
|
||||||
|
if (artifact.kind === "chart") {
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={artifact.id}
|
||||||
|
title={(artifact.params.title as string) ?? artifact.title}
|
||||||
|
chart_type={
|
||||||
|
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
|
}
|
||||||
|
x_data={
|
||||||
|
artifact.params.x_data ??
|
||||||
|
artifact.params.xData ??
|
||||||
|
artifact.params.labels ??
|
||||||
|
artifact.params.categories
|
||||||
|
}
|
||||||
|
series={artifact.params.series}
|
||||||
|
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={artifact.id}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 1.35,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(color, 0.22)}`,
|
||||||
|
bgcolor: alpha(color, 0.055),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(color, 0.12),
|
||||||
|
color,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artifactIcon(artifact.kind)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="caption" fontWeight={800} color="text.primary">
|
||||||
|
{artifact.title}
|
||||||
|
</Typography>
|
||||||
|
{artifact.description ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{artifact.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="已执行"
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
bgcolor: alpha(color, 0.12),
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* 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="request"
|
||||||
|
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(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Collapse,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import SendRounded from "@mui/icons-material/SendRounded";
|
||||||
|
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 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 type { AgentModelOption } from "@/lib/chatModels";
|
||||||
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
export type AgentComposerHandle = {
|
||||||
|
focus: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
append: (text: string) => void;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
getValue: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentComposerProps = {
|
||||||
|
isHydrating?: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
isListening: boolean;
|
||||||
|
isSttSupported: boolean;
|
||||||
|
presets: string[];
|
||||||
|
onSend: (prompt: string) => void;
|
||||||
|
onAbort: () => void;
|
||||||
|
onStartListening: () => void;
|
||||||
|
onStopListening: () => void;
|
||||||
|
modelOptions: AgentModelOption[];
|
||||||
|
selectedModel?: AgentModel;
|
||||||
|
onModelChange: (model: AgentModel) => void;
|
||||||
|
approvalMode: AgentApprovalMode;
|
||||||
|
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderModelIcon = (
|
||||||
|
icon: AgentModelOption["icon"] | undefined,
|
||||||
|
props?: React.ComponentProps<typeof BoltRounded>,
|
||||||
|
) =>
|
||||||
|
icon === "bolt" ? (
|
||||||
|
<BoltRounded {...props} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesomeRounded {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||||
|
isHydrating = false,
|
||||||
|
isStreaming,
|
||||||
|
isListening,
|
||||||
|
isSttSupported,
|
||||||
|
presets,
|
||||||
|
onSend,
|
||||||
|
onAbort,
|
||||||
|
onStartListening,
|
||||||
|
onStopListening,
|
||||||
|
modelOptions,
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
|
approvalMode,
|
||||||
|
onApprovalModeChange,
|
||||||
|
}, ref) {
|
||||||
|
const theme = useTheme();
|
||||||
|
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 selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
|
||||||
|
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
focus: () => inputRef.current?.focus(),
|
||||||
|
clear: () => setInput(""),
|
||||||
|
append: (text: string) => setInput((prev) => prev + text),
|
||||||
|
setValue: (value: string) => setInput(value),
|
||||||
|
getValue: () => input,
|
||||||
|
}),
|
||||||
|
[input],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSend = React.useCallback(() => {
|
||||||
|
const prompt = input.trim();
|
||||||
|
if (!prompt || isStreaming || isHydrating) return;
|
||||||
|
setInput("");
|
||||||
|
onSend(prompt);
|
||||||
|
}, [input, isHydrating, isStreaming, onSend]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
||||||
|
<Paper
|
||||||
|
elevation={isPresetOpen ? 4 : 0}
|
||||||
|
sx={{
|
||||||
|
mb: 1.5,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.5)}`,
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
boxShadow: isPresetOpen ? `0 -8px 24px ${alpha("#00acc1", 0.1)}` : "none",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<AutoAwesomeRounded
|
||||||
|
titleAccess="快捷指令图标"
|
||||||
|
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
|
管网分析快捷指令
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsPresetOpen((value) => !value)}
|
||||||
|
aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
|
||||||
|
sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }}
|
||||||
|
>
|
||||||
|
{isPresetOpen ? (
|
||||||
|
<KeyboardArrowDownRounded fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowUpRounded fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Collapse in={isPresetOpen} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ mt: 1.5, mb: 0.5, pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{presets.map((prompt) => (
|
||||||
|
<Chip
|
||||||
|
key={prompt}
|
||||||
|
label={prompt.replace(/[。.]$/, "")}
|
||||||
|
size="medium"
|
||||||
|
clickable
|
||||||
|
onClick={() => {
|
||||||
|
setInput(prompt);
|
||||||
|
setIsPresetOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "16px",
|
||||||
|
bgcolor: alpha("#fff", 0.7),
|
||||||
|
border: `1px solid ${alpha("#00acc1", 0.15)}`,
|
||||||
|
color: "text.primary",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
boxShadow: `0 2px 6px ${alpha("#000", 0.03)}`,
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#fff", 0.95),
|
||||||
|
boxShadow: `0 4px 10px ${alpha("#00acc1", 0.2)}`,
|
||||||
|
borderColor: alpha("#00acc1", 0.4),
|
||||||
|
color: "#00acc1"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}>
|
||||||
|
<Paper
|
||||||
|
elevation={12}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.75),
|
||||||
|
backdropFilter: "blur(40px)",
|
||||||
|
border: `1px solid ${alpha("#ffffff", 0.9)}`,
|
||||||
|
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
inputRef={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
maxRows={5}
|
||||||
|
variant="standard"
|
||||||
|
disabled={isHydrating}
|
||||||
|
InputProps={{
|
||||||
|
disableUnderline: true,
|
||||||
|
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||||
|
<Select
|
||||||
|
value={approvalMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
onApprovalModeChange(event.target.value as AgentApprovalMode)
|
||||||
|
}
|
||||||
|
disabled={isHydrating || isStreaming}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
MenuProps={{
|
||||||
|
anchorOrigin: { vertical: "top", horizontal: "left" },
|
||||||
|
transformOrigin: { vertical: "bottom", horizontal: "left" },
|
||||||
|
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
|
||||||
|
PaperProps: {
|
||||||
|
sx: {
|
||||||
|
mb: 1.5,
|
||||||
|
width: 210,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.9),
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||||
|
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}`,
|
||||||
|
"& .MuiList-root": { p: 1 },
|
||||||
|
"& .MuiMenuItem-root": {
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.2,
|
||||||
|
mb: 0.5,
|
||||||
|
borderRadius: 3,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
"&:last-child": { mb: 0 },
|
||||||
|
"&.Mui-selected": {
|
||||||
|
bgcolor: alpha("#00acc1", 0.08),
|
||||||
|
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
|
||||||
|
"& .title": { color: "#00838f" },
|
||||||
|
"& .icon": { color: "#00acc1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "18px",
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
color: "text.secondary",
|
||||||
|
".MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||||
|
".MuiSelect-select": {
|
||||||
|
py: 0,
|
||||||
|
pl: 1,
|
||||||
|
pr: "28px !important",
|
||||||
|
minHeight: 36,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
|
||||||
|
bgcolor: alpha("#000", 0.06),
|
||||||
|
color: "text.primary",
|
||||||
|
},
|
||||||
|
".MuiSelect-icon": {
|
||||||
|
color: "text.secondary",
|
||||||
|
right: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||||||
|
<Select
|
||||||
|
value={selectedModel ?? ""}
|
||||||
|
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
||||||
|
disabled={isHydrating || isStreaming || modelOptions.length === 0}
|
||||||
|
aria-label="模型选择"
|
||||||
|
renderValue={() => (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
{renderModelIcon(selectedModelOption?.icon, {
|
||||||
|
sx: {
|
||||||
|
fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
|
||||||
|
color: "inherit",
|
||||||
|
transition: "color 0.2s",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
||||||
|
{selectedModelOption?.label ?? "模型"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
MenuProps={{
|
||||||
|
anchorOrigin: { vertical: "top", horizontal: "center" },
|
||||||
|
transformOrigin: { vertical: "bottom", horizontal: "center" },
|
||||||
|
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
|
||||||
|
PaperProps: {
|
||||||
|
sx: {
|
||||||
|
mb: 1.5,
|
||||||
|
width: 230,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.85),
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||||
|
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||||
|
"& .MuiList-root": {
|
||||||
|
p: 1,
|
||||||
|
},
|
||||||
|
"& .MuiMenuItem-root": {
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.2,
|
||||||
|
mb: 0.5,
|
||||||
|
"&:last-child": { mb: 0 },
|
||||||
|
borderRadius: 3,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#000", 0.03),
|
||||||
|
},
|
||||||
|
"&.Mui-selected": {
|
||||||
|
bgcolor: alpha("#00acc1", 0.08),
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#00acc1", 0.12),
|
||||||
|
},
|
||||||
|
"& .title": { color: "#00838f" },
|
||||||
|
"& .icon": { color: "#00acc1" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "18px",
|
||||||
|
bgcolor: "transparent",
|
||||||
|
color: "text.secondary",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
".MuiOutlinedInput-notchedOutline": {
|
||||||
|
border: "none",
|
||||||
|
},
|
||||||
|
".MuiSelect-select": {
|
||||||
|
py: 0,
|
||||||
|
pl: 1,
|
||||||
|
pr: "28px !important",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
|
||||||
|
bgcolor: alpha("#000", 0.06),
|
||||||
|
color: "text.primary",
|
||||||
|
".MuiSelect-icon": {
|
||||||
|
color: "text.primary",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
".MuiSelect-icon": {
|
||||||
|
color: "text.secondary",
|
||||||
|
right: 4,
|
||||||
|
transition: "color 0.2s ease",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
||||||
|
<AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
||||||
|
模型选择
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{modelOptions.map((model) => (
|
||||||
|
<MenuItem key={model.id} value={model.id}>
|
||||||
|
{renderModelIcon(model.icon, {
|
||||||
|
className: "icon",
|
||||||
|
sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" },
|
||||||
|
})}
|
||||||
|
<Box>
|
||||||
|
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
|
||||||
|
{model.description ? (
|
||||||
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</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}
|
||||||
|
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 }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onAbort}
|
||||||
|
aria-label="停止生成"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: "error.main",
|
||||||
|
color: "#fff",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`,
|
||||||
|
"&:hover": { bgcolor: "error.dark" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StopRounded />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||||
|
<IconButton
|
||||||
|
disabled={!canSend}
|
||||||
|
onClick={handleSend}
|
||||||
|
aria-label="发送"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5),
|
||||||
|
color: canSend ? "#fff" : "action.disabled",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none",
|
||||||
|
"&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SendRounded sx={{ ml: 0.35 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, opacity: 0.6 }}>
|
||||||
|
<Image
|
||||||
|
src="/deepseek-logo.svg"
|
||||||
|
alt="DeepSeek"
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
style={{ width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}>
|
||||||
|
Powered by DeepSeek V4 · TJWater Agent Intelligence
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CheckRounded from "@mui/icons-material/CheckRounded";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type AgentHeaderProps = {
|
||||||
|
sessionTitle?: string;
|
||||||
|
canRenameSessionTitle?: boolean;
|
||||||
|
isHydrating?: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
isHistoryOpen: boolean;
|
||||||
|
onHistoryToggle: () => void;
|
||||||
|
onRenameSessionTitle?: (title: string) => void;
|
||||||
|
onNewConversation: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentHeader = ({
|
||||||
|
sessionTitle,
|
||||||
|
canRenameSessionTitle = false,
|
||||||
|
isHydrating = false,
|
||||||
|
isStreaming,
|
||||||
|
isHistoryOpen,
|
||||||
|
onHistoryToggle,
|
||||||
|
onRenameSessionTitle,
|
||||||
|
onNewConversation,
|
||||||
|
onClose,
|
||||||
|
}: AgentHeaderProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const displayTitle = sessionTitle?.trim() || "新对话";
|
||||||
|
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||||
|
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isEditingTitle) {
|
||||||
|
setDraftTitle(sessionTitle?.trim() || "");
|
||||||
|
}
|
||||||
|
}, [isEditingTitle, sessionTitle]);
|
||||||
|
|
||||||
|
const handleStartEditing = () => {
|
||||||
|
if (!canRenameSessionTitle || isHydrating || isStreaming) return;
|
||||||
|
setDraftTitle(sessionTitle?.trim() || "");
|
||||||
|
setIsEditingTitle(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEditing = () => {
|
||||||
|
setDraftTitle(sessionTitle?.trim() || "");
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmEditing = () => {
|
||||||
|
const normalizedTitle = draftTitle.trim();
|
||||||
|
if (!normalizedTitle) return;
|
||||||
|
onRenameSessionTitle?.(normalizedTitle);
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2.5,
|
||||||
|
zIndex: 10,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
||||||
|
background: `linear-gradient(to bottom, ${alpha("#fff", 0.4)}, ${alpha("#fff", 0.1)})`,
|
||||||
|
boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2} sx={{ minWidth: 0, flex: 1, mr: 2 }}>
|
||||||
|
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex", flexShrink: 0 }}>
|
||||||
|
<Box sx={{ position: "relative" }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
background: alpha("#ffffff", 0.9),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
border: `2px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
p: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/ai-agent.svg"
|
||||||
|
alt="TJWater Agent"
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
bgcolor: isStreaming ? "#ff9800" : "#00e676",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "2.5px solid #fff",
|
||||||
|
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
|
||||||
|
animation: isStreaming ? "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)}` },
|
||||||
|
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</motion.div>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
{isEditingTitle ? (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: "100%" }}>
|
||||||
|
<TextField
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
placeholder="请输入对话标题"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleConfirmEditing();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleCancelEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
padding: "6px 8px",
|
||||||
|
bgcolor: "transparent",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
"&.Mui-focused": {
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
"& fieldset": {
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
"&:hover fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.2),
|
||||||
|
},
|
||||||
|
"&.Mui-focused fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||||
|
borderWidth: "1px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
padding: 0,
|
||||||
|
height: "auto",
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
lineHeight: "1.2",
|
||||||
|
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认"
|
||||||
|
onClick={handleConfirmEditing}
|
||||||
|
disabled={!draftTitle.trim()}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "success.main",
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.1),
|
||||||
|
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消"
|
||||||
|
onClick={handleCancelEditing}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontWeight={800}
|
||||||
|
sx={{
|
||||||
|
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
px: "8px",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</Typography>
|
||||||
|
{canRenameSessionTitle ? (
|
||||||
|
<Tooltip title="修改对话标题">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="修改对话标题"
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
disabled={isHydrating || isStreaming}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
color: "primary.main",
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.25} alignItems="center" sx={{ flexShrink: 0 }}>
|
||||||
|
<Tooltip title="新建对话">
|
||||||
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onNewConversation}
|
||||||
|
aria-label="新建对话"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "text.primary",
|
||||||
|
bgcolor: alpha("#fff", 0.54),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.4)}`,
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: "#00acc1",
|
||||||
|
borderColor: alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditNoteRounded sx={{ fontSize: 22 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={isHistoryOpen ? "收起历史会话" : "打开历史会话"}>
|
||||||
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onHistoryToggle}
|
||||||
|
aria-label={isHistoryOpen ? "收起历史会话" : "打开历史会话"}
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: isHistoryOpen ? "#00acc1" : "text.primary",
|
||||||
|
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.12) : alpha("#fff", 0.54),
|
||||||
|
border: `1px solid ${isHistoryOpen ? alpha("#00acc1", 0.2) : alpha("#fff", 0.4)}`,
|
||||||
|
boxShadow: `0 2px 8px ${isHistoryOpen ? alpha("#00acc1", 0.05) : alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.16) : "#fff",
|
||||||
|
borderColor: isHistoryOpen ? alpha("#00acc1", 0.3) : alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${isHistoryOpen ? alpha("#00acc1", 0.1) : alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HistoryRounded sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="关闭 Agent">
|
||||||
|
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭 Agent"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "text.primary",
|
||||||
|
bgcolor: alpha("#fff", 0.54),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.4)}`,
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: "#e53935",
|
||||||
|
borderColor: alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
|
|
||||||
|
const renderWithTheme = (ui: React.ReactElement) =>
|
||||||
|
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
describe("AgentHistoryPanel", () => {
|
||||||
|
it("shows skeleton rows while history sessions are loading", () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[]}
|
||||||
|
isLoadingSessions
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={jest.fn()}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("正在加载历史会话")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("暂无历史会话")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the loading history session item", () => {
|
||||||
|
const onSelectSession = jest.fn();
|
||||||
|
const onRenameSession = jest.fn();
|
||||||
|
const onDeleteSession = jest.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-loading",
|
||||||
|
title: "正在加载的会话",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
loadingSessionId="session-loading"
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={onRenameSession}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("正在加载的会话")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "修改会话标题" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "删除会话" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("正在加载会话 正在加载的会话"));
|
||||||
|
|
||||||
|
expect(onSelectSession).not.toHaveBeenCalled();
|
||||||
|
expect(onRenameSession).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleteSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames a history session from the list", () => {
|
||||||
|
const onRenameSession = jest.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "旧会话标题",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeSessionId="session-1"
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={onRenameSession}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "修改会话标题" }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), {
|
||||||
|
target: { value: "新的会话标题" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("确认"));
|
||||||
|
|
||||||
|
expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders history by the first message time instead of the latest update time", () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-newer-update",
|
||||||
|
title: "较新的更新",
|
||||||
|
createdAt: new Date("2026-05-18T09:00:00+08:00").getTime(),
|
||||||
|
updatedAt: new Date("2026-05-19T12:00:00+08:00").getTime(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-newer-first-message",
|
||||||
|
title: "较新的首条消息",
|
||||||
|
createdAt: new Date("2026-05-19T08:00:00+08:00").getTime(),
|
||||||
|
updatedAt: new Date("2026-05-19T08:30:00+08:00").getTime(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={jest.fn()}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionTitles = screen.getAllByText(/较新的/).map((element) => element.textContent);
|
||||||
|
|
||||||
|
expect(sessionTitles).toEqual(["较新的首条消息", "较新的更新"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CheckRounded from "@mui/icons-material/CheckRounded";
|
||||||
|
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||||
|
import EditRounded from "@mui/icons-material/EditRounded";
|
||||||
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
|
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
|
||||||
|
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
|
||||||
|
import SearchRounded from "@mui/icons-material/SearchRounded";
|
||||||
|
import WarningRounded from "@mui/icons-material/WarningRounded";
|
||||||
|
import type { ChatSessionSummary } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
type AgentHistoryPanelProps = {
|
||||||
|
sessions: ChatSessionSummary[];
|
||||||
|
activeSessionId?: string;
|
||||||
|
isHydrating?: boolean;
|
||||||
|
isLoadingSessions?: boolean;
|
||||||
|
loadingSessionId?: string;
|
||||||
|
onNewSession: () => void;
|
||||||
|
onRenameSession: (sessionId: string, title: string) => void;
|
||||||
|
onSelectSession: (sessionId: string, title: string) => void;
|
||||||
|
onDeleteSession: (sessionId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRelativeDate = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const isSameDay = date.toDateString() === now.toDateString();
|
||||||
|
if (isSameDay) {
|
||||||
|
return date.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString("zh-CN", {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDayStart = (date: Date) =>
|
||||||
|
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
|
||||||
|
const getSessionGroupLabel = (timestamp: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = getDayStart(now);
|
||||||
|
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
|
||||||
|
const lastWeekStart = todayStart - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (timestamp >= todayStart) return "今天";
|
||||||
|
if (timestamp >= yesterdayStart) return "昨天";
|
||||||
|
if (timestamp >= lastWeekStart) return "过去 7 天";
|
||||||
|
return "更早";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentHistoryPanel = ({
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
isHydrating = false,
|
||||||
|
isLoadingSessions = false,
|
||||||
|
loadingSessionId,
|
||||||
|
onNewSession,
|
||||||
|
onRenameSession,
|
||||||
|
onSelectSession,
|
||||||
|
onDeleteSession,
|
||||||
|
}: AgentHistoryPanelProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [keyword, setKeyword] = React.useState("");
|
||||||
|
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
||||||
|
const [draftTitle, setDraftTitle] = React.useState("");
|
||||||
|
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const filteredSessions = React.useMemo(() => {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||||
|
if (!normalizedKeyword) return sessions;
|
||||||
|
return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword));
|
||||||
|
}, [keyword, sessions]);
|
||||||
|
|
||||||
|
const sortedFilteredSessions = React.useMemo(
|
||||||
|
() =>
|
||||||
|
[...filteredSessions].sort((left, right) => {
|
||||||
|
const createdAtDiff = right.createdAt - left.createdAt;
|
||||||
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
|
|
||||||
|
const updatedAtDiff = right.updatedAt - left.updatedAt;
|
||||||
|
if (updatedAtDiff !== 0) return updatedAtDiff;
|
||||||
|
|
||||||
|
return right.id.localeCompare(left.id);
|
||||||
|
}),
|
||||||
|
[filteredSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedSessions = React.useMemo(() => {
|
||||||
|
const groups = new Map<string, ChatSessionSummary[]>();
|
||||||
|
|
||||||
|
sortedFilteredSessions.forEach((session) => {
|
||||||
|
const label = getSessionGroupLabel(session.createdAt);
|
||||||
|
const existing = groups.get(label);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(session);
|
||||||
|
} else {
|
||||||
|
groups.set(label, [session]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}, [sortedFilteredSessions]);
|
||||||
|
|
||||||
|
const pendingDeleteSession = filteredSessions.find(
|
||||||
|
(session) => session.id === pendingDeleteSessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSessionListSkeleton = () => (
|
||||||
|
<Stack spacing={1} aria-label="正在加载历史会话">
|
||||||
|
{Array.from({ length: 6 }, (_, index) => (
|
||||||
|
<Paper
|
||||||
|
key={index}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#fff", 0.48),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.68)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.025)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.75} sx={{ minHeight: 46, justifyContent: "center" }}>
|
||||||
|
<Skeleton variant="text" width={`${72 - (index % 3) * 12}%`} height={18} />
|
||||||
|
<Skeleton variant="text" width="32%" height={14} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartRename = (sessionId: string, title: string) => {
|
||||||
|
setEditingSessionId(sessionId);
|
||||||
|
setDraftTitle(title);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRename = () => {
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setDraftTitle("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRename = (sessionId: string) => {
|
||||||
|
const normalizedTitle = draftTitle.trim();
|
||||||
|
if (!normalizedTitle) return;
|
||||||
|
onRenameSession(sessionId, normalizedTitle);
|
||||||
|
handleCancelRename();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: 268,
|
||||||
|
minWidth: 268,
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
bgcolor: alpha("#ffffff", 0.54),
|
||||||
|
borderRight: `1px solid ${alpha("#fff", 0.75)}`,
|
||||||
|
backdropFilter: "blur(28px)",
|
||||||
|
boxShadow: `inset -1px 0 0 ${alpha("#fff", 0.35)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
|
||||||
|
历史会话
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="新建对话">
|
||||||
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
disabled={isHydrating}
|
||||||
|
onClick={onNewSession}
|
||||||
|
aria-label="新建对话"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "text.primary",
|
||||||
|
bgcolor: alpha("#fff", 0.65),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.5)}`,
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: "#00acc1",
|
||||||
|
borderColor: alpha("#fff", 0.9),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditNoteRounded sx={{ fontSize: 22 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ px: 1.5, pb: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索历史会话"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
disabled={isHydrating}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <SearchRounded sx={{ fontSize: 16, color: "text.secondary", mr: 0.75 }} />,
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#fff", 0.62),
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
|
||||||
|
{isLoadingSessions ? (
|
||||||
|
renderSessionListSkeleton()
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<Stack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "text.secondary",
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatBubbleOutlineRounded sx={{ fontSize: 24, opacity: 0.7 }} />
|
||||||
|
<Typography variant="body2" fontWeight={700}>
|
||||||
|
暂无历史会话
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
新建对话后会自动出现在这里
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : filteredSessions.length === 0 ? (
|
||||||
|
<Stack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "text.secondary",
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchRounded sx={{ fontSize: 24, opacity: 0.7 }} />
|
||||||
|
<Typography variant="body2" fontWeight={700}>
|
||||||
|
未找到匹配会话
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
试试其他关键词
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{groupedSessions.map(([groupLabel, groupSessions]) => (
|
||||||
|
<Box key={groupLabel}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
fontWeight={800}
|
||||||
|
sx={{ px: 0.5, mb: 0.75, display: "block", letterSpacing: 0.3 }}
|
||||||
|
>
|
||||||
|
{groupLabel}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{groupSessions.map((session) => {
|
||||||
|
const isActive = session.id === activeSessionId;
|
||||||
|
const isLoading = session.id === loadingSessionId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={session.id}
|
||||||
|
elevation={0}
|
||||||
|
aria-label={isLoading ? `正在加载会话 ${session.title}` : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (editingSessionId === session.id || isLoading) return;
|
||||||
|
onSelectSession(session.id, session.title);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: isHydrating || isLoading ? "default" : "pointer",
|
||||||
|
bgcolor:
|
||||||
|
isActive || isLoading
|
||||||
|
? alpha("#00acc1", 0.12)
|
||||||
|
: alpha("#fff", 0.56),
|
||||||
|
border: `1px solid ${
|
||||||
|
isActive || isLoading
|
||||||
|
? alpha("#00acc1", 0.25)
|
||||||
|
: alpha("#fff", 0.72)
|
||||||
|
}`,
|
||||||
|
boxShadow:
|
||||||
|
isActive || isLoading
|
||||||
|
? `0 8px 20px ${alpha("#00acc1", 0.12)}`
|
||||||
|
: `0 4px 12px ${alpha("#000", 0.03)}`,
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
pointerEvents: isHydrating || isLoading ? "none" : "auto",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor:
|
||||||
|
isActive || isLoading
|
||||||
|
? alpha("#00acc1", 0.14)
|
||||||
|
: alpha("#fff", 0.86),
|
||||||
|
borderColor: alpha("#00acc1", 0.2),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{editingSessionId === session.id ? (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
|
<TextField
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
placeholder="请输入会话标题"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleConfirmRename(session.id);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCancelRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
height: 32,
|
||||||
|
bgcolor: alpha("#fff", 0.75),
|
||||||
|
borderRadius: 1.5,
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
"& fieldset": {
|
||||||
|
borderColor: alpha("#000", 0.08),
|
||||||
|
},
|
||||||
|
"&:hover fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.4),
|
||||||
|
},
|
||||||
|
"&.Mui-focused fieldset": {
|
||||||
|
borderColor: theme.palette.primary.main,
|
||||||
|
borderWidth: "1.5px",
|
||||||
|
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleConfirmRename(session.id);
|
||||||
|
}}
|
||||||
|
disabled={!draftTitle.trim()}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "success.main",
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.1),
|
||||||
|
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCancelRename();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
) : isLoading ? (
|
||||||
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
|
<Skeleton variant="text" width="74%" height={18} />
|
||||||
|
<Skeleton variant="text" width="34%" height={14} sx={{ mt: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
) : pendingDeleteSessionId === session.id ? (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: alpha("#ef5350", 0.15),
|
||||||
|
color: "#ef5350",
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WarningRounded sx={{ fontSize: 13 }} />
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={800}
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认删除此会话?
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={isActive ? 800 : 700}
|
||||||
|
color="text.primary"
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
|
||||||
|
{formatRelativeDate(session.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && (
|
||||||
|
<Stack direction="row" spacing={0.25}>
|
||||||
|
<Tooltip title="修改会话标题">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="修改会话标题"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleStartRename(session.id, session.title);
|
||||||
|
}}
|
||||||
|
disabled={isHydrating || editingSessionId === session.id}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
color: "primary.main",
|
||||||
|
bgcolor: alpha("#00acc1", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除会话">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="删除会话"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPendingDeleteSessionId(session.id);
|
||||||
|
}}
|
||||||
|
disabled={isHydrating}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
color: "error.main",
|
||||||
|
bgcolor: alpha("#ef5350", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingDeleteSessionId === session.id && (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认删除"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDeleteSession(session.id);
|
||||||
|
setPendingDeleteSessionId(null);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "error.main",
|
||||||
|
bgcolor: alpha("#ef5350", 0.1),
|
||||||
|
"&:hover": { bgcolor: alpha("#ef5350", 0.2) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消删除"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPendingDeleteSessionId(null);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import type { Element, Root, RootContent, Text } from "hast";
|
||||||
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||||
|
|
||||||
|
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
||||||
|
|
||||||
|
const isTextNode = (node: RootContent): node is Text => node.type === "text";
|
||||||
|
|
||||||
|
const isElementNode = (node: RootContent): node is Element => node.type === "element";
|
||||||
|
|
||||||
|
const createFadeSpan = (value: string, fadeKey: string): Element => ({
|
||||||
|
type: "element",
|
||||||
|
tagName: "span",
|
||||||
|
properties: {
|
||||||
|
className: [markdownStyles.streamFade],
|
||||||
|
dataStreamFadeKey: fadeKey,
|
||||||
|
dataStreamRevealLength: value.length,
|
||||||
|
},
|
||||||
|
children: [{ type: "text", value }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const splitTextTail = (value: string, tailLength: number) => {
|
||||||
|
const codePoints = Array.from(value);
|
||||||
|
const stableText = codePoints.slice(0, -tailLength).join("");
|
||||||
|
const animatedText = codePoints.slice(-tailLength).join("");
|
||||||
|
return { stableText, animatedText };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStreamFadePlugin = (fadeLength: number, fadeKey: string) => {
|
||||||
|
return () => (tree: Root) => {
|
||||||
|
let remainingFadeLength = fadeLength;
|
||||||
|
|
||||||
|
const visitChildren = (parent: Element | Root) => {
|
||||||
|
const nextChildren: RootContent[] = [];
|
||||||
|
|
||||||
|
for (let index = parent.children.length - 1; index >= 0; index -= 1) {
|
||||||
|
const child = (parent.children as RootContent[])[index];
|
||||||
|
if (isTextNode(child)) {
|
||||||
|
if (!child.value.trim()) {
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingFadeLength <= 0) {
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textLength = Array.from(child.value).length;
|
||||||
|
const tailLength = Math.min(textLength, remainingFadeLength);
|
||||||
|
const { stableText, animatedText } = splitTextTail(child.value, tailLength);
|
||||||
|
remainingFadeLength -= tailLength;
|
||||||
|
|
||||||
|
if (animatedText) {
|
||||||
|
nextChildren.unshift(createFadeSpan(animatedText, fadeKey));
|
||||||
|
}
|
||||||
|
if (stableText) {
|
||||||
|
nextChildren.unshift({ ...child, value: stableText });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElementNode(child)) {
|
||||||
|
visitChildren(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children = nextChildren as typeof parent.children;
|
||||||
|
};
|
||||||
|
|
||||||
|
visitChildren(tree);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamFadeSpan: Components["span"] = ({ node, children, ...props }) => {
|
||||||
|
const ref = React.useRef<HTMLSpanElement>(null);
|
||||||
|
const fadeKeyValue = node?.properties?.dataStreamFadeKey;
|
||||||
|
const fadeKey = typeof fadeKeyValue === "string" ? fadeKeyValue : undefined;
|
||||||
|
const revealLengthValue = node?.properties?.dataStreamRevealLength;
|
||||||
|
const revealLength =
|
||||||
|
typeof revealLengthValue === "number"
|
||||||
|
? revealLengthValue
|
||||||
|
: typeof revealLengthValue === "string"
|
||||||
|
? Number.parseInt(revealLengthValue, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!fadeKey) return;
|
||||||
|
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
|
||||||
|
const duration = Math.min(260, Math.max(120, revealLength * 14));
|
||||||
|
const animation = element.animate(
|
||||||
|
[
|
||||||
|
{ clipPath: "inset(0 100% 0 0)", opacity: 0.46 },
|
||||||
|
{ clipPath: "inset(0 0% 0 0)", opacity: 1 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration,
|
||||||
|
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
fill: "both",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
animation.cancel();
|
||||||
|
};
|
||||||
|
}, [fadeKey, revealLength]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span {...props} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdownComponents: Components = {
|
||||||
|
span: StreamFadeSpan,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MarkdownBlock = ({
|
||||||
|
children,
|
||||||
|
streamFadeKey,
|
||||||
|
streamFadeLength,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
streamFadeKey?: string;
|
||||||
|
streamFadeLength?: number | null;
|
||||||
|
}) => {
|
||||||
|
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
const selectedText = window.getSelection()?.toString();
|
||||||
|
if (!selectedText) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
|
||||||
|
}, []);
|
||||||
|
const rehypePlugins = React.useMemo(
|
||||||
|
() =>
|
||||||
|
typeof streamFadeLength === "number" && streamFadeLength > 0
|
||||||
|
? [createStreamFadePlugin(
|
||||||
|
streamFadeLength,
|
||||||
|
streamFadeKey ?? `stream-tail-${children.length}`,
|
||||||
|
)]
|
||||||
|
: [],
|
||||||
|
[children.length, streamFadeKey, streamFadeLength],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
||||||
|
<ReactMarkdown
|
||||||
|
components={markdownComponents}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
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 type { PermissionReply } from "@/lib/chatStream";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
||||||
|
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||||
|
if (permission.permission === "bash") return "执行终端命令";
|
||||||
|
if (permission.permission === "edit") return "修改文件内容";
|
||||||
|
return permission.permission || "工具权限请求";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionPrimaryValue = (
|
||||||
|
permission: NonNullable<Message["permissions"]>[number],
|
||||||
|
) => {
|
||||||
|
if (typeof permission.target === "string" && permission.target.trim()) {
|
||||||
|
return permission.target.trim();
|
||||||
|
}
|
||||||
|
return permission.patterns[0] ?? permission.permission;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionIcon = ({
|
||||||
|
permission,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
}) => {
|
||||||
|
if (permission.permission === "bash") {
|
||||||
|
return <TerminalRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
if (permission.permission === "external_directory") {
|
||||||
|
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
|
||||||
|
if (status === "approved_always") return "已始终允许";
|
||||||
|
if (status === "approved_once") return "已允许一次";
|
||||||
|
if (status === "rejected") return "已拒绝";
|
||||||
|
if (status === "aborted") return "已中断";
|
||||||
|
if (status === "error") return "提交失败";
|
||||||
|
if (status === "submitting") return "提交中";
|
||||||
|
return "等待确认";
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingPermissionColor = "#f9a825";
|
||||||
|
const approvedOncePermissionColor = "#00838f";
|
||||||
|
|
||||||
|
const getPermissionStatusColor = (
|
||||||
|
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "approved_once") return approvedOncePermissionColor;
|
||||||
|
if (status === "approved_always") return theme.palette.success.main;
|
||||||
|
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||||
|
if (status === "aborted") return theme.palette.text.secondary;
|
||||||
|
return pendingPermissionColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionStatusTextColor = (
|
||||||
|
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "approved_once") return "#006c78";
|
||||||
|
if (status === "approved_always") return theme.palette.success.dark;
|
||||||
|
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||||
|
if (status === "aborted") return theme.palette.text.secondary;
|
||||||
|
return "#8a5a00";
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionRequestCard = ({
|
||||||
|
permission,
|
||||||
|
isRunning,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
isRunning: boolean;
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isPending =
|
||||||
|
isRunning && (permission.status === "pending" || permission.status === "error");
|
||||||
|
const isSubmitting = isRunning && permission.status === "submitting";
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const accentColor = getPermissionStatusColor(permission.status, theme);
|
||||||
|
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||||
|
const statusLabel = getPermissionStatusLabel(permission.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.5),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
position: "relative",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
inset: "10px auto 10px 0",
|
||||||
|
width: 3,
|
||||||
|
borderRadius: "0 999px 999px 0",
|
||||||
|
bgcolor: accentColor,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
pl: 1.75,
|
||||||
|
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: accentColor,
|
||||||
|
bgcolor: alpha(accentColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(accentColor, 0.16)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={statusLabel}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
borderRadius: "12px",
|
||||||
|
bgcolor: alpha(accentColor, 0.12),
|
||||||
|
color: statusTextColor,
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha("#000", 0.025),
|
||||||
|
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||||
|
请求目标
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.primary"
|
||||||
|
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
|
||||||
|
sx={{
|
||||||
|
mt: 0.25,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{permission.error ? (
|
||||||
|
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permission.error}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPending || isSubmitting ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
flexWrap="wrap"
|
||||||
|
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>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PermissionRequestGroup = ({
|
||||||
|
permissions,
|
||||||
|
isRunning,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permissions: NonNullable<Message["permissions"]>;
|
||||||
|
isRunning: boolean;
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
||||||
|
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
|
||||||
|
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
|
||||||
|
const abortedCount = permissions.filter((permission) => permission.status === "aborted").length;
|
||||||
|
const pendingCount = permissions.filter(
|
||||||
|
(permission) =>
|
||||||
|
permission.status === "pending" ||
|
||||||
|
permission.status === "submitting" ||
|
||||||
|
permission.status === "error",
|
||||||
|
).length;
|
||||||
|
const hasPendingPermissions = pendingCount > 0;
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const latestPermissions = permissions.slice(-3);
|
||||||
|
const pendingPermissions = permissions.filter(
|
||||||
|
(permission) =>
|
||||||
|
permission.status === "pending" ||
|
||||||
|
permission.status === "submitting" ||
|
||||||
|
permission.status === "error",
|
||||||
|
);
|
||||||
|
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: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
|
||||||
|
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
||||||
|
];
|
||||||
|
const chipColor =
|
||||||
|
pendingCount > 0
|
||||||
|
? getPermissionStatusColor("pending", theme)
|
||||||
|
: abortedCount > 0
|
||||||
|
? getPermissionStatusColor("aborted", theme)
|
||||||
|
: rejectedCount > 0
|
||||||
|
? getPermissionStatusColor("rejected", theme)
|
||||||
|
: getPermissionStatusColor("approved_always", theme);
|
||||||
|
const chipTextColor =
|
||||||
|
pendingCount > 0
|
||||||
|
? getPermissionStatusTextColor("pending", theme)
|
||||||
|
: abortedCount > 0
|
||||||
|
? getPermissionStatusTextColor("aborted", theme)
|
||||||
|
: rejectedCount > 0
|
||||||
|
? getPermissionStatusTextColor("rejected", theme)
|
||||||
|
: getPermissionStatusTextColor("approved_always", theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.46),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.15,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.025) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: chipColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(chipColor, 0.15)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VerifiedUserRounded sx={{ fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
权限请求
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={0.6}
|
||||||
|
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{summaryItems.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.label}
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.45,
|
||||||
|
height: 22,
|
||||||
|
px: 0.8,
|
||||||
|
borderRadius: "11px",
|
||||||
|
bgcolor: alpha(item.color, 0.08),
|
||||||
|
border: `1px solid ${alpha(item.color, 0.12)}`,
|
||||||
|
color: "textColor" in item ? item.textColor : item.color,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
color: "textColor" in item ? item.textColor : item.color,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Box>
|
||||||
|
<Box component="span">{item.value} 项</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
{isRunning && pendingCount > 0 ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`待确认 ${pendingCount} 项`}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: chipTextColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起权限请求" : "展开权限请求"}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? (
|
||||||
|
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
|
||||||
|
{latestPermissions.map((permission, index) => {
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const isLast = index === latestPermissions.length - 1;
|
||||||
|
const itemColor = getPermissionStatusColor(permission.status, theme);
|
||||||
|
const itemTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={permission.requestId}
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
py: 0.8,
|
||||||
|
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
|
||||||
|
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: itemColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
title={primaryValue}
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={getPermissionStatusLabel(permission.status)}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: itemTextColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.85 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!expanded && isRunning && hasPendingPermissions ? (
|
||||||
|
<motion.div
|
||||||
|
key="pending-permissions"
|
||||||
|
initial={{ opacity: 0, y: -10, height: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{pendingPermissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
isRunning={isRunning}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
isRunning={isRunning}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
|
import type { ChatProgress } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
describe("AgentProgressTimeline", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the running step and keeps the timeline expanded while running", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const progress: ChatProgress[] = [
|
||||||
|
{
|
||||||
|
id: "start",
|
||||||
|
phase: "start",
|
||||||
|
status: "running",
|
||||||
|
title: "收到请求",
|
||||||
|
startedAt: now - 5000,
|
||||||
|
elapsedMs: 5000,
|
||||||
|
elapsedSnapshotAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tool",
|
||||||
|
phase: "tool",
|
||||||
|
status: "running",
|
||||||
|
title: "正在调用 tjwater_cli",
|
||||||
|
detail: "analysis bottlenecks",
|
||||||
|
startedAt: now - 1200,
|
||||||
|
elapsedMs: 1200,
|
||||||
|
elapsedSnapshotAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AgentProgressTimeline progress={progress} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("analysis bottlenecks")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1.2s")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes completed steps and lets users expand details", async () => {
|
||||||
|
const progress: ChatProgress[] = [
|
||||||
|
{
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "completed",
|
||||||
|
title: "收到请求",
|
||||||
|
startedAt: Date.now() - 8000,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 8000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "done",
|
||||||
|
phase: "complete",
|
||||||
|
status: "completed",
|
||||||
|
title: "分析完成",
|
||||||
|
startedAt: Date.now() - 1000,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 1000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AgentProgressTimeline progress={progress} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/耗时 8.0s/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("分析完成")).not.toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/Agent 过程:/));
|
||||||
|
|
||||||
|
expect(screen.getByText("分析完成")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats stale running steps as finished after a complete event", () => {
|
||||||
|
const progress: ChatProgress[] = [
|
||||||
|
{
|
||||||
|
id: "tool",
|
||||||
|
phase: "tool",
|
||||||
|
status: "completed",
|
||||||
|
title: "正在调用 tjwater_cli",
|
||||||
|
startedAt: Date.now() - 4000,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "done",
|
||||||
|
phase: "complete",
|
||||||
|
status: "completed",
|
||||||
|
title: "分析完成",
|
||||||
|
startedAt: Date.now() - 500,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 500,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AgentProgressTimeline progress={progress} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("4.0s")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Collapse,
|
||||||
|
LinearProgress,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded";
|
||||||
|
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
||||||
|
import TaskAltRounded from "@mui/icons-material/TaskAltRounded";
|
||||||
|
import PsychologyRounded from "@mui/icons-material/PsychologyRounded";
|
||||||
|
import SyncRounded from "@mui/icons-material/SyncRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
|
||||||
|
import type { ChatProgress } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const formatDuration = (durationMs: number) => {
|
||||||
|
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
||||||
|
return "0s";
|
||||||
|
}
|
||||||
|
if (durationMs < 10_000) {
|
||||||
|
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.round(durationMs / 1000);
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainMinutes = minutes % 60;
|
||||||
|
return `${hours}h ${remainMinutes.toString().padStart(2, "0")}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressElapsedMs = (item: ChatProgress, nowMs: number) => {
|
||||||
|
if (item.durationMs !== undefined) {
|
||||||
|
return item.durationMs;
|
||||||
|
}
|
||||||
|
if (item.status === "running") {
|
||||||
|
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
|
||||||
|
return Math.max(0, item.elapsedMs + (nowMs - item.elapsedSnapshotAt));
|
||||||
|
}
|
||||||
|
if (item.startedAt !== undefined) {
|
||||||
|
return Math.max(0, nowMs - item.startedAt);
|
||||||
|
}
|
||||||
|
return item.elapsedMs;
|
||||||
|
}
|
||||||
|
if (item.startedAt !== undefined && item.endedAt !== undefined) {
|
||||||
|
return Math.max(0, item.endedAt - item.startedAt);
|
||||||
|
}
|
||||||
|
return item.elapsedMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
|
||||||
|
const sx = { fontSize: 16 };
|
||||||
|
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
|
||||||
|
if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />;
|
||||||
|
if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "#00acc1" }} />;
|
||||||
|
if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />;
|
||||||
|
if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />;
|
||||||
|
if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />;
|
||||||
|
if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "#00acc1" }} />;
|
||||||
|
return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatToolTitle = (item: ChatProgress) => {
|
||||||
|
const text = `${item.title} ${item.detail ?? ""}`;
|
||||||
|
if (text.includes("tjwater_cli")) return "查询后端数据";
|
||||||
|
if (text.includes("show_chart")) return "生成图表";
|
||||||
|
if (text.includes("locate_features")) return "地图定位";
|
||||||
|
if (text.includes("view_history")) return "打开历史曲线";
|
||||||
|
if (text.includes("view_scada")) return "打开 SCADA 面板";
|
||||||
|
if (text.includes("render_junctions")) return "渲染节点";
|
||||||
|
return item.title;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentProgressTimelineProps = {
|
||||||
|
progress: ChatProgress[];
|
||||||
|
isAborted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
|
||||||
|
const isOverallComplete = progress.some(
|
||||||
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 修正状态判断:如果外部标记为中断,或者没有完成标记
|
||||||
|
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
|
||||||
|
const hasError = isAborted || progress.some((item) => item.status === "error");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
}, 500);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [hasRunning]);
|
||||||
|
|
||||||
|
// 展开状态逻辑:默认折叠,保持界面整洁
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (isAborted) return `已中断 (进行到第 ${progress.length} 步)`;
|
||||||
|
if (isOverallComplete) {
|
||||||
|
return hasError ? `已完成 (含 ${progress.length} 步探索)` : `已完成 (${progress.length} 步)`;
|
||||||
|
}
|
||||||
|
const runningItem = [...progress].reverse().find((item) => item.status === "running");
|
||||||
|
if (runningItem) return `${runningItem.title}...`;
|
||||||
|
if (hasError) return "过程异常,尝试恢复中...";
|
||||||
|
return `已执行 ${progress.length} 步`;
|
||||||
|
}, [isOverallComplete, hasError, progress, isAborted]);
|
||||||
|
|
||||||
|
const totalDurationLabel = useMemo(() => {
|
||||||
|
const requestProgress = progress.find((item) => item.id === "request-received");
|
||||||
|
const requestElapsed =
|
||||||
|
requestProgress ? getProgressElapsedMs(requestProgress, nowMs) : undefined;
|
||||||
|
if (requestElapsed !== undefined) {
|
||||||
|
return formatDuration(requestElapsed);
|
||||||
|
}
|
||||||
|
const startedAtValues = progress
|
||||||
|
.map((item) => item.startedAt)
|
||||||
|
.filter((value): value is number => value !== undefined);
|
||||||
|
if (startedAtValues.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const minStartedAt = Math.min(...startedAtValues);
|
||||||
|
const endedAtValues = progress
|
||||||
|
.map((item) => item.endedAt)
|
||||||
|
.filter((value): value is number => value !== undefined);
|
||||||
|
const endAnchor = isOverallComplete
|
||||||
|
? endedAtValues.length > 0
|
||||||
|
? Math.max(...endedAtValues)
|
||||||
|
: nowMs
|
||||||
|
: nowMs;
|
||||||
|
return formatDuration(Math.max(0, endAnchor - minStartedAt));
|
||||||
|
}, [isOverallComplete, nowMs, progress]);
|
||||||
|
|
||||||
|
// 根据整体状态决定顶部卡片的颜色主题
|
||||||
|
const statusColor = isOverallComplete
|
||||||
|
? "#4caf50" // Success Green
|
||||||
|
: isAborted || (hasError && !hasRunning)
|
||||||
|
? theme.palette.error.main // Error Red
|
||||||
|
: "#00acc1"; // Primary Cyan
|
||||||
|
|
||||||
|
// 默认折叠:只显示最新的三条
|
||||||
|
const visibleCount = 3;
|
||||||
|
const isCollapsible = progress.length > visibleCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha(statusColor, 0.04),
|
||||||
|
border: `1px solid ${alpha(statusColor, 0.15)}`,
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha(statusColor, 0.06),
|
||||||
|
borderColor: alpha(statusColor, 0.25),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1.5}
|
||||||
|
alignItems="center"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOverallComplete ? (
|
||||||
|
<TaskAltRounded sx={{ fontSize: 18, color: statusColor }} />
|
||||||
|
) : hasRunning ? (
|
||||||
|
<AutoAwesome sx={{ fontSize: 18, color: statusColor, animation: "spin 2s linear infinite", "@keyframes spin": { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" } } }} />
|
||||||
|
) : hasError ? (
|
||||||
|
<ErrorOutlineRounded sx={{ fontSize: 18, color: statusColor }} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesome sx={{ fontSize: 18, color: statusColor }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
|
||||||
|
Agent 过程: {summary}
|
||||||
|
{totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<KeyboardArrowDownRounded
|
||||||
|
sx={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: "text.secondary",
|
||||||
|
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{hasRunning && !expanded ? (
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
height: 2,
|
||||||
|
bgcolor: "transparent",
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapse in={expanded || hasRunning} timeout="auto" unmountOnExit={false}>
|
||||||
|
<Box>
|
||||||
|
{hasRunning ? (
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
height: 1,
|
||||||
|
bgcolor: alpha(statusColor, 0.1),
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 1, bgcolor: alpha(statusColor, 0.1) }} />
|
||||||
|
)}
|
||||||
|
<Stack spacing={0} sx={{ px: 2, py: 1.5 }}>
|
||||||
|
{progress.map((item, index) => {
|
||||||
|
const isLast = index === progress.length - 1;
|
||||||
|
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
|
||||||
|
const stepElapsedMs = getProgressElapsedMs(item, nowMs);
|
||||||
|
|
||||||
|
const itemColor = isAborted && isLast
|
||||||
|
? theme.palette.error.main
|
||||||
|
: item.status === "error"
|
||||||
|
? theme.palette.error.main
|
||||||
|
: item.status === "completed"
|
||||||
|
? "#4caf50"
|
||||||
|
: "#00acc1";
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Stack key={item.id} direction="row" spacing={1.5} alignItems="stretch">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: 20,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
pt: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isLast ? (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 22,
|
||||||
|
bottom: -6,
|
||||||
|
left: "50%",
|
||||||
|
width: 2,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(itemColor, item.status === "completed" ? 0.2 : 0.4),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: alpha(theme.palette.background.paper, 0.9),
|
||||||
|
boxShadow: `0 0 0 2px ${alpha(itemColor, 0.1)}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phaseIcon(
|
||||||
|
item.phase,
|
||||||
|
isAborted && isLast ? "error" :
|
||||||
|
isOverallComplete && item.status === "running"
|
||||||
|
? "completed"
|
||||||
|
: item.status,
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||||
|
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
|
||||||
|
{item.phase === "tool" ? formatToolTitle(item) : item.title}
|
||||||
|
</Typography>
|
||||||
|
{stepElapsedMs !== undefined ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: "0.68rem", fontFamily: "var(--font-mono, monospace)" }}
|
||||||
|
>
|
||||||
|
{formatDuration(stepElapsedMs)}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{item.detail && (
|
||||||
|
<Collapse in={expanded || isLast} timeout="auto">
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(itemColor, 0.05),
|
||||||
|
border: `1px solid ${alpha(itemColor, 0.1)}`,
|
||||||
|
color: "text.secondary",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
fontFamily: "var(--font-mono, monospace)",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.detail}
|
||||||
|
</Typography>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isHiddenWhenCollapsed) {
|
||||||
|
return (
|
||||||
|
<Collapse key={item.id} in={expanded} timeout="auto" unmountOnExit={false}>
|
||||||
|
{content}
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentProgressTimeline = React.memo(
|
||||||
|
AgentProgressTimelineInner,
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.progress === nextProps.progress &&
|
||||||
|
prevProps.isAborted === nextProps.isAborted,
|
||||||
|
);
|
||||||
|
|
||||||
|
AgentProgressTimeline.displayName = "AgentProgressTimeline";
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
FormControlLabel,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
|
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
|
||||||
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||||
|
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const getQuestionStatusLabel = (
|
||||||
|
status: NonNullable<Message["questions"]>[number]["status"],
|
||||||
|
) => {
|
||||||
|
if (status === "answered") return "已回答";
|
||||||
|
if (status === "rejected") return "已跳过";
|
||||||
|
if (status === "error") return "提交失败";
|
||||||
|
if (status === "submitting") return "提交中";
|
||||||
|
return "等待回答";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuestionStatusColor = (
|
||||||
|
status: NonNullable<Message["questions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "answered") return theme.palette.success.main;
|
||||||
|
if (status === "rejected") return theme.palette.text.secondary;
|
||||||
|
if (status === "error") return theme.palette.error.main;
|
||||||
|
return "#0288d1";
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuestionRequestCard = ({
|
||||||
|
questionRequest,
|
||||||
|
onReply,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
questionRequest: NonNullable<Message["questions"]>[number];
|
||||||
|
onReply: (requestId: string, answers: string[][]) => void;
|
||||||
|
onReject: (requestId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isEditable =
|
||||||
|
questionRequest.status === "pending" || questionRequest.status === "error";
|
||||||
|
const isSubmitting = questionRequest.status === "submitting";
|
||||||
|
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
|
||||||
|
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
|
||||||
|
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
|
||||||
|
const [custom, setCustom] = React.useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const answers = React.useMemo(
|
||||||
|
() =>
|
||||||
|
questionRequest.questions.map((question, index) => {
|
||||||
|
const selectedAnswers = selected[index] ?? [];
|
||||||
|
const isCustomSelected =
|
||||||
|
customSelected[index] === true ||
|
||||||
|
(question.custom !== false && question.options.length === 0);
|
||||||
|
const customAnswer = custom[index]?.trim();
|
||||||
|
return isCustomSelected && customAnswer
|
||||||
|
? [...selectedAnswers, customAnswer]
|
||||||
|
: selectedAnswers;
|
||||||
|
}),
|
||||||
|
[custom, customSelected, questionRequest.questions, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
isEditable &&
|
||||||
|
questionRequest.questions.length > 0 &&
|
||||||
|
questionRequest.questions.every((_, index) => {
|
||||||
|
const answer = answers[index] ?? [];
|
||||||
|
return answer.some((item) => item.trim().length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const answerSummary = (questionRequest.answers ?? [])
|
||||||
|
.map((answer) => answer.join("、"))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(";");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.52),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
position: "relative",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
inset: "10px auto 10px 0",
|
||||||
|
width: 3,
|
||||||
|
borderRadius: "0 999px 999px 0",
|
||||||
|
bgcolor: statusColor,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
pl: 1.75,
|
||||||
|
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: statusColor,
|
||||||
|
bgcolor: alpha(statusColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(statusColor, 0.16)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HelpOutlineRounded sx={{ fontSize: 21 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
需要补充信息
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={getQuestionStatusLabel(questionRequest.status)}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
borderRadius: "12px",
|
||||||
|
bgcolor: alpha(statusColor, 0.12),
|
||||||
|
color: statusColor,
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
|
||||||
|
{questionRequest.questions.map((question, index) => {
|
||||||
|
const selectedAnswers = selected[index] ?? [];
|
||||||
|
const isCustomEnabled = question.custom !== false;
|
||||||
|
const isCustomSelected =
|
||||||
|
customSelected[index] === true ||
|
||||||
|
(isCustomEnabled && question.options.length === 0);
|
||||||
|
const setQuestionAnswers = (nextAnswers: string[]) => {
|
||||||
|
setSelected((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: nextAnswers,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const setQuestionCustomSelected = (checked: boolean) => {
|
||||||
|
setCustomSelected((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: checked,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`${question.header}-${index}`}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha("#000", 0.025),
|
||||||
|
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||||
|
{question.header || `问题 ${index + 1}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.primary"
|
||||||
|
sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{question.question}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{question.options.length ? (
|
||||||
|
<Stack spacing={0.75} sx={{ mt: 1 }}>
|
||||||
|
{question.options.map((option) => {
|
||||||
|
const checked = selectedAnswers.includes(option.label);
|
||||||
|
if (question.multiple) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={option.label}
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
setQuestionAnswers([...selectedAnswers, option.label]);
|
||||||
|
} else {
|
||||||
|
setQuestionAnswers(
|
||||||
|
selectedAnswers.filter((item) => item !== option.label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={750}>
|
||||||
|
{option.label}
|
||||||
|
</Typography>
|
||||||
|
{option.description ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{option.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
sx={{ alignItems: "flex-start", m: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.label}
|
||||||
|
size="small"
|
||||||
|
variant={checked ? "contained" : "outlined"}
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
onClick={() => {
|
||||||
|
setQuestionAnswers([option.label]);
|
||||||
|
setQuestionCustomSelected(false);
|
||||||
|
}}
|
||||||
|
startIcon={
|
||||||
|
checked ? (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<RadioButtonUncheckedRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 800,
|
||||||
|
bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45),
|
||||||
|
borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22),
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" fontWeight={800}>
|
||||||
|
{option.label}
|
||||||
|
</Typography>
|
||||||
|
{option.description ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isCustomEnabled ? (
|
||||||
|
question.multiple ? (
|
||||||
|
<FormControlLabel
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={isCustomSelected}
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuestionCustomSelected(event.target.checked)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
color: alpha("#0288d1", 0.55),
|
||||||
|
"&.Mui-checked": { color: "#0288d1" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||||
|
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
|
||||||
|
<Typography variant="body2" fontWeight={800}>
|
||||||
|
自定义回答
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 38,
|
||||||
|
m: 0,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${
|
||||||
|
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
|
||||||
|
}`,
|
||||||
|
bgcolor: isCustomSelected
|
||||||
|
? alpha("#0288d1", 0.1)
|
||||||
|
: alpha("#fff", 0.45),
|
||||||
|
transition: "background-color 0.18s ease, border-color 0.18s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isCustomSelected
|
||||||
|
? alpha("#0288d1", 0.13)
|
||||||
|
: alpha("#0288d1", 0.07),
|
||||||
|
},
|
||||||
|
"& .MuiFormControlLabel-label": {
|
||||||
|
color: isCustomSelected ? "#0277bd" : "text.primary",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={isCustomSelected ? "contained" : "outlined"}
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
onClick={() => {
|
||||||
|
setQuestionAnswers([]);
|
||||||
|
setQuestionCustomSelected(true);
|
||||||
|
}}
|
||||||
|
startIcon={
|
||||||
|
isCustomSelected ? (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<EditNoteRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 800,
|
||||||
|
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
|
||||||
|
borderColor: isCustomSelected
|
||||||
|
? "#0288d1"
|
||||||
|
: alpha("#0288d1", 0.22),
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isCustomSelected
|
||||||
|
? "#0277bd"
|
||||||
|
: alpha("#0288d1", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" fontWeight={800}>
|
||||||
|
自定义回答
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 0.85,
|
||||||
|
px: 1.15,
|
||||||
|
py: 0.85,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha("#fff", 0.62),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.82)}`,
|
||||||
|
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
|
||||||
|
backdropFilter: "blur(18px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={5}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
value={custom[index] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCustom((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="输入自定义回答"
|
||||||
|
InputProps={{
|
||||||
|
disableUnderline: true,
|
||||||
|
sx: {
|
||||||
|
alignItems: "flex-start",
|
||||||
|
fontSize: "0.88rem",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "text.primary",
|
||||||
|
"& textarea::placeholder": {
|
||||||
|
color: alpha(theme.palette.text.primary, 0.38),
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{questionRequest.status === "answered" ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="success.main"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.07),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已回答{answerSummary ? `:${answerSummary}` : ""}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{questionRequest.status === "rejected" ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已跳过
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{questionRequest.error ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{questionRequest.error}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isEditable || isSubmitting ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
flexWrap="wrap"
|
||||||
|
useFlexGap
|
||||||
|
sx={{ px: 1.5, pb: 1.35, pl: 1.75 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReject(questionRequest.requestId)}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
color: "text.secondary",
|
||||||
|
borderColor: alpha(theme.palette.text.secondary, 0.22),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
跳过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
onClick={() => onReply(questionRequest.requestId, answers)}
|
||||||
|
startIcon={
|
||||||
|
isSubmitting ? (
|
||||||
|
<CircularProgress size={14} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
minWidth: 104,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
bgcolor: "#0288d1",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#0277bd",
|
||||||
|
boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
提交回答
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuestionRequestGroup = ({
|
||||||
|
questions,
|
||||||
|
onReply,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
questions: NonNullable<Message["questions"]>;
|
||||||
|
onReply: (requestId: string, answers: string[][]) => void;
|
||||||
|
onReject: (requestId: string) => void;
|
||||||
|
}) => (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{questions.map((question) => (
|
||||||
|
<QuestionRequestCard
|
||||||
|
key={question.requestId}
|
||||||
|
questionRequest={question}
|
||||||
|
onReply={onReply}
|
||||||
|
onReject={onReject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||||
|
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
export const TodoPlanCard = ({
|
||||||
|
todoUpdate,
|
||||||
|
}: {
|
||||||
|
todoUpdate: NonNullable<Message["todos"]>;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const total = todoUpdate.todos.length;
|
||||||
|
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
||||||
|
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
|
||||||
|
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
||||||
|
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
|
||||||
|
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
const isAborted = cancelled > 0 && completed + cancelled === total;
|
||||||
|
const canCollapse = total > 4;
|
||||||
|
const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted);
|
||||||
|
const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos;
|
||||||
|
const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : [];
|
||||||
|
const hiddenCount = expanded ? 0 : collapsibleTodos.length;
|
||||||
|
const latestUpdatedAt = Math.max(
|
||||||
|
todoUpdate.createdAt,
|
||||||
|
...todoUpdate.todos
|
||||||
|
.map((todo) => todo.updatedAt ?? todo.createdAt ?? 0)
|
||||||
|
.filter((value) => value > 0),
|
||||||
|
);
|
||||||
|
const updatedAtLabel =
|
||||||
|
latestUpdatedAt > 0
|
||||||
|
? new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(latestUpdatedAt))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => {
|
||||||
|
if (status === "completed") {
|
||||||
|
return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" };
|
||||||
|
}
|
||||||
|
if (status === "in_progress") {
|
||||||
|
return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" };
|
||||||
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" };
|
||||||
|
}
|
||||||
|
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => {
|
||||||
|
if (priority === "high") return { label: "高优先级", color: "#8a5a00" };
|
||||||
|
if (priority === "medium") return { label: "中优先级", color: "#9a6a16" };
|
||||||
|
if (priority === "low") return { label: "低优先级", color: "#8d7960" };
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusSummary = isAborted
|
||||||
|
? `${completed} 完成 / ${cancelled} 中止`
|
||||||
|
: [
|
||||||
|
completed ? `${completed} 完成` : null,
|
||||||
|
running ? "1 进行中" : null,
|
||||||
|
pending ? `${pending} 待办` : null,
|
||||||
|
cancelled ? `${cancelled} 中止` : null,
|
||||||
|
].filter(Boolean).join(" / ") || "等待任务";
|
||||||
|
const renderTodoRow = (
|
||||||
|
todo: NonNullable<Message["todos"]>["todos"][number],
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const visual = getTodoVisual(todo.status);
|
||||||
|
const priority = getPriorityLabel(todo.priority);
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={`${todo.id}-${index}`}
|
||||||
|
direction="row"
|
||||||
|
alignItems="flex-start"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
py: 0.8,
|
||||||
|
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
|
||||||
|
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 1.25,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: visual.color,
|
||||||
|
bgcolor: alpha(visual.color, 0.08),
|
||||||
|
mt: 0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visual.icon}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
lineHeight: 1.45,
|
||||||
|
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{todo.content}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}>
|
||||||
|
{priority ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={priority.label}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: priority.color,
|
||||||
|
bgcolor: alpha(priority.color, 0.045),
|
||||||
|
border: `1px solid ${alpha(priority.color, 0.16)}`,
|
||||||
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={visual.label}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: visual.color,
|
||||||
|
bgcolor: alpha(visual.color, 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#00838f", 0.16)}`,
|
||||||
|
bgcolor: alpha("#f8fbfc", 0.82),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
spacing={1}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (canCollapse) {
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (canCollapse && (event.key === "Enter" || event.key === " ")) {
|
||||||
|
event.preventDefault();
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.4,
|
||||||
|
py: 1.15,
|
||||||
|
cursor: canCollapse ? "pointer" : "default",
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
"&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: "#00838f",
|
||||||
|
bgcolor: alpha("#00838f", 0.1),
|
||||||
|
border: `1px solid ${alpha("#00838f", 0.14)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.75}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
会话任务
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "10px",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
|
||||||
|
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{canCollapse ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起会话任务" : "展开会话任务"}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: "hidden",
|
||||||
|
bgcolor: alpha("#00838f", 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: isAborted ? theme.palette.text.disabled : "#00838f",
|
||||||
|
transition: "width 0.25s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}>
|
||||||
|
{pinnedTodos.map((todo, index) => renderTodoRow(todo, index))}
|
||||||
|
{canCollapse ? (
|
||||||
|
<Collapse in={expanded} timeout={220} unmountOnExit={false}>
|
||||||
|
<Stack spacing={0}>
|
||||||
|
{collapsibleTodos.map((todo, index) =>
|
||||||
|
renderTodoRow(todo, index + pinnedTodos.length),
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
) : null}
|
||||||
|
{hiddenCount > 0 ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
pt: 0.8,
|
||||||
|
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
还有 {hiddenCount} 项,展开查看全部
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentTurn } from "./AgentTurn";
|
||||||
|
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
span: ({
|
||||||
|
children,
|
||||||
|
animate: _animate,
|
||||||
|
transition: _transition,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => (
|
||||||
|
<span {...props}>{children}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentMarkdownBlock", () => ({
|
||||||
|
MarkdownBlock: ({ children }: { children: string }) => (
|
||||||
|
<div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div>
|
||||||
|
),
|
||||||
|
normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AgentTurn speech selection", () => {
|
||||||
|
it("shows a floating action and reads from the selected text", async () => {
|
||||||
|
const content = "第一段内容。\n\n第二段内容。";
|
||||||
|
const speechText = "第一段内容。\n第二段内容。";
|
||||||
|
const onSpeak = jest.fn();
|
||||||
|
const removeAllRanges = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{ id: "assistant-1", role: "assistant", content }}
|
||||||
|
isStreaming={false}
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedParagraph = screen.getByText("第二段内容。");
|
||||||
|
const selectedTextNode = selectedParagraph.firstChild as Text;
|
||||||
|
const range = {
|
||||||
|
commonAncestorContainer: selectedTextNode,
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
width: 72,
|
||||||
|
height: 20,
|
||||||
|
top: 120,
|
||||||
|
right: 172,
|
||||||
|
bottom: 140,
|
||||||
|
left: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 120,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}),
|
||||||
|
} as unknown as Range;
|
||||||
|
const selection = {
|
||||||
|
rangeCount: 1,
|
||||||
|
isCollapsed: false,
|
||||||
|
getRangeAt: () => range,
|
||||||
|
toString: () => "第二段",
|
||||||
|
removeAllRanges,
|
||||||
|
} as unknown as Selection;
|
||||||
|
|
||||||
|
jest.spyOn(window, "getSelection").mockReturnValue(selection);
|
||||||
|
jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerUp(selectedParagraph);
|
||||||
|
|
||||||
|
const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" });
|
||||||
|
fireEvent.click(speechAction);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, {
|
||||||
|
startOffset: speechText.indexOf("第二段"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(removeAllRanges).toHaveBeenCalledTimes(1);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Button,
|
||||||
|
Grow,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Popper,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||||
|
import { TbArrowsSplit2 } from "react-icons/tb";
|
||||||
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
|
import {
|
||||||
|
parseAssistantMessageSections,
|
||||||
|
parseContentWithToolCalls,
|
||||||
|
type ContentSegment,
|
||||||
|
} from "./chatMessageSections";
|
||||||
|
import type {
|
||||||
|
Message,
|
||||||
|
SpeechState,
|
||||||
|
} from "./GlobalChatbox.types";
|
||||||
|
import { stripMarkdown } from "./globalChatboxUtils";
|
||||||
|
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
|
||||||
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
|
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
||||||
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
|
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||||
|
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||||
|
import { QuestionRequestGroup } from "./AgentQuestionRequests";
|
||||||
|
import { TodoPlanCard } from "./AgentTodoPlanCard";
|
||||||
|
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||||
|
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||||
|
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||||
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
|
|
||||||
|
const floatingActionSurfaceSx = {
|
||||||
|
display: "flex",
|
||||||
|
gap: 0.5,
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: "16px",
|
||||||
|
bgcolor: alpha("#fff", 0.8),
|
||||||
|
backdropFilter: "blur(16px)",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const;
|
||||||
|
const floatingIconButtonSx = {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
color: "#00acc1",
|
||||||
|
bgcolor: alpha("#00acc1", 0.1),
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
const floatingSpeechButtonSx = {
|
||||||
|
minHeight: 34,
|
||||||
|
px: 1.25,
|
||||||
|
color: "text.primary",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
borderRadius: "12px",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#00acc1", 0.1),
|
||||||
|
color: "#00acc1",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SpeechSelection = {
|
||||||
|
startOffset: number;
|
||||||
|
anchorRect: DOMRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentTurnProps = {
|
||||||
|
message: Message;
|
||||||
|
isStreaming: boolean;
|
||||||
|
messageSpeechState: SpeechState;
|
||||||
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
onCreateBranch: (messageId: string) => void;
|
||||||
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamingStatus = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={0.75}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
py: 0.35,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.07),
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={0.35} alignItems="center">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<motion.span
|
||||||
|
key={index}
|
||||||
|
animate={{ opacity: [0.28, 0.86, 0.28] }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.95,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: index * 0.14,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: theme.palette.primary.main,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={700}>
|
||||||
|
正在生成
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamingMarkdownBlock = ({
|
||||||
|
text,
|
||||||
|
isStreaming,
|
||||||
|
segmentKey,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
isStreaming: boolean;
|
||||||
|
segmentKey: string;
|
||||||
|
}) => {
|
||||||
|
const [streamTextState, setStreamTextState] = React.useState<{
|
||||||
|
displayText: string;
|
||||||
|
animatedTailLength: number;
|
||||||
|
}>({
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
setStreamTextState((current) => {
|
||||||
|
if (current.displayText === text) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStreaming) {
|
||||||
|
return {
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.displayText === text) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength:
|
||||||
|
text.length > current.displayText.length &&
|
||||||
|
text.startsWith(current.displayText)
|
||||||
|
? Math.min(48, text.length - current.displayText.length)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [isStreaming, text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownBlock
|
||||||
|
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
|
||||||
|
streamFadeLength={streamTextState.animatedTailLength}
|
||||||
|
>
|
||||||
|
{streamTextState.displayText}
|
||||||
|
</MarkdownBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentTurn = React.memo(
|
||||||
|
({
|
||||||
|
message,
|
||||||
|
isStreaming,
|
||||||
|
messageSpeechState,
|
||||||
|
onSpeak,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onCreateBranch,
|
||||||
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
|
}: AgentTurnProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isErrorMessage = Boolean(message.isError);
|
||||||
|
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||||
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
|
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
||||||
|
const isProgressComplete = message.progress?.some(
|
||||||
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
|
) ?? false;
|
||||||
|
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
||||||
|
message.progress?.some((item) => item.status === "running") ?? false
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedAssistantSections = useMemo(
|
||||||
|
() =>
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseAssistantMessageSections(message.content)
|
||||||
|
: null,
|
||||||
|
[isErrorMessage, isUser, message.content],
|
||||||
|
);
|
||||||
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
|
const speechText = useMemo(
|
||||||
|
() => stripMarkdown(answerContent),
|
||||||
|
[answerContent],
|
||||||
|
);
|
||||||
|
const captureSpeechSelection = React.useCallback(() => {
|
||||||
|
if (!isTtsSupported || isStreamingAssistant) {
|
||||||
|
setSpeechSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const container = answerContentRef.current;
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
|
||||||
|
setSpeechSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (!container.contains(range.commonAncestorContainer)) {
|
||||||
|
setSpeechSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
|
||||||
|
if (startOffset === null) {
|
||||||
|
setSpeechSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorRect = range.getBoundingClientRect();
|
||||||
|
if (anchorRect.width === 0 && anchorRect.height === 0) {
|
||||||
|
setSpeechSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpeechSelection({ startOffset, anchorRect });
|
||||||
|
}, [isStreamingAssistant, isTtsSupported, speechText]);
|
||||||
|
const handleCaptureSpeechSelection = React.useCallback(() => {
|
||||||
|
window.requestAnimationFrame(captureSpeechSelection);
|
||||||
|
}, [captureSpeechSelection]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSpeechSelection(null);
|
||||||
|
}, [message.id, speechText]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!speechSelection) return;
|
||||||
|
|
||||||
|
const closeSpeechSelection = () => setSpeechSelection(null);
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
if (window.getSelection()?.isCollapsed) {
|
||||||
|
closeSpeechSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeSpeechSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("selectionchange", handleSelectionChange);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("resize", closeSpeechSelection);
|
||||||
|
window.addEventListener("scroll", closeSpeechSelection, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("resize", closeSpeechSelection);
|
||||||
|
window.removeEventListener("scroll", closeSpeechSelection, true);
|
||||||
|
};
|
||||||
|
}, [speechSelection]);
|
||||||
|
const handleSpeakMessage = () => {
|
||||||
|
onSpeak(message.id, speechText);
|
||||||
|
};
|
||||||
|
const handleSpeakFromSelection = () => {
|
||||||
|
if (!speechSelection) return;
|
||||||
|
|
||||||
|
onSpeak(message.id, speechText, {
|
||||||
|
startOffset: speechSelection.startOffset,
|
||||||
|
});
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setSpeechSelection(null);
|
||||||
|
};
|
||||||
|
const speechSelectionAnchor = React.useMemo(
|
||||||
|
() => speechSelection
|
||||||
|
? {
|
||||||
|
getBoundingClientRect: () => speechSelection.anchorRect,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[speechSelection],
|
||||||
|
);
|
||||||
|
const isSpeechSelectionOpen = Boolean(speechSelection);
|
||||||
|
const contentSegments: ContentSegment[] = useMemo(
|
||||||
|
() =>
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseContentWithToolCalls(answerContent).segments
|
||||||
|
: [{ type: "text", content: answerContent }],
|
||||||
|
[answerContent, isErrorMessage, isUser],
|
||||||
|
);
|
||||||
|
const hasInlineChart = contentSegments.some(
|
||||||
|
(segment) =>
|
||||||
|
segment.type === "tool_call" &&
|
||||||
|
(segment.toolCall.tool === "chart" ||
|
||||||
|
segment.toolCall.tool === "show_chart"),
|
||||||
|
);
|
||||||
|
const visibleChartArtifacts = useMemo(
|
||||||
|
() =>
|
||||||
|
hasInlineChart
|
||||||
|
? []
|
||||||
|
: (message.artifacts?.filter((artifact) => artifact.kind === "chart") ?? []),
|
||||||
|
[hasInlineChart, message.artifacts],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 350, damping: 25 }}
|
||||||
|
style={{ alignSelf: "flex-end", maxWidth: "86%", position: "relative" }}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
borderBottomRightRadius: 2,
|
||||||
|
color: "#fff",
|
||||||
|
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
|
||||||
|
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
"--chat-md-text": alpha("#fff", 0.96),
|
||||||
|
"--chat-md-heading": "#fff",
|
||||||
|
"--chat-md-link": "#e0f7fa",
|
||||||
|
"--chat-md-link-hover": "#fff",
|
||||||
|
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
|
||||||
|
"--chat-md-inline-code-border": alpha("#fff", 0.1),
|
||||||
|
"--chat-md-inline-code-text": "#fff",
|
||||||
|
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
|
||||||
|
"--chat-md-pre-border": alpha("#fff", 0.1),
|
||||||
|
"--chat-md-pre-text": "#F8FAFC",
|
||||||
|
"--chat-md-quote-border": alpha("#fff", 0.4),
|
||||||
|
"--chat-md-quote-bg": alpha("#fff", 0.05),
|
||||||
|
"--chat-md-quote-text": alpha("#fff", 0.8),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MarkdownBlock>{message.content}</MarkdownBlock>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 14 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 320, damping: 26 }}
|
||||||
|
style={{ width: "100%", position: "relative" }}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
background: alpha("#ffffff", 0.9),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#00acc1", 0.25)}`,
|
||||||
|
border: `1.5px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
color: "#00acc1",
|
||||||
|
mt: 0.25,
|
||||||
|
p: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/ai-agent.svg"
|
||||||
|
alt="TJWater Agent"
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
style={{ objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.65),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
boxShadow: `0 10px 30px -10px ${alpha(theme.palette.common.black, 0.08)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
position: "relative",
|
||||||
|
"--chat-md-text": "text.primary",
|
||||||
|
"--chat-md-heading": "text.primary",
|
||||||
|
"--chat-md-link": "#00838f",
|
||||||
|
"--chat-md-link-hover": "#00acc1",
|
||||||
|
"--chat-md-inline-code-bg": alpha("#00acc1", 0.08),
|
||||||
|
"--chat-md-inline-code-border": alpha("#00acc1", 0.15),
|
||||||
|
"--chat-md-inline-code-text": "#006064",
|
||||||
|
"--chat-md-pre-bg": "#1e293b",
|
||||||
|
"--chat-md-pre-border": "#475569",
|
||||||
|
"--chat-md-pre-text": "#f1f5f9",
|
||||||
|
"--chat-md-quote-border": "#00acc1",
|
||||||
|
"--chat-md-quote-bg": alpha("#00acc1", 0.04),
|
||||||
|
"--chat-md-quote-text": "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{message.progress?.length ? (
|
||||||
|
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message.permissions?.length ? (
|
||||||
|
<PermissionRequestGroup
|
||||||
|
permissions={message.permissions}
|
||||||
|
isRunning={isProgressRunning}
|
||||||
|
onReply={onReplyPermission}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message.questions?.length ? (
|
||||||
|
<QuestionRequestGroup
|
||||||
|
questions={message.questions}
|
||||||
|
onReply={onReplyQuestion}
|
||||||
|
onReject={onRejectQuestion}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message.todos ? (
|
||||||
|
<TodoPlanCard todoUpdate={message.todos} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
ref={answerContentRef}
|
||||||
|
onPointerUp={handleCaptureSpeechSelection}
|
||||||
|
onKeyUp={handleCaptureSpeechSelection}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.4),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.2}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
|
分析结果
|
||||||
|
</Typography>
|
||||||
|
{isStreamingAssistant ? <StreamingStatus /> : null}
|
||||||
|
</Stack>
|
||||||
|
{contentSegments.map((segment, segIdx) => {
|
||||||
|
if (segment.type === "text") {
|
||||||
|
const text = segment.content.trim();
|
||||||
|
if (!text && contentSegments.length > 1) return null;
|
||||||
|
return (
|
||||||
|
<StreamingMarkdownBlock
|
||||||
|
key={segIdx}
|
||||||
|
text={text || "..."}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
|
segmentKey={`${message.id}-${segIdx}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call") {
|
||||||
|
if (
|
||||||
|
segment.toolCall.tool === "chart" ||
|
||||||
|
segment.toolCall.tool === "show_chart"
|
||||||
|
) {
|
||||||
|
const p = segment.toolCall.params;
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
title={(p.title as string) ?? undefined}
|
||||||
|
chart_type={
|
||||||
|
(p.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
|
}
|
||||||
|
x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories}
|
||||||
|
series={p.series}
|
||||||
|
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatToolCallBlock
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
toolCall={segment.toolCall}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call_pending") {
|
||||||
|
return (
|
||||||
|
<ChartGenerationSkeleton
|
||||||
|
key="tool-pending"
|
||||||
|
status={<StreamingStatus />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Popper
|
||||||
|
open={isSpeechSelectionOpen}
|
||||||
|
anchorEl={speechSelectionAnchor}
|
||||||
|
placement="top"
|
||||||
|
transition
|
||||||
|
modifiers={[
|
||||||
|
{ name: "offset", options: { offset: [0, 8] } },
|
||||||
|
{ name: "flip", enabled: true },
|
||||||
|
{ name: "preventOverflow", options: { padding: 8 } },
|
||||||
|
]}
|
||||||
|
sx={{ zIndex: theme.zIndex.tooltip }}
|
||||||
|
>
|
||||||
|
{({ TransitionProps, placement }) => (
|
||||||
|
<Grow
|
||||||
|
{...TransitionProps}
|
||||||
|
timeout={floatingActionTransitionTimeout}
|
||||||
|
style={{
|
||||||
|
transformOrigin: placement.startsWith("bottom")
|
||||||
|
? "center top"
|
||||||
|
: "center bottom",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper elevation={4} sx={floatingActionSurfaceSx}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<VolumeUpRounded sx={{ fontSize: 16 }} />}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={handleSpeakFromSelection}
|
||||||
|
sx={floatingSpeechButtonSx}
|
||||||
|
>
|
||||||
|
从这里开始朗读
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Grow>
|
||||||
|
)}
|
||||||
|
</Popper>
|
||||||
|
|
||||||
|
{visibleChartArtifacts.map((artifact) => (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={artifact.id}
|
||||||
|
title={(artifact.params.title as string) ?? artifact.title}
|
||||||
|
chart_type={
|
||||||
|
(artifact.params.chart_type as "line" | "bar" | "pie") ??
|
||||||
|
"line"
|
||||||
|
}
|
||||||
|
x_data={
|
||||||
|
artifact.params.x_data ??
|
||||||
|
artifact.params.xData ??
|
||||||
|
artifact.params.labels ??
|
||||||
|
artifact.params.categories
|
||||||
|
}
|
||||||
|
series={artifact.params.series}
|
||||||
|
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Grow
|
||||||
|
in={isHovered && !isStreaming}
|
||||||
|
timeout={floatingActionTransitionTimeout}
|
||||||
|
mountOnEnter
|
||||||
|
unmountOnExit
|
||||||
|
style={{ transformOrigin: "right bottom" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
...floatingActionSurfaceSx,
|
||||||
|
position: "absolute",
|
||||||
|
top: -14,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="复制">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="复制"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
normalizeClipboardText(message.content),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sx={floatingIconButtonSx}
|
||||||
|
>
|
||||||
|
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="拆分为新会话">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="拆分为新会话"
|
||||||
|
onClick={() => onCreateBranch(message.id)}
|
||||||
|
sx={floatingIconButtonSx}
|
||||||
|
>
|
||||||
|
<TbArrowsSplit2 size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Paper>
|
||||||
|
</Grow>
|
||||||
|
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!isErrorMessage && isTtsSupported ? (
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
|
||||||
|
{messageSpeechState === "idle" ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleSpeakMessage}
|
||||||
|
aria-label="朗读消息"
|
||||||
|
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||||
|
>
|
||||||
|
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
{messageSpeechState === "loading" ? (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
aria-label="正在生成语音"
|
||||||
|
sx={{ color: "primary.main", p: 0.5 }}
|
||||||
|
>
|
||||||
|
<CircularProgress size={16} thickness={5} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{messageSpeechState === "playing" ? (
|
||||||
|
<>
|
||||||
|
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||||
|
<PauseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{messageSpeechState === "paused" ? (
|
||||||
|
<>
|
||||||
|
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||||
|
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AgentTurn.displayName = "AgentTurn";
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const renderCounts = new Map<string, number>();
|
||||||
|
const mountCounts = new Map<string, number>();
|
||||||
|
const unmountCounts = new Map<string, number>();
|
||||||
|
const streamingFlags = new Map<string, boolean>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
layout: _layout,
|
||||||
|
transition: _transition,
|
||||||
|
whileHover: _whileHover,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||||
|
<div {...props}>{children}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentTurn", () => ({
|
||||||
|
AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
mountCounts.set(message.id, (mountCounts.get(message.id) ?? 0) + 1);
|
||||||
|
return () => {
|
||||||
|
unmountCounts.set(message.id, (unmountCounts.get(message.id) ?? 0) + 1);
|
||||||
|
};
|
||||||
|
}, [message.id]);
|
||||||
|
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
||||||
|
streamingFlags.set(message.id, isStreaming);
|
||||||
|
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AgentWorkspace", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
bottomRef: { current: null },
|
||||||
|
speakingMessageId: null,
|
||||||
|
speechState: "idle" as const,
|
||||||
|
onSpeak: jest.fn(),
|
||||||
|
onPauseSpeech: jest.fn(),
|
||||||
|
onResumeSpeech: jest.fn(),
|
||||||
|
onStopSpeech: jest.fn(),
|
||||||
|
isTtsSupported: false,
|
||||||
|
onCreateBranch: jest.fn(),
|
||||||
|
onReplyPermission: jest.fn(),
|
||||||
|
onReplyQuestion: jest.fn(),
|
||||||
|
onRejectQuestion: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
renderCounts.clear();
|
||||||
|
mountCounts.clear();
|
||||||
|
unmountCounts.clear();
|
||||||
|
streamingFlags.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
|
||||||
|
render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
isLoadingSession
|
||||||
|
messages={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("正在加载历史记录")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: "user-1",
|
||||||
|
role: "user",
|
||||||
|
content: "question",
|
||||||
|
};
|
||||||
|
const assistantHistoryMessage: Message = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "stable answer",
|
||||||
|
};
|
||||||
|
const streamingMessage: Message = {
|
||||||
|
id: "assistant-2",
|
||||||
|
role: "assistant",
|
||||||
|
content: "partial",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantHistoryMessage, streamingMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedStreamingMessage: Message = {
|
||||||
|
...streamingMessage,
|
||||||
|
content: "partial with more tokens",
|
||||||
|
};
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renderCounts.get("user-1")).toBe(1);
|
||||||
|
expect(renderCounts.get("assistant-1")).toBe(1);
|
||||||
|
expect(renderCounts.get("assistant-2")).toBe(2);
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||||
|
expect(streamingFlags.get("assistant-2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remount the streaming assistant turn when streaming finishes", () => {
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: "user-1",
|
||||||
|
role: "user",
|
||||||
|
content: "question",
|
||||||
|
};
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "final answer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(true);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
messages={[userMessage, assistantMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mountCounts.get("assistant-1")).toBe(1);
|
||||||
|
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 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 { AgentTurn } from "./AgentTurn";
|
||||||
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
|
import type {
|
||||||
|
Message,
|
||||||
|
SpeechState,
|
||||||
|
} from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
type AgentWorkspaceProps = {
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
isLoadingSession?: boolean;
|
||||||
|
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onScrollStateChange?: (isNearBottom: boolean) => void;
|
||||||
|
speakingMessageId: string | null;
|
||||||
|
speechState: SpeechState;
|
||||||
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
|
onPauseSpeech: () => void;
|
||||||
|
onResumeSpeech: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
onCreateBranch: (messageId: string) => void;
|
||||||
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TurnListProps = {
|
||||||
|
messages: Message[];
|
||||||
|
isAssistantStreaming: boolean;
|
||||||
|
streamingMessageId: string | null;
|
||||||
|
speakingMessageId: string | null;
|
||||||
|
speechState: SpeechState;
|
||||||
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
|
onPauseSpeech: () => void;
|
||||||
|
onResumeSpeech: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
onCreateBranch: (messageId: string) => void;
|
||||||
|
onReplyPermission: (requestId: string, reply: PermissionReply) => 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 sameMessages = (left: Message[], right: Message[]) =>
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((message, index) => message === right[index]);
|
||||||
|
|
||||||
|
const TurnItem = React.memo(AgentTurn);
|
||||||
|
|
||||||
|
const TurnListInner = ({
|
||||||
|
messages,
|
||||||
|
isAssistantStreaming,
|
||||||
|
streamingMessageId,
|
||||||
|
speakingMessageId,
|
||||||
|
speechState,
|
||||||
|
onSpeak,
|
||||||
|
onPauseSpeech,
|
||||||
|
onResumeSpeech,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onCreateBranch,
|
||||||
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
|
}: TurnListProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<TurnItem
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TurnList = React.memo(
|
||||||
|
TurnListInner,
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||||
|
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
||||||
|
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
||||||
|
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||||
|
prevProps.speechState === nextProps.speechState &&
|
||||||
|
prevProps.onSpeak === nextProps.onSpeak &&
|
||||||
|
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
|
||||||
|
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||||||
|
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||||||
|
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||||
|
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
||||||
|
prevProps.onReplyPermission === nextProps.onReplyPermission &&
|
||||||
|
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
|
||||||
|
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
|
||||||
|
);
|
||||||
|
|
||||||
|
TurnList.displayName = "TurnList";
|
||||||
|
|
||||||
|
const EmptyState = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const capabilities = [
|
||||||
|
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
|
||||||
|
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
|
||||||
|
{ icon: <TroubleshootRounded sx={{ fontSize: 20, color: "#43a047" }} />, label: "调度与改造建议" },
|
||||||
|
{ icon: <MapRounded sx={{ fontSize: 20, color: "#8e24aa" }} />, label: "GIS 地图联动" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||||
|
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#ffffff", 0.4),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
boxShadow: `0 16px 40px ${alpha("#000", 0.05)}`,
|
||||||
|
textAlign: "center",
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -100,
|
||||||
|
right: -100,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
background: "radial-gradient(circle, rgba(0, 172, 193, 0.15) 0%, rgba(255,255,255,0) 70%)",
|
||||||
|
}} />
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
y: [-6, 4, -6],
|
||||||
|
scale: [1, 1.04, 1],
|
||||||
|
rotate: [-3, 3, -3],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "radial-gradient(circle, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.45) 58%, rgba(255,255,255,0) 100%)",
|
||||||
|
boxShadow: "0 10px 28px rgba(0, 131, 143, 0.12)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/ai-agent.svg"
|
||||||
|
alt="TJWater Agent"
|
||||||
|
width={54}
|
||||||
|
height={54}
|
||||||
|
style={{
|
||||||
|
objectFit: "contain",
|
||||||
|
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
|
||||||
|
我已就绪,请描述任务
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
|
||||||
|
你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。
|
||||||
|
</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
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.5,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#fff", 0.5),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.03)}`,
|
||||||
|
color: "text.primary",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#fff", 0.8),
|
||||||
|
borderColor: alpha("#00acc1", 0.4),
|
||||||
|
boxShadow: `0 6px 16px ${alpha("#00acc1", 0.15)}`,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<Typography variant="caption" fontWeight={700}>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionLoadingSkeleton = () => (
|
||||||
|
<Stack
|
||||||
|
spacing={2.25}
|
||||||
|
aria-label="正在加载历史记录"
|
||||||
|
sx={{ width: "100%", maxWidth: 760, alignSelf: "stretch" }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 2 }, (_, turnIndex) => (
|
||||||
|
<Stack key={turnIndex} spacing={1.25}>
|
||||||
|
<Stack direction="row" justifyContent="flex-end">
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: turnIndex === 0 ? "72%" : "64%",
|
||||||
|
maxWidth: "86%",
|
||||||
|
p: 1.75,
|
||||||
|
borderRadius: 5,
|
||||||
|
borderBottomRightRadius: 2,
|
||||||
|
bgcolor: alpha("#00acc1", 0.16),
|
||||||
|
border: `1px solid ${alpha("#00acc1", 0.12)}`,
|
||||||
|
boxShadow: `0 8px 24px -12px ${alpha("#00acc1", 0.35)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.85}>
|
||||||
|
<Skeleton variant="text" width="76%" height={18} />
|
||||||
|
<Skeleton variant="text" width="48%" height={15} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||||
|
<Skeleton
|
||||||
|
variant="circular"
|
||||||
|
width={34}
|
||||||
|
height={34}
|
||||||
|
sx={{ bgcolor: alpha("#00acc1", 0.12), flexShrink: 0, mt: 0.25 }}
|
||||||
|
/>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.52),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
boxShadow: `0 10px 30px -10px ${alpha("#000", 0.06)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Skeleton variant="text" width="38%" height={16} />
|
||||||
|
<Skeleton variant="text" width="94%" height={16} />
|
||||||
|
<Skeleton variant="text" width={turnIndex === 0 ? "88%" : "82%"} height={16} />
|
||||||
|
<Skeleton variant="text" width={turnIndex === 0 ? "78%" : "70%"} height={16} />
|
||||||
|
<Skeleton variant="rounded" width="100%" height={turnIndex === 0 ? 104 : 76} sx={{ borderRadius: 2 }} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AgentWorkspace = ({
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
isLoadingSession = false,
|
||||||
|
scrollContainerRef,
|
||||||
|
bottomRef,
|
||||||
|
onScrollStateChange,
|
||||||
|
speakingMessageId,
|
||||||
|
speechState,
|
||||||
|
onSpeak,
|
||||||
|
onPauseSpeech,
|
||||||
|
onResumeSpeech,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onCreateBranch,
|
||||||
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
|
}: AgentWorkspaceProps) => {
|
||||||
|
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;
|
||||||
|
const distanceToBottom =
|
||||||
|
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
|
onScrollStateChange(
|
||||||
|
distanceToBottom <
|
||||||
|
(isStreaming ? STREAMING_NEAR_BOTTOM_THRESHOLD_PX : 96),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[isStreaming, onScrollStateChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
px: 2.5,
|
||||||
|
py: 2,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
scrollbarGutter: "stable",
|
||||||
|
zIndex: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoadingSession ? (
|
||||||
|
<SessionLoadingSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{messages.length === 0 ? <EmptyState /> : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{messages.length > 0 ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<TurnList
|
||||||
|
messages={messages}
|
||||||
|
isAssistantStreaming={isStreaming}
|
||||||
|
streamingMessageId={streamingMessageId}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPauseSpeech={onPauseSpeech}
|
||||||
|
onResumeSpeech={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
onReplyPermission={onReplyPermission}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={bottomRef}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { normalizeChartData } from "./ChatInlineChart";
|
||||||
|
|
||||||
|
describe("normalizeChartData", () => {
|
||||||
|
it("keeps standard bar chart series data", () => {
|
||||||
|
const result = normalizeChartData(["A", "B"], [
|
||||||
|
{ name: "数量", data: [3, 5], type: "bar" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["A", "B"],
|
||||||
|
series: [{ name: "数量", data: [3, 5], type: "bar" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes line chart point arrays into x labels and y values", () => {
|
||||||
|
const result = normalizeChartData(undefined, [
|
||||||
|
{ name: "压力", data: [["10:00", 12.5], ["11:00", 13.1]] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["10:00", "11:00"],
|
||||||
|
series: [{ name: "压力", data: [12.5, 13.1], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes pie chart point objects into a single series", () => {
|
||||||
|
const result = normalizeChartData(undefined, [
|
||||||
|
{ name: "低风险", value: 8 },
|
||||||
|
{ name: "高风险", value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["低风险", "高风险"],
|
||||||
|
series: [{ name: "数据", data: [8, 2], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a single series object", () => {
|
||||||
|
const result = normalizeChartData(["A", "B"], {
|
||||||
|
name: "流量",
|
||||||
|
values: ["1.2", "2.4"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["A", "B"],
|
||||||
|
series: [{ name: "流量", data: [1.2, 2.4], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Inline chart rendered inside a chat message bubble. */
|
||||||
|
/* Accepts structured data produced by the AI tool_call. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface ChatChartSeries {
|
||||||
|
name: string;
|
||||||
|
data: number[];
|
||||||
|
type?: "line" | "bar";
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawChartPoint =
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| [unknown, unknown]
|
||||||
|
| RawChartPointObject;
|
||||||
|
|
||||||
|
type RawChartPointObject = {
|
||||||
|
x?: unknown;
|
||||||
|
y?: unknown;
|
||||||
|
time?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
label?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawChartSeries = {
|
||||||
|
name?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
points?: unknown;
|
||||||
|
values?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatInlineChartProps {
|
||||||
|
title?: string;
|
||||||
|
chart_type?: "line" | "bar" | "pie";
|
||||||
|
x_data?: unknown;
|
||||||
|
series?: unknown;
|
||||||
|
y_axis_name?: string;
|
||||||
|
x_axis_name?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHART_HEIGHT = 240;
|
||||||
|
export const CHART_MIN_HEIGHT = 286;
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#5470c6",
|
||||||
|
"#91cc75",
|
||||||
|
"#fac858",
|
||||||
|
"#ee6666",
|
||||||
|
"#73c0de",
|
||||||
|
"#3ba272",
|
||||||
|
"#fc8452",
|
||||||
|
"#9a60b4",
|
||||||
|
"#ea7ccc",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => (
|
||||||
|
<Stack spacing={1.25} sx={{ p: 1.5 }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Skeleton variant="text" width="34%" height={20} />
|
||||||
|
{status}
|
||||||
|
</Stack>
|
||||||
|
<Skeleton variant="rounded" height={208} sx={{ borderRadius: 2 }} />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Skeleton variant="text" width="24%" height={16} />
|
||||||
|
<Skeleton variant="text" width="18%" height={16} />
|
||||||
|
<Skeleton variant="text" width="20%" height={16} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: CHART_MIN_HEIGHT,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.78),
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChartSkeletonContent status={status} />
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toFiniteNumber = (value: unknown): number | null => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointToLabelValue = (
|
||||||
|
point: RawChartPoint,
|
||||||
|
fallbackLabel: string,
|
||||||
|
): { label: string; value: number } | null => {
|
||||||
|
const directValue = toFiniteNumber(point);
|
||||||
|
if (directValue !== null) {
|
||||||
|
return { label: fallbackLabel, value: directValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(point)) {
|
||||||
|
const value = toFiniteNumber(point[1]);
|
||||||
|
if (value === null) return null;
|
||||||
|
return { label: String(point[0] ?? fallbackLabel), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point && typeof point === "object") {
|
||||||
|
const value = toFiniteNumber(point.value ?? point.y);
|
||||||
|
if (value === null) return null;
|
||||||
|
const label =
|
||||||
|
point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
|
||||||
|
return { label: String(label), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeXData = (rawXData: unknown): string[] =>
|
||||||
|
Array.isArray(rawXData)
|
||||||
|
? rawXData.map((item) => String(item ?? "")).filter((item) => item.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizeSeriesType = (type: unknown): "line" | "bar" | undefined =>
|
||||||
|
type === "line" || type === "bar" ? type : undefined;
|
||||||
|
|
||||||
|
const isRawChartPoint = (item: unknown): boolean => {
|
||||||
|
if (toFiniteNumber(item) !== null) return true;
|
||||||
|
if (Array.isArray(item)) return item.length >= 2 && toFiniteNumber(item[1]) !== null;
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const rawItem = item as RawChartSeries & RawChartPointObject;
|
||||||
|
return (
|
||||||
|
rawItem.data === undefined &&
|
||||||
|
rawItem.points === undefined &&
|
||||||
|
rawItem.values === undefined &&
|
||||||
|
toFiniteNumber(rawItem.value ?? rawItem.y) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRawSeriesItems = (rawSeries: unknown): unknown[] => {
|
||||||
|
if (!Array.isArray(rawSeries)) {
|
||||||
|
return rawSeries && typeof rawSeries === "object" ? [rawSeries] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint)
|
||||||
|
? [{ name: "数据", data: rawSeries }]
|
||||||
|
: rawSeries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeChartData = (
|
||||||
|
rawXData: unknown,
|
||||||
|
rawSeries: unknown,
|
||||||
|
): { xData: string[]; series: ChatChartSeries[] } => {
|
||||||
|
const xData = normalizeXData(rawXData);
|
||||||
|
const rawSeriesItems = normalizeRawSeriesItems(rawSeries);
|
||||||
|
if (!rawSeriesItems.length) {
|
||||||
|
return { xData, series: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSeries = rawSeriesItems
|
||||||
|
.map((rawItem, seriesIndex): ChatChartSeries | null => {
|
||||||
|
const item =
|
||||||
|
rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
|
||||||
|
? (rawItem as RawChartSeries)
|
||||||
|
: ({ data: rawItem } satisfies RawChartSeries);
|
||||||
|
const rawData = item.data ?? item.points ?? item.values;
|
||||||
|
if (!Array.isArray(rawData)) return null;
|
||||||
|
|
||||||
|
const labelsFromPoints: string[] = [];
|
||||||
|
const data = rawData
|
||||||
|
.map((point, index) => {
|
||||||
|
const parsed = pointToLabelValue(
|
||||||
|
point as RawChartPoint,
|
||||||
|
xData[index] ?? `${index + 1}`,
|
||||||
|
);
|
||||||
|
if (!parsed) return null;
|
||||||
|
labelsFromPoints[index] = parsed.label;
|
||||||
|
return parsed.value;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
if (!data.length) return null;
|
||||||
|
if (!xData.length && labelsFromPoints.length) {
|
||||||
|
xData.push(...labelsFromPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
typeof item.name === "string" && item.name.trim()
|
||||||
|
? item.name
|
||||||
|
: `系列 ${seriesIndex + 1}`,
|
||||||
|
data,
|
||||||
|
type: normalizeSeriesType(item.type),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is ChatChartSeries => Boolean(item));
|
||||||
|
|
||||||
|
return { xData, series: normalizedSeries };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||||
|
title,
|
||||||
|
chart_type: chartType = "line",
|
||||||
|
x_data,
|
||||||
|
series,
|
||||||
|
y_axis_name: yAxisName,
|
||||||
|
x_axis_name: xAxisName,
|
||||||
|
isStreaming = false,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true);
|
||||||
|
const { xData, series: chartSeries } = useMemo(
|
||||||
|
() => normalizeChartData(x_data, series),
|
||||||
|
[x_data, series],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setShowIntroSkeleton(false);
|
||||||
|
}, isStreaming ? 360 : 260);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [isStreaming]);
|
||||||
|
|
||||||
|
const option = useMemo(() => {
|
||||||
|
if (!chartSeries.length) return null;
|
||||||
|
|
||||||
|
/* ---------- Pie chart ---------- */
|
||||||
|
if (chartType === "pie") {
|
||||||
|
const pieData =
|
||||||
|
chartSeries[0]?.data.map((value, i) => ({
|
||||||
|
name: xData?.[i] ?? `${i}`,
|
||||||
|
value,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
|
tooltip: { trigger: "item" },
|
||||||
|
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pie",
|
||||||
|
radius: ["30%", "60%"],
|
||||||
|
data: pieData,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: { fontSize: 11 },
|
||||||
|
animationType: "expansion",
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDelay: (idx: number) => idx * 40,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
color: COLORS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Line / Bar chart ---------- */
|
||||||
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
|
tooltip: { trigger: "axis", confine: true },
|
||||||
|
legend: { top: "top", textStyle: { fontSize: 11 } },
|
||||||
|
grid: {
|
||||||
|
left: "5%",
|
||||||
|
right: "5%",
|
||||||
|
bottom: "12%",
|
||||||
|
top: title ? "18%" : "14%",
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category" as const,
|
||||||
|
boundaryGap: chartType === "bar",
|
||||||
|
data: xData ?? [],
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
rotate: xData && xData.length > 10 ? 30 : 0,
|
||||||
|
},
|
||||||
|
name: xAxisName,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value" as const,
|
||||||
|
scale: true,
|
||||||
|
axisLabel: { fontSize: 10 },
|
||||||
|
name: yAxisName,
|
||||||
|
},
|
||||||
|
dataZoom:
|
||||||
|
xData && xData.length > 20
|
||||||
|
? [{ type: "inside", start: 0, end: 100 }]
|
||||||
|
: undefined,
|
||||||
|
series: chartSeries.map((s, i) => {
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
const isLineSeries = chartType === "line";
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
type: (s.type ?? chartType) as string,
|
||||||
|
data: s.data,
|
||||||
|
symbol: isLineSeries ? "none" : undefined,
|
||||||
|
smooth: isLineSeries,
|
||||||
|
itemStyle: { color },
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationDelay:
|
||||||
|
chartType === "bar"
|
||||||
|
? (idx: number) => i * 80 + idx * 18
|
||||||
|
: i * 80,
|
||||||
|
animationDelayUpdate: 0,
|
||||||
|
...(isLineSeries
|
||||||
|
? {
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: alpha(color, 0.3) },
|
||||||
|
{ offset: 1, color: alpha(color, 0.05) },
|
||||||
|
]),
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
color: COLORS,
|
||||||
|
};
|
||||||
|
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
|
||||||
|
|
||||||
|
if (!option) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: 72,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.72),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
图表数据为空
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: CHART_MIN_HEIGHT,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.92),
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{showIntroSkeleton ? (
|
||||||
|
<Box
|
||||||
|
key="chart-intro-skeleton"
|
||||||
|
component={motion.div}
|
||||||
|
aria-hidden
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
bgcolor: alpha("#fff", 0.92),
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChartSkeletonContent />
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
{title && (
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component={motion.div}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: showIntroSkeleton ? 0.35 : 1 }}
|
||||||
|
transition={{ duration: 0.24, ease: "easeOut" }}
|
||||||
|
sx={{ px: 1, pb: 1, minHeight: CHART_HEIGHT }}
|
||||||
|
>
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height: CHART_HEIGHT, width: "100%" }}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
|
||||||
|
import TimelineRounded from "@mui/icons-material/TimelineRounded";
|
||||||
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useChatToolStore,
|
||||||
|
type ChatToolAction,
|
||||||
|
} from "@/store/chatToolStore";
|
||||||
|
import type { ToolCall } from "./chatMessageSections";
|
||||||
|
import {
|
||||||
|
APPLY_LAYER_STYLE_TOOL,
|
||||||
|
describeApplyLayerStyle,
|
||||||
|
parseApplyLayerStylePayload,
|
||||||
|
} from "./toolCallStyleHelpers";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Interactive card rendered inside a chat bubble for tool actions */
|
||||||
|
/* (locate nodes/pipes, open history/SCADA panels). */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type ToolMeta = {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
actionLabel: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
|
||||||
|
locate_features: "",
|
||||||
|
locate_junctions: "geo_junctions_mat",
|
||||||
|
locate_pipes: "geo_pipes_mat",
|
||||||
|
locate_valves: "geo_valves",
|
||||||
|
locate_reservoirs: "geo_reservoirs",
|
||||||
|
locate_pumps: "geo_pumps",
|
||||||
|
locate_tanks: "geo_tanks",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
|
||||||
|
const LOCATE_ID_PARAM_KEYS = [
|
||||||
|
"ids",
|
||||||
|
"id",
|
||||||
|
"feature_ids",
|
||||||
|
"feature_id",
|
||||||
|
"node_ids",
|
||||||
|
"node_id",
|
||||||
|
"junction_ids",
|
||||||
|
"junction_id",
|
||||||
|
"pipe_ids",
|
||||||
|
"pipe_id",
|
||||||
|
"valve_ids",
|
||||||
|
"valve_id",
|
||||||
|
"reservoir_ids",
|
||||||
|
"reservoir_id",
|
||||||
|
"pump_ids",
|
||||||
|
"pump_id",
|
||||||
|
"tank_ids",
|
||||||
|
"tank_id",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TOOL_META: Record<string, ToolMeta> = {
|
||||||
|
locate_features: {
|
||||||
|
label: "定位要素",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#5470c6",
|
||||||
|
},
|
||||||
|
locate_junctions: {
|
||||||
|
label: "定位节点",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#5470c6",
|
||||||
|
},
|
||||||
|
locate_pipes: {
|
||||||
|
label: "定位管道",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#91cc75",
|
||||||
|
},
|
||||||
|
locate_valves: {
|
||||||
|
label: "定位阀门",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#9a60b4",
|
||||||
|
},
|
||||||
|
locate_reservoirs: {
|
||||||
|
label: "定位水源",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#ea7ccc",
|
||||||
|
},
|
||||||
|
locate_pumps: {
|
||||||
|
label: "定位泵站",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#fc8452",
|
||||||
|
},
|
||||||
|
locate_tanks: {
|
||||||
|
label: "定位水池",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#3ba272",
|
||||||
|
},
|
||||||
|
zoom_to_map: {
|
||||||
|
label: "缩放到坐标",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "缩放到地图",
|
||||||
|
color: "#0ea5e9",
|
||||||
|
},
|
||||||
|
view_history: {
|
||||||
|
label: "查看计算结果",
|
||||||
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "查看曲线",
|
||||||
|
color: "#fac858",
|
||||||
|
},
|
||||||
|
view_scada: {
|
||||||
|
label: "查看监测数据",
|
||||||
|
icon: <SensorsRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "查看数据",
|
||||||
|
color: "#ee6666",
|
||||||
|
},
|
||||||
|
show_chart: {
|
||||||
|
label: "显示图表",
|
||||||
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "显示",
|
||||||
|
color: "#73c0de",
|
||||||
|
},
|
||||||
|
render_junctions: {
|
||||||
|
label: "渲染节点",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "应用渲染",
|
||||||
|
color: "#3b82f6",
|
||||||
|
},
|
||||||
|
[APPLY_LAYER_STYLE_TOOL]: {
|
||||||
|
label: "图层样式",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "应用样式",
|
||||||
|
color: "#14b8a6",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
function normalizeLocateIds(params: Record<string, unknown>): string[] {
|
||||||
|
for (const key of LOCATE_ID_PARAM_KEYS) {
|
||||||
|
const rawValue = params[key];
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
const normalized = rawValue
|
||||||
|
.map((id) => String(id).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof rawValue === "string" || typeof rawValue === "number") {
|
||||||
|
const normalized = String(rawValue)
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZoomTo3857Action(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Extract<ChatToolAction, { type: "zoom_to_map" }> | null {
|
||||||
|
const rawCoordinate = params.coordinate ?? params.coordinates ?? params.center;
|
||||||
|
const tuple = Array.isArray(rawCoordinate)
|
||||||
|
? rawCoordinate
|
||||||
|
: [params.x ?? params.lon ?? params.longitude, params.y ?? params.lat ?? params.latitude];
|
||||||
|
const x = readFiniteNumber(tuple[0]);
|
||||||
|
const y = readFiniteNumber(tuple[1]);
|
||||||
|
if (x === null || y === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = readFiniteNumber(params.zoom);
|
||||||
|
const durationMs = readFiniteNumber(params.duration_ms ?? params.durationMs);
|
||||||
|
const rawSourceCrs = params.source_crs ?? params.sourceCrs ?? params.crs;
|
||||||
|
const normalizedSourceCrs =
|
||||||
|
typeof rawSourceCrs === "string" ? rawSourceCrs.trim().toUpperCase() : "";
|
||||||
|
const sourceCrs =
|
||||||
|
normalizedSourceCrs === "EPSG:4326" ? "EPSG:4326" : "EPSG:3857";
|
||||||
|
return {
|
||||||
|
type: "zoom_to_map",
|
||||||
|
coordinate: [x, y],
|
||||||
|
sourceCrs,
|
||||||
|
zoom: zoom ?? undefined,
|
||||||
|
durationMs: durationMs ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolDescription(toolCall: ToolCall): string {
|
||||||
|
const { params } = toolCall;
|
||||||
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
|
const rawFeatureInfos = params.feature_infos;
|
||||||
|
if (Array.isArray(rawFeatureInfos)) {
|
||||||
|
const normalizedFeatureInfos = rawFeatureInfos
|
||||||
|
.map((item) => (Array.isArray(item) ? item : null))
|
||||||
|
.filter((item): item is [unknown, unknown] => Boolean(item))
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.filter(([id]) => id.trim().length > 0);
|
||||||
|
if (normalizedFeatureInfos.length > 0) {
|
||||||
|
return normalizedFeatureInfos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDeviceIds =
|
||||||
|
params.device_ids ??
|
||||||
|
params.deviceId ??
|
||||||
|
params.device_id ??
|
||||||
|
params.id ??
|
||||||
|
params.ids;
|
||||||
|
const deviceIds = Array.isArray(rawDeviceIds)
|
||||||
|
? rawDeviceIds.map((id) => String(id))
|
||||||
|
: typeof rawDeviceIds === "string"
|
||||||
|
? rawDeviceIds
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return deviceIds.map((id) => [id, "scada"]);
|
||||||
|
};
|
||||||
|
const resolveTimeRange = () => ({
|
||||||
|
startTime:
|
||||||
|
(params.start_time as string | undefined) ??
|
||||||
|
(params.startTime as string | undefined) ??
|
||||||
|
(params.from as string | undefined) ??
|
||||||
|
(params.start as string | undefined),
|
||||||
|
endTime:
|
||||||
|
(params.end_time as string | undefined) ??
|
||||||
|
(params.endTime as string | undefined) ??
|
||||||
|
(params.to as string | undefined) ??
|
||||||
|
(params.end as string | undefined),
|
||||||
|
});
|
||||||
|
const resolveLocateFeatureType = (): string => {
|
||||||
|
const rawType = params.feature_type;
|
||||||
|
if (typeof rawType === "string" && rawType.trim()) {
|
||||||
|
return rawType.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
switch (toolCall.tool) {
|
||||||
|
case "locate_features":
|
||||||
|
case "locate_junctions":
|
||||||
|
case "locate_pipes":
|
||||||
|
case "locate_valves":
|
||||||
|
case "locate_reservoirs":
|
||||||
|
case "locate_pumps":
|
||||||
|
case "locate_tanks": {
|
||||||
|
const ids = normalizeLocateIds(params);
|
||||||
|
const idsText =
|
||||||
|
ids.length > 3
|
||||||
|
? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个`
|
||||||
|
: ids.join(", ");
|
||||||
|
if (toolCall.tool !== "locate_features") {
|
||||||
|
return idsText;
|
||||||
|
}
|
||||||
|
const featureType = resolveLocateFeatureType();
|
||||||
|
if (!featureType) {
|
||||||
|
return idsText;
|
||||||
|
}
|
||||||
|
return idsText
|
||||||
|
? `${featureType} · ${idsText}`
|
||||||
|
: featureType;
|
||||||
|
}
|
||||||
|
case "view_history":
|
||||||
|
case "view_scada": {
|
||||||
|
const infos =
|
||||||
|
toolCall.tool === "view_scada"
|
||||||
|
? resolveScadaFeatureInfos()
|
||||||
|
: ((params.feature_infos as [string, string][] | undefined) ?? []);
|
||||||
|
const names = infos.map(([id]) => id);
|
||||||
|
const base =
|
||||||
|
names.length > 3
|
||||||
|
? `${names.slice(0, 3).join(", ")} 等 ${names.length} 个`
|
||||||
|
: names.join(", ");
|
||||||
|
const { startTime, endTime } = resolveTimeRange();
|
||||||
|
if (!startTime && !endTime) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const rangeLabel = `时间段: ${startTime ?? "--"} ~ ${endTime ?? "--"}`;
|
||||||
|
return base ? `${base} · ${rangeLabel}` : rangeLabel;
|
||||||
|
}
|
||||||
|
case "show_chart": {
|
||||||
|
return (params.title as string | undefined) ?? "数据图表";
|
||||||
|
}
|
||||||
|
case "render_junctions": {
|
||||||
|
return (params.render_ref as string | undefined) ?? "渲染引用";
|
||||||
|
}
|
||||||
|
case "zoom_to_map": {
|
||||||
|
const action = buildZoomTo3857Action(params);
|
||||||
|
if (!action) {
|
||||||
|
return "地图坐标";
|
||||||
|
}
|
||||||
|
const zoom = action.zoom === undefined ? "" : ` · zoom ${action.zoom}`;
|
||||||
|
return `${action.coordinate[0]}, ${action.coordinate[1]} · ${action.sourceCrs}${zoom}`;
|
||||||
|
}
|
||||||
|
case APPLY_LAYER_STYLE_TOOL: {
|
||||||
|
const payload = parseApplyLayerStylePayload(params);
|
||||||
|
return payload ? describeApplyLayerStyle(payload) : "图层样式";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
||||||
|
const { params } = toolCall;
|
||||||
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
|
const rawFeatureInfos = params.feature_infos;
|
||||||
|
if (Array.isArray(rawFeatureInfos)) {
|
||||||
|
const normalizedFeatureInfos = rawFeatureInfos
|
||||||
|
.map((item) => (Array.isArray(item) ? item : null))
|
||||||
|
.filter((item): item is [unknown, unknown] => Boolean(item))
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.filter(([id]) => id.trim().length > 0);
|
||||||
|
if (normalizedFeatureInfos.length > 0) {
|
||||||
|
return normalizedFeatureInfos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDeviceIds =
|
||||||
|
params.device_ids ??
|
||||||
|
params.deviceId ??
|
||||||
|
params.device_id ??
|
||||||
|
params.id ??
|
||||||
|
params.ids;
|
||||||
|
const deviceIds = Array.isArray(rawDeviceIds)
|
||||||
|
? rawDeviceIds.map((id) => String(id))
|
||||||
|
: typeof rawDeviceIds === "string"
|
||||||
|
? rawDeviceIds
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return deviceIds.map((id) => [id, "scada"]);
|
||||||
|
};
|
||||||
|
const resolveTimeRange = () => ({
|
||||||
|
startTime:
|
||||||
|
(params.start_time as string | undefined) ??
|
||||||
|
(params.startTime as string | undefined) ??
|
||||||
|
(params.from as string | undefined) ??
|
||||||
|
(params.start as string | undefined),
|
||||||
|
endTime:
|
||||||
|
(params.end_time as string | undefined) ??
|
||||||
|
(params.endTime as string | undefined) ??
|
||||||
|
(params.to as string | undefined) ??
|
||||||
|
(params.end as string | undefined),
|
||||||
|
});
|
||||||
|
switch (toolCall.tool) {
|
||||||
|
case "zoom_to_map":
|
||||||
|
return buildZoomTo3857Action(params);
|
||||||
|
case "locate_features": {
|
||||||
|
const featureTypeRaw = params.feature_type;
|
||||||
|
const featureType =
|
||||||
|
typeof featureTypeRaw === "string"
|
||||||
|
? featureTypeRaw.trim().toLowerCase()
|
||||||
|
: "";
|
||||||
|
const config = locateFeatureTypeToConfig(featureType);
|
||||||
|
if (!config) return null;
|
||||||
|
return {
|
||||||
|
type: "locate_features",
|
||||||
|
ids: normalizeLocateIds(params),
|
||||||
|
layer: config.layer,
|
||||||
|
geometryKind: config.geometryKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "locate_junctions":
|
||||||
|
case "locate_pipes":
|
||||||
|
case "locate_valves":
|
||||||
|
case "locate_reservoirs":
|
||||||
|
case "locate_pumps":
|
||||||
|
case "locate_tanks": {
|
||||||
|
const layer = LOCATE_TOOL_TO_LAYER[toolCall.tool];
|
||||||
|
if (!layer) return null;
|
||||||
|
return {
|
||||||
|
type: "locate_features",
|
||||||
|
ids: normalizeLocateIds(params),
|
||||||
|
layer,
|
||||||
|
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "view_history": {
|
||||||
|
const historyRange = resolveTimeRange();
|
||||||
|
return {
|
||||||
|
type: "view_history",
|
||||||
|
featureInfos:
|
||||||
|
(params.feature_infos as [string, string][] | undefined) ?? [],
|
||||||
|
dataType:
|
||||||
|
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
||||||
|
"realtime",
|
||||||
|
startTime: historyRange.startTime,
|
||||||
|
endTime: historyRange.endTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "view_scada": {
|
||||||
|
const scadaRange = resolveTimeRange();
|
||||||
|
return {
|
||||||
|
type: "view_scada",
|
||||||
|
featureInfos: resolveScadaFeatureInfos(),
|
||||||
|
startTime: scadaRange.startTime,
|
||||||
|
endTime: scadaRange.endTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "show_chart":
|
||||||
|
return {
|
||||||
|
type: "show_chart",
|
||||||
|
title: params.title as string | undefined,
|
||||||
|
chartType:
|
||||||
|
(params.chart_type as "line" | "bar" | "pie" | undefined) ?? "line",
|
||||||
|
xData: (params.x_data as string[] | undefined) ?? [],
|
||||||
|
series:
|
||||||
|
(params.series as
|
||||||
|
| Array<{ name: string; data: number[]; type?: "line" | "bar" }>
|
||||||
|
| undefined) ?? [],
|
||||||
|
xAxisName: params.x_axis_name as string | undefined,
|
||||||
|
yAxisName: params.y_axis_name as string | undefined,
|
||||||
|
};
|
||||||
|
case "render_junctions": {
|
||||||
|
const renderRef =
|
||||||
|
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
|
||||||
|
if (!renderRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "render_junctions",
|
||||||
|
renderRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case APPLY_LAYER_STYLE_TOOL: {
|
||||||
|
const payload = parseApplyLayerStylePayload(params);
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "apply_layer_style",
|
||||||
|
layerId: payload.layerId,
|
||||||
|
resetToDefault: payload.resetToDefault,
|
||||||
|
styleConfig: payload.styleConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- component ---------- */
|
||||||
|
|
||||||
|
export interface ChatToolCallBlockProps {
|
||||||
|
toolCall: ToolCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
|
||||||
|
toolCall,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const dispatch = useChatToolStore((s) => s.dispatch);
|
||||||
|
const [executed, setExecuted] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
|
||||||
|
label: toolCall.tool,
|
||||||
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "执行",
|
||||||
|
color: "#00acc1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const description = getToolDescription(toolCall);
|
||||||
|
|
||||||
|
const handleExecute = useCallback(() => {
|
||||||
|
const action = buildAction(toolCall);
|
||||||
|
if (action) {
|
||||||
|
dispatch(action);
|
||||||
|
setExecuted(true);
|
||||||
|
}
|
||||||
|
}, [toolCall, dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
mb: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${alpha(meta.color, 0.3)}`,
|
||||||
|
bgcolor: alpha(meta.color, 0.05),
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha(meta.color, 0.08),
|
||||||
|
border: `1px solid ${alpha(meta.color, 0.4)}`,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: alpha(meta.color, 0.15),
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: meta.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.icon}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</Typography>
|
||||||
|
{!expanded && description && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 180,
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
• {description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}>
|
||||||
|
{expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
|
||||||
|
<Stack direction="column" spacing={1.5}>
|
||||||
|
{description && (
|
||||||
|
<Box sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#000", 0.03),
|
||||||
|
border: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}>
|
||||||
|
执行参数
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="flex-end">
|
||||||
|
{executed ? (
|
||||||
|
<Chip
|
||||||
|
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
|
||||||
|
label="已执行"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: alpha("#00e676", 0.15),
|
||||||
|
color: "#00c853",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
|
||||||
|
sx={{
|
||||||
|
bgcolor: meta.color,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
borderRadius: 2.5,
|
||||||
|
px: 2,
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: meta.color,
|
||||||
|
filter: "brightness(0.9)",
|
||||||
|
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const locateFeatureTypeToConfig = (
|
||||||
|
featureType: string,
|
||||||
|
): { layer: string; geometryKind: "point" | "line" } | null => {
|
||||||
|
switch (featureType) {
|
||||||
|
case "junction":
|
||||||
|
case "junctions":
|
||||||
|
return { layer: "geo_junctions_mat", geometryKind: "point" };
|
||||||
|
case "pipe":
|
||||||
|
case "pipes":
|
||||||
|
return { layer: "geo_pipes_mat", geometryKind: "line" };
|
||||||
|
case "valve":
|
||||||
|
case "valves":
|
||||||
|
return { layer: "geo_valves", geometryKind: "point" };
|
||||||
|
case "reservoir":
|
||||||
|
case "reservoirs":
|
||||||
|
return { layer: "geo_reservoirs", geometryKind: "point" };
|
||||||
|
case "pump":
|
||||||
|
case "pumps":
|
||||||
|
return { layer: "geo_pumps", geometryKind: "point" };
|
||||||
|
case "tank":
|
||||||
|
case "tanks":
|
||||||
|
return { layer: "geo_tanks", geometryKind: "point" };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { GlobalChatbox } from "./GlobalChatbox";
|
||||||
|
|
||||||
|
const createSession = jest.fn();
|
||||||
|
let mockCurrentProjectId = "project-1";
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useNotification: () => ({ open: jest.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatModels", () => ({
|
||||||
|
fetchAgentModels: jest.fn(() => new Promise(() => {})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/store/projectStore", () => ({
|
||||||
|
useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
|
||||||
|
selector({ currentProjectId: mockCurrentProjectId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./globalChatboxVoice", () => ({
|
||||||
|
useSpeechSynthesis: () => ({
|
||||||
|
speechState: "idle",
|
||||||
|
speakingMessageId: null,
|
||||||
|
speak: jest.fn(),
|
||||||
|
pause: jest.fn(),
|
||||||
|
resume: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
isSupported: true,
|
||||||
|
}),
|
||||||
|
useSpeechRecognition: () => ({
|
||||||
|
isListening: false,
|
||||||
|
start: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
isSupported: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./hooks/useAgentToolActions", () => ({
|
||||||
|
useAgentToolActions: () => jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./hooks/useAgentChatSession", () => ({
|
||||||
|
useAgentChatSession: () => ({
|
||||||
|
messages: [],
|
||||||
|
chatSessions: [],
|
||||||
|
activeSessionId: undefined,
|
||||||
|
isHydrating: false,
|
||||||
|
loadingSessionId: null,
|
||||||
|
isStreaming: false,
|
||||||
|
sessionTitle: "新会话",
|
||||||
|
sendPrompt: jest.fn(),
|
||||||
|
createBranch: jest.fn(),
|
||||||
|
abort: jest.fn(),
|
||||||
|
replyPermission: jest.fn(),
|
||||||
|
replyQuestion: jest.fn(),
|
||||||
|
rejectQuestion: jest.fn(),
|
||||||
|
createSession,
|
||||||
|
renameSession: jest.fn(),
|
||||||
|
removeSession: jest.fn(),
|
||||||
|
switchSession: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentHeader", () => ({
|
||||||
|
AgentHeader: () => <div>Agent header</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentHistoryPanel", () => ({
|
||||||
|
AgentHistoryPanel: () => <div>History</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentWorkspace", () => ({
|
||||||
|
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentComposer", () => ({
|
||||||
|
AgentComposer: React.forwardRef(function MockAgentComposer() {
|
||||||
|
return <div>Composer</div>;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./GlobalChatboxParts", () => ({
|
||||||
|
Blob: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GlobalChatbox lifecycle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
createSession.mockClear();
|
||||||
|
mockCurrentProjectId = "project-1";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
||||||
|
const { rerender } = render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<GlobalChatbox open={false} onClose={jest.fn()} />);
|
||||||
|
act(() => jest.advanceTimersByTime(300));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockCurrentProjectId = "project-2";
|
||||||
|
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||||
|
import { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
|
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
||||||
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||||
|
import { AgentHeader } from "./AgentHeader";
|
||||||
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
|
import { Blob } from "./GlobalChatboxParts";
|
||||||
|
import type { Props } from "./GlobalChatbox.types";
|
||||||
|
import { PRESET_PROMPTS } from "./globalChatboxUtils";
|
||||||
|
import { useSpeechRecognition, useSpeechSynthesis } from "./globalChatboxVoice";
|
||||||
|
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||||
|
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||||
|
|
||||||
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
|
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
||||||
|
|
||||||
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [width, setWidth] = useState(520);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||||
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
||||||
|
const [approvalMode, setApprovalMode] =
|
||||||
|
useState<AgentApprovalMode>("request");
|
||||||
|
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const workspaceScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isNearBottomRef = useRef(true);
|
||||||
|
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||||
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
|
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||||
|
const theme = useTheme();
|
||||||
|
const { open: openNotification } = useNotification();
|
||||||
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
speechState,
|
||||||
|
speakingMessageId,
|
||||||
|
speak: handleSpeak,
|
||||||
|
pause: handlePauseSpeech,
|
||||||
|
resume: handleResumeSpeech,
|
||||||
|
stop: handleStopSpeech,
|
||||||
|
isSupported: isTtsSupported,
|
||||||
|
} = useSpeechSynthesis();
|
||||||
|
|
||||||
|
const handleSpeechResult = useCallback((text: string) => {
|
||||||
|
composerRef.current?.append(text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isListening,
|
||||||
|
start: startListening,
|
||||||
|
stop: stopListening,
|
||||||
|
isSupported: isSttSupported,
|
||||||
|
} = useSpeechRecognition(handleSpeechResult);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadModels();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToolCall = useAgentToolActions();
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
chatSessions,
|
||||||
|
activeSessionId,
|
||||||
|
isHydrating,
|
||||||
|
loadingSessionId,
|
||||||
|
isStreaming,
|
||||||
|
sessionTitle,
|
||||||
|
sendPrompt,
|
||||||
|
createBranch,
|
||||||
|
abort,
|
||||||
|
replyPermission,
|
||||||
|
replyQuestion,
|
||||||
|
rejectQuestion,
|
||||||
|
createSession,
|
||||||
|
renameSession,
|
||||||
|
removeSession,
|
||||||
|
switchSession,
|
||||||
|
} = useAgentChatSession({
|
||||||
|
projectId: currentProjectId,
|
||||||
|
onToolCall: handleToolCall,
|
||||||
|
onBeforeSend: stopListening,
|
||||||
|
getModel: () => selectedModel,
|
||||||
|
getApprovalMode: () => approvalMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelStreamingScroll = useCallback(() => {
|
||||||
|
if (streamingScrollFrameRef.current === null) return;
|
||||||
|
window.cancelAnimationFrame(streamingScrollFrameRef.current);
|
||||||
|
streamingScrollFrameRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleStreamingScrollToBottom = useCallback(() => {
|
||||||
|
if (streamingScrollFrameRef.current !== null) return;
|
||||||
|
streamingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
streamingScrollFrameRef.current = null;
|
||||||
|
const container = workspaceScrollRef.current;
|
||||||
|
if (!container || !isNearBottomRef.current) return;
|
||||||
|
|
||||||
|
const distanceToBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return;
|
||||||
|
|
||||||
|
container.scrollTop = container.scrollHeight - container.clientHeight;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => {
|
||||||
|
isNearBottomRef.current = isNearBottom;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetConversationView = useCallback(() => {
|
||||||
|
composerRef.current?.clear();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("auto");
|
||||||
|
}, 0);
|
||||||
|
}, [cancelStreamingScroll, scrollToBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
|
if (!isNearBottomRef.current) return;
|
||||||
|
scheduleStreamingScrollToBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("smooth");
|
||||||
|
}, [
|
||||||
|
cancelStreamingScroll,
|
||||||
|
isStreaming,
|
||||||
|
messages,
|
||||||
|
scheduleStreamingScrollToBottom,
|
||||||
|
scrollToBottom,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancelStreamingScroll();
|
||||||
|
},
|
||||||
|
[cancelStreamingScroll],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!open ||
|
||||||
|
isHydrating ||
|
||||||
|
initializedProjectIdRef.current === currentProjectId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializedProjectIdRef.current = currentProjectId;
|
||||||
|
createSession();
|
||||||
|
resetConversationView();
|
||||||
|
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
|
if (isStreaming || isCheckingAuth) return;
|
||||||
|
|
||||||
|
setIsCheckingAuth(true);
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
composerRef.current?.setValue(prompt);
|
||||||
|
openNotification?.({
|
||||||
|
type: "error",
|
||||||
|
message: "登录状态已失效",
|
||||||
|
description: "请重新登录后再发送对话。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendPrompt(prompt);
|
||||||
|
} catch (error) {
|
||||||
|
composerRef.current?.setValue(prompt);
|
||||||
|
openNotification?.({
|
||||||
|
type: "error",
|
||||||
|
message: "登录状态校验失败",
|
||||||
|
description: error instanceof Error ? error.message : "请重新登录后再试。",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingAuth(false);
|
||||||
|
}
|
||||||
|
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]);
|
||||||
|
|
||||||
|
const handleNewConversation = useCallback(() => {
|
||||||
|
handleStopSpeech();
|
||||||
|
stopListening();
|
||||||
|
createSession();
|
||||||
|
resetConversationView();
|
||||||
|
}, [createSession, handleStopSpeech, resetConversationView, stopListening]);
|
||||||
|
|
||||||
|
const handleHistoryToggle = useCallback(() => {
|
||||||
|
setIsHistoryOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectSession = useCallback(
|
||||||
|
(sessionId: string, title: string) => {
|
||||||
|
composerRef.current?.clear();
|
||||||
|
void switchSession(sessionId, title);
|
||||||
|
},
|
||||||
|
[switchSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteSession = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
void removeSession(sessionId);
|
||||||
|
},
|
||||||
|
[removeSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameSession = useCallback(
|
||||||
|
(sessionId: string, title: string) => {
|
||||||
|
void renameSession(sessionId, title);
|
||||||
|
},
|
||||||
|
[renameSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameActiveSession = useCallback(
|
||||||
|
(title: string) => {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
void renameSession(activeSessionId, title);
|
||||||
|
},
|
||||||
|
[activeSessionId, renameSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
const newWidth = window.innerWidth - event.clientX;
|
||||||
|
if (newWidth > 360 && newWidth < 800) {
|
||||||
|
setWidth(newWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isResizing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
variant="temporary"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
hideBackdrop
|
||||||
|
disableScrollLock
|
||||||
|
disableEnforceFocus
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{
|
||||||
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
width: { xs: "100%", sm: width },
|
||||||
|
background: "transparent",
|
||||||
|
boxShadow: "none",
|
||||||
|
overflow: open ? "visible" : "hidden",
|
||||||
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
|
pointerEvents: "auto",
|
||||||
|
transition: isResizing ? "none" : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
bgcolor: alpha("#fff", 0.76),
|
||||||
|
backdropFilter: "blur(30px)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "6px",
|
||||||
|
cursor: "col-resize",
|
||||||
|
zIndex: 200,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.2),
|
||||||
|
},
|
||||||
|
"&::after": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: "2px",
|
||||||
|
height: "40px",
|
||||||
|
bgcolor: alpha(theme.palette.divider, 0.4),
|
||||||
|
borderRadius: "1px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Blob color={alpha(theme.palette.primary.main, 0.28)} size={300} top="-10%" left="-20%" delay={0} />
|
||||||
|
<Blob color={alpha(theme.palette.secondary.main, 0.24)} size={250} top="40%" left="60%" delay={2} />
|
||||||
|
<Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} />
|
||||||
|
|
||||||
|
<AgentHeader
|
||||||
|
sessionTitle={sessionTitle}
|
||||||
|
canRenameSessionTitle={Boolean(activeSessionId)}
|
||||||
|
isHydrating={isHydrating}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isHistoryOpen={isHistoryOpen}
|
||||||
|
onHistoryToggle={handleHistoryToggle}
|
||||||
|
onRenameSessionTitle={handleRenameActiveSession}
|
||||||
|
onNewConversation={handleNewConversation}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, display: "flex", minHeight: 0, position: "relative", overflow: "hidden" }}>
|
||||||
|
<Box
|
||||||
|
onClick={() => setIsHistoryOpen(false)}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
opacity: isHistoryOpen ? 1 : 0,
|
||||||
|
pointerEvents: isHistoryOpen ? "auto" : "none",
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 268,
|
||||||
|
zIndex: 20,
|
||||||
|
transform: isHistoryOpen ? "translateX(0)" : "translateX(-100%)",
|
||||||
|
transition: "transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
boxShadow: isHistoryOpen ? `4px 0 24px ${alpha("#000", 0.08)}` : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={chatSessions}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
isHydrating={isHydrating}
|
||||||
|
isLoadingSessions={isHydrating && chatSessions.length === 0}
|
||||||
|
loadingSessionId={loadingSessionId}
|
||||||
|
onNewSession={() => {
|
||||||
|
handleNewConversation();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
}}
|
||||||
|
onSelectSession={(id, title) => {
|
||||||
|
handleSelectSession(id, title);
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
}}
|
||||||
|
onRenameSession={handleRenameSession}
|
||||||
|
onDeleteSession={handleDeleteSession}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
|
||||||
|
<AgentWorkspace
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isLoadingSession={Boolean(loadingSessionId)}
|
||||||
|
scrollContainerRef={workspaceScrollRef}
|
||||||
|
bottomRef={bottomRef}
|
||||||
|
onScrollStateChange={handleWorkspaceScrollStateChange}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={handleSpeak}
|
||||||
|
onPauseSpeech={handlePauseSpeech}
|
||||||
|
onResumeSpeech={handleResumeSpeech}
|
||||||
|
onStopSpeech={handleStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onCreateBranch={createBranch}
|
||||||
|
onReplyPermission={replyPermission}
|
||||||
|
onReplyQuestion={replyQuestion}
|
||||||
|
onRejectQuestion={rejectQuestion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AgentComposer
|
||||||
|
ref={composerRef}
|
||||||
|
isHydrating={isHydrating || isCheckingAuth}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isListening={isListening}
|
||||||
|
isSttSupported={isSttSupported}
|
||||||
|
presets={PRESET_PROMPTS}
|
||||||
|
onSend={handleSend}
|
||||||
|
onAbort={abort}
|
||||||
|
onStartListening={startListening}
|
||||||
|
onStopListening={stopListening}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
approvalMode={approvalMode}
|
||||||
|
onApprovalModeChange={setApprovalMode}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
|
||||||
|
export type ChatProgress = {
|
||||||
|
id: string;
|
||||||
|
phase: string;
|
||||||
|
status: "running" | "completed" | "error";
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
|
||||||
|
|
||||||
|
export type AgentArtifact = {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
kind: AgentArtifactKind;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentPermissionStatus =
|
||||||
|
| "pending"
|
||||||
|
| "submitting"
|
||||||
|
| "approved_once"
|
||||||
|
| "approved_always"
|
||||||
|
| "rejected"
|
||||||
|
| "aborted"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type AgentPermissionRequest = {
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
permission: string;
|
||||||
|
patterns: string[];
|
||||||
|
target?: string;
|
||||||
|
always: string[];
|
||||||
|
tool?: {
|
||||||
|
messageID: string;
|
||||||
|
callID: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
repliedAt?: number;
|
||||||
|
status: AgentPermissionStatus;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
isError?: boolean;
|
||||||
|
progress?: ChatProgress[];
|
||||||
|
artifacts?: AgentArtifact[];
|
||||||
|
permissions?: AgentPermissionRequest[];
|
||||||
|
questions?: AgentQuestionRequest[];
|
||||||
|
todos?: AgentTodoUpdate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeechState = "idle" | "loading" | "playing" | "paused";
|
||||||
|
|
||||||
|
export type ChatSessionSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
runStatus?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoadedChatState = {
|
||||||
|
sessionId?: string;
|
||||||
|
title?: string;
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming?: boolean;
|
||||||
|
runStatus?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
.markdown {
|
||||||
|
color: var(--chat-md-text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown p {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown p + p {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown h1,
|
||||||
|
.markdown h2,
|
||||||
|
.markdown h3,
|
||||||
|
.markdown h4,
|
||||||
|
.markdown h5,
|
||||||
|
.markdown h6 {
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--chat-md-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown h1 { font-size: 1.2rem; }
|
||||||
|
.markdown h2 { font-size: 1.12rem; }
|
||||||
|
.markdown h3 { font-size: 1.04rem; }
|
||||||
|
|
||||||
|
.markdown a {
|
||||||
|
color: var(--chat-md-link);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown a:hover {
|
||||||
|
color: var(--chat-md-link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :not(pre) > code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background: var(--chat-md-inline-code-bg);
|
||||||
|
border: 1px solid var(--chat-md-inline-code-border);
|
||||||
|
color: var(--chat-md-inline-code-text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.12rem 0.4rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown pre {
|
||||||
|
background: var(--chat-md-pre-bg);
|
||||||
|
border: 1px solid var(--chat-md-pre-border);
|
||||||
|
color: var(--chat-md-pre-text);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.9rem 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown pre code {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown ul,
|
||||||
|
.markdown ol {
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown li {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
border: 1px solid var(--chat-md-inline-code-border);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown th,
|
||||||
|
.markdown td {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border: 1px solid var(--chat-md-inline-code-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown th {
|
||||||
|
background-color: var(--chat-md-inline-code-bg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--chat-md-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown tr:nth-child(even) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown blockquote {
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-left: 3px solid var(--chat-md-quote-border);
|
||||||
|
background: var(--chat-md-quote-bg);
|
||||||
|
color: var(--chat-md-quote-text);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamFade {
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export const Blob = ({
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
delay,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
top: string;
|
||||||
|
left: string;
|
||||||
|
delay: number;
|
||||||
|
}) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }}
|
||||||
|
animate={{
|
||||||
|
scale: [0.8, 1.2, 0.8],
|
||||||
|
opacity: [0.3, 0.5, 0.3],
|
||||||
|
x: [0, 30, 0],
|
||||||
|
y: [0, -30, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
filter: "blur(60px)",
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
parseAssistantMessageSections,
|
||||||
|
parseContentWithToolCalls,
|
||||||
|
} from "./chatMessageSections";
|
||||||
|
|
||||||
|
describe("parseAssistantMessageSections", () => {
|
||||||
|
it("returns plain assistant content when there is no thought block", () => {
|
||||||
|
expect(parseAssistantMessageSections("直接回答")).toEqual({
|
||||||
|
answer: "直接回答",
|
||||||
|
thought: null,
|
||||||
|
thoughtComplete: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts a completed thought block and keeps the final answer visible", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantMessageSections("<think>先分析需求</think>\n\n最终回答"),
|
||||||
|
).toEqual({
|
||||||
|
answer: "最终回答",
|
||||||
|
thought: "先分析需求",
|
||||||
|
thoughtComplete: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports streaming thought content before the closing tag arrives", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantMessageSections("准备中...\n<think>继续推理中"),
|
||||||
|
).toEqual({
|
||||||
|
answer: "准备中...",
|
||||||
|
thought: "继续推理中",
|
||||||
|
thoughtComplete: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges multiple thought blocks into a single collapsed section", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantMessageSections(
|
||||||
|
"<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\n答案结尾",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
answer: "答案开头\n\n答案结尾",
|
||||||
|
thought: "第一段思考\n\n第二段思考",
|
||||||
|
thoughtComplete: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseContentWithToolCalls", () => {
|
||||||
|
it("returns a single text segment when there are no tool calls", () => {
|
||||||
|
const result = parseContentWithToolCalls("普通文本回答");
|
||||||
|
expect(result.segments).toEqual([
|
||||||
|
{ type: "text", content: "普通文本回答" },
|
||||||
|
]);
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a complete tool_call block", () => {
|
||||||
|
const content =
|
||||||
|
'分析完成。\n<tool_call>{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(1);
|
||||||
|
expect(result.toolCalls[0].tool).toBe("locate_junctions");
|
||||||
|
expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] });
|
||||||
|
|
||||||
|
expect(result.segments).toHaveLength(3);
|
||||||
|
expect(result.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "分析完成。",
|
||||||
|
});
|
||||||
|
expect(result.segments[1]).toMatchObject({
|
||||||
|
type: "tool_call",
|
||||||
|
toolCall: { tool: "locate_junctions" },
|
||||||
|
});
|
||||||
|
expect(result.segments[2]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "以上是结果。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multiple tool_call blocks", () => {
|
||||||
|
const content =
|
||||||
|
'文本1\n<tool_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(2);
|
||||||
|
expect(result.toolCalls[0].tool).toBe("locate_pipes");
|
||||||
|
expect(result.toolCalls[1].tool).toBe("chart");
|
||||||
|
expect(result.segments).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects an unclosed tool_call tag as pending (streaming)", () => {
|
||||||
|
const content = '正在分析...\n<tool_call>{"tool":"locate_no';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.segments).toHaveLength(2);
|
||||||
|
expect(result.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "正在分析...",
|
||||||
|
});
|
||||||
|
expect(result.segments[1]).toEqual({ type: "tool_call_pending" });
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips partial opening tags during streaming", () => {
|
||||||
|
const content = "正在分析...\n<tool_c";
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.segments).toHaveLength(1);
|
||||||
|
expect(result.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "正在分析...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed JSON gracefully", () => {
|
||||||
|
const content =
|
||||||
|
'前文\n<tool_call>{invalid json}</tool_call>\n后文';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
// Malformed tool call is treated as text
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
expect(result.segments.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty segments for empty content", () => {
|
||||||
|
const result = parseContentWithToolCalls("");
|
||||||
|
expect(result.segments).toHaveLength(0);
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user