短文配图:快发盒上传 + Twitter 式网格 + 全站 lightbox 预览;整卡点击进详情、时间线短文豁免首字

This commit is contained in:
Sakurasan
2026-09-27 23:41:34 +08:00
parent 08250b58ef
commit 740d47bbf6
11 changed files with 519 additions and 5 deletions
+3
View File
@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import TopBar from './components/TopBar.vue' import TopBar from './components/TopBar.vue'
import ThemeSwitcher from './components/ThemeSwitcher.vue' import ThemeSwitcher from './components/ThemeSwitcher.vue'
import ImgLightbox from './components/ImgLightbox.vue'
import YohakuTop from './ui/yohaku/YohakuTop.vue' import YohakuTop from './ui/yohaku/YohakuTop.vue'
import YohakuFoot from './ui/yohaku/YohakuFoot.vue' import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
import { site, setAdminScope } from './site' import { site, setAdminScope } from './site'
@@ -36,6 +37,8 @@ watch(
<RouterView /> <RouterView />
<YohakuFoot v-if="isVivid" /> <YohakuFoot v-if="isVivid" />
<!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) --> <!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) -->
<!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 -->
<ImgLightbox />
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。 <!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 --> vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" /> <ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { onBeforeUnmount, watch } from 'vue'
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
// 图片预览层:App.vue 全局挂一次。铺满全屏的暗底 + 居中大图,
// 多图时左右箭头 / 方向键切换,角标显示第几张;Esc / 点空白收起。
function onKey(e) {
if (e.key === 'Escape') closeLightbox()
if (e.key === 'ArrowLeft') stepLightbox(-1)
if (e.key === 'ArrowRight') stepLightbox(1)
}
watch(
() => st.open,
(v) => {
if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
}
)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<Transition name="lb">
<div v-if="st.open" class="lb-overlay" @click="closeLightbox">
<img
class="lb-img"
:src="st.list[st.index]"
alt=""
@click.stop
/>
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
<span v-if="st.list.length > 1" class="lb-count">{{ st.index + 1 }} / {{ st.list.length }}</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.lb-overlay {
position: fixed;
inset: 0;
z-index: 130;
background: rgba(12, 11, 10, 0.88);
display: flex;
align-items: center;
justify-content: center;
}
.lb-img {
max-width: min(1200px, 92vw);
max-height: 88vh;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
user-select: none;
}
.lb-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 26px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
padding-bottom: 4px;
transition: background-color 0.15s, color 0.15s;
}
.lb-nav:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-nav.prev {
left: 22px;
}
.lb-nav.next {
right: 22px;
}
.lb-close {
position: absolute;
top: 18px;
right: 22px;
width: 38px;
height: 38px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 17px;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.lb-close:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-count {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 12.5px;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.lb-img {
max-width: 96vw;
max-height: 74vh;
}
.lb-nav {
width: 38px;
height: 38px;
font-size: 22px;
}
.lb-nav.prev {
left: 10px;
}
.lb-nav.next {
right: 10px;
}
.lb-close {
top: 10px;
right: 10px;
}
}
.lb-enter-active {
transition: opacity 0.2s ease;
}
.lb-leave-active {
transition: opacity 0.18s ease;
}
.lb-enter-from,
.lb-leave-to {
opacity: 0;
}
</style>
+84 -3
View File
@@ -1,14 +1,25 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { site } from '../site' import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils' import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
const router = useRouter()
const props = defineProps({ const props = defineProps({
post: { type: Object, required: true } post: { type: Object, required: true }
}) })
const isShort = computed(() => props.post.kind === 'short') const isShort = computed(() => props.post.kind === 'short')
// 整卡可点进详情(Twitter 式);标签 / 配图自己会停冒泡不误跳。
// 用户在选中文字时不跳转(复制内容是第一意图)。
function openPost() {
if (window.getSelection()?.toString()) return
router.push(`/post/${props.post.slug}`)
}
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要 // 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
const body = computed(() => { const body = computed(() => {
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '') if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
@@ -25,7 +36,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
</script> </script>
<template> <template>
<article class="row-feed" :class="{ short: isShort }"> <article class="row-feed" :class="{ short: isShort }" @click="openPost">
<div class="avatar" aria-hidden="true">{{ initial }}</div> <div class="avatar" aria-hidden="true">{{ initial }}</div>
<div class="body"> <div class="body">
@@ -46,9 +57,26 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<div v-if="isShort" class="prose short-body" v-html="body"></div> <div v-if="isShort" class="prose short-body" v-html="body"></div>
<div v-else class="summary">{{ summaryText }}</div> <div v-else class="summary">{{ summaryText }}</div>
<!-- 短文配图:Twitter 式网格(1 全宽 / 2 并排 / 3 左大右二 / 4 四宫格);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click.stop="openLightbox(post.images, i)"
/>
</div>
<div class="meta"> <div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read"> <RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
{{ isShort ? '查看' : '继续阅读' }} → <time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
</RouterLink> </RouterLink>
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span> <span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
<span v-if="post.tags && post.tags.length" class="tags"> <span v-if="post.tags && post.tags.length" class="tags">
@@ -57,6 +85,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
:key="t" :key="t"
:to="`/tag/${encodeURIComponent(t)}`" :to="`/tag/${encodeURIComponent(t)}`"
class="tag-chip" class="tag-chip"
@click.stop
> >
{{ t }} {{ t }}
</RouterLink> </RouterLink>
@@ -76,6 +105,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
padding: 14px 30px; padding: 14px 30px;
margin: 0 -16px; margin: 0 -16px;
border-bottom: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background 0.15s ease; transition: background 0.15s ease;
} }
@@ -154,6 +184,51 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
margin: 0; margin: 0;
} }
/* 短文配图:Twitter 式网格。1 张全宽(限高裁切),2/4 等分,3 张左大右二 */
.imgs {
display: grid;
gap: 3px;
margin-top: 10px;
border-radius: 12px;
overflow: hidden;
}
.imgs img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
cursor: zoom-in;
}
.imgs.n1 img {
max-height: 440px;
}
.imgs.n2,
.imgs.n4 {
grid-template-columns: 1fr 1fr;
}
.imgs.n3 {
grid-template-columns: 1fr 1fr;
}
.imgs.n2 img,
.imgs.n4 img {
aspect-ratio: 1;
}
.imgs.n3 img {
aspect-ratio: 1;
}
.imgs.n3 img:first-child {
grid-row: span 2;
aspect-ratio: auto;
height: 100%;
}
.meta { .meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -164,7 +239,13 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
color: var(--faint); color: var(--faint);
} }
/* 时间戳即入口(整卡可点后的键盘 / 视觉兜底):平时弱化,hover 亮起 */
.read { .read {
color: var(--faint);
transition: color 0.15s ease;
}
.read:hover {
color: var(--accent); color: var(--accent);
} }
+140 -2
View File
@@ -23,8 +23,40 @@ const publishing = ref(false)
const error = ref('') const error = ref('')
const sheetOpen = ref(false) const sheetOpen = ref(false)
// 配图:选图即上传(走 /api/admin/files),最多 4 张,发布时随帖子带上
const images = ref([]) // [{ url, name }]
const MAX_IMAGES = 4
const imgInput = ref(null)
const imgUploading = ref(false)
function pickImages() {
imgInput.value?.click()
}
async function onImagesPick(e) {
const files = [...(e.target.files || [])].slice(0, MAX_IMAGES - images.value.length)
e.target.value = '' // 允许分批续选
if (!files.length) return
imgUploading.value = true
error.value = ''
try {
const fd = new FormData()
for (const f of files) fd.append('file', f)
const data = await adminApi.uploadFiles(fd)
for (const f of data) images.value.push({ url: f.url, name: f.name })
} catch (e2) {
error.value = e2.message || '图片上传失败'
} finally {
imgUploading.value = false
}
}
function removeImage(i) {
images.value.splice(i, 1)
}
const initial = computed(() => (userName.value || 'O').trim().slice(0, 1).toUpperCase()) const initial = computed(() => (userName.value || 'O').trim().slice(0, 1).toUpperCase())
const canPost = computed(() => draft.value.trim().length > 0 && !publishing.value) const canPost = computed(
() => (draft.value.trim().length > 0 || images.value.length > 0) && !publishing.value
)
onMounted(async () => { onMounted(async () => {
try { try {
@@ -53,9 +85,11 @@ async function publish() {
await adminApi.createPost({ await adminApi.createPost({
kind: 'short', kind: 'short',
content_md: draft.value.trim(), content_md: draft.value.trim(),
status: 'published' status: 'published',
images: images.value.map((i) => i.url)
}) })
draft.value = '' draft.value = ''
images.value = []
sheetOpen.value = false sheetOpen.value = false
emit('published') emit('published')
} catch (e) { } catch (e) {
@@ -79,13 +113,30 @@ async function publish() {
<span class="qs-av">{{ initial }}</span> <span class="qs-av">{{ initial }}</span>
<div class="qs-body"> <div class="qs-body">
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" /> <textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts"> <div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span> <span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span> <span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish"> <button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }} {{ publishing ? '发布中…' : '发布' }}
</button> </button>
</div> </div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div> </div>
</div> </div>
</div> </div>
@@ -102,13 +153,30 @@ async function publish() {
<button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button> <button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button>
</div> </div>
<textarea v-model="draft" class="qs-input" rows="4" placeholder="记点短的…" @keydown="onKey" /> <textarea v-model="draft" class="qs-input" rows="4" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts"> <div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span> <span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span> <span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish"> <button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }} {{ publishing ? '发布中…' : '发布' }}
</button> </button>
</div> </div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -201,6 +269,76 @@ async function publish() {
cursor: default; cursor: default;
} }
/* 配图:缩略图条 + 添加按钮(最多 4 张,Twitter 同款) */
.qs-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qs-thumb {
position: relative;
width: 64px;
height: 64px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--line);
}
.qs-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.qs-thumb .x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 11px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.qs-img-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: none;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
}
.qs-img-btn:hover:not(:disabled) {
color: var(--accent);
background: var(--accent-soft);
}
.qs-img-btn:disabled {
opacity: 0.4;
cursor: default;
}
.qs-img-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* 手机:FAB 常驻右下,点开上拉 sheet */ /* 手机:FAB 常驻右下,点开上拉 sheet */
.qs-fab { .qs-fab {
display: none; display: none;
+31
View File
@@ -0,0 +1,31 @@
import { reactive } from 'vue'
// 全站通用的图片预览(lightbox)。任何地方的配图都可以
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
// 打开时锁 body 滚动(和菜单 sheet 同一套做法)。
const state = reactive({
list: [],
index: 0,
open: false
})
export const lightboxState = state
export function openLightbox(list, index = 0) {
if (!list || !list.length) return
state.list = list
state.index = Math.max(0, Math.min(index, list.length - 1))
state.open = true
document.body.style.overflow = 'hidden'
}
export function closeLightbox() {
state.open = false
document.body.style.overflow = ''
}
export function stepLightbox(delta) {
if (!state.open || state.list.length < 2) return
state.index = (state.index + delta + state.list.length) % state.list.length
}
+26
View File
@@ -304,6 +304,32 @@ html.topbar-hidden {--topbar-h: 0px;}
line-height: inherit; line-height: inherit;
margin: 0; margin: 0;
color: inherit;} color: inherit;}
/* 时间线里的短文同样不放大首字:一句话的纸条压个大字像截断
(详情页 .prose-short 同此;长文详情的首字下沉保留) */
.prose.short-body > p:first-of-type::first-letter {float: none;
font-size: inherit;
line-height: inherit;
font-family: inherit;
margin: 0;
color: inherit;}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;详情页用) */
.post-imgs {display: grid;
gap: 3px;
margin-top: 18px;
border-radius: 6px;
overflow: hidden;}
.post-imgs img {display: block;
width: 100%;
height: 100%;
object-fit: cover;}
.post-imgs.n2, .post-imgs.n4 {grid-template-columns: 1fr 1fr;}
.post-imgs.n3 {grid-template-columns: 1fr 1fr;}
.post-imgs.n2 img, .post-imgs.n4 img {aspect-ratio: 1;}
.post-imgs.n3 img {aspect-ratio: 1;}
.post-imgs.n3 img:first-child {grid-row: span 2;
aspect-ratio: auto;
height: 100%;}
.post-imgs.n1 img {max-height: 560px;}
.empty {padding: 48px 0; .empty {padding: 48px 0;
text-align: center; text-align: center;
color: var(--muted); color: var(--muted);
+18
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils' import { sanitizeHtml } from '../../utils'
import { openLightbox } from '../../lightbox'
import CommentSection from '../../components/comments/CommentSection.vue' import CommentSection from '../../components/comments/CommentSection.vue'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」: // 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
@@ -168,6 +169,23 @@ function onTocClick(e) {
<div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" /> <div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && props.post.images && props.post.images.length"
class="post-imgs"
:class="'n' + Math.min(props.post.images.length, 4)"
>
<img
v-for="(u, i) in props.post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(props.post.images, i)"
/>
</div>
<div class="end"> <div class="end">
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink> <RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
</div> </div>
+17
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { openLightbox } from '../../lightbox'
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签), // 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。 // 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
@@ -54,6 +55,22 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<!-- 短文:纸条,正文直接铺开 --> <!-- 短文:纸条,正文直接铺开 -->
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`"> <RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="body" v-html="p.content_html" /> <div class="body" v-html="p.content_html" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览(不跳详情) -->
<div
v-if="p.images && p.images.length"
class="imgs"
:class="'n' + Math.min(p.images.length, 4)"
>
<img
v-for="(u, i) in p.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click.prevent.stop="openLightbox(p.images, i)"
/>
</div>
<div class="meta"> <div class="meta">
<span class="badge short">SHORT</span> <span class="badge short">SHORT</span>
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time> <time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
+5
View File
@@ -130,6 +130,11 @@ html[data-ui='vivid'] .paper > .end {margin-top:40px;padding-top:24px;border-top
display:flex;gap:14px;flex-wrap:wrap;align-items:center;} display:flex;gap:14px;flex-wrap:wrap;align-items:center;}
html[data-ui='vivid'] .paper > .end .tg {padding:6px 0} html[data-ui='vivid'] .paper > .end .tg {padding:6px 0}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;跟 prose 同宽,圆角跟 vivid 走) */
html[data-ui='vivid'] .post-imgs {margin-top: 6px;
border-radius: var(--radius);}
html[data-ui='vivid'] .post-imgs.n1 img {max-height: 520px;}
/* ========================================================================== /* ==========================================================================
把 classic 的 .prose 挡回去 把 classic 的 .prose 挡回去
-------------------------------------------------------------------------- --------------------------------------------------------------------------
+13
View File
@@ -128,6 +128,19 @@ html[data-ui='vivid'] .note .body a {color:var(--accent);font-weight:600;
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)} html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)}
} }
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张) */
html[data-ui='vivid'] .note .imgs {display:grid;gap:3px;margin-top:10px;
border-radius:var(--radius);overflow:hidden;}
html[data-ui='vivid'] .note .imgs img {display:block;width:100%;height:100%;
object-fit:cover;cursor:zoom-in;}
html[data-ui='vivid'] .note .imgs.n2,
html[data-ui='vivid'] .note .imgs.n4 {grid-template-columns:1fr 1fr;}
html[data-ui='vivid'] .note .imgs.n3 {grid-template-columns:1fr 1fr;}
html[data-ui='vivid'] .note .imgs.n2 img,
html[data-ui='vivid'] .note .imgs.n4 img {aspect-ratio:1;}
html[data-ui='vivid'] .note .imgs.n3 img {aspect-ratio:1;}
html[data-ui='vivid'] .note .imgs.n3 img:first-child {grid-row:span 2;
aspect-ratio:auto;height:100%;}
html[data-ui='vivid'] .note .tags {display:flex;gap:14px;flex-wrap:wrap;margin-top:8px} html[data-ui='vivid'] .note .tags {display:flex;gap:14px;flex-wrap:wrap;margin-top:8px}
html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center; html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center;
margin-top:36px;flex-wrap:wrap;} margin-top:36px;flex-wrap:wrap;}
+18
View File
@@ -5,6 +5,7 @@ import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue' import RightRail from '../components/RightRail.vue'
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue' import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
import CommentSection from '../components/comments/CommentSection.vue' import CommentSection from '../components/comments/CommentSection.vue'
import { openLightbox } from '../lightbox'
import { publicApi } from '../api' import { publicApi } from '../api'
import { site, applyDocTitle } from '../site' import { site, applyDocTitle } from '../site'
import { formatDate, minutesLabel, sanitizeHtml } from '../utils' import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
@@ -106,6 +107,23 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
v-html="sanitizeHtml(contentHtml)" v-html="sanitizeHtml(contentHtml)"
></div> ></div>
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="post-imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(post.images, i)"
/>
</div>
<footer class="foot"> <footer class="foot">
<div v-if="post.tags && post.tags.length" class="tags"> <div v-if="post.tags && post.tags.length" class="tags">
<RouterLink <RouterLink