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