照片墙重写改造

This commit is contained in:
灌糖包子 2026-05-17 23:48:14 +08:00
parent 8fc5ef572c
commit 5b2d25fe78
Signed by: sookie
GPG Key ID: 0599BECB75C1E68D
3 changed files with 391 additions and 130 deletions

1
components.d.ts vendored
View File

@ -10,6 +10,7 @@ declare module '@vue/runtime-core' {
CaptchaPanel: typeof import('./src/components/CaptchaPanel.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']

View File

@ -1,175 +1,435 @@
<template>
<div class="page-wrapper">
<el-form inline :model="search">
<el-form-item label="文件名">
<el-input v-model="search.name" />
</el-form-item>
<el-form-item label="宽度" >
<el-input-number v-model="search.widthMin" :min="0" controls-position="right" step-strictly>
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
<el-input-number v-model="search.widthMax" :min="0" controls-position="right" step-strictly>
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
</el-form-item>
<el-form-item label="高度" >
<el-input-number v-model="search.heightMin" :min="0" controls-position="right" step-strictly>
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
<el-input-number v-model="search.heightMax" :min="0" controls-position="right" step-strictly>
<template #prefix></template>
<template #suffix>px</template>
</el-input-number>
</el-form-item>
</el-form>
<div class="btn-container">
<el-upload
:action="`${apiBase}/photoWall/upload`"
accept="image/jpeg,image/png"
name="image"
:headers="{token: store.state.loginInfo.token}"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-right: 10px;">
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')}文件大小不超过10MB。`">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>上传图片</el-button>
</el-tooltip>
</el-upload>
<el-button type="danger" @click="deleteAll" style="vertical-align: bottom" v-permission="'photoWall:delete'" plain>批量删除</el-button>
<div class="search-btn">
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>搜索</el-button>
<el-button @click="reset" icon="RefreshLeft" plain>重置</el-button>
<div class="page-wrapper" ref="rootEl">
<!-- 操作栏 -->
<div class="btn-container">
<el-upload
:action="`${apiBase}/photoWall/upload`"
accept="image/jpeg,image/png"
name="image"
:headers="{ token: store.state.loginInfo.token }"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="margin-right: 10px;">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>
上传图片
</el-button>
</el-upload>
<el-button
v-if="selected.size > 0"
type="danger"
icon="Delete"
plain
v-permission="'photoWall:delete'"
@click="deleteSelected">
删除选中 ({{ selected.size }})
</el-button>
<div class="search-btn">
<el-button link type="primary" :disabled="!photos.length" @click="selectAll" v-if="!isAllSelected">全选</el-button>
<el-button link type="primary" @click="clearSelect" v-else>取消全选</el-button>
<el-button link type="primary" :disabled="!photos.length" @click="invertSelect">反选</el-button>
</div>
</div>
<!-- 瀑布流可滚动区域 -->
<div class="masonry-scroll">
<div class="masonry-scene">
<div
v-for="(col, ci) in columns"
:key="ci"
class="masonry-col">
<div
v-for="item in col"
:key="item._id"
class="card-wrap"
:class="{ 'is-selected': selected.has(item._id), 'is-entered': entered.has(item._id) }"
@click="toggleSelect(item)">
<div class="card-inner">
<!-- 图片 -->
<img
class="card-img"
:src="cdnBase ? `${cdnBase}/${item.thumbnail || item.name}` : ''"
:alt="item.name"
loading="lazy"
draggable="false" />
<!-- 选中角标 -->
<div class="check-badge">
<el-icon><Check /></el-icon>
</div>
<!-- Hover 浮层 -->
<div class="card-overlay">
<div class="overlay-info">
<p class="overlay-name" :title="item.name">{{ item.name }}</p>
<p class="overlay-size">{{ item.width }} × {{ item.height }}</p>
</div>
<el-button-group class="overlay-actions">
<el-button size="small" icon="View" plain @click.stop="preview(item)">预览</el-button>
<el-button
size="small"
icon="Delete"
type="danger"
plain
v-permission="'photoWall:delete'"
@click.stop="deleteSingle(item)">
删除
</el-button>
</el-button-group>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="page-container">
<el-pagination
background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:page-size="search.limit"
:total="total"
@size-change="pageSizeChange"
@current-change="pageChange" />
</div>
<!-- 图片预览 -->
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrls"
:initial-index="previewIndex"
@close="previewVisible = false" />
</div>
<el-table class="table-container" :data="photowallData" v-loading="loading" stripe @selection-change="dataSelect">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="文件名" />
<el-table-column prop="md5" label="md5" width="300" />
<el-table-column prop="thumbnail" label="缩略图" />
<el-table-column prop="width" label="宽度" width="70" />
<el-table-column prop="height" label="高度" width="70" />
<el-table-column label="操作" width="100" >
<template #default="scope">
<el-button link icon="View" @click="preview(scope.row)" title="预览"></el-button>
</template>
</el-table-column>
</el-table>
<div class="page-container">
<el-pagination background
:page-sizes="store.state.pageSizeOpts"
:layout="store.state.pageLayout"
:current-page="search.pageNum"
:total="total"
@size-change="pageSizeChange"
@current-change="pageChange">
</el-pagination>
</div>
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrlList"
:initial-index="previewIndex"
@close="previewVisible = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useStore } from 'vuex'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MsgResult, Page } from '@/model/common.dto'
import type { MsgResult } from '@/model/common.dto'
import { Page } from '@/model/common.dto'
import { useBaseList } from '@/model/baselist'
import PhotoWallModel from '@/model/api/photowall'
import type PhotoWallModel from '@/model/api/photowall'
import http from '@/utils/http'
class PhotoWallPage extends Page {
name?: string
widthMin!: number | null
widthMax!: number | null
heightMin!: number | null
heightMax!: number | null
reset() {
super.reset()
this.name = undefined
this.widthMin = null
this.widthMax = null
this.heightMin = null
this.heightMax = null
}
}
//
const COLUMN_WIDTH = 220 // (px)
const COL_GAP = 14 // (px)
//
const store = useStore()
const apiBase = import.meta.env.VITE_APP_API_BASE
const { loading, total, search, setLoadData, loadDataBase, reset, pageChange, pageSizeChange } = useBaseList(new PhotoWallPage())
const allowUploadExt = ['jpg', 'jpeg', 'png']
const photowallData = ref<PhotoWallModel[]>([])
const rootEl = ref<HTMLElement | null>(null)
const photos = ref<PhotoWallModel[]>([])
const isUploading = ref(false)
class GalleryPage extends Page {
constructor() { super(); this.limit = 50 }
}
const { loading, total, search, setLoadData, pageChange, pageSizeChange } = useBaseList(new GalleryPage())
const cdnBase = ref('')
const selected = ref<Set<string>>(new Set())
const entered = ref<Set<string>>(new Set())
const previewVisible = ref(false)
const previewUrlList = ref<string[]>([])
const previewUrls = ref<string[]>([])
const previewIndex = ref(0)
let selectedData: string[] = []
//
const colCount = ref(4)
function recalcCols() {
if (!rootEl.value) return
const w = rootEl.value.clientWidth - 32
colCount.value = Math.max(1, Math.floor((w + COL_GAP) / (COLUMN_WIDTH + COL_GAP)))
}
const columns = computed<PhotoWallModel[][]>(() => {
const cols: PhotoWallModel[][] = Array.from({ length: colCount.value }, () => [])
photos.value.forEach((item, i) => {
cols[i % colCount.value].push(item)
})
return cols
})
//
async function loadData() {
loading.value = true
const data = await http.get<PhotoWallPage, any>('/photoWall/list', {params: search})
selectedData = []
loading.value = false
total.value = data.total
photowallData.value = data.list
entered.value = new Set()
try {
if (!cdnBase.value) {
cdnBase.value = await http.get('/common/config/picture_cdn')
}
const data = await http.get<any, any>('/photoWall/list', {
params: { pageNum: search.pageNum, limit: search.limit }
})
total.value = data.total
photos.value = data.list as PhotoWallModel[]
previewUrls.value = photos.value.map(item => `${cdnBase.value}/${item.name}`)
await nextTick()
entered.value = new Set(photos.value.map(p => p._id))
} finally {
loading.value = false
}
}
setLoadData(loadData)
function deleteAll() {
if (!selectedData || !selectedData.length) {
ElMessage.warning('请选择要删除的数据')
return
}
ElMessageBox.confirm(`是否确认删除选中的${selectedData.length}条数据?`, '确认删除', {type: 'warning'}).then(async () => {
await http.delete('/photoWall/delete', {params: {ids: selectedData}})
ElMessage.success('删除成功')
loadData()
}).catch(() => {})
//
function toggleSelect(item: PhotoWallModel) {
const s = new Set(selected.value)
s.has(item._id) ? s.delete(item._id) : s.add(item._id)
selected.value = s
}
function dataSelect(selection: PhotoWallModel[]) {
selectedData = selection.map(item => item._id)
function selectAll() {
selected.value = new Set(photos.value.map(p => p._id))
}
function invertSelect() {
const s = new Set<string>()
photos.value.forEach(p => {
if (!selected.value.has(p._id)) s.add(p._id)
})
selected.value = s
}
function clearSelect() {
selected.value = new Set()
}
const isAllSelected = computed(() => photos.value.length && selected.value.size === photos.value.length)
//
function preview(item: PhotoWallModel) {
previewIndex.value = photos.value.findIndex(p => p._id === item._id) || 0
previewVisible.value = true
}
//
async function deleteSingle(item: PhotoWallModel) {
await ElMessageBox.confirm(`确认删除「${item.name}」?`, '确认删除', { type: 'warning' })
await http.delete('/photoWall/delete', { params: { ids: [item._id] } })
ElMessage.success('删除成功')
loadData()
}
async function deleteSelected() {
const ids = [...selected.value]
await ElMessageBox.confirm(`确认删除选中的 ${ids.length} 张图片?`, '确认删除', { type: 'warning' })
await http.delete('/photoWall/delete', { params: { ids } })
selected.value = new Set()
ElMessage.success('删除成功')
loadData()
}
//
function beforeUpload(file: File): boolean {
if (file.size > 10 << 20) {
ElMessage.warning('文件大小超过10MB')
ElMessage.warning('文件大小超过 10MB')
return false
}
isUploading.value = true
return true
}
function uploadSuccess(response: MsgResult) {
isUploading.value = false
if (response.code === 0) {
ElMessage.success('上传成功')
loadData()
} else {
ElMessage.warning(response.message || '上传失败')
}
isUploading.value = false
}
function uploadError(error: Error) {
isUploading.value = false
ElMessage.error(error.message)
}
async function preview(row: PhotoWallModel) {
const pictureCdn = await http.get('/common/config/picture_cdn')
const index = photowallData.value.findIndex(item => item._id === row._id)
previewUrlList.value = photowallData.value.map(item => `${pictureCdn}/${item.name}`)
previewIndex.value = index >= 0 ? index : 0
previewVisible.value = true
//
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
recalcCols()
resizeObserver = new ResizeObserver(() => recalcCols())
if (rootEl.value) resizeObserver.observe(rootEl.value)
await loadData()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<style scoped lang="less">
//
.masonry-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
// created
loadData()
</script>
.masonry-scene {
display: flex;
gap: 14px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
//
.card-wrap {
opacity: 0;
transform: translateY(20px);
border-radius: 10px;
outline: 2px solid transparent;
outline-offset: 2px;
transition:
opacity 0.35s ease,
transform 0.35s ease,
outline-color 0.2s ease;
cursor: pointer;
&.is-entered {
opacity: 1;
transform: translateY(0);
}
&.is-selected {
outline-color: var(--el-color-primary);
.check-badge {
opacity: 1;
transform: scale(1);
}
}
&:hover .card-overlay {
opacity: 1;
transform: translateY(0);
}
&:hover .overlay-info {
opacity: 1;
transform: translateY(0);
}
&:hover .overlay-actions {
opacity: 1;
transform: translateY(0);
}
}
//
.card-inner {
position: relative;
width: 100%;
min-height: 80px;
border-radius: 10px;
overflow: hidden;
background: var(--el-fill-color-light);
}
.card-img {
display: block;
width: 100%;
height: auto;
border-radius: 10px;
transition: transform 0.4s ease;
.card-wrap:hover & {
transform: scale(1.04);
}
}
//
.check-badge {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
opacity: 0;
transform: scale(0.5);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
// Hover
.card-overlay {
position: absolute;
inset: 0;
border-radius: 10px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.82) 0%,
rgba(0, 0, 0, 0.4) 55%,
rgba(0, 0, 0, 0.08) 100%
);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.overlay-info {
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.3s ease 0.04s,
transform 0.3s ease 0.04s;
.overlay-name {
margin: 0 0 3px;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
word-break: break-all;
display: -webkit-box;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.overlay-size {
margin: 0 0 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
}
}
.overlay-actions {
display: flex;
justify-content: center;
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.3s ease 0.08s,
transform 0.3s ease 0.08s;
}
</style>

View File

@ -11,7 +11,7 @@
:on-error="uploadError"
auto-upload
:show-file-list="false"
style="display: inline-block;margin-right: 10px;">
style="margin-right: 10px;">
<el-tooltip :content="`图片格式为${allowUploadExt.join('、')}文件大小不超过10MB。`">
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'sourceImage:save'" plain>上传图片</el-button>
</el-tooltip>