同步数据
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Soul创业派对 - 内容解析工具
|
||||
* 卡若创业派对 - 内容解析工具
|
||||
* 解析 TipTap HTML 为阅读页可展示的 segments
|
||||
*
|
||||
* segment 类型:
|
||||
* { type: 'text', text }
|
||||
* { type: 'mention', userId, nickname } — @某人,点击加好友
|
||||
* { type: 'linkTag', label, url } — #链接标签,点击跳转
|
||||
* { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead)
|
||||
* { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap:外链→link-preview、小程序→navigateToMiniProgram)
|
||||
* { type: 'image', src, alt } — 图片
|
||||
*/
|
||||
|
||||
@@ -27,32 +27,99 @@ function decodeEntities(str) {
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文里 img 的 src:管理端常见为 /uploads/...(相对 API 根),浏览器会自动补全域名;
|
||||
* 小程序 <image> 不会拼接 baseUrl,需在此用 assetBase 转成可请求的绝对地址。
|
||||
* @param {string} src
|
||||
* @param {string} [assetBase] - 如 https://soulapi.example.com(无末尾 /)
|
||||
*/
|
||||
function resolveArticleImageSrc(src, assetBase) {
|
||||
if (!src || typeof src !== 'string') return src
|
||||
const s = src.trim()
|
||||
if (!s) return s
|
||||
if (/^(https?:|wxfile:|data:|blob:)/i.test(s)) return s
|
||||
if (s.startsWith('//')) return 'https:' + s
|
||||
if (s.startsWith('/')) {
|
||||
const base = String(assetBase || '').replace(/\/$/, '')
|
||||
if (!base) return s
|
||||
return base + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/** 从 <img ...> 标签取出 src(双引号/单引号) */
|
||||
function extractImgSrcFromTag(tag) {
|
||||
const d = tag.match(/src\s*=\s*"([^"]*)"/i)
|
||||
if (d && d[1] != null) return d[1]
|
||||
const q = tag.match(/src\s*=\s*'([^']*)'/i)
|
||||
if (q && q[1] != null) return q[1]
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
|
||||
*/
|
||||
function cleanSingleLineField(s) {
|
||||
if (!s && s !== 0) return ''
|
||||
let t = decodeEntities(String(s))
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
.replace(/[\s\u00a0\u200b\u200c\u200d\ufeff\u3000]+/g, ' ')
|
||||
.trim()
|
||||
return t
|
||||
}
|
||||
|
||||
/** @提及昵称:去首尾空白、零宽、全角空格;合并内部换行/<br> */
|
||||
function cleanMentionNickname(n) {
|
||||
return cleanSingleLineField(n)
|
||||
}
|
||||
|
||||
/** 纯文本在 mention 节点前若已有「@」,去掉末尾 @,避免渲染成「找@@阿浪」 */
|
||||
function stripTrailingAtForMention(before) {
|
||||
return before.replace(/[@@][\s\u00a0\u200b]*$/u, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个 HTML block 字符串解析为 segments 数组
|
||||
* 处理三种内联元素:mention / linkTag(span) / linkTag(a) / img
|
||||
*/
|
||||
function parseBlockToSegments(block) {
|
||||
function parseBlockToSegments(block, config) {
|
||||
const segs = []
|
||||
const normalize = s => (s || '').trim().toLowerCase()
|
||||
const personTokenSet = new Set()
|
||||
for (const p of ((config && config.persons) || [])) {
|
||||
const token = normalize((p && p.token) || '')
|
||||
if (token) personTokenSet.add(token)
|
||||
}
|
||||
// 合并匹配所有内联元素
|
||||
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<img[^>]*\/?>/gi
|
||||
let lastEnd = 0
|
||||
let m
|
||||
|
||||
while ((m = tokenRe.exec(block)) !== null) {
|
||||
// 前置纯文本
|
||||
const before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
|
||||
// 前置纯文本(mention 紧挨手写「找@」时去掉重复 @)
|
||||
let before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
|
||||
const tag = m[0]
|
||||
if (/data-type="mention"/i.test(tag)) {
|
||||
before = stripTrailingAtForMention(before)
|
||||
}
|
||||
if (before.trim()) segs.push({ type: 'text', text: before })
|
||||
|
||||
const tag = m[0]
|
||||
|
||||
if (/data-type="mention"/i.test(tag)) {
|
||||
// @mention — TipTap mention span
|
||||
// @mention — TipTap mention span(span 内常见「@ 昵称」多空格,统一紧挨显示)
|
||||
const idMatch = tag.match(/data-id="([^"]*)"/)
|
||||
const labelMatch = tag.match(/data-label="([^"]*)"/)
|
||||
const innerText = tag.replace(/<[^>]+>/g, '')
|
||||
const userId = idMatch ? idMatch[1].trim() : ''
|
||||
const nickname = labelMatch ? labelMatch[1].trim() : innerText.replace(/^@/, '').trim()
|
||||
if (userId || nickname) segs.push({ type: 'mention', userId, nickname })
|
||||
let nickname = labelMatch ? labelMatch[1] : innerText.replace(/^[@@]\s*/, '')
|
||||
nickname = cleanMentionNickname((nickname || '').trim())
|
||||
const userExists = !!normalize(userId) && personTokenSet.has(normalize(userId))
|
||||
if (userExists && nickname) {
|
||||
segs.push({ type: 'mention', userId, nickname, mentionDisplay: '@' + nickname })
|
||||
} else if (nickname) {
|
||||
// 被 @ 人物不存在时降级为普通文本,保持“静态 @某人”展示
|
||||
segs.push({ type: 'text', text: '@' + nickname })
|
||||
}
|
||||
|
||||
} else if (/data-type="linkTag"/i.test(tag)) {
|
||||
// #linkTag — 自定义 span 格式(data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..." data-app-id="...")
|
||||
@@ -62,7 +129,7 @@ function parseBlockToSegments(block) {
|
||||
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
|
||||
const appIdMatch = tag.match(/data-app-id="([^"]*)"/)
|
||||
const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/)
|
||||
const innerText = tag.replace(/<[^>]+>/g, '').replace(/^#/, '').trim()
|
||||
const innerText = cleanSingleLineField(tag.replace(/<[^>]+>/g, '').replace(/^#/, ''))
|
||||
const url = urlMatch ? urlMatch[1] : ''
|
||||
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
|
||||
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
|
||||
@@ -75,16 +142,21 @@ function parseBlockToSegments(block) {
|
||||
// #linkTag — 旧格式 <a href>(insertLinkTag 旧版产生,url 可能为空)
|
||||
// m[1] = href, m[2] = innerText(以 # 开头)
|
||||
const url = m[1] || ''
|
||||
const label = (m[2] || '').replace(/^#/, '').trim()
|
||||
const label = cleanSingleLineField((m[2] || '').replace(/^#/, ''))
|
||||
// 旧格式没有 tagType,在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
|
||||
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
|
||||
|
||||
} else if (/^<img /i.test(tag)) {
|
||||
// 图片
|
||||
const srcMatch = tag.match(/src="([^"]*)"/)
|
||||
const altMatch = tag.match(/alt="([^"]*)"/)
|
||||
if (srcMatch) {
|
||||
segs.push({ type: 'image', src: srcMatch[1], alt: altMatch ? altMatch[1] : '' })
|
||||
// 图片(src 可能为相对路径,需结合 config.assetBase)
|
||||
const rawSrc = extractImgSrcFromTag(tag)
|
||||
const altMatch = tag.match(/alt\s*=\s*"([^"]*)"/i) || tag.match(/alt\s*=\s*'([^']*)'/i)
|
||||
if (rawSrc) {
|
||||
const decoded = decodeEntities(rawSrc)
|
||||
const src =
|
||||
config && config.assetBase
|
||||
? resolveArticleImageSrc(decoded, config.assetBase)
|
||||
: resolveArticleImageSrc(decoded, '')
|
||||
segs.push({ type: 'image', src, alt: altMatch ? decodeEntities(altMatch[1]) : '' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +173,7 @@ function parseBlockToSegments(block) {
|
||||
/**
|
||||
* 从 HTML 中解析出 lines(纯文本行)和 segments(含富文本片段)
|
||||
* @param {string} html
|
||||
* @param {object} [config] - { persons: [], linkTags: [] },用于对 text 段自动匹配 @人名 / #标签
|
||||
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string },用于对 text 段自动匹配 @人名 / #标签;assetBase 用于补全图片相对 URL
|
||||
*/
|
||||
function parseHtmlToSegments(html, config) {
|
||||
const lines = []
|
||||
@@ -127,7 +199,7 @@ function parseHtmlToSegments(html, config) {
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) continue
|
||||
|
||||
let blockSegs = parseBlockToSegments(block)
|
||||
let blockSegs = parseBlockToSegments(block, config)
|
||||
if (!blockSegs.length) continue
|
||||
|
||||
// 纯图片行独立成段
|
||||
@@ -181,18 +253,31 @@ function stripMarkdownFormatting(text) {
|
||||
|
||||
/**
|
||||
* 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
|
||||
* config: { persons: [{personId, name, aliases}], linkTags: [{tagId, label, type, pagePath, mpKey, url, aliases}] }
|
||||
* config: { persons: [{ personId, token, name, label, aliases }], linkTags: [...] }
|
||||
* 点击加好友时须传 persons.token(与 CKB lead 的 targetUserId 一致),不能用 personId。
|
||||
*/
|
||||
function matchLineToSegments(line, config) {
|
||||
if (!config || (!config.persons?.length && !config.linkTags?.length)) {
|
||||
return [{ type: 'text', text: line }]
|
||||
}
|
||||
// 编辑器/系统在 @ 与人名之间插入的普通空格,合并为紧挨 @(避免「找@ 阿浪」无法匹配人名)
|
||||
line = line.replace(/([@@])\s+(?=[\u4e00-\u9fffA-Za-z0-9_\u00b7])/g, '$1')
|
||||
const normalize = s => (s || '').trim().toLowerCase()
|
||||
const personMap = {}
|
||||
const tagMap = {}
|
||||
for (const p of (config.persons || [])) {
|
||||
const keys = [p.name, ...(p.aliases ? p.aliases.split(',') : [])].map(normalize).filter(Boolean)
|
||||
for (const k of keys) { if (!personMap[k]) personMap[k] = p }
|
||||
const token = (p.token || '').trim()
|
||||
if (!token) continue
|
||||
const display = (p.name || p.label || '').trim()
|
||||
const aliasStr = p.aliases != null ? String(p.aliases) : ''
|
||||
const keys = [display, p.label, ...(aliasStr ? aliasStr.split(',') : [])]
|
||||
.map((x) => (x != null ? String(x) : '').trim())
|
||||
.filter(Boolean)
|
||||
.map(normalize)
|
||||
.filter(Boolean)
|
||||
for (const k of keys) {
|
||||
if (!personMap[k]) personMap[k] = p
|
||||
}
|
||||
}
|
||||
for (const t of (config.linkTags || [])) {
|
||||
const keys = [t.label, ...(t.aliases ? t.aliases.split(',') : [])].map(normalize).filter(Boolean)
|
||||
@@ -204,8 +289,8 @@ function matchLineToSegments(line, config) {
|
||||
if (!personNames.length && !tagLabels.length) return [{ type: 'text', text: line }]
|
||||
|
||||
const parts = []
|
||||
if (personNames.length) parts.push('[@@](' + personNames.join('|') + ')')
|
||||
if (tagLabels.length) parts.push('[##](' + tagLabels.join('|') + ')')
|
||||
if (personNames.length) parts.push('[@@]\\s*(' + personNames.join('|') + ')')
|
||||
if (tagLabels.length) parts.push('[##]\\s*(' + tagLabels.join('|') + ')')
|
||||
const pattern = new RegExp(parts.join('|'), 'gi')
|
||||
|
||||
const segs = []
|
||||
@@ -216,16 +301,22 @@ function matchLineToSegments(line, config) {
|
||||
segs.push({ type: 'text', text: line.slice(lastEnd, m.index) })
|
||||
}
|
||||
const full = m[0]
|
||||
const prefix = full[0]
|
||||
const body = full.slice(1)
|
||||
if (prefix === '@' || prefix === '@') {
|
||||
if (/^[@@]/u.test(full)) {
|
||||
const body = full.replace(/^[@@]\s*/u, '')
|
||||
const person = personMap[normalize(body)]
|
||||
if (person) {
|
||||
segs.push({ type: 'mention', userId: person.personId || '', nickname: person.name || body })
|
||||
const nick = cleanSingleLineField(person.name || person.label || body)
|
||||
const uid = (person.token || '').trim()
|
||||
if (uid) {
|
||||
segs.push({ type: 'mention', userId: uid, nickname: nick, mentionDisplay: '@' + nick })
|
||||
} else {
|
||||
segs.push({ type: 'text', text: full })
|
||||
}
|
||||
} else {
|
||||
segs.push({ type: 'text', text: full })
|
||||
}
|
||||
} else {
|
||||
const body = full.replace(/^[##]\s*/u, '')
|
||||
const tag = tagMap[normalize(body)]
|
||||
if (tag) {
|
||||
segs.push({
|
||||
@@ -285,5 +376,6 @@ function parseContent(rawContent, config) {
|
||||
|
||||
module.exports = {
|
||||
parseContent,
|
||||
isHtmlContent
|
||||
isHtmlContent,
|
||||
cleanSingleLineField,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user