短文支持链接预览卡片:新增 linkmeta 抓取 + 字符集解码

- internal/linkmeta:取正文第一个 http(s) 链接,解析 og: → twitter: → <title>/
  description 逐级兜底,解 HTML 实体、相对图片补成绝对地址。按对外抓取设防:
  只放行 http(s)、读满 512KB 即停、最多 3 次跳转、整体限时;SSRF 防护做在拨号
  那一刻——解析出 IP 后筛掉回环/私网/链路本地/组播/CGNAT 再直连该 IP,堵住
  DNS 重绑定窗口
- 字符集转换:按「HTTP 头 charset → <meta charset> → UTF-8」解码,gb2312 归一
  成 gbk(超集,按声明解会漏字);认不出的字符名退回原始字节不报错。为此引入
  golang.org/x/text(官方包,约 +1MB 二进制)。tidy 顺带把 aws-sdk 三个包从
  indirect 修正为直接依赖——storage/r2.go 本来就直接用它们
- posts 加 link_card 列(JSON,沿用 images 的编解码);admin 保存短文时重算:
  同链接沿用旧卡片不重复打远端,删链接或抓取失败则清空,长文不参与
- 前端 LinkCard 组件接两套 UI 的时间线与详情页,配色写成 vivid 变量优先、
  classic 变量兜底,一份样式两边通用;外链图挂了自动隐藏不留空框
- 卡片用 div[role=link] 而非 <a>(vivid 整条短文包在 RouterLink 里,嵌套 <a>
  非法),点击必须 preventDefault——只 stop 挡不住祖先 <a> 的默认激活行为
This commit is contained in:
Sakurasan
2026-09-28 17:13:20 +08:00
parent 232abf2019
commit 661d157b6c
17 changed files with 1081 additions and 16 deletions
+123
View File
@@ -0,0 +1,123 @@
<script setup>
import { ref } from 'vue'
// 链接预览卡片:正文里第一个外链在发布时由后端抓好(posts.link_card),
// 这里只负责展示。没有可展示内容时父组件根本不会渲染它。
//
// 用 div[role=link] 而不是 <a>:vivid 的整条短文包在 RouterLink(一个 <a>)里,
// 再嵌一个 <a> 是非法 HTML。也因此点击必须 preventDefault——
// 只 stop 的话祖先 <a> 的默认激活行为照样会把点击变成跳转。
const props = defineProps({
card: { type: Object, required: true }
})
const imgBroken = ref(false)
function open() {
window.open(props.card.url, '_blank', 'noopener,noreferrer')
}
</script>
<template>
<div
class="link-card"
role="link"
tabindex="0"
:title="card.url"
@click.prevent.stop="open"
@keydown.enter.prevent="open"
>
<div class="lc-text">
<span v-if="card.site" class="lc-site">{{ card.site }}</span>
<span v-if="card.title" class="lc-title">{{ card.title }}</span>
<span v-if="card.desc" class="lc-desc">{{ card.desc }}</span>
</div>
<img
v-if="card.image && !imgBroken"
class="lc-img"
:src="card.image"
alt=""
loading="lazy"
decoding="async"
@error="imgBroken = true"
/>
</div>
</template>
<style scoped>
/* 颜色一律写成「vivid 变量优先、classic 变量兜底」:同一份组件在两套 UI 里
各归各的调色板,不必再维护第二份样式表。 */
.link-card {
display: flex;
align-items: stretch;
gap: 12px;
margin-top: 10px;
padding: 10px 12px;
border: 1px solid var(--v-line, var(--line-soft));
border-radius: var(--v-radius-sm, 10px);
background: var(--v-surface, var(--card));
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.link-card:hover {
border-color: var(--v-line-strong, var(--accent-line));
}
.link-card:focus-visible {
outline: 2px solid var(--v-accent, var(--accent));
outline-offset: 2px;
}
.lc-text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.lc-site {
font-size: 11.5px;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--v-muted, var(--faint));
}
.lc-title {
font-size: 15px;
line-height: 1.45;
font-weight: 600;
color: var(--v-text, var(--ink));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.lc-desc {
font-size: 13px;
line-height: 1.6;
color: var(--v-text-soft, var(--muted));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.lc-img {
flex: 0 0 auto;
width: 84px;
height: 84px;
object-fit: cover;
border-radius: var(--v-radius-sm, 8px);
background: var(--v-surface-2, var(--paper-sunken));
}
@media (max-width: 520px) {
.lc-img {
width: 64px;
height: 64px;
}
}
</style>
+4
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
import LinkCard from './LinkCard.vue'
const router = useRouter()
@@ -83,6 +84,9 @@ const gridW = computed(() => (props.post.images && props.post.images.length ===
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>