feat(ai): W1-AI-CLOSURE 超级 Sprint — 9 APP 全链路收口 + chat 上下文真激活

Phase 2.3 chat 上下文捕获链路从未真正激活到完整工作:
- 14 处 ai-float-button 补 sourcePage,chat.ts 三分支同步设 pageFilters.contextId
- 后端 page_context 4 层 BUG 修(列名错位 + RLS site_id 未重设)
- xcx_chat filters.pop 破坏 body.page_context 引用 — dict() 浅拷贝隔离
- chat 流式 markdown 实时解析(表格/标题/列表/加粗 + KPI 富卡)
- reference_card KPI 富卡接入 SSE 路径,db 真写入
- 维客线索 source 显示规则:AI 来源用机器人 icon 替代长文字

数据库:
- public.member_retention_clue 加 emoji + runtime_mode + sandbox_instance_id
- biz.ai_run_logs 加 assistant_id + 复合索引
- chk_ai_cache_type CHECK 约束 8 类应用名
- cache_type / app_type 命名统一(app6_note / app7_customer / app8_consolidation)
- 历史 emoji 抽取脚本 44/44 成功

后端 silent failure 修:
- cleanup_service WHERE app_type → cache_type(90 天清理 + 20K 上限重新生效)
- _build_ai_insight 字段错位修复(app4 → app7 + 字段对齐 prompt schema)
- task_manager talkingPoints 改 app5_tactics + tactics 字段
- task_manager aiSuggestion 改取 one_line_summary
- cache_service.CACHE_EXPIRY_DAYS 加 app2a_finance_area
- WS /ws/ai-cache 加 token + JWT + site_id 校验(P0 信息泄露漏洞)
- internal_ai token 改 hmac.compare_digest

工具/文档:
- main.py 加 RotatingFileHandler logs/backend.log + uvicorn /health 过滤
- 新建 utils/clue_category.py(VI 6 类配色 + emoji fallback + source 显示规则)
- 新建 utils/markdown.ts(轻量 md 转 rich-text 解析 + streaming 容错)
- audit + 数据库变更说明 + backlog §七 #14 收口 + #15-#38 残余子任务
- backlog 追加 §十一 App1 参数/MCP/沙箱审计 + §十二 百炼/SQL MCP 主任务线

实地 MCP 走查:14 入口数据层 + 5 代表入口 sourcePage 注入 + customer-detail 全模块 + chat md 渲染 + reference_card 富卡 都已验证。9 项预先 BUG/UX 登记 §七 #29-#38 后续修复。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-05-06 16:39:07 +08:00
parent c9c2bce101
commit 2dfc926f96
56 changed files with 1983 additions and 278 deletions

View File

@@ -5,7 +5,17 @@
<view class="clue-content">
<view class="clue-text-container">
<text class="clue-text"><text class="clue-emoji">{{emoji}}</text> {{title}}</text>
<text class="clue-source">{{source}}</text>
<!-- source = "AI" 时用 icon 替代文字(W1-AI-CLOSURE 复盘:节字符 + 直观);其他显示文字 -->
<view class="clue-source">
<text class="clue-source-prefix">By:</text>
<image
wx:if="{{source === 'AI' || source === 'By:AI'}}"
class="clue-source-ai-icon"
src="/assets/icons/ai-robot.svg"
mode="aspectFit"
/>
<text wx:else class="clue-source-text">{{source}}</text>
</view>
</view>
</view>
<view class="clue-desc" wx:if="{{content}}">

View File

@@ -103,6 +103,8 @@
position: absolute;
bottom: 0;
right: 0;
display: flex;
align-items: center;
font-size: 24rpx;
line-height: 36rpx;
color: var(--color-gray-7);
@@ -111,6 +113,17 @@
padding-left: 50rpx;
z-index: 1;
}
.clue-source-prefix,
.clue-source-text {
font-size: 24rpx;
color: var(--color-gray-7);
}
.clue-source-ai-icon {
width: 32rpx;
height: 32rpx;
margin-left: 2rpx;
vertical-align: middle;
}
.clue-desc {
line-height: 30rpx;

View File

@@ -133,7 +133,7 @@
<!-- 自定义底部导航栏 -->
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 — 在导航栏上方 -->
<ai-float-button />
<!-- AI 悬浮按钮 — 在导航栏上方(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button sourcePage="board-coach" />
<dev-fab wx:if="{{false}}" />

View File

@@ -312,7 +312,7 @@
<!-- 自定义底部导航栏 -->
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button sourcePage="board-customer" />
<dev-fab wx:if="{{false}}" />

View File

@@ -876,7 +876,8 @@
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{200}}" />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage,具体 timeDimension/areaFilter
ai-float-button 组件按 page data 自动收集,这里不重复传 pageFilters) -->
<ai-float-button bottom="{{200}}" sourcePage="board-finance" />
<dev-fab wx:if="{{false}}" />

