- posts 表加 view_count 列(迁移自动补列),公开文章详情每被拉取 +1; 列表接口子查询带出非删除评论数 - classic 首页文章卡补上操作行(回复 / 赞 / 查看),与 vivid 同行为: 回复跳评论区锚点、赞本机 localStorage、查看进详情; 卡片底部 meta 行的日期不再重复包「查看」链接 - vivid 操作行的回复 / 查看旁展示对应计数(万以上缩写)
166 lines
6.7 KiB
JavaScript
166 lines
6.7 KiB
JavaScript
import DOMPurify from 'dompurify'
|
||
|
||
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
|
||
// 所有 v-html 出口必须经过它。
|
||
export function sanitizeHtml(html) {
|
||
return enhanceProse(DOMPurify.sanitize(html || ''))
|
||
}
|
||
|
||
// ---------- 缩略图 ----------
|
||
// 时间线只展示小图:把我们存储上的图片直链改写为 /uploads/thumb/{key}?w=,
|
||
// 后端懒生成并永久缓存。识别规则:以 uploads_public_base 开头的直链,
|
||
// 或同源的 /uploads/ 相对路径(本地存储兜底)。外链(图床/头像)原样返回。
|
||
// base 由 site.js 拉到站点设置后经 setThumbBase 注入(不走 import 避免循环依赖)。
|
||
const IMG_EXT = /\.(jpe?g|png|gif)([?#].*)?$/i
|
||
let thumbBase = ''
|
||
|
||
export function setThumbBase(base) {
|
||
thumbBase = (base || '').replace(/\/+$/, '')
|
||
}
|
||
|
||
export function thumbURL(u, w = 480) {
|
||
if (!u || typeof u !== 'string') return u
|
||
let key = ''
|
||
if (thumbBase && u.startsWith(thumbBase + '/')) key = u.slice(thumbBase.length + 1)
|
||
else if (u.startsWith('/uploads/') && !u.startsWith('/uploads/thumb/')) key = u.slice('/uploads/'.length)
|
||
else return u
|
||
if (!IMG_EXT.test(key)) return u
|
||
return '/uploads/thumb/' + key + '?w=' + w
|
||
}
|
||
|
||
// ---------- xLog 式排版增强 ----------
|
||
// 对消毒后的正文 HTML 做三件展示层增强(markdown/库里内容不动):
|
||
// 1) pre 包一层容器并注入悬停复制按钮;
|
||
// 2) 带 id 的标题(withHeadingIds 生成)尾部加 # 锚点;
|
||
// 3) (盘古之白在后端渲染出口做,见 backend/internal/render/pangu.go)
|
||
export function enhanceProse(html) {
|
||
if (!html) return html
|
||
// 复制按钮:包在 .pre-wrap 里,按钮悬浮于 pre 右上
|
||
html = html
|
||
.replace(/<pre\b/g, '<div class="pre-wrap"><button type="button" class="pre-copy" aria-label="复制代码">复制</button><pre')
|
||
.replace(/<\/pre>/g, '</pre></div>')
|
||
// 标题锚点:h2-h4 且有 id 的,末尾补一个 # 链接(悬停浮现)
|
||
html = html.replace(
|
||
/<h([2-4])([^>]*\bid="([^"]+)"[^>]*)>([\s\S]*?)<\/h\1>/g,
|
||
(m, lvl, attrs, id, inner) =>
|
||
`<h${lvl}${attrs}>${inner}<a class="h-anchor" href="#${id}" aria-label="标题链接">#</a></h${lvl}>`
|
||
)
|
||
return html
|
||
}
|
||
|
||
// 编辑器拖拽缩放的比例展示:Milkdown 图片块把高度比例写进 markdown 的 alt
|
||
// (),渲染出的 <img alt="0.6"> 在这里还原成对应的宽度并居中。
|
||
// 纯数字 alt 才生效(>=1 的当 100% 处理),正常图片的描述性 alt 不受影响。
|
||
export function applyImageRatio(html) {
|
||
if (!html) return html
|
||
return html.replace(/<img\b[^>]*>/gi, (tag) => {
|
||
const m = tag.match(/\balt="(\d*\.?\d+)"/i)
|
||
if (!m) return tag
|
||
const ratio = Number.parseFloat(m[1])
|
||
if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) return tag
|
||
const style = `display:block;width:${(ratio * 100).toFixed(1)}%;max-width:100%;height:auto;margin-inline:auto;`
|
||
if (/\bstyle="/i.test(tag)) return tag.replace(/\bstyle="/i, 'style="' + style)
|
||
return tag.replace(/^<img/i, '<img style="' + style + '"')
|
||
})
|
||
}
|
||
|
||
// 时间线正文 HTML(短文全文 / 长文 600 字符截断)里的 <img> 换缩略图 + 补懒加载。
|
||
// 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签,
|
||
// 正则只匹配完整的 <img ...src="..."> ,坏尾巴交给 sanitize/浏览器容错。
|
||
export function thumbifyHtml(html, w = 480) {
|
||
if (!html) return html
|
||
return html.replace(/<img\b[^>]*?>/gi, (tag) => {
|
||
let out = tag.replace(/\bsrc="([^"]*)"/i, (m, src) => 'src="' + thumbURL(src, w) + '"')
|
||
if (!/\bloading=/i.test(out)) out = out.replace(/^<img/i, '<img loading="lazy"')
|
||
return out
|
||
})
|
||
}
|
||
|
||
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
|
||
const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric'
|
||
})
|
||
const shortDateFmt = new Intl.DateTimeFormat('sv-SE') // YYYY-MM-DD
|
||
|
||
// 操作行的计数展示:万以上缩写,避免一长串数字压弯布局
|
||
export function fmtCount(n) {
|
||
const v = Number(n) || 0
|
||
if (v >= 10000) return (v / 10000).toFixed(1).replace(/\.0$/, '') + '万'
|
||
return String(v)
|
||
}
|
||
|
||
export function formatDate(iso) {
|
||
if (!iso) return ''
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return iso
|
||
return longDateFmt.format(d)
|
||
}
|
||
|
||
export function formatDateShort(iso) {
|
||
if (!iso) return ''
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return iso
|
||
return shortDateFmt.format(d)
|
||
}
|
||
|
||
// 时间线上的相对时间:三天内用「几小时前」,同年省略年份
|
||
export function relativeDate(iso) {
|
||
if (!iso) return ''
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return iso
|
||
const diff = Date.now() - d.getTime()
|
||
const hour = 3600 * 1000
|
||
if (diff < hour) return `${Math.max(1, Math.floor(diff / 60000))} 分钟前`
|
||
if (diff < 24 * hour) return `${Math.floor(diff / hour)} 小时前`
|
||
if (diff < 3 * 24 * hour) return `${Math.floor(diff / (24 * hour))} 天前`
|
||
const sameYear = d.getFullYear() === new Date().getFullYear()
|
||
return sameYear
|
||
? `${d.getMonth() + 1} 月 ${d.getDate()} 日`
|
||
: `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||
}
|
||
|
||
export function stripTags(html) {
|
||
// 不用 div.innerHTML:那会真正解析并触发 <img onerror> 之类的事件
|
||
return new DOMParser().parseFromString(html || '', 'text/html').body.textContent || ''
|
||
}
|
||
|
||
// 短文在列表里没有标题,用正文首句当索引
|
||
export function displayTitle(post) {
|
||
if (post.kind === 'short') return ''
|
||
return post.title || '无题'
|
||
}
|
||
|
||
export function excerpt(post, limit = 60) {
|
||
if (post.summary) return post.summary
|
||
const text = stripTags(post.content_html || '')
|
||
return text.length > limit ? text.slice(0, limit) + '…' : text
|
||
}
|
||
|
||
export function minutesLabel(post) {
|
||
if (post.kind === 'short') return '短文'
|
||
return `${post.reading_minutes || 1} 分钟`
|
||
}
|
||
|
||
// 把 text 插进 textarea 光标处(没聚焦过就追加到末尾),返回新值和插入后的
|
||
// 光标位。光标重定位要在赋值后的 nextTick 里做(DOM 更新完 setSelectionRange
|
||
// 才不会被打断),所以这里只算,不动 DOM:
|
||
// const r = insertAtCursor(ta, draft.value, text); draft.value = r.value
|
||
// nextTick(() => placeCursor(ta, r.pos))
|
||
export function insertAtCursor(ta, value, text) {
|
||
const focused = !!ta && document.activeElement === ta && typeof ta.selectionStart === 'number'
|
||
const pos = focused ? ta.selectionStart : value.length
|
||
return { value: value.slice(0, pos) + text + value.slice(pos), pos: pos + text.length }
|
||
}
|
||
|
||
export function placeCursor(ta, pos) {
|
||
if (!ta) return
|
||
ta.focus()
|
||
try {
|
||
ta.setSelectionRange(pos, pos)
|
||
} catch (_) {
|
||
/* 个别输入类型不支持 setSelectionRange,忽略 */
|
||
}
|
||
}
|