feat(bigscreen): 任务展示页 + 暂时关闭语音对话页 + 导航/文档同步

- App.jsx 移除 /voice、新增 /tasks→TaskDisplay。
- pageNav PAGE_ORDER 移除 voice、加 tasks(方向键/页眉/MQTT navigate_rel 自动跟随)。
- useMqttControl:PAGE_ALIAS 加 tasks;移除 voice_*、改 task_refresh。
- PageHeader STATUS_BY_PATH 加 /tasks。
- 新增 TaskDisplay.jsx + task-display.css:轮播卡(分类/状态/标题/摘要/标签/预算/任务ID+QRCodeSVG 扫码接单)、useTasks 轮询 /park/api/tasks、接受 MQTT task_refresh。
- 新增 utils/taskData.js(useTasks, tenantFetch 带租户身份)。
- docs/mqtt-commands.md:navigate 列表/语音章节标注语音页关闭、tasks 页指令。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-25 18:42:32 +08:00
parent 1566477b97
commit d61b59acf5
13 changed files with 441 additions and 41 deletions
+5 -5
View File
@@ -42,7 +42,7 @@
| action | params | 效果 | | action | params | 效果 |
|---|---|---| |---|---|---|
| `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/voice" \| "/wall" \| "/screen"`(或别名 home/twin/ai/voice/wall/screen/数据大屏/企业展示墙…) | 切换到指定页面 | | `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/tasks" \| "/wall" \| "/screen"`(或别名 home/twin/ai/tasks/wall/screen/数据大屏/任务展示/企业展示墙…) | 切换到指定页面 |
| `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 | | `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 |
### 4.2 全局视觉识别 ### 4.2 全局视觉识别
@@ -60,13 +60,13 @@
| `ai_company` | `action: "next" \| "prev"` | 切换展示企业 | | `ai_company` | `action: "next" \| "prev"` | 切换展示企业 |
| `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 | | `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 |
### 4.4 语音对话页(/voice) ### 4.4 任务展示页(/tasks)
| action | params | 效果 | | action | params | 效果 |
|---|---|---| |---|---|---|
| `voice_start` | — | 启动语音对话 | | `task_refresh` | — | 触发任务页刷新重拉 |
| `voice_stop` | — | 关闭语音对话 |
| `voice_refresh` | — | 刷新页面 | > 语音对话页(/voice)已**暂时关闭**,`voice_start/stop/refresh` 不再处理。
### 4.5 媒体轮播页(/screen) ### 4.5 媒体轮播页(/screen)
+3 -3
View File
@@ -3,8 +3,8 @@ import MediaScreen from './pages/MediaScreen';
import DataScreen from './pages/DataScreen'; import DataScreen from './pages/DataScreen';
import DigitalTwin from './pages/DigitalTwin'; import DigitalTwin from './pages/DigitalTwin';
import AiAssistant from './pages/AiAssistant'; import AiAssistant from './pages/AiAssistant';
import VoiceAssistant from './pages/VoiceAssistant';
import CompanyWall from './pages/CompanyWall'; import CompanyWall from './pages/CompanyWall';
import TaskDisplay from './pages/TaskDisplay';
import ScreenLayout from './components/ScreenLayout'; import ScreenLayout from './components/ScreenLayout';
import './styles/tokens.css'; import './styles/tokens.css';
@@ -18,8 +18,8 @@ export default function App() {
<Route path="/twin" element={<DigitalTwin />} /> <Route path="/twin" element={<DigitalTwin />} />
<Route path="/ai" element={<AiAssistant />} /> <Route path="/ai" element={<AiAssistant />} />
<Route path="/screen" element={<MediaScreen />} /> <Route path="/screen" element={<MediaScreen />} />
{/* 实时语音对话页(React 原生,复用页眉) */} {/* 任务展示页(系统任务中心:轮播卡 + 二维码扫码接单) */}
<Route path="/voice" element={<VoiceAssistant />} /> <Route path="/tasks" element={<TaskDisplay />} />
{/* 入驻企业展示墙 */} {/* 入驻企业展示墙 */}
<Route path="/wall" element={<CompanyWall />} /> <Route path="/wall" element={<CompanyWall />} />
</Route> </Route>
+32 -5
View File
@@ -1,4 +1,6 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { unbindDevice } from '../utils/tenant';
/* ========================================================= /* =========================================================
AboutDialog —— 应用关于弹窗(PineSound 品牌) AboutDialog —— 应用关于弹窗(PineSound 品牌)
@@ -20,6 +22,8 @@ const APP_INFO = {
}; };
export default function AboutDialog({ open, onClose }) { export default function AboutDialog({ open, onClose }) {
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
// Esc 关闭 // Esc 关闭
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -27,9 +31,25 @@ export default function AboutDialog({ open, onClose }) {
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]); }, [open, onClose]);
// 关闭弹窗时重置确认/忙碌态,下次打开保持初始
useEffect(() => {
if (!open) { setConfirming(false); setBusy(false); }
}, [open]);
if (!open) return null; if (!open) return null;
return (
// 两步确认(不用 window.confirm —— Tauri WebView 的原生 confirm 不可靠/可能返回假值):
// 第一次点击进入确认态,再次点击才真正执行解绑;加载中禁止重复触发。
const handleUnbind = async () => {
if (busy) return;
if (!confirming) { setConfirming(true); return; }
setBusy(true);
await unbindDevice(); // 通知服务端解除 → 清本地 → 刷新回绑定页(内部有超时,不卡顿)
};
// Portal 到 body:页眉含 backdrop-filter,会成为 position:fixed 的包含块,
// 使弹窗叠在页眉盒子上;portal 到 body 后方能真正以视口为基准居中。
return createPortal(
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true"> <div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="about-modal" onClick={(e) => e.stopPropagation()}> <div className="about-modal" onClick={(e) => e.stopPropagation()}>
<button className="about-close" type="button" aria-label="关闭" onClick={onClose}>×</button> <button className="about-close" type="button" aria-label="关闭" onClick={onClose}>×</button>
@@ -50,10 +70,17 @@ export default function AboutDialog({ open, onClose }) {
</div> </div>
<div className="about-meta"> <div className="about-meta">
<span>版本 v{APP_INFO.version}</span> <div className="about-meta-info">
<span>{APP_INFO.copyright}</span> <span>版本 v{APP_INFO.version}</span>
<span>{APP_INFO.copyright}</span>
{confirming && !busy && <span className="about-unbind-hint">再次点击确认解除,点击空白处取消</span>}
</div>
<button className="about-unbind" type="button" disabled={busy} onClick={handleUnbind}>
{busy ? '解除中…' : confirming ? '确认解除绑定' : '解除绑定'}
</button>
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
+1
View File
@@ -19,6 +19,7 @@ const STATUS_BY_PATH = {
'/': '数据实时', '/': '数据实时',
'/twin': '3D 实时', '/twin': '3D 实时',
'/ai': 'AI 助手', '/ai': 'AI 助手',
'/tasks': '任务中心',
'/screen': '媒体播放', '/screen': '媒体播放',
}; };
+15 -8
View File
@@ -43,8 +43,15 @@ const TOOL_RANK = [
]; ];
/* ---------- 工具函数 ---------- */ /* ---------- 工具函数 ---------- */
const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN'); const fmtInt = (n) => (Number.isFinite(Number(n)) ? Math.round(Number(n)).toLocaleString('zh-CN') : '--');
const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1)); /** 万/亿格式化:非有限数值(缺失/不一致口径)安全显示 '--',避免崩溃 */
const fmtWan = (n) => {
const v = Number(n);
if (!Number.isFinite(v)) return '--';
return (v >= 10000 ? (v / 10000).toFixed(2) + '亿' : v >= 100 ? v.toFixed(1) + '万' : v.toFixed(1));
};
/** 安全取数:缺失/非数值回退 0,供直接 .toFixed 调用点使用 */
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
/* 数据源:后端 /api/dashboard/snapshot(离线时本地模拟兜底),见 utils/parkData.js */ /* 数据源:后端 /api/dashboard/snapshot(离线时本地模拟兜底),见 utils/parkData.js */
@@ -265,7 +272,7 @@ function TodayOverview({ d }) {
</div> </div>
<div className="bd-cell bd-cell-tools"> <div className="bd-cell bd-cell-tools">
<KpiCard tone="tool" icon="wrench" label="工具调用" value={d.tools.today} unit="次" <KpiCard tone="tool" icon="wrench" label="工具调用" value={d.tools.today} unit="次"
sub={`累计 ${(d.tools.total / 10000).toFixed(1)}万次 · 成功率 ${d.tools.success}%`} /> sub={`累计 ${(num(d.tools.total) / 10000).toFixed(1)}万次 · 成功率 ${d.tools.success}%`} />
</div> </div>
<div className="bd-cell bd-cell-jobs"> <div className="bd-cell bd-cell-jobs">
<KpiCard tone="job" icon="users" label="带动就业" value={d.jobs.total} unit="人" <KpiCard tone="job" icon="users" label="带动就业" value={d.jobs.total} unit="人"
@@ -294,10 +301,10 @@ function TokenBig({ d }) {
</div> </div>
} /> } />
<div className="bd-real-metrics"> <div className="bd-real-metrics">
<span>首 token 平均 <b>{d.live.firstToken.toFixed(1)}s</b></span> <span>首 token 平均 <b>{num(d.live.firstToken).toFixed(1)}s</b></span>
<span>吞吐 <b>{d.live.throughput}</b> tok/s</span> <span>吞吐 <b>{d.live.throughput}</b> tok/s</span>
<span>缓存命中 <b>{d.live.cacheHit.toFixed(1)}%</b></span> <span>缓存命中 <b>{num(d.live.cacheHit).toFixed(1)}%</b></span>
<span>会话输入 <b>{d.live.inputM.toFixed(1)}M</b> / 输出 <b>{d.live.outputM.toFixed(1)}M</b> tok</span> <span>会话输入 <b>{num(d.live.inputM).toFixed(1)}M</b> / 输出 <b>{num(d.live.outputM).toFixed(1)}M</b> tok</span>
<span><b>{d.live.rounds}</b> 轮 · <b>{d.live.steps}</b> 步</span> <span><b>{d.live.rounds}</b> 轮 · <b>{d.live.steps}</b> 步</span>
</div> </div>
<div className="bd-big-body"> <div className="bd-big-body">
@@ -338,7 +345,7 @@ function ToolsBig({ d }) {
<CardHead title="工具调用" icon="wrench" right={ <CardHead title="工具调用" icon="wrench" right={
<div className="bd-head-stats"> <div className="bd-head-stats">
<span className="bd-ost-chip"><i>今日调用</i><b>{fmtInt(d.tools.today)} 次</b></span> <span className="bd-ost-chip"><i>今日调用</i><b>{fmtInt(d.tools.today)} 次</b></span>
<span className="bd-ost-chip"><i>累计调用</i><b>{(d.tools.total / 10000).toFixed(1)}万 次</b></span> <span className="bd-ost-chip"><i>累计调用</i><b>{(num(d.tools.total) / 10000).toFixed(1)}万 次</b></span>
<span className="bd-ost-chip"><i>成功率</i><b>{d.tools.success}%</b></span> <span className="bd-ost-chip"><i>成功率</i><b>{d.tools.success}%</b></span>
<span className="bd-ost-chip"><i>服务节点</i><b>{d.tools.nodes} / {d.tools.nodes} 在线</b></span> <span className="bd-ost-chip"><i>服务节点</i><b>{d.tools.nodes} / {d.tools.nodes} 在线</b></span>
</div> </div>
@@ -346,7 +353,7 @@ function ToolsBig({ d }) {
<div className="bd-big-body"> <div className="bd-big-body">
<div className="bd-big-right bd-big-stats"> <div className="bd-big-right bd-big-stats">
<div className="bd-big-stat"><span>今日调用</span><b>{fmtInt(d.tools.today)}<em>次</em></b></div> <div className="bd-big-stat"><span>今日调用</span><b>{fmtInt(d.tools.today)}<em>次</em></b></div>
<div className="bd-big-stat"><span>累计调用</span><b>{(d.tools.total / 10000).toFixed(1)}<em>万次</em></b></div> <div className="bd-big-stat"><span>累计调用</span><b>{(num(d.tools.total) / 10000).toFixed(1)}<em>万次</em></b></div>
<div className="bd-big-stat"><span>成功率</span><b>{d.tools.success}<em>%</em></b></div> <div className="bd-big-stat"><span>成功率</span><b>{d.tools.success}<em>%</em></b></div>
<div className="bd-big-stat"><span>AI 服务节点</span><b>12<em>/12 在线</em></b></div> <div className="bd-big-stat"><span>AI 服务节点</span><b>12<em>/12 在线</em></b></div>
</div> </div>
+110
View File
@@ -0,0 +1,110 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { useTasks } from '../utils/taskData';
import '../styles/task-display.css';
/* 任务展示页:轮播卡片 + 二维码扫码接单。
· 数据:useTasks() 轮询 /park/api/tasks(系统任务中心上墙任务)
· 卡片:分类/状态徽标、标题、摘要(2行)、标签、预算、任务ID + 二维码(扫码即领)
· 轮播:每页若干张,自动翻页;MQTT task_refresh 可触发刷新 */
const PAGE_SIZE = 3;
const ROTATE_MS = 14_000;
export default function TaskDisplay() {
const { tasks, loading, error } = useTasks();
const [page, setPage] = useState(0);
const [refreshTick, setRefreshTick] = useState(0);
const tickRef = useRef(0);
// 接受 MQTT task_refresh → 触发重新轮询
useEffect(() => {
const onRefresh = () => { tickRef.current += 1; setRefreshTick(tickRef.current); };
window.addEventListener('dpm:task-control', onRefresh);
return () => window.removeEventListener('dpm:task-control', onRefresh);
}, []);
// 自动翻页(仅当任务多于一页时滚动)
const pageCount = Math.max(1, Math.ceil(tasks.length / PAGE_SIZE));
useEffect(() => {
if (pageCount <= 1) { setPage(0); return; }
const t = setInterval(() => setPage((p) => (p + 1) % pageCount), ROTATE_MS);
return () => clearInterval(t);
}, [pageCount]);
const visible = useMemo(() => {
if (tasks.length === 0) return [];
const start = page * PAGE_SIZE;
return Array.from({ length: PAGE_SIZE }, (_, i) => tasks[(start + i) % tasks.length]);
}, [tasks, page]);
return (
<div className="tasks-screen">
<div className="tasks-head">
<span className="tasks-head-dot" />
<span className="tasks-head-title">系统任务中心</span>
<span className="tasks-head-sub">扫码即可接单 · 园区企业发标 · 合伙人接单</span>
<span className="tasks-head-count">{tasks.length} 个任务</span>
</div>
{loading && tasks.length === 0 ? (
<div className="tasks-loading">正在加载任务…</div>
) : error && tasks.length === 0 ? (
<div className="tasks-loading">{error}</div>
) : (
<>
<div className="tasks-grid">
{visible.map((t) => (
<TaskCard key={t.key} task={t} />
))}
</div>
{pageCount > 1 && (
<div className="tasks-dots">
{Array.from({ length: pageCount }).map((_, i) => (
<span key={i} className={i === page ? 'tasks-dot on' : 'tasks-dot'} />
))}
</div>
)}
</>
)}
</div>
);
}
function TaskCard({ task }) {
const qrValue = task.scan_payload || task.task_code;
return (
<div className={task.claimed ? 'task-card claimed' : 'task-card'}>
<div className="task-top">
<span className="task-cat">{task.category}</span>
{task.claimed ? (
<span className="task-status taken">已接单</span>
) : (
<span className="task-status open">可接单</span>
)}
</div>
<div className="task-title">{task.title}</div>
{task.summary ? <p className="task-summary">{task.summary}</p> : null}
<div className="task-tags">
{task.tags.slice(0, 4).map((tg) => (
<span key={tg} className="task-tag">{tg}</span>
))}
</div>
<div className="task-budget">预算 {task.budgetLabel}</div>
<div className="task-foot">
<div className="task-meta">
<span className="task-id-label">任务ID</span>
<span className="task-id">{task.task_code || task.id}</span>
</div>
<div className="task-qr">
<QRCodeSVG value={qrValue || 'TASK'} size={72} fgColor="#1d2c44" level="M" />
<span className="task-qr-hint">扫码接单</span>
</div>
</div>
</div>
);
}
+35
View File
@@ -2996,6 +2996,8 @@
.about-modal { .about-modal {
position: relative; position: relative;
width: min(420px, calc(100vw - 48px)); width: min(420px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow-y: auto;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
padding: 28px 30px 22px; padding: 28px 30px 22px;
@@ -3087,10 +3089,43 @@
padding-top: 14px; padding-top: 14px;
border-top: 1px dashed #e3ecf7; border-top: 1px dashed #e3ecf7;
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px;
font-size: 11px; font-size: 11px;
color: #9fb0c6; color: #9fb0c6;
} }
.about-meta-info {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.4;
}
.about-unbind-hint {
margin-top: 3px;
color: #e5484d;
font-size: 11px;
}
.about-unbind {
flex: none;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid rgba(240, 68, 56, 0.35);
background: #fff;
color: #e5484d;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: all 0.2s;
}
.about-unbind:hover {
background: #fef2f2;
border-color: rgba(240, 68, 56, 0.6);
}
.about-unbind:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 页眉开发署名(© 图标 + 公司名 + 开发;空间不足自动折叠为两行) */ /* 页眉开发署名(© 图标 + 公司名 + 开发;空间不足自动折叠为两行) */
.bd-credit { .bd-credit {
+98
View File
@@ -0,0 +1,98 @@
/* ============ 任务展示页(系统任务中心 · 轮播卡 + 二维码扫码接单)============ */
.tasks-screen {
display: flex;
flex-direction: column;
gap: 18px;
padding: 24px 34px;
width: 100%;
height: 100%;
overflow: hidden;
background: radial-gradient(ellipse at 20% 0%, rgba(79, 135, 255, 0.08), transparent 55%),
radial-gradient(ellipse at 80% 100%, rgba(23, 178, 106, 0.06), transparent 55%);
}
.tasks-head {
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--bd-line, #e3ecf7);
padding-bottom: 14px;
}
.tasks-head-dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--bd-green, #17b26a);
box-shadow: 0 0 10px var(--bd-green, #17b26a);
}
.tasks-head-title { font-size: 22px; font-weight: 700; color: var(--bd-ink, #1d2c44); letter-spacing: 0.02em; }
.tasks-head-sub { font-size: 13px; color: var(--bd-sub, #7d8fa9); }
.tasks-head-count { margin-left: auto; font-size: 13px; color: var(--bd-blue, #2f6bff); font-weight: 600; }
.tasks-loading {
color: var(--bd-sub, #7d8fa9); font-size: 15px;
display: flex; align-items: center; justify-content: center; height: 60%;
}
.tasks-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
align-content: start;
overflow: hidden;
}
.task-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 20px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--bd-line, #e3ecf7);
backdrop-filter: blur(10px);
box-shadow: 0 8px 24px rgba(29, 44, 68, 0.06);
transition: all 0.25s ease;
}
.task-card:hover { transform: translateY(-3px); border-color: var(--bd-blue, #2f6bff); }
.task-card.claimed { background: rgba(255, 249, 235, 0.92); border-color: rgba(247, 144, 9, 0.4); }
.task-top { display: flex; align-items: center; gap: 8px; }
.task-cat {
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px;
background: rgba(47, 107, 255, 0.12); color: var(--bd-blue, #2f6bff);
}
.task-status { margin-left: auto; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
.task-status.open { background: rgba(23, 178, 106, 0.14); color: var(--bd-green, #17b26a); }
.task-status.taken { background: rgba(247, 144, 9, 0.16); color: var(--bd-orange, #f79009); }
.task-title { font-size: 18px; font-weight: 700; color: var(--bd-ink, #1d2c44); line-height: 1.3; }
.task-summary {
margin: 0; font-size: 13px; line-height: 1.55; color: var(--bd-sub, #7d8fa9);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.task-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.task-tag {
font-size: 11px; padding: 2px 8px; border-radius: 999px;
background: rgba(29, 44, 68, 0.06); color: var(--bd-ink, #1d2c44);
}
.task-budget { font-size: 15px; font-weight: 700; color: var(--bd-blue, #2f6bff); }
.task-foot {
margin-top: auto;
display: flex; align-items: flex-end; justify-content: space-between; gap: 12px;
border-top: 1px solid var(--bd-line, #e3ecf7); padding-top: 12px;
}
.task-meta { display: flex; flex-direction: column; gap: 4px; }
.task-id-label { font-size: 11px; color: var(--bd-sub, #7d8fa9); }
.task-id { font-size: 14px; font-weight: 600; color: var(--bd-ink, #1d2c44); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.task-qr { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.task-qr svg { border-radius: 8px; background: #fff; padding: 4px; }
.task-qr-hint { font-size: 11px; color: var(--bd-blue, #2f6bff); font-weight: 600; }
.task-dots { display: flex; justify-content: center; gap: 8px; padding-bottom: 6px; }
.task-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(29, 44, 68, 0.15); transition: all 0.25s; }
.task-dot.on { background: var(--bd-blue, #2f6bff); width: 20px; border-radius: 999px; }
+2 -2
View File
@@ -4,7 +4,7 @@ import { Sfx } from './sounds';
/* ========================================================= /* =========================================================
页面循环导航 —— 左右方向键切换大屏页面 页面循环导航 —— 左右方向键切换大屏页面
顺序:数据大屏 → 数字孪生 → AI 助手 → 媒体轮播(循环) 顺序:数据大屏 → 数字孪生 → AI 助手 → 任务展示 → 企业展示墙 → 媒体轮播(循环)
管理后台不参与循环切换,仅在登录后进入 管理后台不参与循环切换,仅在登录后进入
← 向左切换 · → 向右切换 ← 向左切换 · → 向右切换
========================================================= */ ========================================================= */
@@ -13,7 +13,7 @@ export const PAGE_ORDER = [
{ path: '/', label: '数据大屏' }, { path: '/', label: '数据大屏' },
{ path: '/twin', label: '数字孪生' }, { path: '/twin', label: '数字孪生' },
{ path: '/ai', label: 'AI 助手' }, { path: '/ai', label: 'AI 助手' },
{ path: '/voice', label: '语音对话' }, { path: '/tasks', label: '任务展示' },
{ path: '/wall', label: '企业展示墙' }, { path: '/wall', label: '企业展示墙' },
{ path: '/screen', label: '媒体轮播' }, { path: '/screen', label: '媒体轮播' },
]; ];
+36 -3
View File
@@ -13,8 +13,13 @@ import { tenantFetch } from './tenant';
工具调用:按每次调用约 2 万 tokens 折算(今日 ≈ 3.3 万次) 工具调用:按每次调用约 2 万 tokens 折算(今日 ≈ 3.3 万次)
========================================================= */ ========================================================= */
export const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN'); export const fmtInt = (n) => (Number.isFinite(Number(n)) ? Math.round(Number(n)).toLocaleString('zh-CN') : '--');
export const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1)); /** 万/亿格式化:非有限数值(缺失/不一致口径)安全显示 '--',避免崩溃 */
export const fmtWan = (n) => {
const v = Number(n);
if (!Number.isFinite(v)) return '--';
return (v >= 10000 ? (v / 10000).toFixed(2) + '亿' : v >= 100 ? v.toFixed(1) + '万' : v.toFixed(1));
};
/* ---------- 真实锚点(官方统计《2026年7月运行情况统计表》+ 附件1省级材料 + 宣传册) ---------- */ /* ---------- 真实锚点(官方统计《2026年7月运行情况统计表》+ 附件1省级材料 + 宣传册) ---------- */
const REAL = { const REAL = {
@@ -207,6 +212,34 @@ function nextSnapshot(s) {
}; };
} }
/* ---------- 后端快照归一化:字段缺失/为 null 时用本地真实锚点补齐,避免大屏崩溃 ---------- */
function mergeObj(base, src) {
if (!src || typeof src !== 'object') return base;
const out = { ...base };
for (const k of Object.keys(src)) if (src[k] !== undefined && src[k] !== null) out[k] = src[k];
return out;
}
function normalizeSnapshot(d) {
const base = initSnapshot(); // 完整本地默认(真实锚点),作为缺省兜底
if (!d || typeof d !== 'object') return base;
return {
...base,
...d,
live: mergeObj(base.live, d.live),
token: mergeObj(base.token, d.token),
tools: mergeObj(base.tools, d.tools),
projects: mergeObj(base.projects, d.projects),
jobs: mergeObj(base.jobs, d.jobs),
revenue: mergeObj(base.revenue, d.revenue),
cumulative: mergeObj(base.cumulative, d.cumulative),
park: mergeObj(base.park, d.park),
zones: Array.isArray(d.zones) ? d.zones : base.zones,
companies: Array.isArray(d.companies) ? d.companies : base.companies,
industryMix: Array.isArray(d.industryMix) ? d.industryMix : base.industryMix,
feed: Array.isArray(d.feed) ? d.feed : base.feed,
};
}
export function useParkSim() { export function useParkSim() {
const [snap, setSnap] = useState(initSnapshot); const [snap, setSnap] = useState(initSnapshot);
useEffect(() => { useEffect(() => {
@@ -216,7 +249,7 @@ export function useParkSim() {
const res = await tenantFetch('/api/dashboard/snapshot', { cache: 'no-store' }); const res = await tenantFetch('/api/dashboard/snapshot', { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
if (!cancelled && data && data.token) setSnap(data); if (!cancelled && data && data.token) setSnap(normalizeSnapshot(data));
} catch { } catch {
// 后端不可用:本地真实快照继续(仅 TOKEN/工具轻微波动) // 后端不可用:本地真实快照继续(仅 TOKEN/工具轻微波动)
if (!cancelled) setSnap((p) => nextSnapshot(p)); if (!cancelled) setSnap((p) => nextSnapshot(p));
+68
View File
@@ -0,0 +1,68 @@
/* 任务展示页数据:轮询 /park/api/tasks(系统任务中心上墙任务),带租户身份。
参考 parkData.js 的 useParkSim 轮询 + 归一化防崩模式。 */
import { useEffect, useRef, useState } from 'react';
import { tenantFetch } from './tenant';
const POLL_MS = 8_000;
function normalizeTask(raw, idx) {
const t = raw || {};
return {
id: t.id || '',
task_code: t.task_code || '',
scan_payload: t.scan_payload || t.task_code || '',
title: t.title || '未命名任务',
category: t.category || '综合',
summary: t.summary || '',
tags: Array.isArray(t.tags) ? t.tags : [],
budgetMin: Number(t.budget_min) || 0,
budgetMax: Number(t.budget_max) || 0,
budgetLabel: fmtBudget(t.budget_min, t.budget_max),
status: t.status || 'published',
claimed: t.status === 'claimed' || t.status === 'doing' || !!t.claimed_by,
claimedBy: t.claimed_by || '',
claimedAt: t.claimed_at || '',
key: t.id || `task-${idx}`,
};
}
function fmtBudget(min, max) {
const n = Number(min) || 0, x = Number(max) || 0;
if (!n && !x) return '面议';
if (!x) return `¥${n}`;
if (n === x) return `¥${n}`;
return `¥${n}~¥${x}`;
}
export function useTasks() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const aliveRef = useRef(true);
useEffect(() => {
aliveRef.current = true;
setLoading(true);
const load = async () => {
try {
const res = await tenantFetch('/api/tasks');
if (!res.ok) throw new Error(`任务加载失败(${res.status})`);
const body = await res.json();
const items = Array.isArray(body?.items) ? body.items : [];
if (aliveRef.current) {
setTasks(items.map(normalizeTask));
setError('');
}
} catch (e) {
if (aliveRef.current) setError(e.message || '任务加载失败');
} finally {
if (aliveRef.current) setLoading(false);
}
};
load();
const timer = setInterval(load, POLL_MS);
return () => { aliveRef.current = false; clearInterval(timer); };
}, []);
return { tasks, loading, error };
}
+16
View File
@@ -42,6 +42,22 @@ export function clearBinding() {
window.location.href = '/'; window.location.href = '/';
} }
/** 解除大屏与园区的绑定:先通知服务端清除该设备绑定,再清本地并回到待绑定页。
* 服务端请求带 4s 硬超时:无论服务端成功 / 不可达 / 鉴权失败,都会继续本地解绑,
* 保证不会因请求挂起而卡住界面。 */
export async function unbindDevice() {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
await tenantFetch(`/api/devices/${getDeviceId()}/unbind`, { method: 'POST', signal: ctrl.signal });
clearTimeout(timer);
} catch (e) {
/* 超时 / 网络错误 / 鉴权异常:忽略,继续本地解绑 */
}
// 桌面端清理本地绑定状态,并刷新回绑定页(main.jsx 见 isDeviceBound()=false 渲染 BindScreen)
clearBinding();
}
/** 带设备 token 的 fetch:附 Authorization(设备 token)。 */ /** 带设备 token 的 fetch:附 Authorization(设备 token)。 */
export function tenantFetch(path, options = {}) { export function tenantFetch(path, options = {}) {
const tok = getDeviceToken(); const tok = getDeviceToken();
+20 -15
View File
@@ -1,8 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, getApiBase as API_BASE } from '../config'; import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, MQTT_TOPIC_BIND, getApiBase as API_BASE } from '../config';
import { getTenantId } from './tenant'; import { getTenantId, clearBinding } from './tenant';
import { Sfx } from './sounds'; import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav'; import { PAGE_ORDER } from './pageNav';
import { aiStatus } from './statusBus'; import { aiStatus } from './statusBus';
@@ -31,6 +31,7 @@ const PAGE_ALIAS = {
home: '/', '数据大屏': '/', '首页': '/', home: '/', '数据大屏': '/', '首页': '/',
twin: '/twin', '数字孪生': '/twin', '3d': '/twin', twin: '/twin', '数字孪生': '/twin', '3d': '/twin',
ai: '/ai', 'ai助手': '/ai', ai: '/ai', 'ai助手': '/ai',
tasks: '/tasks', '任务展示': '/tasks', task: '/tasks',
screen: '/screen', '媒体轮播': '/screen', media: '/screen', screen: '/screen', '媒体轮播': '/screen', media: '/screen',
wall: '/wall', '企业展示墙': '/wall', '企业墙': '/wall', '展示墙': '/wall', wall: '/wall', '企业展示墙': '/wall', '企业墙': '/wall', '展示墙': '/wall',
}; };
@@ -61,6 +62,19 @@ export function useMqttControl() {
}).catch(() => {}); }).catch(() => {});
}, [mqttStatus]); }, [mqttStatus]);
// 后端主动解绑:监听绑定频道,收到 unbound 事件 → 清理本地绑定并退出到待绑定页
// (大屏已绑定后仍订阅 bind/<clientId>,见 mqtt.js subscribe;unbind 后 main.jsx 见
// isDeviceBound()=false 渲染 BindScreen)
useEffect(() => {
connectMqtt();
const off = onMqttMessage((topic, data) => {
if (topic === `${MQTT_TOPIC_BIND}/${getClientId()}` && data && data.event === 'unbound') {
clearBinding();
}
});
return off;
}, []);
// 大屏在线心跳(后端据此统计在线大屏数) // 大屏在线心跳(后端据此统计在线大屏数)
useEffect(() => { useEffect(() => {
connectMqtt(); connectMqtt();
@@ -170,19 +184,10 @@ export function useMqttControl() {
})); }));
break; break;
// ── 语音对话页操作 ── // ── 任务展示页操作(语音页已暂时关闭,voice_* 指令不再处理) ──
case 'voice_start': case 'task_refresh':
// admin 主动问候测试:携带 text;若不在 /voice 页先跳转,文本暂存 sessionStorage window.dispatchEvent(new CustomEvent('dpm:task-control', {
if (cmd.params?.text) sessionStorage.setItem('dpm_voice_greet', cmd.params.text); detail: { action: 'refresh', ...(cmd.params || {}) },
if (location.pathname !== '/voice') navigate('/voice');
window.dispatchEvent(new CustomEvent('dpm:voice-control', {
detail: { action: 'start', text: cmd.params?.text || '' },
}));
break;
case 'voice_stop':
case 'voice_refresh':
window.dispatchEvent(new CustomEvent('dpm:voice-control', {
detail: { action: cmd.action.replace('voice_', '') },
})); }));
break; break;