Files
blog/src/views/Gallery.vue
T

380 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(135,196,235,0.05),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(200,182,224,0.04),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(249,199,216,0.03),transparent_28%),linear-gradient(180deg,#fcfaf6_0%,#fbf8f7_44%,#fefcfb_100%)]">
<div
class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(135,196,235,0.10)] opacity-48 blur-[88px]">
</div>
<div
class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(249,199,216,0.08)] opacity-48 blur-[88px]">
</div>
<div
class="pointer-events-none absolute inset-0 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]">
</div>
<n-scrollbar ref="virtualListInst" class="relative z-[1] py-6" :style="boxStyle" @scroll="handleScroll">
<section
class="relative mx-auto w-[82%] rounded-[28px] border border-white/62 bg-[linear-gradient(145deg,rgba(255,251,246,0.68),rgba(237,246,244,0.56))] p-6 shadow-[0_1px_3px_rgba(128,118,126,0.05),0_16px_38px_rgba(128,118,126,0.09),0_44px_84px_rgba(215,26,68,0.07),inset_0_1px_0_rgba(255,255,255,0.7)] backdrop-blur-[20px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-[linear-gradient(120deg,rgba(255,255,255,0.24),transparent_45%)] after:content-['']">
<div class="max-w-[48rem]">
<h1 class="m-0 text-[clamp(1.8rem,2.5vw,2.8rem)] leading-[1.2] text-[#2c3a4a]">
把喜欢的画面收进一片更柔和的光里
</h1>
<p class="mt-[0.9rem] max-w-[42rem] leading-[1.75] text-[#66778b]">
如果图片效果模糊的话可以点击图片查看原图
</p>
</div>
<div
class="mt-6 rounded-full border border-white bg-[linear-gradient(135deg,rgba(255,249,241,0.76),rgba(236,245,243,0.72)),radial-gradient(circle_at_top_right,rgba(255,255,255,0.28),transparent_34%)] shadow-[0_0_0_2px_rgba(255,255,255,0.75),inset_0_1px_0_rgba(255,255,255,0.55)] transition-[box-shadow,border-color] duration-300">
<n-input class="gallery-search" round size="large" clearable v-model:value="kw" @keyup.enter="onSearch"
placeholder="输入分享者 | 图片名,按回车开始搜索">
<template #suffix>
<n-icon class="cursor-pointer text-slate-500 transition-colors duration-200 hover:text-primary" size="large" @click="onSearch">
<icon-search />
</n-icon>
</template>
</n-input>
</div>
</section>
<section
class="mx-auto mt-6 w-[82%] min-h-[80vh] rounded-[30px] border border-white/62 bg-white/42 p-6 shadow-[0_1px_3px_rgba(128,118,126,0.05),0_14px_30px_rgba(128,118,126,0.09),0_36px_72px_rgba(215,26,68,0.07),inset_0_1px_0_rgba(255,255,255,0.7)] backdrop-blur-[18px]">
<div class="mb-5 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div class="text-[1.6rem] font-semibold text-[#5c3d46]">图片列表</div>
<div class="mt-1 flex items-center gap-2 text-[13px] text-slate-500">
<span v-if="searchKeyword" class="inline-flex items-center rounded-full bg-[rgba(215,26,68,0.1)] px-2.5 py-0.5 font-semibold text-[rgb(215,26,68)]">
关键词{{ searchKeyword }}
</span>
<span>{{ galleryStats.status }}</span>
</div>
</div>
<div class="inline-flex items-center gap-2 self-start rounded-full bg-white/68 px-5 py-2 text-[#72515a] shadow-[0_2px_8px_rgba(128,118,126,0.08),inset_0_1px_0_rgba(255,255,255,0.7)]">
<span class="inline-block h-2 w-2 rounded-full" :class="loading ? 'animate-pulse bg-[#D71A44]' : 'bg-[#DB5F51]'"></span>
{{ galleryStats.loaded }}
</div>
</div>
<Waterfall ref="waterfall" :list="fileList" :gutter="gutter" :columns="column" row-key="filepath" class="w-full" img-selector="url"
animation-effect="fadeIn" :animation-duration="1000" :animation-delay="300" backgroundColor="transparent">
<template #item="{ item }">
<div
class="gallery-card group relative flex h-full items-center justify-center overflow-hidden rounded-[12px] border border-white/82 bg-[linear-gradient(180deg,rgba(255,252,248,0.92),rgba(239,244,241,0.78))] backdrop-blur-[14px] transition-[transform,box-shadow,border-color] duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1">
<LazyImg class="gallery-img overflow-hidden rounded-[12px]" :Pwidth="item.width" :Pheight="item.height"
:url="item.thumbnail" :preUrl="item.filepath" />
<div
class="pointer-events-none absolute left-0 right-0 top-0 z-10 pt-1.5 px-3 text-center truncate rounded-[12px] bg-gradient-to-b from-[rgba(39,50,65,0.55)] to-transparent text-sm text-white opacity-0 backdrop-blur-[6px] transition-opacity duration-300 group-hover:opacity-100">
{{ item.filename }}
</div>
<div
class="absolute bottom-0 left-0 right-0 z-10 flex translate-y-[102%] items-center justify-between gap-3 rounded-[12px] bg-[rgba(39,50,65,0.55)] px-3 py-0.5 text-white backdrop-blur-[12px] transition-transform duration-[320ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] group-hover:translate-y-0">
<div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-[#eef4f8]">
<span class="text-lg text-[#ffd8cb]">{{ item.nickname }}</span> 分享
</div>
<div
class="inline-flex flex-shrink-0 cursor-pointer items-center rounded-full bg-white/18 text-sm px-2 py-0.5 text-[#ffe3d3] transition-[background-color,transform] duration-200 hover:scale-105 hover:bg-white/28 hover:text-white active:scale-95"
@click.stop="downloadFile(item.filepath)">
<icon-download class="mr-1 h-4 w-4" />
下载
</div>
</div>
</div>
</template>
</Waterfall>
<div v-if="fileList.length === 0 && !loading" class="flex flex-col items-center justify-center py-16 text-slate-400">
<icon-search class="mb-3 h-10 w-10 opacity-50" />
<p class="text-[15px]">{{ searchKeyword ? '未找到相关图片,换个关键词试试' : '暂无图片' }}</p>
</div>
<div v-if="loading" class="flex items-center justify-center gap-2 py-6 text-sm text-slate-400">
<icon-loading class="zhuan h-5 w-5 text-primary" />
<span>{{ fileList.length ? '加载中...' : '首次加载中...' }}</span>
</div>
<div v-else-if="isLoadAll && fileList.length" class="py-5 text-center text-[13px] text-slate-400">
已加载全部 {{ fileList.length }} 张图片
</div>
</section>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { throttle } from 'es-toolkit'
import { LazyImg, Waterfall } from '../lib'
definePage({
name: 'gallery',
meta: {
title: '画廊',
}
})
interface GalleryItem {
filepath: string
filename: string
nickname: string
width: number
height: number
}
interface ScrollContainer extends EventTarget {
scrollTop: number
scrollHeight: number
offsetHeight: number
}
const GALLERY_WIDTH_RATIO = 0.82
const PAGE_SIZE = 20
const SCROLL_THRESHOLD = 100
const waterfall = ref<any>(null)
const nav: any = $store.nav.useNavStore()
const boxStyle = ref<Record<string, string>>({})
const fileList = ref<GalleryItem[]>([])
const pn = ref(1)
const ps = ref(PAGE_SIZE)
const loading = ref(false)
const kw = ref('')
const cwidth = ref(240)
const column = ref(5)
const gutter = ref(18)
const isLoadAll = ref(false)
let requestSeq = 0
const searchKeyword = computed(() => kw.value.trim())
const galleryStats = computed(() => {
let status = '浏览全部'
if (loading.value) {
status = '加载中'
} else if (isLoadAll.value && fileList.value.length > 0) {
status = '已全部加载'
} else if (searchKeyword.value) {
status = '搜索结果'
}
return {
loaded: fileList.value.length,
columns: column.value,
status,
keyword: searchKeyword.value ? `关键词:${searchKeyword.value}` : '关键词:全部图片',
}
})
function renderWaterfall() {
waterfall.value?.renderer()
}
function calculateColumns() {
const totalWidth = window.innerWidth * GALLERY_WIDTH_RATIO
const col = Math.floor((totalWidth + gutter.value) / (cwidth.value + gutter.value))
column.value = col > 0 ? col : 1
renderWaterfall()
}
function updateBoxStyle() {
boxStyle.value = {
maxHeight: `calc(100vh - 5px - ${nav.navH}px)`,
}
}
function appendFileList(list: GalleryItem[]) {
if (pn.value === 1) {
fileList.value = list
return
}
list.forEach((item) => {
fileList.value.push(item)
})
}
function resetGalleryState() {
pn.value = 1
kw.value = ''
fileList.value = []
isLoadAll.value = false
}
async function getFileList() {
if (loading.value) return
loading.value = true
const seq = ++requestSeq
try {
const res = await $http.file.getFileList({
keyword: kw.value,
page_num: pn.value,
page_size: ps.value,
})
const list = res.data as GalleryItem[]
// 过期请求直接丢弃,避免搜索时旧结果覆盖新结果
if (seq !== requestSeq) return
if (list.length < ps.value) {
isLoadAll.value = true
}
appendFileList(list)
} catch (error) {
console.error('获取图片列表失败:', error)
} finally {
if (seq === requestSeq) {
loading.value = false
renderWaterfall()
}
}
}
const handleScroll = throttle((e: Event) => {
if (loading.value || isLoadAll.value) return
const target = e.target as ScrollContainer | null
if (!target) return
const scrollTop = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.offsetHeight
if (scrollTop + clientHeight >= scrollHeight - SCROLL_THRESHOLD) {
pn.value += 1
getFileList()
}
}, 300)
function onSearch() {
pn.value = 1
kw.value = kw.value.trim()
isLoadAll.value = false
// 立即清空旧列表,避免搜索时看到上一批结果
fileList.value = []
// 若正在加载中,先解除守卫,让新搜索请求可以发起
loading.value = false
requestSeq += 1
getFileList()
}
function downloadFile(url: string) {
const link = document.createElement('a')
link.href = url
const fileName = url.split('/').pop() || 'downloaded-file'
link.download = fileName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
onMounted(() => {
calculateColumns()
updateBoxStyle()
getFileList()
window.addEventListener('resize', handleResize)
})
function handleResize() {
calculateColumns()
updateBoxStyle()
}
onUnmounted(() => {
resetGalleryState()
window.removeEventListener('resize', handleResize)
})
// 防抖函数
</script>
<style scoped lang="less">
:deep(.gallery-search .n-input) {
background: rgba(255, 255, 255, 0.74) !important;
border: 1px solid rgba(255, 255, 255, 0.7) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.6),
0 2px 6px rgba(97, 121, 146, 0.05),
0 10px 24px rgba(97, 121, 146, 0.09) !important;
backdrop-filter: blur(18px);
transition:
border-color 0.25s ease,
box-shadow 0.25s ease;
}
:deep(.gallery-search .n-input__input-el) {
color: #314155 !important;
}
:deep(.gallery-search .n-input__placeholder) {
color: #93a2b3 !important;
}
:deep(.gallery-search .n-input.n-input--focus) {
border-color: rgba(122, 160, 148, 0.7) !important;
box-shadow:
0 0 0 4px rgba(122, 160, 148, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.6),
0 2px 6px rgba(97, 121, 146, 0.06),
0 16px 36px rgba(97, 121, 146, 0.14) !important;
}
/* 图片卡片 hover 增强 */
.gallery-card {
box-shadow:
0 1px 3px rgba(97, 121, 146, 0.05),
0 12px 26px rgba(97, 121, 146, 0.09),
0 32px 60px rgba(178, 128, 88, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.72) !important;
}
.gallery-card:hover {
border-color: rgba(255, 255, 255, 0.88) !important;
box-shadow:
0 2px 6px rgba(97, 121, 146, 0.06),
0 18px 38px rgba(97, 121, 146, 0.14),
0 48px 90px rgba(178, 128, 88, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.8) !important;
}
.gallery-img {
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.gallery-card:hover .gallery-img {
transform: scale(1.045);
}
/* 加载图标旋转 */
.zhuan {
animation: gallery-spin 1.5s linear infinite;
}
@keyframes gallery-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
:deep(.n-image img) {
border-radius: 1rem !important;
}
:deep(.n-input) {
--n-border: none !important;
--n-border-hover: none !important;
--n-border-focus: none !important;
--n-box-shadow-focus: none !important;
background: transparent !important;
}
:deep(.n-input__input-el),
:deep(.n-base-selection-label) {
color: #314155 !important;
}
</style>