From d61b59acf5b095e001a50e4cec62f8a79d14868a Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 25 Aug 2026 18:42:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(bigscreen):=20=E4=BB=BB=E5=8A=A1=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E9=A1=B5=20+=20=E6=9A=82=E6=97=B6=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E5=AF=B9=E8=AF=9D=E9=A1=B5=20+=20=E5=AF=BC?= =?UTF-8?q?=E8=88=AA/=E6=96=87=E6=A1=A3=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/mqtt-commands.md | 10 +-- src/App.jsx | 6 +- src/components/AboutDialog.jsx | 37 +++++++++-- src/components/PageHeader.jsx | 1 + src/pages/DataScreen.jsx | 23 ++++--- src/pages/TaskDisplay.jsx | 110 +++++++++++++++++++++++++++++++++ src/styles/datascreen.css | 35 +++++++++++ src/styles/task-display.css | 98 +++++++++++++++++++++++++++++ src/utils/pageNav.js | 4 +- src/utils/parkData.js | 39 +++++++++++- src/utils/taskData.js | 68 ++++++++++++++++++++ src/utils/tenant.js | 16 +++++ src/utils/useMqttControl.js | 35 ++++++----- 13 files changed, 441 insertions(+), 41 deletions(-) create mode 100644 src/pages/TaskDisplay.jsx create mode 100644 src/styles/task-display.css create mode 100644 src/utils/taskData.js diff --git a/docs/mqtt-commands.md b/docs/mqtt-commands.md index 5a57f1e..eeb6eb6 100644 --- a/docs/mqtt-commands.md +++ b/docs/mqtt-commands.md @@ -42,7 +42,7 @@ | 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` | 向右 / 向左循环切换页面 | ### 4.2 全局视觉识别 @@ -60,13 +60,13 @@ | `ai_company` | `action: "next" \| "prev"` | 切换展示企业 | | `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 | -### 4.4 语音对话页(/voice) +### 4.4 任务展示页(/tasks) | action | params | 效果 | |---|---|---| -| `voice_start` | — | 启动语音对话 | -| `voice_stop` | — | 关闭语音对话 | -| `voice_refresh` | — | 刷新页面 | +| `task_refresh` | — | 触发任务页刷新重拉 | + +> 语音对话页(/voice)已**暂时关闭**,`voice_start/stop/refresh` 不再处理。 ### 4.5 媒体轮播页(/screen) diff --git a/src/App.jsx b/src/App.jsx index a9bb0fb..047c960 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,8 +3,8 @@ import MediaScreen from './pages/MediaScreen'; import DataScreen from './pages/DataScreen'; import DigitalTwin from './pages/DigitalTwin'; import AiAssistant from './pages/AiAssistant'; -import VoiceAssistant from './pages/VoiceAssistant'; import CompanyWall from './pages/CompanyWall'; +import TaskDisplay from './pages/TaskDisplay'; import ScreenLayout from './components/ScreenLayout'; import './styles/tokens.css'; @@ -18,8 +18,8 @@ export default function App() { } /> } /> } /> - {/* 实时语音对话页(React 原生,复用页眉) */} - } /> + {/* 任务展示页(系统任务中心:轮播卡 + 二维码扫码接单) */} + } /> {/* 入驻企业展示墙 */} } /> diff --git a/src/components/AboutDialog.jsx b/src/components/AboutDialog.jsx index d135ef7..fb56d56 100644 --- a/src/components/AboutDialog.jsx +++ b/src/components/AboutDialog.jsx @@ -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 品牌) @@ -20,6 +22,8 @@ const APP_INFO = { }; export default function AboutDialog({ open, onClose }) { + const [busy, setBusy] = useState(false); + const [confirming, setConfirming] = useState(false); // Esc 关闭 useEffect(() => { if (!open) return; @@ -27,9 +31,25 @@ export default function AboutDialog({ open, onClose }) { window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); + // 关闭弹窗时重置确认/忙碌态,下次打开保持初始 + useEffect(() => { + if (!open) { setConfirming(false); setBusy(false); } + }, [open]); 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(
e.stopPropagation()}> @@ -50,10 +70,17 @@ export default function AboutDialog({ open, onClose }) {
- 版本 v{APP_INFO.version} - {APP_INFO.copyright} +
+ 版本 v{APP_INFO.version} + {APP_INFO.copyright} + {confirming && !busy && 再次点击确认解除,点击空白处取消} +
+
- + , + document.body ); } diff --git a/src/components/PageHeader.jsx b/src/components/PageHeader.jsx index 7912df5..0797fc9 100644 --- a/src/components/PageHeader.jsx +++ b/src/components/PageHeader.jsx @@ -19,6 +19,7 @@ const STATUS_BY_PATH = { '/': '数据实时', '/twin': '3D 实时', '/ai': 'AI 助手', + '/tasks': '任务中心', '/screen': '媒体播放', }; diff --git a/src/pages/DataScreen.jsx b/src/pages/DataScreen.jsx index 16115ed..cd41419 100644 --- a/src/pages/DataScreen.jsx +++ b/src/pages/DataScreen.jsx @@ -43,8 +43,15 @@ const TOOL_RANK = [ ]; /* ---------- 工具函数 ---------- */ -const fmtInt = (n) => Math.round(n).toLocaleString('zh-CN'); -const fmtWan = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '亿' : n >= 100 ? n.toFixed(1) + '万' : n.toFixed(1)); +const fmtInt = (n) => (Number.isFinite(Number(n)) ? Math.round(Number(n)).toLocaleString('zh-CN') : '--'); +/** 万/亿格式化:非有限数值(缺失/不一致口径)安全显示 '--',避免崩溃 */ +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 */ @@ -265,7 +272,7 @@ function TodayOverview({ d }) {
+ sub={`累计 ${(num(d.tools.total) / 10000).toFixed(1)}万次 · 成功率 ${d.tools.success}%`} />
} />
- 首 token 平均 {d.live.firstToken.toFixed(1)}s + 首 token 平均 {num(d.live.firstToken).toFixed(1)}s 吞吐 {d.live.throughput} tok/s - 缓存命中 {d.live.cacheHit.toFixed(1)}% - 会话输入 {d.live.inputM.toFixed(1)}M / 输出 {d.live.outputM.toFixed(1)}M tok + 缓存命中 {num(d.live.cacheHit).toFixed(1)}% + 会话输入 {num(d.live.inputM).toFixed(1)}M / 输出 {num(d.live.outputM).toFixed(1)}M tok {d.live.rounds} 轮 · {d.live.steps} 步
@@ -338,7 +345,7 @@ function ToolsBig({ d }) { 今日调用{fmtInt(d.tools.today)} 次 - 累计调用{(d.tools.total / 10000).toFixed(1)}万 次 + 累计调用{(num(d.tools.total) / 10000).toFixed(1)}万 次 成功率{d.tools.success}% 服务节点{d.tools.nodes} / {d.tools.nodes} 在线
@@ -346,7 +353,7 @@ function ToolsBig({ d }) {
今日调用{fmtInt(d.tools.today)}次
-
累计调用{(d.tools.total / 10000).toFixed(1)}万次
+
累计调用{(num(d.tools.total) / 10000).toFixed(1)}万次
成功率{d.tools.success}%
AI 服务节点12/12 在线
diff --git a/src/pages/TaskDisplay.jsx b/src/pages/TaskDisplay.jsx new file mode 100644 index 0000000..06c2906 --- /dev/null +++ b/src/pages/TaskDisplay.jsx @@ -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 ( +
+
+ + 系统任务中心 + 扫码即可接单 · 园区企业发标 · 合伙人接单 + {tasks.length} 个任务 +
+ + {loading && tasks.length === 0 ? ( +
正在加载任务…
+ ) : error && tasks.length === 0 ? ( +
{error}
+ ) : ( + <> +
+ {visible.map((t) => ( + + ))} +
+ {pageCount > 1 && ( +
+ {Array.from({ length: pageCount }).map((_, i) => ( + + ))} +
+ )} + + )} +
+ ); +} + +function TaskCard({ task }) { + const qrValue = task.scan_payload || task.task_code; + return ( +
+
+ {task.category} + {task.claimed ? ( + 已接单 + ) : ( + 可接单 + )} +
+ +
{task.title}
+ {task.summary ?

{task.summary}

: null} + +
+ {task.tags.slice(0, 4).map((tg) => ( + {tg} + ))} +
+ +
预算 {task.budgetLabel}
+ +
+
+ 任务ID + {task.task_code || task.id} +
+
+ + 扫码接单 +
+
+
+ ); +} diff --git a/src/styles/datascreen.css b/src/styles/datascreen.css index 70959c7..94f6acf 100644 --- a/src/styles/datascreen.css +++ b/src/styles/datascreen.css @@ -2996,6 +2996,8 @@ .about-modal { position: relative; width: min(420px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow-y: auto; background: #fff; border-radius: 16px; padding: 28px 30px 22px; @@ -3087,10 +3089,43 @@ padding-top: 14px; border-top: 1px dashed #e3ecf7; display: flex; + align-items: center; justify-content: space-between; + gap: 12px; font-size: 11px; 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 { diff --git a/src/styles/task-display.css b/src/styles/task-display.css new file mode 100644 index 0000000..ab08d71 --- /dev/null +++ b/src/styles/task-display.css @@ -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; } diff --git a/src/utils/pageNav.js b/src/utils/pageNav.js index 88929c4..b6fe27a 100644 --- a/src/utils/pageNav.js +++ b/src/utils/pageNav.js @@ -4,7 +4,7 @@ import { Sfx } from './sounds'; /* ========================================================= 页面循环导航 —— 左右方向键切换大屏页面 - 顺序:数据大屏 → 数字孪生 → AI 助手 → 媒体轮播(循环) + 顺序:数据大屏 → 数字孪生 → AI 助手 → 任务展示 → 企业展示墙 → 媒体轮播(循环) 管理后台不参与循环切换,仅在登录后进入 ← 向左切换 · → 向右切换 ========================================================= */ @@ -13,7 +13,7 @@ export const PAGE_ORDER = [ { path: '/', label: '数据大屏' }, { path: '/twin', label: '数字孪生' }, { path: '/ai', label: 'AI 助手' }, - { path: '/voice', label: '语音对话' }, + { path: '/tasks', label: '任务展示' }, { path: '/wall', label: '企业展示墙' }, { path: '/screen', label: '媒体轮播' }, ]; diff --git a/src/utils/parkData.js b/src/utils/parkData.js index e36db65..b2f5fc8 100644 --- a/src/utils/parkData.js +++ b/src/utils/parkData.js @@ -13,8 +13,13 @@ import { tenantFetch } from './tenant'; 工具调用:按每次调用约 2 万 tokens 折算(今日 ≈ 3.3 万次) ========================================================= */ -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)); +export const fmtInt = (n) => (Number.isFinite(Number(n)) ? Math.round(Number(n)).toLocaleString('zh-CN') : '--'); +/** 万/亿格式化:非有限数值(缺失/不一致口径)安全显示 '--',避免崩溃 */ +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省级材料 + 宣传册) ---------- */ 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() { const [snap, setSnap] = useState(initSnapshot); useEffect(() => { @@ -216,7 +249,7 @@ export function useParkSim() { const res = await tenantFetch('/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); + if (!cancelled && data && data.token) setSnap(normalizeSnapshot(data)); } catch { // 后端不可用:本地真实快照继续(仅 TOKEN/工具轻微波动) if (!cancelled) setSnap((p) => nextSnapshot(p)); diff --git a/src/utils/taskData.js b/src/utils/taskData.js new file mode 100644 index 0000000..693c438 --- /dev/null +++ b/src/utils/taskData.js @@ -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 }; +} diff --git a/src/utils/tenant.js b/src/utils/tenant.js index 23b917b..29b390e 100644 --- a/src/utils/tenant.js +++ b/src/utils/tenant.js @@ -42,6 +42,22 @@ export function clearBinding() { 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)。 */ export function tenantFetch(path, options = {}) { const tok = getDeviceToken(); diff --git a/src/utils/useMqttControl.js b/src/utils/useMqttControl.js index 87b83fd..f199348 100644 --- a/src/utils/useMqttControl.js +++ b/src/utils/useMqttControl.js @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; -import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, getApiBase as API_BASE } from '../config'; -import { getTenantId } from './tenant'; +import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, MQTT_TOPIC_BIND, getApiBase as API_BASE } from '../config'; +import { getTenantId, clearBinding } from './tenant'; import { Sfx } from './sounds'; import { PAGE_ORDER } from './pageNav'; import { aiStatus } from './statusBus'; @@ -31,6 +31,7 @@ const PAGE_ALIAS = { home: '/', '数据大屏': '/', '首页': '/', twin: '/twin', '数字孪生': '/twin', '3d': '/twin', ai: '/ai', 'ai助手': '/ai', + tasks: '/tasks', '任务展示': '/tasks', task: '/tasks', screen: '/screen', '媒体轮播': '/screen', media: '/screen', wall: '/wall', '企业展示墙': '/wall', '企业墙': '/wall', '展示墙': '/wall', }; @@ -61,6 +62,19 @@ export function useMqttControl() { }).catch(() => {}); }, [mqttStatus]); + // 后端主动解绑:监听绑定频道,收到 unbound 事件 → 清理本地绑定并退出到待绑定页 + // (大屏已绑定后仍订阅 bind/,见 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(() => { connectMqtt(); @@ -170,19 +184,10 @@ export function useMqttControl() { })); break; - // ── 语音对话页操作 ── - case 'voice_start': - // admin 主动问候测试:携带 text;若不在 /voice 页先跳转,文本暂存 sessionStorage - if (cmd.params?.text) sessionStorage.setItem('dpm_voice_greet', cmd.params.text); - 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_', '') }, + // ── 任务展示页操作(语音页已暂时关闭,voice_* 指令不再处理) ── + case 'task_refresh': + window.dispatchEvent(new CustomEvent('dpm:task-control', { + detail: { action: 'refresh', ...(cmd.params || {}) }, })); break;