feat: 添加 AI 助手预设问题面板,优化园区概览信息展示
This commit is contained in:
@@ -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 广播到所有大屏
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"> <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">
|
||||
{/* 媒体轮播页:页眉内嵌播放控制 */}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 真实数据面板补充样式 ============ */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user