blog-admin-web/src/views/api/PhotoWall.vue
灌糖包子 4305eb3fe6
feat: 接入 vue-i18n 实现全站国际化
引入 vue-i18n,支持简体中文、繁体中文、英文三种语言。
提取所有页面硬编码中文为国际化词条,Header 右上角新增语言切换下拉菜单。
语言偏好存储于 localStorage,首次访问根据 navigator.language 自动检测。
同步切换 Element Plus 组件语言,校验规则改为 computed 保证切换后实时更新。
2026-06-02 09:52:48 +08:00

440 lines
11 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="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-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>
</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 {
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 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
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 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>