482 lines
13 KiB
Vue
482 lines
13 KiB
Vue
<template>
|
||
<div class="page-wrapper" ref="rootEl">
|
||
|
||
<!-- 搜索栏 -->
|
||
<el-form inline :model="search" @submit.prevent>
|
||
<el-form-item :label="t('api.photoWall.searchFileName')">
|
||
<el-input v-model="search.name" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('api.photoWall.searchWidth')">
|
||
<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 style="margin-left: 8px;">
|
||
<template #prefix>≤</template>
|
||
<template #suffix>px</template>
|
||
</el-input-number>
|
||
</el-form-item>
|
||
<el-form-item :label="t('api.photoWall.searchHeight')">
|
||
<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 style="margin-left: 8px;">
|
||
<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="margin-right: 10px;">
|
||
<el-tooltip :content="t('api.photoWall.uploadTooltip', { exts: allowUploadExt.join('\u3001') })">
|
||
<el-button type="primary" icon="Upload" :loading="isUploading" v-permission="'photoWall:save'" plain>{{ t('api.photoWall.uploadBtn') }}</el-button>
|
||
</el-tooltip>
|
||
</el-upload>
|
||
<el-button
|
||
v-if="selected.size > 0"
|
||
type="danger"
|
||
icon="Delete"
|
||
plain
|
||
v-permission="'photoWall:delete'"
|
||
@click="deleteSelected">
|
||
{{ t('api.photoWall.deleteSelected', { count: selected.size }) }}
|
||
</el-button>
|
||
<div class="search-btn">
|
||
<el-button link type="primary" :disabled="!photos.length" @click="selectAll" v-if="!isAllSelected">{{ t('api.photoWall.selectAll') }}</el-button>
|
||
<el-button link type="primary" @click="clearSelect" v-else>{{ t('api.photoWall.deselectAll') }}</el-button>
|
||
<el-button link type="primary" :disabled="!photos.length" @click="invertSelect">{{ t('api.photoWall.invertSelect') }}</el-button>
|
||
<el-button type="primary" @click="loadDataBase(true)" icon="Search" plain>{{ t('common.search') }}</el-button>
|
||
<el-button @click="reset" icon="RefreshLeft" plain>{{ t('common.reset') }}</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 }} x {{ item.height }}</p>
|
||
</div>
|
||
<el-button-group class="overlay-actions">
|
||
<el-button size="small" icon="View" plain @click.stop="preview(item)">{{ t('api.photoWall.previewBtn') }}</el-button>
|
||
<el-button
|
||
size="small"
|
||
icon="Delete"
|
||
type="danger"
|
||
plain
|
||
v-permission="'photoWall:delete'"
|
||
@click.stop="deleteSingle(item)">
|
||
{{ t('common.delete') }}
|
||
</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>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||
import { useStore } from 'vuex'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import type { MsgResult } from '@/model/common.dto'
|
||
import { Page } from '@/model/common.dto'
|
||
import { useBaseList } from '@/model/baselist'
|
||
import type PhotoWallModel from '@/model/api/photowall'
|
||
import http from '@/utils/http'
|
||
|
||
const { t } = useI18n()
|
||
|
||
// --- 常量 ---
|
||
const COLUMN_WIDTH = 220 // 每列目标宽度(px)
|
||
const COL_GAP = 14 // 列间距(px)
|
||
|
||
// --- 基础状态 ---
|
||
const store = useStore()
|
||
const apiBase = import.meta.env.VITE_APP_API_BASE
|
||
const rootEl = ref<HTMLElement | null>(null)
|
||
const photos = ref<PhotoWallModel[]>([])
|
||
const isUploading = ref(false)
|
||
const allowUploadExt = ['jpg', 'jpeg', 'png']
|
||
|
||
class GalleryPage extends Page {
|
||
name?: string
|
||
widthMin?: number | null
|
||
widthMax?: number | null
|
||
heightMin?: number | null
|
||
heightMax?: number | null
|
||
constructor() { super(); this.limit = 50 }
|
||
reset() {
|
||
super.reset()
|
||
this.limit = 50
|
||
this.name = undefined
|
||
this.widthMin = null
|
||
this.widthMax = null
|
||
this.heightMin = null
|
||
this.heightMax = null
|
||
}
|
||
}
|
||
const { loading, total, search, setLoadData, loadDataBase, reset, 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 previewUrls = ref<string[]>([])
|
||
const previewIndex = ref(0)
|
||
|
||
// --- 瀑布流分列 ---
|
||
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
|
||
selected.value = new Set()
|
||
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: search })
|
||
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 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 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(t('api.photoWall.deleteSingleConfirm', { name: item.name }), t('common.deleteConfirm'), { type: 'warning' })
|
||
await http.delete('/photoWall/delete', { params: { ids: [item._id] } })
|
||
ElMessage.success(t('common.deleteSuccess'))
|
||
loadData()
|
||
}
|
||
|
||
async function deleteSelected() {
|
||
const ids = [...selected.value]
|
||
await ElMessageBox.confirm(t('api.photoWall.deleteMultiConfirm', { count: ids.length }), t('common.deleteConfirm'), { type: 'warning' })
|
||
await http.delete('/photoWall/delete', { params: { ids } })
|
||
selected.value = new Set()
|
||
ElMessage.success(t('common.deleteSuccess'))
|
||
loadData()
|
||
}
|
||
|
||
// --- 上传 ---
|
||
function beforeUpload(file: File): boolean {
|
||
if (file.size > 10 << 20) {
|
||
ElMessage.warning(t('common.fileSizeExceeded'))
|
||
return false
|
||
}
|
||
isUploading.value = true
|
||
return true
|
||
}
|
||
function uploadSuccess(response: MsgResult) {
|
||
isUploading.value = false
|
||
if (response.code === 0) {
|
||
ElMessage.success(t('common.uploadSuccess'))
|
||
loadData()
|
||
} else {
|
||
ElMessage.warning(response.message || t('common.uploadFailed'))
|
||
}
|
||
}
|
||
function uploadError(error: Error) {
|
||
isUploading.value = false
|
||
ElMessage.error(error.message)
|
||
}
|
||
|
||
// --- 响应式列数 ---
|
||
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;
|
||
}
|
||
|
||
.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>
|