短文配图:快发盒上传 + Twitter 式网格 + 全站 lightbox 预览;整卡点击进详情、时间线短文豁免首字
This commit is contained in:
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user