feat: 添加 AI 助手预设问题面板,优化园区概览信息展示

This commit is contained in:
Pine
2026-08-18 06:17:57 +08:00
parent 418d94d7b7
commit 6b7fd0ec95
10 changed files with 420 additions and 21 deletions
+73
View File
@@ -323,6 +323,79 @@ async def park_zones():
# ==================== AI 对话(通义千问 + 工具调用) ====================
# 预设问题(围绕昆明市大学生创业园 / OPC 园区性质设计,前端从后端读取)
AI_GROUPED_QUESTIONS = [
{
"title": "入驻与流程",
"questions": [
"如何申请入驻园区?",
"园区入驻的条件有哪些?",
"入驻需要准备哪些材料?",
"入驻流程是怎样的?",
"入驻评审如何打分?",
"入驻需要多长时间?",
],
},
{
"title": "政策与扶持",
"questions": [
"园区有哪些创业政策扶持?",
"如何申请创业补贴?",
"如何申请创业担保贷款?",
"对高校毕业生有什么优惠?",
"科技成果转化有哪些支持?",
],
},
{
"title": "OPC 概念",
"questions": [
"什么是 OPC",
"OPC 创业有哪些模式?",
"OPC 适合哪些人?",
"OPC 创业者从哪里开始?",
"OPC 常用的人工智能工具有哪些?",
],
},
{
"title": "场地与服务",
"questions": [
"园区提供哪些免费办公空间?",
"园区有哪些孵化服务?",
"园区提供哪些创业辅导?",
"园区有哪些基础配套?",
"园区可以免费使用哪些资源?",
],
},
{
"title": "园区企业介绍",
"questions": [
"介绍一下园区入驻企业",
"园区有哪些 AI 科技企业?",
"园区有哪些跨境电商企业?",
"园区有哪些生物医药企业?",
"介绍一下云南派音人工智能科技",
"介绍一下米勒克尔蓝宝石珠宝",
"介绍一下研X同行者网络",
],
},
]
AI_OPC_TOOLS = [
"DeepSeek-V3", "通义千问", "ChatGPT", "豆包",
"Midjourney", "Stable Diffusion", "剪映", "Notion AI",
"WPS AI", "GitHub Copilot",
]
@router.get("/api/ai/questions")
async def ai_questions():
"""预设问题列表(围绕园区性质设计,前端右侧面板展示)"""
return {
"groups": AI_GROUPED_QUESTIONS,
"opcTools": AI_OPC_TOOLS,
}
@router.post("/api/ai/chat")
async def ai_chat(body: ChatBody):
"""大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏
+32
View File
@@ -644,6 +644,38 @@ function Building() {
walls.push(<Wall key="railW" x={gx0} z={(gz0 + gz1) / 2} w={gz1 - gz0} h={railH} axis="x" y={FLOOR2} />);
walls.push(<Wall key="railE" x={gx1} z={(gz0 + gz1) / 2} w={gz1 - gz0} h={railH} axis="x" y={FLOOR2} />);
/* ===== 过道/大厅绿植(靠墙摆放 · 严禁穿墙 · 排列整齐) =====
每盆绿植按【墙线 wall(x 或 z)+ 内侧方向 side(+1 向过道内侧 / -1)+ 沿墙间距】确定:
中心离墙 = 冠半径 + 固定余量 0.15,保证完全落在过道地面内、不越墙、不挡门 */
const plant = (s, wall, side, offset) => ({
s,
x: wall.kind === 'x' ? wall.v + side * (0.28 * s + 0.18) : offset,
z: wall.kind === 'z' ? wall.v + side * (0.28 * s + 0.18) : offset,
});
// wall: 沿墙线;offset: 沿该墙线的间距(x 或 z 坐标)
const corridorPlants = [
// —— H04 竖向过道西墙(x=50,过道 x50-58,内侧 = +x 方向)——
plant(0.9, { kind: 'x', v: 50 }, 1, 30),
plant(0.8, { kind: 'x', v: 50 }, 1, 29.2),
// —— H02 竖向过道(x50-53)绿植靠东内侧 x≈50.6,沿 z 等距 ——
plant(0.85, { kind: 'x', v: 50.5 }, 1, 20),
plant(0.85, { kind: 'x', v: 50.5 }, 1, 14),
plant(0.85, { kind: 'x', v: 50.5 }, 1, 8),
// —— H03 横向过道北墙(z=28,过道在 z<28,内侧 = -z)——
// plant(0.9, { kind: 'z', v: 28 }, -1, 48),
// plant(0.9, { kind: 'z', v: 28 }, -1, 54),
// plant(0.9, { kind: 'z', v: 28 }, -1, 56),
// —— 中庭围栏(z=1 北外墙内侧 +z / z=27 南缘内侧 -z)——
// plant(0.85, { kind: 'z', v: 1 }, 1, 40),
// plant(0.85, { kind: 'z', v: 27 }, -1, 40),
// plant(0.85, { kind: 'z', v: 27 }, -1, 2),
// —— W02 共享工位区南缘(z=32,内侧 = +z 回工位区)——
plant(0.8, { kind: 'z', v: 32 }, 1, 36),
plant(0.8, { kind: 'z', v: 32 }, 1, 40),
];
corridorPlants.forEach((p, i) => {
furniture.push(<Plant key={`cp${i}`} x={cx(p.x)} z={cz(p.z)} y={FLOOR2} s={p.s} />);
});
return (
<group>
+3 -2
View File
@@ -12,7 +12,7 @@ import {
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
Send, Trash, Eraser, Headset, CircleDot, AlertCircle,
Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle,
Camera, CameraOff, HandStop,
Camera, CameraOff, HandStop, Copyright,
} from '@appica/icons-react';
/* 名称 → 图标组件映射(推荐的调用方式:icon="bolt" */
@@ -89,6 +89,7 @@ const NAME_MAP = {
camera: Camera,
'camera-off': CameraOff,
'hand-stop': HandStop,
copyright: Copyright,
};
/**
@@ -123,5 +124,5 @@ export {
Volume, VolumeOff, ChevronLeft, ChevronRight, PlayerPlay, PlayerPause,
Send, Trash, Eraser, Headset, CircleDot, AlertCircle,
Microphone, MicrophoneFilled, MicrophoneOff, MessageCircle,
Camera, CameraOff, HandStop,
Camera, CameraOff, HandStop, Copyright,
};
+8 -2
View File
@@ -51,9 +51,15 @@ export default function PageHeader() {
</Avatar>
<div className="bd-header-titles">
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1>
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span> 七位一体</div>
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div>
</div>
<span className="bd-credit">&nbsp;&nbsp; <Icon name="Copyright"></Icon> 云南派音人工智能科技有限公司 开发</span>
<span className="bd-credit">
<Icon name="copyright" size={13} />
<span className="bd-credit-text">
<span className="bd-credit-name">云南派音人工智能科技有限公司</span>
<span className="bd-credit-dev">开发</span>
</span>
</span>
</div>
<div className="bd-header-right">
{/* 媒体轮播页:页眉内嵌播放控制 */}
+8 -8
View File
@@ -7,23 +7,23 @@ import { fmtInt, fmtWan } from '../utils/parkData';
export default function ParkOverviewStrip({ d }) {
const chips = [
{ icon: 'building', label: '实际入驻企业', value: `${d.projects.inPark}` },
{ icon: 'building', label: '入驻企业', value: `${d.projects.inPark}` },
{ icon: 'users', label: '带动就业', value: `${fmtInt(d.jobs.total)}` },
{ icon: 'coin', label: '累计生产经营总额', value: `${d.revenue.total} 万元` },
{ icon: 'chart-line', label: '累计上缴税利', value: `${d.revenue.tax} 万元` },
{ icon: 'wallet', label: '累计投入运营资金', value: `${d.projects.invested} 万元` },
{ icon: 'briefcase', label: '容纳企业', value: `${d.projects.capacity}` },
{ icon: 'coin', label: '经营总额', value: `${d.revenue.total} 万元` },
{ icon: 'chart-line', label: '上缴税利', value: `${d.revenue.tax} 万元` },
{ icon: 'wallet', label: '运营资金', value: `${d.projects.invested} 万元` },
{ icon: 'briefcase', label: '容纳企业', value: `${d.projects.capacity}` },
{ icon: 'map-pin', label: '园区面积', value: `${d.park.area}` },
{ icon: 'clock', label: '成立时间', value: `${d.park.founded}` },
{ icon: 'trophy', label: '省级创业园', value: `${d.park.provinceLevel} 年认定` },
{ icon: 'headset', label: '报名电话', value: d.park.phone },
// { icon: 'headset', label: '报名电话', value: d.park.phone },
];
return (
<footer className="bd-strip">
<div className="bd-strip-title">
{/* <div className="bd-strip-title">
<Icon name="map-pin" size={14} />
园区概览
</div>
</div> */}
<div className="bd-strip-chips">
{chips.map((c, i) => (
<div className="bd-chip" key={i}>
+86
View File
@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
import Icon from './Icons';
import { API_BASE } from '../config';
/* =========================================================
AI 助手 · 预设问题面板(右侧)
预设问题由【后端 /api/ai/questions】定义(围绕园区性质设计),前端读取展示。
点击问题 → dispatch dpm:ai-action {type:'input', text} → AiChatPanel 发送
========================================================= */
export default function PromptPanel({ onSend }) {
const [groups, setGroups] = useState([]);
const [tools, setTools] = useState([]);
// 从后端加载预设问题
useEffect(() => {
let cancelled = false;
(async () => {
try {
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) {
setGroups(data.groups || []);
setTools(data.opcTools || []);
}
} catch {
// 后端不可用时离线内置兜底
if (!cancelled && groups.length === 0) {
setGroups([
{ title: '入驻与流程', questions: ['如何申请入驻园区?', '园区入驻的条件有哪些?', '入驻流程是怎样的?'] },
{ title: '政策与扶持', questions: ['园区有哪些创业政策扶持?', '如何申请创业补贴?', '如何申请创业担保贷款?'] },
{ title: 'OPC 概念', questions: ['什么是 OPC', 'OPC 常用的人工智能工具有哪些?'] },
{ title: '园区企业介绍', questions: ['介绍一下园区入驻企业', '介绍一下云南派音人工智能科技'] },
]);
setTools(['DeepSeek-V3', '通义千问', '豆包', '剪映']);
}
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handle = (q) => {
window.dispatchEvent(new CustomEvent('dpm:ai-action', {
detail: { type: 'input', text: q },
}));
if (onSend) onSend(q);
};
return (
<aside className="bd-prompt-side">
<div className="bd-prompt-head">
<Icon name="bulb" size={15} />
<b>您可以这样问</b>
<span>预设问题 · 点击即向 AI 提问</span>
</div>
<div className="bd-prompt-groups">
{groups.map((g, gi) => (
<div className="bd-prompt-group" key={gi}>
<div className="bd-prompt-group-title">{g.title}</div>
<div className="bd-prompt-list">
{(g.questions || []).map((q) => (
<button className="bd-prompt-item" key={q} onClick={() => handle(q)}>
<Icon name="sparkles" size={11} />
<span>{q}</span>
</button>
))}
</div>
</div>
))}
</div>
{tools.length > 0 && (
<div className="bd-prompt-group">
<div className="bd-prompt-group-title">OPC 常用 AI 工具</div>
<div className="bd-prompt-tools">
{tools.map((t) => (
<span className="bd-prompt-tool" key={t}>{t}</span>
))}
</div>
</div>
)}
</aside>
);
}
+5 -5
View File
@@ -1,10 +1,10 @@
import '../styles/datascreen.css';
import AiChatPanel from '../components/AiChatPanel';
import ParkSidePanel from '../components/ParkSidePanel';
import PromptPanel from '../components/PromptPanel';
/* =========================================================
AI 智能助手 · 独立页面(/ai)
布局沿用数字孪生页接口:左侧 AI 对话区,右侧为企业分布面板
布局:左侧 AI 对话区,右侧预设问题面板
页眉由 ScreenLayout 统一注入,左右方向键切换页面
========================================================= */
@@ -12,11 +12,11 @@ export default function AiAssistant() {
return (
<div className="bd-twin-page">
<main className="bd-twin-main">
{/* 左侧:AI 智能助手对话区(替代 3D 模型展示区) */}
{/* 左侧:AI 智能助手对话区 */}
<AiChatPanel />
{/* 右侧:企业分布面板(与数字孪生页一致 */}
<ParkSidePanel />
{/* 右侧:预设问题面板(围绕园区设计,点击即问 */}
<PromptPanel />
</main>
</div>
);
+1 -1
View File
@@ -234,7 +234,7 @@ function TodayOverview({ d }) {
<div className="bd-overview-head">
<div className="bd-title">
<span className="bd-title-ico"><Icon name="chart-area" size={15} /></span>
<span>园区今日概览</span>
<span>园区概览</span>
</div>
<div className="bd-ov-status">
{status.map((s) => (
+48 -3
View File
@@ -108,6 +108,8 @@
letter-spacing: 0.03em;
color: var(--bd-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bd-header-title-accent {
color: #fc8318;
@@ -2696,10 +2698,13 @@
color: #9fb0c6;
}
/* 页眉开发署名(跟在左侧标题后,上下居中 */
/* 页眉开发署名(© 图标 + 公司名 + 开发;空间不足自动折叠为两行 */
.bd-credit {
flex: none;
font-size: 15px;
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 13px;
color: var(--bd-sub, #7d8fa9);
white-space: nowrap;
letter-spacing: 0.02em;
@@ -2707,7 +2712,47 @@
padding: 0 6px;
border-left: 1px solid rgba(47, 107, 255, 0.18);
margin-left: 4px;
line-height: 1;
line-height: 1.2;
}
.bd-credit .appica-ico {
flex-shrink: 0;
color: var(--bd-blue);
opacity: 0.7;
}
.bd-credit-text {
display: inline-flex;
align-items: baseline;
gap: 4px;
min-width: 0;
}
.bd-credit-name {
white-space: nowrap;
}
.bd-credit-dev {
color: #9fb0c6;
white-space: nowrap;
}
/* 空间不足(窄屏):折叠为两行 —— 公司名 / 开发 */
@media (max-width: 1560px) {
.bd-credit {
font-size: 12px;
gap: 4px;
}
.bd-credit-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
line-height: 1.15;
}
.bd-credit-dev {
font-size: 11px;
}
}
/* 更窄:仅保留公司名,隐藏「开发」 */
@media (max-width: 1180px) {
.bd-credit-dev {
display: none;
}
}
/* ============ 真实数据面板补充样式 ============ */
+156
View File
@@ -0,0 +1,156 @@
/* =========================================================
技能:Markdown 格式化 / 可视化渲染器(问问小园回复专用)
—— 将 LLM 返回的 md 文本解析为美观的富文本结构
---------------------------------------------------------
支持:标题 · 无序/有序列表 · 表格 · 加粗 · 行内代码
引用 · 分隔线 · 代码块 · 段落
返回 React 节点(美观 UI 结构)
========================================================= */
/* 行内解析:加粗、行内代码 */
export function inlineMd(text) {
const out = [];
// 先保行内代码,再处理加粗
const codeRe = /`([^`]+)`/g;
const boldRe = /\*\*([^*]+)\*\*/g;
const chunks = [];
let last = 0;
let coll = '';
// 拆分行内代码与加粗
let segs = [];
const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
for (const p of parts) {
if (!p) continue;
if (p.startsWith('`') && p.endsWith('`')) {
segs.push({ type: 'code', text: p.slice(1, -1) });
} else if (p.startsWith('**') && p.endsWith('**')) {
segs.push({ type: 'bold', text: p.slice(2, -2) });
} else {
segs.push({ type: 'text', text: p });
}
}
return segs.map((s, i) => {
if (s.type === 'code') return <code key={i} className="md-code">{s.text}</code>;
if (s.type === 'bold') return <b key={i} className="md-bold">{s.text}</b>;
return <span key={i}>{s.text}</span>;
});
}
/* 表格解析:文本行数组 → 表头/行 */
function parseTable(rows) {
const cells = rows.map((r) => r.split('|').map((c) => c.trim()).filter((c, i, a) => !(i === 0 && c === '' && a.length > 2) && !(i === a.length - 1 && c === '')));
// 去掉全空/分隔线行
return cells.filter((r) => !(r.length === 1 && /^[-:]+$/.test(r[0])));
}
/* 将整段 md 解析为结构行(AST 风格数组) */
export function parseMarkdown(mdText) {
const lines = String(mdText || '').split('\n');
const nodes = [];
let i = 0;
let inCode = false;
let codeBuf = [];
let tableBuf = [];
const flushCode = () => {
if (codeBuf.length) { nodes.push({ type: 'codeblock', text: codeBuf.join('\n') }); codeBuf = []; }
};
const flushTable = () => {
if (tableBuf.length >= 2) {
const tbl = parseTable(tableBuf);
if (tbl.length >= 2) nodes.push({ type: 'table', rows: tbl });
}
tableBuf = [];
};
for (; i < lines.length; i++) {
const line = lines[i];
const t = line.trim();
// 代码块
if (t.startsWith('```')) {
if (inCode) { inCode = false; flushCode(); }
else { inCode = true; codeBuf = []; }
continue;
}
if (inCode) { codeBuf.push(line); continue; }
// 表格(连续 | 开头的行)
if (t.startsWith('|')) { tableBuf.push(t); continue; }
flushTable();
// 空行
if (!t) continue;
// 标题
const hm = t.match(/^(#{1,4})\s+(.*)$/);
if (hm) { nodes.push({ type: 'heading', level: hm[1].length, text: hm[2] }); continue; }
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) { nodes.push({ type: 'hr' }); continue; }
// 引用
if (t.startsWith('>')) { nodes.push({ type: 'quote', text: t.replace(/^>\s?/, '') }); continue; }
// 无序列表
const um = t.match(/^[-*+]\s+(.*)$/);
if (um) { nodes.push({ type: 'bullet', text: um[1] }); continue; }
// 有序列表
const om = t.match(/^\d+[.)]\s+(.*)$/);
if (om) { nodes.push({ type: 'ordered', text: om[1] }); continue; }
// 普通段落
nodes.push({ type: 'paragraph', text: line });
}
flushCode();
flushTable();
return nodes;
}
/* 渲染为 React 节点(美观 UI */
export function renderMarkdown(mdText) {
const nodes = parseMarkdown(mdText);
const key = { current: 0 };
const nk = () => key.current++;
return (
<div className="md-block">
{nodes.map((n) => {
const k = nk();
switch (n.type) {
case 'heading':
if (n.level === 1) return <h3 key={k} className="md-h1">{inlineMd(n.text)}</h3>;
if (n.level === 2) return <h4 key={k} className="md-h2">{inlineMd(n.text)}</h4>;
return <h5 key={k} className="md-h3">{inlineMd(n.text)}</h5>;
case 'paragraph':
return <p key={k} className="md-p">{inlineMd(n.text)}</p>;
case 'bullet':
return <div key={k} className="md-li"><span className="md-dot" />{inlineMd(n.text)}</div>;
case 'ordered':
return <div key={k} className="md-li md-oli">{inlineMd(n.text)}</div>;
case 'quote':
return <div key={k} className="md-quote">{inlineMd(n.text)}</div>;
case 'hr':
return <div key={k} className="md-hr" />;
case 'codeblock':
return <pre key={k} className="md-pre"><code>{n.text}</code></pre>;
case 'table':
return (
<table key={k} className="md-table">
<thead>
<tr>{n.rows[0].map((c, ci) => <th key={ci}>{inlineMd(c)}</th>)}</tr>
</thead>
<tbody>
{n.rows.slice(1).map((r, ri) => (
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inlineMd(c)}</td>)}</tr>
))}
</tbody>
</table>
);
default:
return null;
}
})}
</div>
);
}