380 lines
13 KiB
Vue
380 lines
13 KiB
Vue
<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>
|