View File

@@ -54,8 +54,8 @@
<text class="footer-text">— 已加载全部记录 —</text>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:对话列表入口仍可启动新对话) -->
<ai-float-button sourcePage="chat-history" />
</view>
<dev-fab />

View File

@@ -10,6 +10,7 @@ import { checkPageAccess } from '../../utils/auth-guard'
import { fetchChatMessages, fetchChatMessagesByContext } from '../../services/api'
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
import { API_BASE } from '../../utils/config'
import { mdToRichHtml } from '../../utils/markdown'
/** API 返回的消息项类型 */
interface ApiMessageItem {
@@ -31,12 +32,15 @@ function toDataList(data?: Record<string, string>): Array<{ key: string; value:
return Object.keys(data).map((k) => ({ key: k, value: data[k] }))
}
/** 为消息列表补充展示字段时间分割线、IM时间、引用卡片*/
/** 为消息列表补充展示字段时间分割线、IM时间、引用卡片、md 渲染*/
function enrichMessages(msgs: ApiMessageItem[]) {
return msgs.map((m, i) => ({
id: String(m.id),
role: m.role,
content: m.content,
// W1-AI-CLOSURE 任务 1:AI 消息预解析 markdown 为 rich-text html
// (用户消息保持纯文本)
contentHtml: m.role === 'assistant' ? mdToRichHtml(m.content) : '',
timestamp: m.createdAt,
timeLabel: formatRelativeTime(m.createdAt),
imTimeLabel: formatIMTime(m.createdAt),
@@ -146,6 +150,8 @@ type DisplayMessage = {
id: string
role: 'user' | 'assistant'
content: string
/** AI 消息预解析的 markdown → rich-text html(用户消息为空字符串)*/
contentHtml?: string
timestamp: string
timeLabel?: string
imTimeLabel?: string
@@ -223,13 +229,27 @@ Page({
this.loadMessages(options.historyId)
} else if (options?.taskId) {
// 从 task-detail 跳转:同一 taskId 始终复用同一对话(无时限)
// W1-AI-CLOSURE 组 6:同步 sourcePage + pageContext.contextId 让后端 SSE
// 阶段能注入页面上下文(build_page_text)+ build_app1_reference_card 跳转卡。
this.setData({
sourcePage: 'task-detail',
pageFilters: { contextId: options.taskId },
})
this.loadMessagesByContext('task', options.taskId)
} else if (options?.customerId) {
// 从 customer-detail 跳转:≤ 3 天复用、> 3 天新建
this.setData({ customerId: options.customerId })
this.setData({
customerId: options.customerId,
sourcePage: 'customer-detail',
pageFilters: { contextId: options.customerId },
})
this.loadMessagesByContext('customer', options.customerId)
} else if (options?.coachId) {
// 从 coach-detail 跳转:≤ 3 天复用、> 3 天新建
this.setData({
sourcePage: 'coach-detail',
pageFilters: { contextId: options.coachId },
})
this.loadMessagesByContext('coach', options.coachId)
} else if (options?.sourcePage) {
// 看板类入口:保存来源页面和筛选参数
@@ -411,6 +431,7 @@ Page({
id: aiMsgId,
role: 'assistant',
content: '',
contentHtml: '',
timestamp: aiNow,
timeLabel: '刚刚',
imTimeLabel: formatIMTime(aiNow),
@@ -429,12 +450,12 @@ Page({
let fullContent = ''
const parser = new SSEParser(
// onMessage: 逐 token 追加
// onMessage: 逐 token 追加 + 实时 markdown 解析
(token: string) => {
fullContent += token
const key = `messages[${aiIndex}].content`
this.setData({
[key]: fullContent,
[`messages[${aiIndex}].content`]: fullContent,
[`messages[${aiIndex}].contentHtml`]: mdToRichHtml(fullContent),
streamingContent: fullContent,
})
this.scrollToBottom()

View File

@@ -82,14 +82,15 @@
</view>
</view>
<!-- AI 消息:左对齐白色 -->
<!-- AI 消息:左对齐白色W1-AI-CLOSURE 任务 1流式 markdown 实时渲染)-->
<view class="message-row message-assistant" wx:else id="msg-{{item.id}}">
<view class="ai-avatar">
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
</view>
<view class="bubble-col">
<view class="bubble bubble-assistant">
<text class="bubble-text">{{item.content}}</text>
<rich-text wx:if="{{item.contentHtml}}" class="bubble-md" nodes="{{item.contentHtml}}" />
<text wx:else class="bubble-text">{{item.content}}</text>
</view>
<!-- AI 侧引用卡片(后端 referenceCard 附加在 assistant 回复中)-->
<view

View File

@@ -376,3 +376,86 @@ page {
width: 44rpx;
height: 44rpx;
}
/* W1-AI-CLOSURE 任务 1: AI 消息 markdown 渲染样式 ===================== */
/* rich-text 不允许内联 style,样式通过 class 控制 */
.bubble-md {
font-size: 30rpx;
line-height: 1.65;
color: var(--color-gray-13, #242424);
word-break: break-word;
}
/* 段落 */
.md-p {
margin: 0 0 16rpx;
}
.md-p:last-child {
margin-bottom: 0;
}
/* 标题 */
.md-h1 { font-size: 38rpx; font-weight: 700; margin: 24rpx 0 14rpx; color: #1a1a1a; }
.md-h2 { font-size: 34rpx; font-weight: 700; margin: 22rpx 0 12rpx; color: #1a1a1a; }
.md-h3 { font-size: 30rpx; font-weight: 600; margin: 18rpx 0 10rpx; color: #2c2c2c; }
.md-h4 { font-size: 28rpx; font-weight: 600; margin: 14rpx 0 8rpx; color: #2c2c2c; }
/* 列表 */
.md-ul, .md-ol {
margin: 8rpx 0 16rpx;
padding-left: 36rpx;
}
.md-li {
margin-bottom: 6rpx;
line-height: 1.6;
}
/* 表格 */
.md-table {
border-collapse: collapse;
margin: 12rpx 0 18rpx;
width: 100%;
font-size: 26rpx;
background: #fafafa;
border-radius: 12rpx;
overflow: hidden;
}
.md-th, .md-td {
border: 1rpx solid #e0e0e0;
padding: 10rpx 14rpx;
text-align: left;
vertical-align: top;
}
.md-th {
background: rgba(0, 82, 217, 0.06);
font-weight: 600;
color: #2c2c2c;
}
/* 行内代码 */
.md-code {
font-family: SFMono-Regular, Menlo, Consolas, monospace;
font-size: 26rpx;
background: rgba(0, 0, 0, 0.05);
padding: 2rpx 10rpx;
border-radius: 6rpx;
color: #c7254e;
}
/* 代码块 */
.md-pre {
margin: 12rpx 0 16rpx;
padding: 16rpx 20rpx;
background: #2d2d2d;
border-radius: 12rpx;
overflow-x: auto;
}
.md-code-block {
font-family: SFMono-Regular, Menlo, Consolas, monospace;
font-size: 24rpx;
line-height: 1.55;
color: #e0e0e0;
white-space: pre;
background: transparent;
}

View File

@@ -319,6 +319,9 @@
</view>
</view>
</view>
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 coach-detail 此前死注册的组件 + sourcePage) -->
<ai-float-button coachId="{{detail.id}}" sourcePage="coach-detail" />
</block>
<dev-fab />

View File

@@ -117,7 +117,7 @@
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button sourcePage="coach-service-records" />
<dev-fab />

View File

@@ -55,17 +55,21 @@ Page({
},
phoneVisible: false,
aiColor: "indigo" as "red" | "orange" | "yellow" | "blue" | "indigo" | "purple",
// 对齐 demo 标杆 wxml(`{{item.text}}` 单字段);color 由前端按 index 轮换。
aiInsight: {
summary: '',
strategies: [
{ color: '', text: '' },
{ color: '', text: '' },
],
strategies: [] as Array<{ color: string; text: string }>,
},
clues: [
{ category: '', categoryColor: '', text: '', source: '' },
{ category: '', categoryColor: '', text: '', source: '', detail: '' },
],
// W1-AI-CLOSURE 组 6:clues 字段对齐 RetentionClue schema
// {tag, tag_color, emoji, text, source, desc} → camelCase {tag, tagColor, emoji, text, source, desc}
clues: [] as Array<{
tag: string
tagColor: string
emoji: string
text: string
source: string
desc: string
}>,
coachTasks: [
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
@@ -142,15 +146,19 @@ Page({
},
async _loadAIInsight(memberId: string) {
const cache = await fetchAICache('app7_customer_analysis', memberId)
// cache_type 统一为 'app7_customer'(W1-AI-CLOSURE 组 1 数据库迁移已对齐)。
// App7Result.strategies = [{title, content}],前端拼成 demo 标杆 {color, text} 单字段。
const cache = await fetchAICache('app7_customer', memberId)
if (!cache?.result_json) return
const rj = cache.result_json as any
const rj = cache.result_json as { summary?: string; strategies?: Array<{ title?: string; content?: string; text?: string }> }
const COLORS = ['blue', 'indigo', 'purple', 'red', 'orange', 'yellow'] as const
const strategies = Array.isArray(rj.strategies)
? rj.strategies.map((s: any, i: number) => ({
color: COLORS[i % COLORS.length],
text: s.title || s.text || '',
}))
? rj.strategies.map((s, i) => {
const t = (s?.title || '').trim()
const c = (s?.content || '').trim()
const text = s?.text || (t && c ? `${t}${c}` : (c || t))
return { color: COLORS[i % COLORS.length], text }
})
: []
this.setData({
'aiInsight.summary': rj.summary || '',

View File

@@ -59,8 +59,8 @@
<!-- 主体内容 -->
<view class="main-content" >
<!-- AI 智能洞察 -->
<view class="ai-insight-card">
<!-- AI 智能洞察(W1-AI-CLOSURE 复盘:cache miss 时整段不渲染,避免空白卡) -->
<view class="ai-insight-card" wx:if="{{aiInsight.summary || aiInsight.strategies.length > 0}}">
<view class="ai-insight-header">
<view class="ai-icon-box">
<image class="ai-icon-img" src="/assets/icons/ai-robot.svg" mode="aspectFit" />
@@ -84,7 +84,7 @@
</view>
<!-- 维客线索 -->
<view class="card">
<view class="card" wx:if="{{clues.length > 0}}">
<view class="card-header">
<text class="section-title title-green">维客线索</text>
<ai-title-badge color="{{aiColor}}" />
@@ -93,12 +93,12 @@
<clue-card
wx:for="{{clues}}"
wx:key="index"
tag="{{item.category}}"
category="{{item.categoryColor}}"
emoji=""
tag="{{item.tag}}"
category="{{item.tagColor}}"
emoji="{{item.emoji}}"
title="{{item.text}}"
source="By:{{item.source}}"
content="{{item.detail}}"
source="{{item.source}}"
content="{{item.desc}}"
/>
</view>
</view>
@@ -325,8 +325,8 @@
<!-- 备注弹窗 -->
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.name}}" showExpandBtn="{{false}}" showRating="{{false}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
<!-- AI 悬浮按钮 -->
<ai-float-button customerId="{{detail.id}}" />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage 让 chat 上下文捕获生效) -->
<ai-float-button customerId="{{detail.id}}" sourcePage="customer-detail" />
</block>
<dev-fab />

View File

@@ -121,8 +121,8 @@
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button customerId="{{customerId}}" />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button customerId="{{customerId}}" sourcePage="customer-service-records" />
</block>

View File

@@ -62,7 +62,7 @@
</view>
</view>
<!-- AI 悬浮按钮 — TabBar 页面 bottom 200rpx -->
<ai-float-button visible="{{true}}" />
<!-- AI 悬浮按钮 — TabBar 页面 bottom 200rpx(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button visible="{{true}}" sourcePage="my-profile" />
<dev-fab wx:if="{{false}}" />

View File

@@ -50,8 +50,8 @@
<text class="footer-text">— 没有更多了 —</text>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button sourcePage="notes" />
</view>
<dev-fab />

View File

@@ -117,7 +117,7 @@
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button sourcePage="performance-records" />
<dev-fab />

View File

@@ -296,7 +296,7 @@
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button sourcePage="performance" />
<dev-fab />

View File

@@ -13,6 +13,7 @@
"t-loading": "tdesign-miniprogram/loading/loading",
"clue-card": "/components/clue-card/clue-card",
"service-record-card": "/components/service-record-card/service-record-card",
"dev-fab": "/components/dev-fab/dev-fab"
"dev-fab": "/components/dev-fab/dev-fab",
"ai-float-button": "/components/ai-float-button/ai-float-button"
}
}

View File

@@ -10,10 +10,10 @@ import { sortByTimestamp } from '../../utils/sort'
import { formatRelativeTime } from '../../utils/time'
import { formatStorageLevel } from '../../utils/storage-level'
/** 维客线索项 */
/** 维客线索项(W1-AI-CLOSURE 组 6:tagColor 6 类对齐 VI-DESIGN-SYSTEM v1.1) */
interface RetentionClue {
tag: string
tagColor: 'primary' | 'success' | 'purple' | 'error'
tagColor: 'primary' | 'success' | 'orange' | 'gold' | 'purple' | 'error'
emoji: string
text: string
source: string
@@ -21,6 +21,12 @@ interface RetentionClue {
expanded?: boolean
}
/** 话术参考项(W1-AI-CLOSURE 组 6:对齐 App5Result.tactics) */
interface TacticItem {
scenario: string
script: string
}
/** 服务记录项 */
interface ServiceRecord {
table: string
@@ -58,12 +64,8 @@ Page({
{ tag: '', tagColor: 'error', emoji: '', text: '', source: '', desc: '', expanded: false },
] as RetentionClue[],
// --- 话术参考 ---
talkingPoints: [
'',
'',
'',
] as string[],
// --- 话术参考(对齐 App5Result.tactics 字段) ---
talkingPoints: [] as TacticItem[],
copiedIndex: -1,
// --- 近期服务记录 ---
@@ -264,13 +266,13 @@ Page({
this.setData({ [key]: !current })
},
/** 复制话术 */
/** 复制话术(W1-AI-CLOSURE 组 6:tactics 含 scenario+script 双字段,复制 script 部分) */
onCopySpeech(e: WechatMiniprogram.BaseEvent) {
const idx = e.currentTarget.dataset.index as number
const text = this.data.talkingPoints[idx]
if (!text) return
const item = this.data.talkingPoints[idx]
if (!item || !item.script) return
wx.setClipboardData({
data: text,
data: item.script,
success: () => {
this.setData({ copiedIndex: idx })
setTimeout(() => this.setData({ copiedIndex: -1 }), 2000)

View File

@@ -95,7 +95,7 @@
<view class="speech-bubble" wx:for="{{talkingPoints}}" wx:key="index">
<view class="speech-text-wrap">
<ai-inline-icon color="{{aiColor}}" />
<text class="speech-text">{{item}}</text>
<text class="speech-text">{{item.scenario ? item.scenario + '' + item.script : item.script}}</text>
</view>
<view class="speech-copy-row">
<view class="copy-btn" bindtap="onCopySpeech" data-index="{{index}}" hover-class="copy-btn--hover">
@@ -277,4 +277,7 @@
<text>🔧</text>
</view>
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 task-detail 整页缺失的入口 + sourcePage) -->
<ai-float-button taskId="{{detail.id}}" sourcePage="task-detail" />
</block>

View File

@@ -329,8 +329,8 @@
bind:cancel="onNoteCancel"
/>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
<ai-float-button sourcePage="task-list" />
<!-- 开发调试 FAB -->
<dev-fab />

View File

@@ -0,0 +1,205 @@
/**
* 轻量 Markdown 转 rich-text 兼容 HTML 字符串(W1-AI-CLOSURE 任务 1)。
*
* 用途:chat 页 AI 回复流式渲染。每次 token 拼接后调用,输出供小程序
* <rich-text nodes="{{html}}"> 渲染。
*
* 支持(80% AI 输出场景覆盖):
* 段落 / 换行 / 标题 H1-H4 / 粗体 / 斜体 / 行内代码 / 代码块
* 无序列表 / 有序列表 / GFM 表格 / 行内 emoji 不变
*
* Streaming 容错:partial 标记(未闭合 双星 / 未结尾 三引号 / 未完整 竖线)
* 不渲染对应节点,降级为纯文本。每次 token 来重新解析整段。
*
* rich-text 限制:
* 不允许 inline style 属性
* 仅支持有限 tag(p / div / h1-h6 / strong / em / code / ul / ol / li /
* table / tr / th / td / pre / br / a)
* class 由 wxss 全局样式控制
*/
const HTML_ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE_MAP[c])
}
/**
* 解析行内 markdown(粗体 / 斜体 / 行内代码),返回带 tag 的 html。
* 优先级:行内代码 > 粗体 > 斜体。已转义 html 特殊字符。
*/
function parseInline(text: string): string {
if (!text) return ""
// 步骤 1:抽出 `code` 占位(纯 ASCII 标记防与原文冲突)
const codeStash: string[] = []
let s = text.replace(/`([^`\n]+)`/g, (_m, c) => {
const idx = codeStash.length
codeStash.push(c)
return `[[MDCODE${idx}MDCODE]]`
})
// 步骤 2:转义 html(占位标记 [[MDCODE0MDCODE]] 中的 [ ] 不会转义)
s = escapeHtml(s)
// 步骤 3:还原 code 为 <code> 标签
s = s.replace(/\[\[MDCODE(\d+)MDCODE\]\]/g, (_m, i) => {
const c = codeStash[Number(i)] || ""
return `<code class="md-code">${escapeHtml(c)}</code>`
})
// 步骤 4:粗体 **xx**(确保有闭合)
s = s.replace(/\*\*([^\n*][^\n*]*?)\*\*/g, "<strong>$1</strong>")
// 步骤 5:斜体 *xx*(避免 **)
s = s.replace(/(^|[^*])\*([^\n*][^\n*]*?)\*(?!\*)/g, "$1<em>$2</em>")
return s
}
/**
* 主入口:Markdown 转 rich-text 兼容 HTML 字符串。
*/
export function mdToRichHtml(md: string): string {
if (!md) return ""
const lines = md.split("\n")
const out: string[] = []
let i = 0
let inCodeBlock = false
let codeBuf: string[] = []
while (i < lines.length) {
const line = lines[i]
// 代码块 ```xx```
if (line.trim().startsWith("```")) {
if (!inCodeBlock) {
inCodeBlock = true
codeBuf = []
} else {
out.push(
`<pre class="md-pre"><code class="md-code-block">${escapeHtml(
codeBuf.join("\n"),
)}</code></pre>`,
)
inCodeBlock = false
}
i++
continue
}
if (inCodeBlock) {
codeBuf.push(line)
i++
continue
}
// 标题 #
const h = line.match(/^(#{1,4})\s+(.+)$/)
if (h) {
const level = h[1].length
out.push(`<h${level} class="md-h${level}">${parseInline(h[2])}</h${level}>`)
i++
continue
}
// GFM 表格(连续 2 行,第二行为分隔)
if (
line.trim().startsWith("|") &&
i + 1 < lines.length &&
/^\s*\|[\s\-:|]+\|\s*$/.test(lines[i + 1])
) {
const headers = line.split("|").slice(1, -1).map((c) => c.trim())
i += 2
const rows: string[][] = []
while (
i < lines.length &&
lines[i].trim().startsWith("|") &&
lines[i].trim().endsWith("|")
) {
rows.push(lines[i].split("|").slice(1, -1).map((c) => c.trim()))
i++
}
let html = '<table class="md-table"><thead><tr>'
for (const h0 of headers) html += `<th class="md-th">${parseInline(h0)}</th>`
html += "</tr></thead><tbody>"
for (const r of rows) {
html += "<tr>"
for (const c of r) html += `<td class="md-td">${parseInline(c)}</td>`
html += "</tr>"
}
html += "</tbody></table>"
out.push(html)
continue
}
// 无序列表
if (/^\s*[-*]\s+/.test(line)) {
const items: string[] = []
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*]\s+/, ""))
i++
}
out.push(
`<ul class="md-ul">${items
.map((it) => `<li class="md-li">${parseInline(it)}</li>`)
.join("")}</ul>`,
)
continue
}
// 有序列表
if (/^\s*\d+\.\s+/.test(line)) {
const items: string[] = []
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ""))
i++
}
out.push(
`<ol class="md-ol">${items
.map((it) => `<li class="md-li">${parseInline(it)}</li>`)
.join("")}</ol>`,
)
continue
}
// 空行 → 段落分隔
if (line.trim() === "") {
i++
continue
}
// 普通段落(连续非空行合并,内部用 <br/> 保留软换行)
const para: string[] = [line]
i++
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].trim().startsWith("#") &&
!lines[i].trim().startsWith("|") &&
!/^\s*[-*]\s+/.test(lines[i]) &&
!/^\s*\d+\.\s+/.test(lines[i]) &&
!lines[i].trim().startsWith("```")
) {
para.push(lines[i])
i++
}
out.push(`<p class="md-p">${parseInline(para.join("<br/>"))}</p>`)
}
// 流未闭合的代码块降级为纯文本 pre
if (inCodeBlock && codeBuf.length > 0) {
out.push(
`<pre class="md-pre"><code class="md-code-block">${escapeHtml(
codeBuf.join("\n"),
)}</code></pre>`,
)
}
return out.join("")
}