feat: 添加后端部署和API基本检索的Docker Compose配置
- 引入新的`docker-compose.yml`文件,便于后端和MQTT代理(EMQX)的部署。 - 更新多个组件中的API基本检索,使用函数进行动态解析。 - 加强了多个组件中API调用的错误处理和日志记录。 - 优化了AI聊天面板离线场景的回退响应。 - 更新DataScreen和MediaScreen组件中的数据显示和统计,以反映准确的指标。 - 重构MQTT连接逻辑,以支持动态凭证和客户端ID。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import Icon from './Icons';
|
||||
import { API_BASE } from '../config';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
import { getMqttStatus } from '../utils/mqtt';
|
||||
import { renderMarkdown } from '../voice/markdown.jsx';
|
||||
|
||||
@@ -19,12 +19,12 @@ const QUICK_QUESTIONS = [
|
||||
'切换到数字孪生页面',
|
||||
];
|
||||
|
||||
/* 离线兜底回复(后端不可用时) */
|
||||
/* 离线兜底回复(后端不可用时 · 口径按《2026年7月统计表》/《附件1省级材料》/《宣传册》) */
|
||||
const FALLBACK_REPLIES = [
|
||||
'园区提供「空间 + 孵化 + 融资 + 政策 + 资源 + AI 赋能 + 综合服务」七位一体服务,在园项目 158 家,累计带动就业 2,186 人,累计营收 2.08 亿元。',
|
||||
'入驻流程:提交申请 → 项目评审 → 签订协议 → 安排场地并对接导师服务。在园项目 158 家,累计孵化 208 余家。',
|
||||
'园区政策覆盖场地租金减免、创业补贴、税费优惠与高企认定申报辅导(在园高企 32 家)。',
|
||||
'园区按成长阶段分三个区域:加速区(A1–A12)、国际区(I1–I7)、成长区(G1–G8),共 39 家入驻企业。',
|
||||
'园区提供「空间 + 孵化 + 融资 + 政策 + 资源 + AI 赋能 + 综合服务」七位一体服务。按官方统计(2026年7月):实际入驻企业 38 家,当年累计带动就业 213 人,当年生产经营总额 252.15 万元。',
|
||||
'入驻流程(按《入驻管理规程》):提出申请(入驻申请表、创业计划书、身份证、学历证明等材料)→ 项目初审 → 入驻评审(导师库抽 3 名专家、路演答辩、100 分制 80 分通过)→ 评审公示(市人社局官网)→ 签订《入驻孵化协议》、安排场地工位。',
|
||||
'园区政策服务覆盖:免费办公空间、孵化辅导、融资对接、政策申报、资源整合、AI 专项培训、工商注册与法律咨询等一站式服务;每年原则上组织两次入驻。',
|
||||
'园区按发展阶段设四大孵化区域(《宣传册》):OPC创业空间(AI轻资产、最长2年)、创业苗圃区(未注册团队、最长2年)、孵化加速区(已注册企业、基础2年可延1年)、国际创客区(留学生、参照加速区)。',
|
||||
];
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function AiChatPanel() {
|
||||
.filter((m) => m.role === 'me' || (m.role === 'ai' && !/我是「问问小园」/.test(m.text)))
|
||||
.map((m) => ({ role: m.role === 'me' ? 'user' : 'assistant', content: m.text }));
|
||||
history.push({ role: 'user', content: text });
|
||||
const res = await fetch(`${API_BASE}/api/ai/chat`, {
|
||||
const res = await fetch(`${API_BASE()}/api/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: history }),
|
||||
@@ -183,7 +183,7 @@ export default function AiChatPanel() {
|
||||
const form = new FormData();
|
||||
form.append('file', blob, `recording.${fmt === 'mp4' ? 'm4a' : fmt}`);
|
||||
form.append('format', fmt === 'mp4' ? 'm4a' : fmt);
|
||||
const res = await fetch(`${API_BASE}/api/ai/asr`, { method: 'POST', body: form });
|
||||
const res = await fetch(`${API_BASE()}/api/ai/asr`, { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
const text = (data && data.text || '').trim();
|
||||
if (text) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Icon from './Icons';
|
||||
import { API_BASE } from '../config';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
DpmOverlays —— 后端/AI 指令驱动的全局覆盖层
|
||||
@@ -16,17 +16,17 @@ function CardContent({ card }) {
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (type === 'companies') {
|
||||
fetch(`${API_BASE}/api/park/companies`)
|
||||
fetch(`${API_BASE()}/api/park/companies`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => alive && setData(d.companies || []))
|
||||
.catch(() => alive && setData([]));
|
||||
} else if (type === 'zones') {
|
||||
fetch(`${API_BASE}/api/park/zones`)
|
||||
fetch(`${API_BASE()}/api/park/zones`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => alive && setData(d.zones || []))
|
||||
.catch(() => alive && setData([]));
|
||||
} else if (type === 'overview') {
|
||||
fetch(`${API_BASE}/api/dashboard/snapshot`)
|
||||
fetch(`${API_BASE()}/api/dashboard/snapshot`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => alive && setData(d))
|
||||
.catch(() => alive && setData(null));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Icon from './Icons';
|
||||
import { API_BASE } from '../config';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
AI 助手 · 预设问题面板(右侧)
|
||||
@@ -17,7 +17,7 @@ export default function PromptPanel({ onSend }) {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/ai/questions`, { cache: 'no-store' });
|
||||
const res = await fetch(`${API_BASE()}/api/ai/questions`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
|
||||
+34
-8
@@ -1,20 +1,46 @@
|
||||
/* =========================================================
|
||||
运行配置 —— 后端地址 / MQTT Broker
|
||||
优先级:window.__DPM_*__ 全局变量 > Vite 环境变量(.env.local)> 内置默认
|
||||
部署时在项目根目录 .env.local 配置(已 gitignore):
|
||||
VITE_API_BASE=http://192.168.x.x:18003
|
||||
VITE_MQTT_URL=ws://192.168.x.x:8083/mqtt
|
||||
VITE_MQTT_USERNAME=...
|
||||
VITE_MQTT_PASSWORD=...
|
||||
优先级:window.__DPM_*__ 全局变量(运行时可被 /api/config 引导覆盖)
|
||||
> Vite 环境变量(.env.local,构建时)> 内置默认
|
||||
内置默认(打包部署):Tauri 内 WebView 的 hostname 为 tauri.localhost,
|
||||
后端与 MQTT Broker 若与展播程序同机部署,回退到 127.0.0.1;
|
||||
浏览器开发/局域网访问时回退到当前主机 hostname。
|
||||
========================================================= */
|
||||
|
||||
const env = import.meta.env || {};
|
||||
|
||||
export const API_BASE = window.__DPM_API__ || env.VITE_API_BASE || 'http://192.168.1.9:10085';
|
||||
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || 'ws://192.168.1.3:8083/mqtt';
|
||||
/* 运行期解析(惰性读取 window.__DPM_*__,供 main.jsx bootstrap 覆盖后再生效) */
|
||||
function resolveHost() {
|
||||
const h = window.location.hostname || '127.0.0.1';
|
||||
// Tauri 打包 WebView(tauri.localhost / localhost)→ 服务同机部署用 127.0.0.1
|
||||
if (h === 'tauri.localhost' || h === 'localhost' || h === '127.0.0.1' || h.includes('tauri')) {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
return h; // 浏览器开发/局域网:当前主机 IP
|
||||
}
|
||||
|
||||
export const API_BASE = window.__DPM_API__ || env.VITE_API_BASE || `http://${resolveHost()}:10085`;
|
||||
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || `ws://${resolveHost()}:8083/mqtt`;
|
||||
export const MQTT_USERNAME = window.__DPM_MQTT_USER__ || env.VITE_MQTT_USERNAME || '';
|
||||
export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD || '';
|
||||
|
||||
/** 惰性读取 API 基址(允许 bootstrap 从后端 /api/config 覆盖后再生效) */
|
||||
export function getApiBase() {
|
||||
return window.__DPM_API__ || API_BASE;
|
||||
}
|
||||
|
||||
/** 惰性读取 MQTT 地址(允许 bootstrap 覆盖后再生效) */
|
||||
export function getMqttUrl() {
|
||||
return window.__DPM_MQTT__ || MQTT_URL;
|
||||
}
|
||||
|
||||
export function getMqttCredentials() {
|
||||
return {
|
||||
username: window.__DPM_MQTT_USER__ || MQTT_USERNAME,
|
||||
password: window.__DPM_MQTT_PASS__ || MQTT_PASSWORD,
|
||||
};
|
||||
}
|
||||
|
||||
export const MQTT_TOPIC_COMMAND = 'opc/display/command';
|
||||
export const MQTT_TOPIC_TICK = 'opc/dashboard/tick';
|
||||
export const MQTT_TOPIC_HEARTBEAT = 'opc/display/heartbeat';
|
||||
|
||||
+33
-7
@@ -2,12 +2,38 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider";
|
||||
import { API_BASE } from "./config";
|
||||
import "./styles/global.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
/* =========================================================
|
||||
启动引导(打包部署关键):
|
||||
渲染前先从后端 GET /api/config 拉取运行时配置
|
||||
(MQTT 地址/账号、API 基址),写入 window.__DPM_*__,
|
||||
之后 api.js / mqtt.js 均以惰性读取方式生效。
|
||||
后端不可达时静默使用内置默认(Tauri 打包回退 127.0.0.1)。
|
||||
========================================================= */
|
||||
async function bootstrapRuntimeConfig() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE()}/api/config`, { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
const cfg = await res.json();
|
||||
// 仅覆盖 MQTT 连接信息(后端 .env 为准);API 基址以前端实际可达地址为准
|
||||
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
|
||||
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
|
||||
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
|
||||
// eslint-disable-next-line no-console
|
||||
if (cfg.mqtt_url) console.info(`[bootstrap] MQTT -> ${cfg.mqtt_url}`);
|
||||
} catch {
|
||||
/* 后端不可达:使用内置默认地址 */
|
||||
}
|
||||
}
|
||||
|
||||
bootstrapRuntimeConfig().then(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,15 +8,18 @@ import { useParkSim } from '../utils/parkData';
|
||||
昆明市大学生创业园 · OPC 智能园区数字运营中心
|
||||
—— 数据大屏(真实数据展示)
|
||||
---------------------------------------------------------
|
||||
数据口径(用户指定,仅两个权威来源):
|
||||
· 《昆明市大学生创业园运行情况统计表(2026年7月).xls》
|
||||
实际入驻 39 家 / 可容纳 49 个 / 累计投入 560 万 /
|
||||
带动就业 213 人 / 累计生产经营总额 252.15 万 / 累计税利 1.03 万 / 面积 3000㎡
|
||||
数据口径(标准资料,多个权威来源):
|
||||
· 《昆明市大学生创业园运行情况统计表(2026年7月).xls》(官方统计)
|
||||
实际入驻 38 家 / 可容纳 49 个 / 累计投入 560 万 /
|
||||
当年累计带动就业 213 人 / 当年生产经营总额 252.15 万 / 当年税利 1.03 万 / 面积 3000㎡
|
||||
· 《附件1.省级创业孵化载体相关情况简介材料》(省级绩效累计口径,截至2026年5月)
|
||||
累计孵化 181 家 / 在孵 31 家 / 累计就业 1,529 人 / 累计经营收入 21,911.14 万
|
||||
· 《昆明市创业园宣传册.pdf》
|
||||
2009 年成立 / 2012 年省级创业园 / 民航路 229 号 / 四大孵化区域 / 28 家企业名录
|
||||
测算口径(用户确认):
|
||||
2009 年成立 / 2012 年省级创业园 / 民航路 229 号 / 四大孵化区域
|
||||
· 《入驻企业信息表》39 家名录(含创始人/简介/场地安排)
|
||||
测算口径(AI 平台业务锚点,非政府统计口径):
|
||||
· TOKEN:月均 200 亿 tokens → 日均 ≈ 6.67 亿,实时 ≈ 7,700 t/s,近30日累计 200 亿
|
||||
· 工具调用:按每次调用约 2 万 tokens 折算(今日 ≈ 3.3 万次)
|
||||
· 工具调用:按每次调用约 15 万 tokens 折算
|
||||
TOKEN/工具为基于真实锚点的测算演示,其余为真实统计值
|
||||
========================================================= */
|
||||
|
||||
|
||||
+62
-13
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as api from '../utils/api';
|
||||
import { getApiBase } from '../config';
|
||||
import { API_BASE } from '../utils/api';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { enable, disable } from '@tauri-apps/plugin-autostart';
|
||||
@@ -58,7 +59,8 @@ export default function MediaScreen() {
|
||||
useEffect(() => { soundBlockedRef.current = soundBlocked; }, [soundBlocked]);
|
||||
|
||||
const getUrl = useCallback((item) => {
|
||||
return item.source === 'url' ? item.relative_path : `${API_BASE}/file/${item.relative_path}`;
|
||||
// 惰性读取 API 基址:打包部署引导覆盖后媒体文件地址同步生效
|
||||
return item.source === 'url' ? item.relative_path : `${getApiBase()}/file/${item.relative_path}`;
|
||||
}, []);
|
||||
|
||||
// ============ 核心播放控制 ============
|
||||
@@ -106,14 +108,19 @@ export default function MediaScreen() {
|
||||
const url = getUrl(item);
|
||||
video.src = url;
|
||||
video.load();
|
||||
// 打包部署:WebView2 已加 --autoplay-policy=no-user-gesture-required,
|
||||
// 这里仍先尝试带声音播放;仅真实自动播放策略拦截(NotAllowedError)才进入静音待解锁
|
||||
video.muted = soundBlocked;
|
||||
video.volume = volume;
|
||||
const p = video.play();
|
||||
if (p !== undefined) {
|
||||
p.catch(() => {
|
||||
setSoundBlocked(true);
|
||||
video.muted = true;
|
||||
video.play().catch(() => {});
|
||||
p.catch((err) => {
|
||||
if (err && (err.name === 'NotAllowedError' || /autoplay|gesture/i.test(String(err.message || '')))) {
|
||||
setSoundBlocked(true);
|
||||
video.muted = true;
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
// 其他错误(网络/解码等)不强制静音,等重试
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -132,15 +139,54 @@ export default function MediaScreen() {
|
||||
if (!item || item.type !== 'video') return;
|
||||
const video = videoRef.current;
|
||||
if (video && video.paused && video.src) {
|
||||
video.play().catch(() => {
|
||||
setSoundBlocked(true);
|
||||
video.muted = true;
|
||||
video.play().catch(() => {});
|
||||
video.play().catch((err) => {
|
||||
if (err && (err.name === 'NotAllowedError' || /autoplay|gesture/i.test(String(err.message || '')))) {
|
||||
setSoundBlocked(true);
|
||||
video.muted = true;
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paused]);
|
||||
|
||||
// ============ 声音自动恢复(打包部署无需人工点击) ============
|
||||
// 1) 视频真正开始播放(playing)→ 解除静音标记
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const onPlaying = () => {
|
||||
setSoundBlocked(false);
|
||||
if (video.muted && !soundBlockedRef.current) {
|
||||
video.muted = false;
|
||||
}
|
||||
};
|
||||
video.addEventListener('playing', onPlaying);
|
||||
return () => video.removeEventListener('playing', onPlaying);
|
||||
}, [mediaKey, loaded]);
|
||||
|
||||
// 2) 若因自动播放策略被静音,每 3 秒自动尝试解静音播放(无需用户点击)
|
||||
useEffect(() => {
|
||||
if (!loaded) return undefined;
|
||||
const iv = setInterval(() => {
|
||||
if (!soundBlockedRef.current) return;
|
||||
const video = videoRef.current;
|
||||
if (!video || !video.src) return;
|
||||
video.muted = false;
|
||||
const p = video.play();
|
||||
if (p !== undefined) {
|
||||
p.then(() => {
|
||||
setSoundBlocked(false);
|
||||
const hint = document.getElementById('soundHint');
|
||||
if (hint) hint.style.display = 'none';
|
||||
}).catch(() => {
|
||||
video.muted = true;
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(iv);
|
||||
}, [loaded]);
|
||||
|
||||
// ============ 事件监听 ============
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -176,9 +222,12 @@ export default function MediaScreen() {
|
||||
testPlay.then(() => {
|
||||
setSoundBlocked(false);
|
||||
video.pause();
|
||||
}).catch(() => {
|
||||
setSoundBlocked(true);
|
||||
video.muted = true;
|
||||
}).catch((err) => {
|
||||
if (err && (err.name === 'NotAllowedError' || /autoplay|gesture/i.test(String(err.message || '')))) {
|
||||
setSoundBlocked(true);
|
||||
video.muted = true;
|
||||
}
|
||||
// 其他错误不强制静音(自动重试由 3s 定时器接管)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -188,7 +237,7 @@ export default function MediaScreen() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`${API_BASE}/api/events`);
|
||||
const es = new EventSource(`${getApiBase()}/api/events`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js';
|
||||
import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js';
|
||||
import { API_BASE } from '../config';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
import '../styles/datascreen.css';
|
||||
import './voice-original.css';
|
||||
import './voice-overrides.css';
|
||||
@@ -222,7 +222,7 @@ export default function VoiceAssistant() {
|
||||
pushMessage('assistant', `🔧 调用工具:${name}`, false);
|
||||
let output;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/tools/exec`, {
|
||||
const res = await fetch(`${API_BASE()}/api/tools/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, args }),
|
||||
@@ -303,7 +303,7 @@ export default function VoiceAssistant() {
|
||||
// 基础提示词:从后端拉取(配置 + 园区知识库已由服务端组装);失败用极简兜底
|
||||
let instructions = FALLBACK_INSTRUCTIONS;
|
||||
try {
|
||||
const insRes = await fetch(`${API_BASE}/api/s2s/instructions`, { cache: 'no-store' });
|
||||
const insRes = await fetch(`${API_BASE()}/api/s2s/instructions`, { cache: 'no-store' });
|
||||
const insData = await insRes.json();
|
||||
if (insData?.ok && insData.instructions) instructions = insData.instructions;
|
||||
} catch { /* 后端不可用不影响对话 */ }
|
||||
@@ -452,7 +452,7 @@ export default function VoiceAssistant() {
|
||||
} else {
|
||||
void start(); // start 连接成功后检查 pendingGreetingRef 再发问候
|
||||
}
|
||||
fetch(`${API_BASE}/api/vision/event`, {
|
||||
fetch(`${API_BASE()}/api/vision/event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ event: 'triggered', faces: vision.faces, dwell_ms: VISION_DWELL_MS }),
|
||||
@@ -466,7 +466,7 @@ export default function VoiceAssistant() {
|
||||
// 摄像头识别状态变化 → 上报后端(详细日志定位)
|
||||
useEffect(() => {
|
||||
if (!vision.status || vision.status === 'off' || vision.status === 'loading') return;
|
||||
fetch(`${API_BASE}/api/vision/event`, {
|
||||
fetch(`${API_BASE()}/api/vision/event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
+12
-10
@@ -1,10 +1,12 @@
|
||||
// API 基地址 —— 独立 FastAPI 后端(配置见 src/config.js,可用 window.__DPM_API__ 覆盖)
|
||||
import { API_BASE as BASE } from '../config';
|
||||
import { getApiBase } from '../config';
|
||||
|
||||
export const API_BASE = BASE;
|
||||
/* 惰性读取 API 基址:允许 main.jsx 引导阶段从后端 /api/config 覆盖(打包部署时生效) */
|
||||
export const API_BASE = getApiBase();
|
||||
const base = () => getApiBase();
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
const res = await fetch(`${base()}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
@@ -19,7 +21,7 @@ function formEncode(data) {
|
||||
|
||||
// ============ Auth ============
|
||||
export async function login(username, password) {
|
||||
const res = await fetch(`${BASE}/api/login`, {
|
||||
const res = await fetch(`${base()}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formEncode({ username, password }),
|
||||
@@ -29,7 +31,7 @@ export async function login(username, password) {
|
||||
|
||||
// ============ Settings ============
|
||||
export async function getSettings() {
|
||||
const res = await fetch(`${BASE}/api/settings`);
|
||||
const res = await fetch(`${base()}/api/settings`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ export async function updateSettings(data) {
|
||||
|
||||
// ============ Media Files ============
|
||||
export async function getMediaFiles() {
|
||||
const res = await fetch(`${BASE}/media`);
|
||||
const res = await fetch(`${base()}/media`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ export async function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch(`${BASE}/upload`, { method: 'POST', body: fd });
|
||||
const res = await fetch(`${base()}/upload`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error(`上传失败 (${res.status})`);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +62,7 @@ export async function uploadFilesWithProgress(files, onProgress) {
|
||||
const file = files[i];
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${BASE}/upload`);
|
||||
xhr.open('POST', `${base()}/upload`);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
@@ -109,7 +111,7 @@ export async function deleteMedia(path) {
|
||||
|
||||
// ============ Playlist ============
|
||||
export async function getPlaylist() {
|
||||
const res = await fetch(`${BASE}/api/playlist`);
|
||||
const res = await fetch(`${base()}/api/playlist`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ export async function sendControl(action) {
|
||||
}
|
||||
|
||||
export async function getState() {
|
||||
const res = await fetch(`${BASE}/api/state`);
|
||||
const res = await fetch(`${base()}/api/state`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
+19
-6
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import mqtt from 'mqtt';
|
||||
import { MQTT_URL, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK, MQTT_USERNAME, MQTT_PASSWORD } from '../config';
|
||||
import { getMqttUrl, getMqttCredentials, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
MQTT 客户端(mqtt.js)—— 后端控制通道
|
||||
@@ -17,18 +17,31 @@ function notifyStatus() {
|
||||
window.dispatchEvent(new CustomEvent('dpm:mqtt-status', { detail: { status } }));
|
||||
}
|
||||
|
||||
/* 每个连接生成全局唯一 client_id(独立客户端 ID 铁律):
|
||||
优先 crypto.randomUUID(WebView2/现代浏览器),回退时间戳+双段高熵随机 */
|
||||
function makeClientId() {
|
||||
try {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||||
return `dpm-screen-${crypto.randomUUID().replace(/-/g, '')}`;
|
||||
}
|
||||
} catch { /* 继续回退 */ }
|
||||
return `dpm-screen-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 12)}-${Math.random().toString(16).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function connectMqtt() {
|
||||
if (client) return;
|
||||
try {
|
||||
// 每个页面会话生成唯一 client_id(时间戳 + 高熵随机),避免多屏/多实例竞争
|
||||
clientId = `dpm-screen-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 12)}`;
|
||||
client = mqtt.connect(MQTT_URL, {
|
||||
// 独立客户端 ID:避免多屏/多实例/多标签页同 ID 被 broker 互相踢下线
|
||||
clientId = makeClientId();
|
||||
// 连接时惰性读取 MQTT 地址/账号:打包部署时 main.jsx 引导已从后端 /api/config 覆盖
|
||||
const { username, password } = getMqttCredentials();
|
||||
client = mqtt.connect(getMqttUrl(), {
|
||||
reconnectPeriod: 3000,
|
||||
connectTimeout: 8000,
|
||||
clientId,
|
||||
clean: true,
|
||||
username: MQTT_USERNAME || undefined,
|
||||
password: MQTT_PASSWORD || undefined,
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
});
|
||||
client.on('connect', () => {
|
||||
status = 'connected';
|
||||
|
||||
+13
-13
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_BASE } from '../config';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
园区数据引擎(共享)—— 供数据大屏 / 媒体播放页复用
|
||||
@@ -15,19 +15,19 @@ import { API_BASE } from '../config';
|
||||
export const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN');
|
||||
export const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1));
|
||||
|
||||
/* ---------- 真实锚点(仅来自 xls 统计表 + 宣传册) ---------- */
|
||||
/* ---------- 真实锚点(官方统计《2026年7月运行情况统计表》+ 附件1省级材料 + 宣传册) ---------- */
|
||||
const REAL = {
|
||||
tokenMonthlyYi: 200, // 月均 200 亿 tokens(真实业务锚点)
|
||||
tokenDailyWan: 66666.7, // 日均 6.67 亿 tokens
|
||||
tokenRate: 7700, // 实时 t/s
|
||||
toolsPerCallTokens: 150000, // 平均每次工具调用消耗 tokens(真实测算:12.5M输入 ÷ 81步 ≈ 15.4万)
|
||||
occupied: 39, // 实际入驻企业数(信息表名单 39 家,统一口径)
|
||||
tokenMonthlyYi: 200, // 月均 200 亿 tokens(AI 平台业务锚点,非政府统计口径)
|
||||
tokenDailyWan: 66666.7, // 日均 6.67 亿 tokens(AI 平台锚点)
|
||||
tokenRate: 7700, // 实时 t/s(AI 平台锚点)
|
||||
toolsPerCallTokens: 150000, // 平均每次工具调用消耗 tokens(AI 平台测算)
|
||||
occupied: 38, // 实际入驻企业数(官方口径:《2026年7月运行情况统计表》38 家)
|
||||
capacity: 49, // 可容纳企业个数(统计表)
|
||||
invested: 560, // 累计投入运营资金(万元)
|
||||
jobs: 213, // 累计带动就业(统计表)
|
||||
revenueTotal: 252.15, // 累计生产经营总额(万元)
|
||||
revenueTax: 1.03, // 累计上缴税利(万元)
|
||||
area: 3000, // 建筑面积(㎡)
|
||||
invested: 560, // 开园以来累计投入运营资金(万元,统计表)
|
||||
jobs: 213, // 带动就业人数(统计表,当年累计口径)
|
||||
revenueTotal: 252.15, // 生产经营总额(万元,统计表,当年累计口径)
|
||||
revenueTax: 1.03, // 上缴税利总额(万元,统计表,当年累计口径)
|
||||
area: 3000, // 建筑面积(㎡,统计表+宣传册)
|
||||
founded: 2009, // 成立年份(宣传册)
|
||||
provinceLevel: 2012, // 省级创业园认定年份(宣传册)
|
||||
address: '昆明市民航路229号(人力资源中心1-2楼)',
|
||||
@@ -212,7 +212,7 @@ export function useParkSim() {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/dashboard/snapshot`, { cache: 'no-store' });
|
||||
const res = await fetch(`${API_BASE()}/api/dashboard/snapshot`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!cancelled && data && data.token) setSnap(data);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- 帧经局域网传到后端(大屏机同机部署则不出设备);画面预览仍本地
|
||||
========================================================= */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { API_BASE } from '../config';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
|
||||
export const VISION_DWELL_MS = 10000; // 面向停留阈值
|
||||
export const VISION_SILENT_MS = 180000; // 触发(自动问候)后静默 3 分钟,期间不重复触发
|
||||
@@ -107,7 +107,7 @@ export default function useVisionDetection({ onTrigger, onGesture, enabled = tru
|
||||
// JPEG base64 → 后端 YOLO
|
||||
const b64 = canvasRef.current.toDataURL('image/jpeg', 0.6).split(',')[1];
|
||||
setModelPhase('ok');
|
||||
const res = await fetch(`${API_BASE}/api/vision/frame`, {
|
||||
const res = await fetch(`${API_BASE()}/api/vision/frame`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: b64 }),
|
||||
|
||||
Reference in New Issue
Block a user