+ {{ initial }} @@ -46,9 +57,26 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp {{ summaryText }} + + + + + - - {{ isShort ? '查看' : '继续阅读' }} → + + {{ relativeDate(post.published_at) }} {{ minutesLabel(post) }} @@ -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 }} @@ -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); } diff --git a/frontend/src/components/QuickShort.vue b/frontend/src/components/QuickShort.vue index cef938d..9054964 100644 --- a/frontend/src/components/QuickShort.vue +++ b/frontend/src/components/QuickShort.vue @@ -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() { {{ initial }} + + + + × + + + + + {{ error }} Ctrl / ⌘ + Enter 发布 {{ publishing ? '发布中…' : '发布' }} + @@ -102,13 +153,30 @@ async function publish() { ✕ + + + + × + + + + + {{ error }} Ctrl / ⌘ + Enter 发布 {{ publishing ? '发布中…' : '发布' }} + @@ -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; diff --git a/frontend/src/lightbox.js b/frontend/src/lightbox.js new file mode 100644 index 0000000..a28bf9e --- /dev/null +++ b/frontend/src/lightbox.js @@ -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 +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2e305ff..08cd653 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -304,6 +304,32 @@ html.topbar-hidden {--topbar-h: 0px;} line-height: inherit; margin: 0; 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; text-align: center; color: var(--muted); diff --git a/frontend/src/ui/yohaku/YohakuArticle.vue b/frontend/src/ui/yohaku/YohakuArticle.vue index d5116d7..ffb13f5 100644 --- a/frontend/src/ui/yohaku/YohakuArticle.vue +++ b/frontend/src/ui/yohaku/YohakuArticle.vue @@ -1,6 +1,7 @@
{{ initial }} @@ -46,9 +57,26 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp {{ summaryText }} + + + + + - - {{ isShort ? '查看' : '继续阅读' }} → + + {{ relativeDate(post.published_at) }} {{ minutesLabel(post) }} @@ -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 }} @@ -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); } diff --git a/frontend/src/components/QuickShort.vue b/frontend/src/components/QuickShort.vue index cef938d..9054964 100644 --- a/frontend/src/components/QuickShort.vue +++ b/frontend/src/components/QuickShort.vue @@ -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() { {{ initial }} + + + + × + + + + + {{ error }} Ctrl / ⌘ + Enter 发布 {{ publishing ? '发布中…' : '发布' }} + @@ -102,13 +153,30 @@ async function publish() { ✕ + + + + × + + + + + {{ error }} Ctrl / ⌘ + Enter 发布 {{ publishing ? '发布中…' : '发布' }} + @@ -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; diff --git a/frontend/src/lightbox.js b/frontend/src/lightbox.js new file mode 100644 index 0000000..a28bf9e --- /dev/null +++ b/frontend/src/lightbox.js @@ -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 +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2e305ff..08cd653 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -304,6 +304,32 @@ html.topbar-hidden {--topbar-h: 0px;} line-height: inherit; margin: 0; 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; text-align: center; color: var(--muted); diff --git a/frontend/src/ui/yohaku/YohakuArticle.vue b/frontend/src/ui/yohaku/YohakuArticle.vue index d5116d7..ffb13f5 100644 --- a/frontend/src/ui/yohaku/YohakuArticle.vue +++ b/frontend/src/ui/yohaku/YohakuArticle.vue @@ -1,6 +1,7 @@