短文支持链接预览卡片:新增 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:
@@ -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,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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { sanitizeHtml } from '../../utils'
|
||||
import { openLightbox } from '../../lightbox'
|
||||
import CommentSection from '../../components/comments/CommentSection.vue'
|
||||
import LinkCard from '../../components/LinkCard.vue'
|
||||
|
||||
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
|
||||
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
|
||||
@@ -186,6 +187,9 @@ function onTocClick(e) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
|
||||
<LinkCard v-if="isShort && props.post.link_card" :card="props.post.link_card" />
|
||||
|
||||
<div class="end">
|
||||
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { openLightbox } from '../../lightbox'
|
||||
import { thumbURL, thumbifyHtml } from '../../utils'
|
||||
import LinkCard from '../../components/LinkCard.vue'
|
||||
|
||||
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
|
||||
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
|
||||
@@ -72,6 +73,9 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
|
||||
@click.prevent.stop="openLightbox(p.images, i)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
|
||||
<LinkCard v-if="p.link_card" :card="p.link_card" />
|
||||
|
||||
<div class="meta">
|
||||
<span class="badge short">SHORT</span>
|
||||
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
|
||||
|
||||
@@ -5,6 +5,7 @@ import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
|
||||
import CommentSection from '../components/comments/CommentSection.vue'
|
||||
import LinkCard from '../components/LinkCard.vue'
|
||||
import { openLightbox } from '../lightbox'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
@@ -124,6 +125,9 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
|
||||
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
|
||||
|
||||
<footer class="foot">
|
||||
<div v-if="post.tags && post.tags.length" class="tags">
|
||||
<RouterLink
|
||||
|
||||
Reference in New Issue
Block a user