Files
ONE/frontend/src/utils.js
T
Sakurasan 916dd74034 时间线操作行补真实计数(评论数 / 浏览数),classic 首页同步加操作行
- posts 表加 view_count 列(迁移自动补列),公开文章详情每被拉取 +1;
  列表接口子查询带出非删除评论数
- classic 首页文章卡补上操作行(回复 / 赞 / 查看),与 vivid 同行为:
  回复跳评论区锚点、赞本机 localStorage、查看进详情;
  卡片底部 meta 行的日期不再重复包「查看」链接
- vivid 操作行的回复 / 查看旁展示对应计数(万以上缩写)
2026-10-08 00:15:34 +08:00

166 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// (![0.6](url)),渲染出的 <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,忽略 */
}
}