Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b4a4157c | ||
|
|
efd04fd651 | ||
|
|
5aa28c8409 | ||
|
|
8b6dda08e6 | ||
|
|
427cbe70b3 | ||
|
|
6410df0cb7 | ||
|
|
ff5cbfde9c | ||
|
|
5cbf1e82f8 | ||
|
|
259202ca8f | ||
|
|
bfa4020239 |
@@ -7,10 +7,10 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/"
|
|||||||
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
|
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
|
||||||
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
|
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
|
||||||
NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn"
|
NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn"
|
||||||
|
NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
|
||||||
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
|
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
|
||||||
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
|
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
|
||||||
NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25"
|
NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25"
|
||||||
# NEXT_PUBLIC_MAP_AVAILABLE_LAYERS="junctions, pipes, reservoirs, scada"
|
|
||||||
NEXT_PUBLIC_NETWORK_NAME="tjwater"
|
NEXT_PUBLIC_NETWORK_NAME="tjwater"
|
||||||
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"
|
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"
|
||||||
NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec"
|
NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: Build Push and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-image:
|
||||||
|
runs-on: ubuntu
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY_HOST }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and Push Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ github.repository }}:latest
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_BACKEND_URL=${{ vars.NEXT_PUBLIC_BACKEND_URL }}
|
||||||
|
NEXT_PUBLIC_COPILOT_URL=${{ vars.NEXT_PUBLIC_COPILOT_URL }}
|
||||||
|
NEXT_PUBLIC_AUDIO_SERVICE_URL=${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}
|
||||||
|
NEXT_PUBLIC_MAP_URL=${{ vars.NEXT_PUBLIC_MAP_URL }}
|
||||||
|
NEXT_PUBLIC_MAP_WORKSPACE=${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}
|
||||||
|
NEXT_PUBLIC_MAP_EXTENT=${{ vars.NEXT_PUBLIC_MAP_EXTENT }}
|
||||||
|
NEXT_PUBLIC_NETWORK_NAME=${{ vars.NEXT_PUBLIC_NETWORK_NAME }}
|
||||||
|
NEXT_PUBLIC_MAPBOX_TOKEN=${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}
|
||||||
|
NEXT_PUBLIC_TIANDITU_TOKEN=${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}
|
||||||
|
|
||||||
|
- name: Notify Deploy Server
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
|
||||||
|
-d "{\"image\":\"${IMAGE}\",\"tag\":\"${{ github.ref_name }}\",\"repo\":\"${{ github.repository }}\"}"
|
||||||
|
|
||||||
|
deploy-fallback-log:
|
||||||
|
runs-on: ubuntu
|
||||||
|
needs: docker-image
|
||||||
|
if: failure()
|
||||||
|
steps:
|
||||||
|
- name: Deployment not triggered
|
||||||
|
run: echo "Image build/push failed, deployment webhook was not called."
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: Build and Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [24.x]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Package Source Code
|
|
||||||
run: |
|
|
||||||
tar --warning=no-file-changed -czf source-code.tar.gz \
|
|
||||||
--exclude='node_modules' \
|
|
||||||
--exclude='.next' \
|
|
||||||
--exclude='dist' \
|
|
||||||
--exclude='source-code.tar.gz' \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Upload Source Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: source-code
|
|
||||||
path: source-code.tar.gz
|
|
||||||
@@ -18,6 +18,8 @@ FROM base AS builder
|
|||||||
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
|
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
|
||||||
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
|
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
|
||||||
ARG NEXT_PUBLIC_BACKEND_URL
|
ARG NEXT_PUBLIC_BACKEND_URL
|
||||||
|
ARG NEXT_PUBLIC_COPILOT_URL
|
||||||
|
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
|
||||||
ARG NEXT_PUBLIC_MAP_URL
|
ARG NEXT_PUBLIC_MAP_URL
|
||||||
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
||||||
ARG NEXT_PUBLIC_MAP_EXTENT
|
ARG NEXT_PUBLIC_MAP_EXTENT
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: ${IMAGE_NAME:-refinedev/tjwater-frontend:latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
|
||||||
|
NEXT_PUBLIC_COPILOT_URL: ${NEXT_PUBLIC_COPILOT_URL}
|
||||||
|
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
|
||||||
|
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
|
||||||
|
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
|
||||||
|
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
|
||||||
|
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
|
||||||
|
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
|
||||||
|
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
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
|
||||||
@@ -182,15 +182,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "爆管模拟",
|
label: "爆管模拟",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "爆管定位",
|
|
||||||
list: "/hydraulic-simulation/burst-location",
|
|
||||||
meta: {
|
|
||||||
parent: "Hydraulic Simulation",
|
|
||||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
|
||||||
label: "爆管定位",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "爆管侦测",
|
name: "爆管侦测",
|
||||||
list: "/hydraulic-simulation/burst-detection",
|
list: "/hydraulic-simulation/burst-detection",
|
||||||
@@ -200,6 +191,15 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "爆管侦测",
|
label: "爆管侦测",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "爆管定位",
|
||||||
|
list: "/hydraulic-simulation/burst-location",
|
||||||
|
meta: {
|
||||||
|
parent: "Hydraulic Simulation",
|
||||||
|
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||||
|
label: "爆管定位",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "DMA 漏损识别",
|
name: "DMA 漏损识别",
|
||||||
list: "/hydraulic-simulation/dma-leak-detection",
|
list: "/hydraulic-simulation/dma-leak-detection",
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import config from "@/config/config";
|
||||||
import type { SpeechState } from "./GlobalChatbox.types";
|
import type { SpeechState } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
type AudioStreamStartResponse = {
|
||||||
|
stream_id?: string;
|
||||||
|
audio_url?: string;
|
||||||
|
status_url?: string;
|
||||||
|
result_url?: string;
|
||||||
|
sample_rate?: number;
|
||||||
|
channels?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AudioStreamStatusResponse = {
|
||||||
|
state?: "starting" | "running" | "done" | "failed" | "closed";
|
||||||
|
ready?: boolean;
|
||||||
|
failed?: boolean;
|
||||||
|
closed?: boolean;
|
||||||
|
status_text?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AudioStreamResultResponse = {
|
||||||
|
run_status?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// WebKit Speech Recognition compatibility
|
// WebKit Speech Recognition compatibility
|
||||||
interface SpeechRecognitionEvent extends Event {
|
interface SpeechRecognitionEvent extends Event {
|
||||||
readonly resultIndex: number;
|
readonly resultIndex: number;
|
||||||
@@ -29,70 +54,534 @@ declare global {
|
|||||||
new (): SpeechRecognition;
|
new (): SpeechRecognition;
|
||||||
prototype: SpeechRecognition;
|
prototype: SpeechRecognition;
|
||||||
};
|
};
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpeechSynthesis() {
|
export function useSpeechSynthesis() {
|
||||||
const [speechState, setSpeechState] = useState<SpeechState>("idle");
|
const [speechState, setSpeechState] = useState<SpeechState>("idle");
|
||||||
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
||||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const streamAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const activeSourceNodesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
|
||||||
|
const streamIdRef = useRef<string | null>(null);
|
||||||
|
const closeUrlRef = useRef<string | null>(null);
|
||||||
|
const statusUrlRef = useRef<string | null>(null);
|
||||||
|
const resultUrlRef = useRef<string | null>(null);
|
||||||
|
const statusPollTimeoutRef = useRef<number | null>(null);
|
||||||
|
const playbackTokenRef = useRef(0);
|
||||||
|
|
||||||
const isSupported = typeof window !== "undefined" && "speechSynthesis" in window;
|
const isSupported =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.FormData !== "undefined" &&
|
||||||
|
(typeof window.AudioContext !== "undefined" ||
|
||||||
|
typeof window.webkitAudioContext !== "undefined");
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const trimTrailingSlash = useCallback((value: string) => value.replace(/\/+$/, ""), []);
|
||||||
if (!isSupported) return;
|
|
||||||
window.speechSynthesis.cancel();
|
const buildServiceUrl = useCallback(
|
||||||
utteranceRef.current = null;
|
(path: string) => `${trimTrailingSlash(config.AUDIO_SERVICE_URL)}${path.startsWith("/") ? path : `/${path}`}`,
|
||||||
|
[trimTrailingSlash],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveServiceUrl = useCallback(
|
||||||
|
(pathOrUrl: string) => {
|
||||||
|
if (/^https?:\/\//i.test(pathOrUrl)) {
|
||||||
|
return pathOrUrl;
|
||||||
|
}
|
||||||
|
return buildServiceUrl(pathOrUrl);
|
||||||
|
},
|
||||||
|
[buildServiceUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const withQueryParams = useCallback(
|
||||||
|
(urlString: string, params: Record<string, string>) => {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const readErrorMessage = useCallback(async (response: Response, fallback: string) => {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string; message?: string };
|
||||||
|
return payload.error || payload.message || fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeStream = useCallback(async (closeUrl: string) => {
|
||||||
|
const response = await fetch(closeUrl, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("[GlobalChatbox] Failed to close audio stream:", closeUrl);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopStatusPolling = useCallback(() => {
|
||||||
|
if (statusPollTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(statusPollTimeoutRef.current);
|
||||||
|
statusPollTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStreamResult = useCallback(
|
||||||
|
async (resultUrl: string) => {
|
||||||
|
const response = await fetch(resultUrl);
|
||||||
|
if (response.status === 202) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readErrorMessage(
|
||||||
|
response,
|
||||||
|
`Audio stream result failed with status ${response.status}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as AudioStreamResultResponse;
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[readErrorMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAudio = useCallback(async () => {
|
||||||
|
const abortController = streamAbortControllerRef.current;
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
abortController?.abort();
|
||||||
|
|
||||||
|
activeSourceNodesRef.current.forEach((source) => {
|
||||||
|
try {
|
||||||
|
source.onended = null;
|
||||||
|
source.stop();
|
||||||
|
} catch {
|
||||||
|
// ignore stop errors when source already ended
|
||||||
|
}
|
||||||
|
source.disconnect();
|
||||||
|
});
|
||||||
|
activeSourceNodesRef.current.clear();
|
||||||
|
|
||||||
|
const audioContext = audioContextRef.current;
|
||||||
|
audioContextRef.current = null;
|
||||||
|
if (!audioContext) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await audioContext.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close errors when context already closed
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playPcmStream = useCallback(
|
||||||
|
async ({
|
||||||
|
audioUrl,
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
playbackToken,
|
||||||
|
}: {
|
||||||
|
audioUrl: string;
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
playbackToken: number;
|
||||||
|
}) => {
|
||||||
|
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
|
||||||
|
if (!AudioContextCtor) {
|
||||||
|
throw new Error("WebAudio AudioContext is not available in this browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
streamAbortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
const response = await fetch(withQueryParams(audioUrl, { format: "pcm" }), {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readErrorMessage(response, `Audio stream failed with status ${response.status}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Audio stream response body is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContext = new AudioContextCtor({
|
||||||
|
sampleRate,
|
||||||
|
});
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const bytesPerFrame = Math.max(1, channels) * 2;
|
||||||
|
let bufferedRemainder = new Uint8Array(0);
|
||||||
|
let nextStartTime = audioContext.currentTime + 0.05;
|
||||||
|
let activeSources = 0;
|
||||||
|
let streamEnded = false;
|
||||||
|
let resolvePlaybackDrain: (() => void) | null = null;
|
||||||
|
const playbackDrainPromise = new Promise<void>((resolve) => {
|
||||||
|
resolvePlaybackDrain = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maybeResolvePlaybackDrain = () => {
|
||||||
|
if (streamEnded && activeSources === 0) {
|
||||||
|
resolvePlaybackDrain?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePcmChunk = (pcmBytes: Uint8Array) => {
|
||||||
|
const frameCount = pcmBytes.byteLength / bytesPerFrame;
|
||||||
|
if (frameCount <= 0) return;
|
||||||
|
|
||||||
|
const buffer = audioContext.createBuffer(Math.max(1, channels), frameCount, sampleRate);
|
||||||
|
const view = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength);
|
||||||
|
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||||
|
for (let channel = 0; channel < Math.max(1, channels); channel += 1) {
|
||||||
|
const sampleIndex = frame * Math.max(1, channels) + channel;
|
||||||
|
const pcm = view.getInt16(sampleIndex * 2, true);
|
||||||
|
buffer.getChannelData(channel)[frame] = pcm / 32768;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
const sourceStartTime = Math.max(nextStartTime, audioContext.currentTime + 0.01);
|
||||||
|
nextStartTime = sourceStartTime + buffer.duration;
|
||||||
|
|
||||||
|
activeSources += 1;
|
||||||
|
activeSourceNodesRef.current.add(source);
|
||||||
|
source.onended = () => {
|
||||||
|
activeSources -= 1;
|
||||||
|
activeSourceNodesRef.current.delete(source);
|
||||||
|
source.disconnect();
|
||||||
|
maybeResolvePlaybackDrain();
|
||||||
|
};
|
||||||
|
source.start(sourceStartTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const concatUint8Arrays = (a: Uint8Array, b: Uint8Array) => {
|
||||||
|
if (a.byteLength === 0) return b;
|
||||||
|
if (b.byteLength === 0) return a;
|
||||||
|
const merged = new Uint8Array(a.byteLength + b.byteLength);
|
||||||
|
merged.set(a);
|
||||||
|
merged.set(b, a.byteLength);
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (playbackToken !== playbackTokenRef.current) {
|
||||||
|
throw new DOMException("PCM stream playback cancelled", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (!value || value.byteLength === 0) continue;
|
||||||
|
|
||||||
|
const merged = concatUint8Arrays(bufferedRemainder, value);
|
||||||
|
const alignedByteLength = merged.byteLength - (merged.byteLength % bytesPerFrame);
|
||||||
|
if (alignedByteLength === 0) {
|
||||||
|
bufferedRemainder = new Uint8Array(merged);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignedChunk = merged.slice(0, alignedByteLength);
|
||||||
|
bufferedRemainder = new Uint8Array(merged.slice(alignedByteLength));
|
||||||
|
schedulePcmChunk(alignedChunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamEnded = true;
|
||||||
|
maybeResolvePlaybackDrain();
|
||||||
|
await playbackDrainPromise;
|
||||||
|
},
|
||||||
|
[readErrorMessage, withQueryParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopPlayback = useCallback(async () => {
|
||||||
|
await clearAudio();
|
||||||
|
stopStatusPolling();
|
||||||
|
|
||||||
|
const closeUrl = closeUrlRef.current;
|
||||||
|
streamIdRef.current = null;
|
||||||
|
closeUrlRef.current = null;
|
||||||
|
statusUrlRef.current = null;
|
||||||
|
resultUrlRef.current = null;
|
||||||
setSpeechState("idle");
|
setSpeechState("idle");
|
||||||
setSpeakingMessageId(null);
|
setSpeakingMessageId(null);
|
||||||
}, [isSupported]);
|
|
||||||
|
if (closeUrl) {
|
||||||
|
try {
|
||||||
|
await closeStream(closeUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to close audio stream:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [clearAudio, closeStream, stopStatusPolling]);
|
||||||
|
|
||||||
|
const pollStreamStatus = useCallback(
|
||||||
|
(playbackToken: number, statusUrl: string, resultUrl: string) => {
|
||||||
|
stopStatusPolling();
|
||||||
|
|
||||||
|
statusPollTimeoutRef.current = window.setTimeout(async () => {
|
||||||
|
if (
|
||||||
|
playbackToken !== playbackTokenRef.current ||
|
||||||
|
statusUrlRef.current !== statusUrl ||
|
||||||
|
resultUrlRef.current !== resultUrl
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(statusUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readErrorMessage(
|
||||||
|
response,
|
||||||
|
`Audio stream status failed with status ${response.status}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as AudioStreamStatusResponse;
|
||||||
|
if (
|
||||||
|
playbackToken !== playbackTokenRef.current ||
|
||||||
|
statusUrlRef.current !== statusUrl ||
|
||||||
|
resultUrlRef.current !== resultUrl
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.failed || payload.state === "failed") {
|
||||||
|
console.error(
|
||||||
|
"[GlobalChatbox] Audio stream failed:",
|
||||||
|
payload.error || payload.status_text || statusUrl,
|
||||||
|
);
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
void stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.closed || payload.state === "closed") {
|
||||||
|
stopStatusPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.ready || payload.state === "done") {
|
||||||
|
try {
|
||||||
|
const isResultReady = await fetchStreamResult(resultUrl);
|
||||||
|
if (isResultReady) {
|
||||||
|
stopStatusPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
playbackToken === playbackTokenRef.current &&
|
||||||
|
statusUrlRef.current === statusUrl &&
|
||||||
|
resultUrlRef.current === resultUrl
|
||||||
|
) {
|
||||||
|
console.error("[GlobalChatbox] Failed to poll audio stream status:", error);
|
||||||
|
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
[fetchStreamResult, readErrorMessage, stopPlayback, stopStatusPolling],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
void stopPlayback();
|
||||||
|
}, [stopPlayback]);
|
||||||
|
|
||||||
const speak = useCallback(
|
const speak = useCallback(
|
||||||
(messageId: string, text: string) => {
|
async (messageId: string, text: string) => {
|
||||||
if (!isSupported || !text) return;
|
const normalizedText = text.trim();
|
||||||
window.speechSynthesis.cancel();
|
if (!isSupported || !normalizedText) return;
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(text);
|
const playbackToken = playbackTokenRef.current + 1;
|
||||||
utterance.lang = "zh-CN";
|
playbackTokenRef.current = playbackToken;
|
||||||
utterance.rate = 1;
|
await stopPlayback();
|
||||||
utterance.onend = () => {
|
|
||||||
setSpeechState("idle");
|
|
||||||
setSpeakingMessageId(null);
|
|
||||||
utteranceRef.current = null;
|
|
||||||
};
|
|
||||||
utterance.onerror = () => {
|
|
||||||
setSpeechState("idle");
|
|
||||||
setSpeakingMessageId(null);
|
|
||||||
utteranceRef.current = null;
|
|
||||||
};
|
|
||||||
utterance.onpause = () => setSpeechState("paused");
|
|
||||||
utterance.onresume = () => setSpeechState("playing");
|
|
||||||
|
|
||||||
utteranceRef.current = utterance;
|
|
||||||
setSpeakingMessageId(messageId);
|
setSpeakingMessageId(messageId);
|
||||||
setSpeechState("playing");
|
setSpeechState("playing");
|
||||||
window.speechSynthesis.speak(utterance);
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("text", normalizedText);
|
||||||
|
formData.append("demo_id", "demo-1");
|
||||||
|
|
||||||
|
const response = await fetch(buildServiceUrl("/api/generate-stream/start"), {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readErrorMessage(
|
||||||
|
response,
|
||||||
|
`Audio stream start failed with status ${response.status}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as AudioStreamStartResponse;
|
||||||
|
const streamId = payload.stream_id;
|
||||||
|
const sampleRate =
|
||||||
|
typeof payload.sample_rate === "number" && payload.sample_rate > 0
|
||||||
|
? payload.sample_rate
|
||||||
|
: 24000;
|
||||||
|
const channels =
|
||||||
|
typeof payload.channels === "number" && payload.channels > 0
|
||||||
|
? payload.channels
|
||||||
|
: 1;
|
||||||
|
const audioUrl = payload.audio_url
|
||||||
|
? resolveServiceUrl(payload.audio_url)
|
||||||
|
: buildServiceUrl(
|
||||||
|
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/audio?format=pcm`,
|
||||||
|
);
|
||||||
|
const rawStatusUrl = payload.status_url
|
||||||
|
? resolveServiceUrl(payload.status_url)
|
||||||
|
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/status`);
|
||||||
|
const statusUrl = withQueryParams(rawStatusUrl, { compact: "1" });
|
||||||
|
const rawResultUrl = payload.result_url
|
||||||
|
? resolveServiceUrl(payload.result_url)
|
||||||
|
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/result`);
|
||||||
|
const resultUrl = withQueryParams(rawResultUrl, {
|
||||||
|
compact: "1",
|
||||||
|
include_audio: "0",
|
||||||
|
});
|
||||||
|
const closeUrl = buildServiceUrl(
|
||||||
|
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/close`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!streamId) {
|
||||||
|
throw new Error(payload.error || "Audio stream start response is missing stream_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackToken !== playbackTokenRef.current) {
|
||||||
|
await closeStream(closeUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamIdRef.current = streamId;
|
||||||
|
closeUrlRef.current = closeUrl;
|
||||||
|
statusUrlRef.current = statusUrl;
|
||||||
|
resultUrlRef.current = resultUrl;
|
||||||
|
|
||||||
|
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
||||||
|
await playPcmStream({
|
||||||
|
audioUrl,
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
playbackToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (playbackToken !== playbackTokenRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearAudio();
|
||||||
|
if (streamIdRef.current === streamId) {
|
||||||
|
streamIdRef.current = null;
|
||||||
|
closeUrlRef.current = null;
|
||||||
|
statusUrlRef.current = null;
|
||||||
|
resultUrlRef.current = null;
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
}
|
||||||
|
stopStatusPolling();
|
||||||
|
await fetchStreamResult(resultUrl).catch((error) => {
|
||||||
|
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
|
||||||
|
});
|
||||||
|
await closeStream(closeUrl);
|
||||||
|
} catch (error) {
|
||||||
|
await clearAudio();
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "AbortError" &&
|
||||||
|
playbackToken !== playbackTokenRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const closeUrl = closeUrlRef.current;
|
||||||
|
streamIdRef.current = null;
|
||||||
|
closeUrlRef.current = null;
|
||||||
|
statusUrlRef.current = null;
|
||||||
|
resultUrlRef.current = null;
|
||||||
|
setSpeechState("idle");
|
||||||
|
setSpeakingMessageId(null);
|
||||||
|
if (closeUrl) {
|
||||||
|
try {
|
||||||
|
await closeStream(closeUrl);
|
||||||
|
} catch (closeError) {
|
||||||
|
console.error("[GlobalChatbox] Failed to close audio stream:", closeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error("[GlobalChatbox] Failed to play audio stream:", error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[isSupported],
|
[
|
||||||
|
buildServiceUrl,
|
||||||
|
clearAudio,
|
||||||
|
closeStream,
|
||||||
|
fetchStreamResult,
|
||||||
|
isSupported,
|
||||||
|
playPcmStream,
|
||||||
|
readErrorMessage,
|
||||||
|
resolveServiceUrl,
|
||||||
|
pollStreamStatus,
|
||||||
|
stopPlayback,
|
||||||
|
stopStatusPolling,
|
||||||
|
withQueryParams,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
if (!isSupported) return;
|
if (!isSupported || !audioContextRef.current) return;
|
||||||
window.speechSynthesis.pause();
|
void audioContextRef.current.suspend().then(
|
||||||
|
() => {
|
||||||
|
setSpeechState("paused");
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("[GlobalChatbox] Failed to pause PCM playback:", error);
|
||||||
|
},
|
||||||
|
);
|
||||||
}, [isSupported]);
|
}, [isSupported]);
|
||||||
|
|
||||||
const resume = useCallback(() => {
|
const resume = useCallback(() => {
|
||||||
if (!isSupported) return;
|
if (!isSupported || !audioContextRef.current) return;
|
||||||
window.speechSynthesis.resume();
|
void audioContextRef.current.resume().then(
|
||||||
}, [isSupported]);
|
() => {
|
||||||
|
setSpeechState("playing");
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
playbackTokenRef.current += 1;
|
||||||
|
void stopPlayback();
|
||||||
|
console.error("[GlobalChatbox] Failed to resume audio playback:", error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [isSupported, stopPlayback]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (typeof window !== "undefined" && "speechSynthesis" in window) {
|
playbackTokenRef.current += 1;
|
||||||
window.speechSynthesis.cancel();
|
void stopPlayback();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, [stopPlayback]);
|
||||||
|
|
||||||
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
|
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import AnalysisParameters from "./AnalysisParameters";
|
import AnalysisParameters from "./AnalysisParameters";
|
||||||
import DetectionResults from "./DetectionResults";
|
import DetectionResults from "./DetectionResults";
|
||||||
import SchemeQuery from "./SchemeQuery";
|
import SchemeQuery from "./SchemeQuery";
|
||||||
import { BurstDetectionResult } from "./types";
|
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
value,
|
value,
|
||||||
@@ -32,6 +32,7 @@ const BurstDetectionPanel: React.FC = () => {
|
|||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
||||||
|
const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
||||||
|
|
||||||
const drawerWidth = 450;
|
const drawerWidth = 450;
|
||||||
const panelTitle = "爆管侦测";
|
const panelTitle = "爆管侦测";
|
||||||
@@ -139,7 +140,7 @@ const BurstDetectionPanel: React.FC = () => {
|
|||||||
<AnalysisParameters onResult={handleResult} />
|
<AnalysisParameters onResult={handleResult} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={1}>
|
<TabPanel value={tab} index={1}>
|
||||||
<SchemeQuery onViewResult={handleResult} />
|
<SchemeQuery onViewResult={handleResult} schemes={schemes} onSchemesChange={setSchemes} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={2}>
|
<TabPanel value={tab} index={2}>
|
||||||
<DetectionResults result={result} />
|
<DetectionResults result={result} />
|
||||||
|
|||||||
@@ -31,15 +31,19 @@ import {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: BurstDetectionResult) => void;
|
onViewResult: (result: BurstDetectionResult) => void;
|
||||||
|
schemes?: BurstDetectionSchemeRecord[];
|
||||||
|
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchemeQuery: React.FC<Props> = ({ onViewResult }) => {
|
const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [queryAll, setQueryAll] = useState(true);
|
const [queryAll, setQueryAll] = useState(true);
|
||||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
||||||
const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
|
||||||
const buildDisplayResult = (
|
const buildDisplayResult = (
|
||||||
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
|
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||||
@@ -88,11 +92,12 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get("/api/v1/burst-detection/schemes/", { params });
|
const response = await api.get("/api/v1/burst-detection/schemes/", { params });
|
||||||
setSchemes(response.data);
|
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
|
||||||
|
setSchemes(nextSchemes);
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
description: `共找到 ${response.data.length} 条侦测记录。`,
|
description: `共找到 ${nextSchemes.length} 条侦测记录。`,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import AnalysisParameters from "./AnalysisParameters";
|
import AnalysisParameters from "./AnalysisParameters";
|
||||||
import LocationResults from "./LocationResults";
|
import LocationResults from "./LocationResults";
|
||||||
import SchemeQuery from "./SchemeQuery";
|
import SchemeQuery from "./SchemeQuery";
|
||||||
import { BurstLocationResult } from "./types";
|
import { BurstLocationResult, BurstSchemeRecord } from "./types";
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
value,
|
value,
|
||||||
@@ -32,6 +32,7 @@ const BurstLocationPanel: React.FC = () => {
|
|||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
||||||
|
const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||||
|
|
||||||
const drawerWidth = 450;
|
const drawerWidth = 450;
|
||||||
const panelTitle = "爆管定位";
|
const panelTitle = "爆管定位";
|
||||||
@@ -148,7 +149,7 @@ const BurstLocationPanel: React.FC = () => {
|
|||||||
<AnalysisParameters onResult={handleResult} />
|
<AnalysisParameters onResult={handleResult} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={1}>
|
<TabPanel value={tab} index={1}>
|
||||||
<SchemeQuery onViewResult={handleViewResult} />
|
<SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={2}>
|
<TabPanel value={tab} index={2}>
|
||||||
<LocationResults result={result} />
|
<LocationResults result={result} />
|
||||||
|
|||||||
@@ -32,15 +32,19 @@ import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: BurstLocationResult) => void;
|
onViewResult: (result: BurstLocationResult) => void;
|
||||||
|
schemes?: BurstSchemeRecord[];
|
||||||
|
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchemeQuery: React.FC<Props> = ({ onViewResult }) => {
|
const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [queryAll, setQueryAll] = useState(true);
|
const [queryAll, setQueryAll] = useState(true);
|
||||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
||||||
const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
|
||||||
const buildDisplayResult = (
|
const buildDisplayResult = (
|
||||||
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
|
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||||
@@ -87,11 +91,12 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get(url, { params });
|
const response = await api.get(url, { params });
|
||||||
setSchemes(response.data);
|
const nextSchemes = response.data as BurstSchemeRecord[];
|
||||||
|
setSchemes(nextSchemes);
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
description: `共找到 ${response.data.length} 条记录`,
|
description: `共找到 ${nextSchemes.length} 条记录`,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -122,17 +122,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSchemes(
|
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
||||||
filteredResults.map((item: SchemaItem) => ({
|
id: item.scheme_id,
|
||||||
id: item.scheme_id,
|
schemeName: item.scheme_name,
|
||||||
schemeName: item.scheme_name,
|
type: item.scheme_type,
|
||||||
type: item.scheme_type,
|
user: item.username,
|
||||||
user: item.username,
|
create_time: item.create_time,
|
||||||
create_time: item.create_time,
|
startTime: item.scheme_start_time,
|
||||||
startTime: item.scheme_start_time,
|
schemeDetail: item.scheme_detail,
|
||||||
schemeDetail: item.scheme_detail,
|
}));
|
||||||
})),
|
setSchemes(nextSchemes);
|
||||||
);
|
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -195,17 +195,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSchemes(
|
const nextSchemes = filteredResults.map((item: ContaminantSchemaItem) => ({
|
||||||
filteredResults.map((item: ContaminantSchemaItem) => ({
|
id: item.scheme_id,
|
||||||
id: item.scheme_id,
|
schemeName: item.scheme_name,
|
||||||
schemeName: item.scheme_name,
|
type: item.scheme_type,
|
||||||
type: item.scheme_type,
|
user: item.username,
|
||||||
user: item.username,
|
create_time: item.create_time,
|
||||||
create_time: item.create_time,
|
startTime: item.scheme_start_time,
|
||||||
startTime: item.scheme_start_time,
|
schemeDetail: item.scheme_detail,
|
||||||
schemeDetail: item.scheme_detail,
|
}));
|
||||||
})),
|
setSchemes(nextSchemes);
|
||||||
);
|
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import ContaminantAnalysisParameters from "./AnalysisParameters";
|
import ContaminantAnalysisParameters from "./AnalysisParameters";
|
||||||
import ContaminantSchemeQuery from "./SchemeQuery";
|
import ContaminantSchemeQuery from "./SchemeQuery";
|
||||||
import { useData } from "@components/olmap/core/MapComponent";
|
import { useData } from "@components/olmap/core/MapComponent";
|
||||||
|
import { ContaminantSchemeRecord } from "./types";
|
||||||
|
|
||||||
interface WaterQualityPanelProps {
|
interface WaterQualityPanelProps {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -32,6 +33,7 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [internalOpen, setInternalOpen] = useState(true);
|
const [internalOpen, setInternalOpen] = useState(true);
|
||||||
const [currentTab, setCurrentTab] = useState(0);
|
const [currentTab, setCurrentTab] = useState(0);
|
||||||
|
const [schemes, setSchemes] = useState<ContaminantSchemeRecord[]>([]);
|
||||||
|
|
||||||
const data = useData();
|
const data = useData();
|
||||||
|
|
||||||
@@ -172,7 +174,11 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={1}>
|
<TabPanel value={currentTab} index={1}>
|
||||||
<ContaminantSchemeQuery onViewResults={() => setCurrentTab(2)} />
|
<ContaminantSchemeQuery
|
||||||
|
schemes={schemes}
|
||||||
|
onSchemesChange={setSchemes}
|
||||||
|
onViewResults={() => setCurrentTab(2)}
|
||||||
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import AnalysisParameters from "./AnalysisParameters";
|
|||||||
import SchemeQuery from "./SchemeQuery";
|
import SchemeQuery from "./SchemeQuery";
|
||||||
import RecognitionResults from "./RecognitionResults";
|
import RecognitionResults from "./RecognitionResults";
|
||||||
import { getAreaColor } from "./utils";
|
import { getAreaColor } from "./utils";
|
||||||
import { LeakageResultDetail } from "./types";
|
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
@@ -52,6 +52,7 @@ const DMALeakDetectionPanel: React.FC = () => {
|
|||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<LeakageResultDetail | null>(null);
|
const [result, setResult] = useState<LeakageResultDetail | null>(null);
|
||||||
const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null);
|
const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null);
|
||||||
|
const [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]);
|
||||||
|
|
||||||
const drawerWidth = 450;
|
const drawerWidth = 450;
|
||||||
const panelTitle = "DMA 漏损识别";
|
const panelTitle = "DMA 漏损识别";
|
||||||
@@ -277,7 +278,7 @@ const DMALeakDetectionPanel: React.FC = () => {
|
|||||||
<AnalysisParameters onResult={handleAnalysisResult} />
|
<AnalysisParameters onResult={handleAnalysisResult} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={1}>
|
<TabPanel value={tab} index={1}>
|
||||||
<SchemeQuery onViewResult={handleViewResult} />
|
<SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={2}>
|
<TabPanel value={tab} index={2}>
|
||||||
<RecognitionResults result={result} />
|
<RecognitionResults result={result} />
|
||||||
|
|||||||
@@ -28,15 +28,19 @@ import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: LeakageResultDetail) => void;
|
onViewResult: (result: LeakageResultDetail) => void;
|
||||||
|
schemes?: LeakageSchemeRecord[];
|
||||||
|
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchemeQuery: React.FC<Props> = ({ onViewResult }) => {
|
const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [queryAll, setQueryAll] = useState(true);
|
const [queryAll, setQueryAll] = useState(true);
|
||||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
||||||
const [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
|
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -48,7 +52,8 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => {
|
|||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
setSchemes(response.data);
|
const nextSchemes = response.data as LeakageSchemeRecord[];
|
||||||
|
setSchemes(nextSchemes);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00");
|
const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
@@ -242,7 +242,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
// but axios usually handles array as valves[]=1&valves[]=2
|
// but axios usually handles array as valves[]=1&valves[]=2
|
||||||
// FastAPI default expects repeated query params.
|
// FastAPI default expects repeated query params.
|
||||||
|
|
||||||
const response = await api.get(`${config.BACKEND_URL}/flushing_analysis/`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing_analysis/`, {
|
||||||
params,
|
params,
|
||||||
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { MdCleaningServices } from "react-icons/md";
|
import { MdCleaningServices } from "react-icons/md";
|
||||||
import AnalysisParameters from "./AnalysisParameters";
|
import AnalysisParameters from "./AnalysisParameters";
|
||||||
import SchemeQuery from "./SchemeQuery";
|
import SchemeQuery from "./SchemeQuery";
|
||||||
|
import { SchemeRecord } from "./types";
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -51,6 +52,7 @@ const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [internalOpen, setInternalOpen] = useState(true);
|
const [internalOpen, setInternalOpen] = useState(true);
|
||||||
const [currentTab, setCurrentTab] = useState(0);
|
const [currentTab, setCurrentTab] = useState(0);
|
||||||
|
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||||
|
|
||||||
// Using controlled or uncontrolled state
|
// Using controlled or uncontrolled state
|
||||||
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||||
@@ -183,7 +185,7 @@ const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={1}>
|
<TabPanel value={currentTab} index={1}>
|
||||||
<SchemeQuery />
|
<SchemeQuery schemes={schemes} onSchemesChange={setSchemes} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -238,17 +238,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSchemes(
|
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
||||||
filteredResults.map((item: SchemaItem) => ({
|
id: item.scheme_id,
|
||||||
id: item.scheme_id,
|
schemeName: item.scheme_name,
|
||||||
schemeName: item.scheme_name,
|
type: item.scheme_type,
|
||||||
type: item.scheme_type,
|
user: item.username,
|
||||||
user: item.username,
|
create_time: item.create_time,
|
||||||
create_time: item.create_time,
|
startTime: item.scheme_start_time,
|
||||||
startTime: item.scheme_start_time,
|
schemeDetail: item.scheme_detail,
|
||||||
schemeDetail: item.scheme_detail,
|
}));
|
||||||
})),
|
setSchemes(nextSchemes);
|
||||||
);
|
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -163,17 +163,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSchemes(
|
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
||||||
filteredResults.map((item: SchemaItem) => ({
|
id: item.id,
|
||||||
id: item.id,
|
schemeName: item.scheme_name,
|
||||||
schemeName: item.scheme_name,
|
sensorNumber: item.sensor_number,
|
||||||
sensorNumber: item.sensor_number,
|
minDiameter: item.min_diameter,
|
||||||
minDiameter: item.min_diameter,
|
user: item.username,
|
||||||
user: item.username,
|
create_time: item.create_time,
|
||||||
create_time: item.create_time,
|
sensorLocation: item.sensor_location,
|
||||||
sensorLocation: item.sensor_location,
|
}));
|
||||||
})),
|
setSchemes(nextSchemes);
|
||||||
);
|
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
+11
-5
@@ -1,6 +1,8 @@
|
|||||||
export const config = {
|
export const config = {
|
||||||
BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000",
|
BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000",
|
||||||
COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787",
|
COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787",
|
||||||
|
AUDIO_SERVICE_URL:
|
||||||
|
process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083",
|
||||||
MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver",
|
MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater",
|
MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater",
|
||||||
MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT
|
MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT
|
||||||
@@ -21,11 +23,15 @@ export const config = {
|
|||||||
8, // 在缩放级别 24 时,圆形半径为 8px
|
8, // 在缩放级别 24 时,圆形半径为 8px
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
MAP_AVAILABLE_LAYERS: process.env.NEXT_PUBLIC_MAP_AVAILABLE_LAYERS
|
MAP_AVAILABLE_LAYERS: [
|
||||||
? process.env.NEXT_PUBLIC_MAP_AVAILABLE_LAYERS.split(",").map((item) =>
|
"junctions",
|
||||||
item.trim().toLowerCase(),
|
"pipes",
|
||||||
)
|
"valves",
|
||||||
: ["junctions", "pipes", "valves", "reservoirs", "pumps", "tanks", "scada"],
|
"reservoirs",
|
||||||
|
"pumps",
|
||||||
|
"tanks",
|
||||||
|
"scada",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
export let NETWORK_NAME = process.env.NEXT_PUBLIC_NETWORK_NAME || "tjwater";
|
export let NETWORK_NAME = process.env.NEXT_PUBLIC_NETWORK_NAME || "tjwater";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
|||||||
type AuthContextHeaderOptions,
|
type AuthContextHeaderOptions,
|
||||||
} from "@/lib/requestHeaders";
|
} from "@/lib/requestHeaders";
|
||||||
|
|
||||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL;
|
export const API_URL = config.BACKEND_URL;
|
||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "esnext",
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user