今日调用{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;