短文配图:快发盒上传 + 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
+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>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
const router = useRouter()
const props = defineProps({
post: { type: Object, required: true }
})
const isShort = computed(() => props.post.kind === 'short')
// 整卡可点进详情(Twitter 式);标签 / 配图自己会停冒泡不误跳。
// 用户在选中文字时不跳转(复制内容是第一意图)。
function openPost() {
if (window.getSelection()?.toString()) return
router.push(`/post/${props.post.slug}`)
}
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
const body = computed(() => {
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>
<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="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-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">
<RouterLink :to="`/post/${post.slug}`" class="read">
{{ isShort ? '查看' : '继续阅读' }} →
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
</RouterLink>
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
<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"
:to="`/tag/${encodeURIComponent(t)}`"
class="tag-chip"
@click.stop
>
{{ t }}
</RouterLink>
@@ -76,6 +105,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
padding: 14px 30px;
margin: 0 -16px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background 0.15s ease;
}
@@ -154,6 +184,51 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
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 {
display: flex;
align-items: center;
@@ -164,7 +239,13 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
color: var(--faint);
}
/* 时间戳即入口(整卡可点后的键盘 / 视觉兜底):平时弱化,hover 亮起 */
.read {
color: var(--faint);
transition: color 0.15s ease;
}
.read:hover {
color: var(--accent);
}
+140 -2
View File
@@ -23,8 +23,40 @@ const publishing = ref(false)
const error = ref('')
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 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 () => {
try {
@@ -53,9 +85,11 @@ async function publish() {
await adminApi.createPost({
kind: 'short',
content_md: draft.value.trim(),
status: 'published'
status: 'published',
images: images.value.map((i) => i.url)
})
draft.value = ''
images.value = []
sheetOpen.value = false
emit('published')
} catch (e) {
@@ -79,13 +113,30 @@ async function publish() {
<span class="qs-av">{{ initial }}</span>
<div class="qs-body">
<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">
<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-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }}
</button>
</div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div>
</div>
</div>
@@ -102,13 +153,30 @@ async function publish() {
<button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button>
</div>
<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">
<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-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }}
</button>
</div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div>
</div>
</Transition>
@@ -201,6 +269,76 @@ async function publish() {
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 */
.qs-fab {
display: none;