feat: 从收藏中选择图片组件

This commit is contained in:
zhangyh
2025-09-22 15:30:26 +08:00
parent 9681b4fb8a
commit d314a228ce
5 changed files with 756 additions and 1 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -16,6 +16,7 @@ const emit = defineEmits([
"zoom-out",
"toggle-red-green-mode",
"undo-redo-status-changed",
"trigger-library"
]);
const {t} = useI18n()
const props = defineProps({
@@ -151,6 +152,13 @@ const normalToolsList = ref([
icon: { name: "CUpload", size: "26" },
class: "upload-btn",
},
{
id: "library",
title: t("LibraryPage.library"),
action: triggerLibrary,
icon: { name: "CLibrary", size: "26" },
class: "library-btn",
},
{
id: "addText",
title: t("Canvas.AddText"),
@@ -228,6 +236,10 @@ function triggerImageUpload() {
emit("trigger-image-upload");
}
function triggerLibrary() {
emit("trigger-library");
}
function addText() {
emit("add-text");
}

View File

@@ -52,6 +52,7 @@ const emit = defineEmits([
"trigger-red-green-mouseup", // 红绿图模式鼠标抬起事件
"changeCanvas", // 画布变更事件
"canvasInit", // 画布初始化事件
"trigger-library", // 触发打开Library选择图片事件
]);
const props = defineProps({
@@ -705,6 +706,11 @@ function handleImageUpload(event) {
});
}
function triggerLibrary() {
console.log('打开收藏')
emit("trigger-library");
}
function handleAddText() {
if (toolManager && canvasManager && canvasManager.canvas) {
// 在画布中央创建文本
@@ -1043,6 +1049,7 @@ defineExpose({
@zoom-in="zoomIn"
@zoom-out="zoomOut"
@undo-redo-status-changed="changeCanvas"
@trigger-library="triggerLibrary"
>
<template #customToolsTop="{ toolTopProps }">
<slot name="customToolsTop" :tool-button-props="toolTopProps" />

View File

@@ -0,0 +1,720 @@
<template>
<div v-if="showPanel" class="image-list-overlay" @click.self="showPanel = false">
<div class="image-list-modal">
<div class="modal-header">
<h3>{{ $t(navTypeList(t).library.label) }}</h3>
<button class="close-btn" @click="showPanel = false">&times;</button>
</div>
<div class="modal-content">
<!-- 分类标签 -->
<div class="image-categories">
<div
v-for="category in categories"
:key="category"
:class="['category-btn', { active: selectedCategory === category }]"
@click="handleChangeCategory(category)"
>
{{ category }}
</div>
</div>
<!-- 图片网格 -->
<div class="image-grid" @scroll="handleScroll">
<div
v-for="(item, index) in list"
:key="index"
class="image-item"
@click="handleImageClick(item)"
>
<div class="image-wrapper">
<img
v-lazy="item.url"
:alt="item.name || '图片'"
@error="handleImageError"
loading="lazy"
/>
<div class="image-overlay">
<span class="image-name">{{ item.name || '未命名' }}</span>
</div>
</div>
<div class="image-select" v-show="selectList.includes(item.url)">
<i class="fi fi-sr-check-circle"></i>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading && list.length > 0" class="loading-state">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
<!-- 空状态 -->
<div v-if="list.length === 0 && !loading" class="empty-state">
<div class="empty-icon">📷</div>
<p>{{ $t('Canvas.NoPicture') }}</p>
</div>
</div>
<div class="modal-footer">
<div class="image-count">
{{ $t('Canvas.general') }} {{ total }}
{{ $t('Canvas.PicturesInTotal') }}
</div>
<div class="image-submit gallery_btn" @click="confirm">
{{ $t('Canvas.confirm') }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
defineProps,
defineEmits,
defineExpose,
onMounted,
onUnmounted,
nextTick,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { Https } from '@/tool/https'
import { navTypeList } from '@/tool/listData'
const { t } = useI18n()
const libraryTypeList = [
{ label: t('Canvas.all'), value: '' },
...navTypeList(t).library.list
]
// Props
const props = defineProps({
api: {
type: String,
default: ''
},
isLibrary: {
type: Boolean,
default: false
}
})
// Emits
const emits = defineEmits(['select'])
// 响应式数据
const showPanel = ref(false)
const selectedCategory = ref(t('Canvas.all'))
const selectList = ref([])
const list = ref([])
// 新增:分页和加载状态
const currentPage = ref(1)
const hasMore = ref(true)
const loading = ref(false)
// 内部管理的分页大小
const pageSize = ref(10)
const total = ref(0)
// 计算属性:获取所有分类
const categories = computed(() => {
if (props.isLibrary) {
return libraryTypeList.map(item => item.label)
} else {
return []
}
})
// 新增API请求函数
const fetchImages = async (
page = 1,
category = selectedCategory.value,
reset = false
) => {
if (!props.api) return
loading.value = true
const type = libraryTypeList.find(item => item.label === category).value
console.log('type', type)
const params = {
classificationIdList: [],
level1Type: 'Printboard',
level2Type: '',
page,
ageGroup: '',
modelSex: '',
pictureName: '',
size: pageSize.value,
intersection: 1
}
const res = await Https.axiosPost(props.api, params)
loading.value = false
if (res && res.content) {
const newData = res.content
total.value = res.total
if (reset) {
list.value = newData
currentPage.value = 1
} else {
list.value = [...list.value, ...newData]
}
// 判断是否还有更多数据
hasMore.value = page < res.pages
currentPage.value = page
if (currentPage.value === 1 && hasMore.value) {
loadMore()
}
}
}
// 新增:无限滚动处理
const handleScroll = event => {
const { scrollTop, scrollHeight, clientHeight } = event.target
const threshold = 100 // 距离底部100px时触发加载
if (
scrollHeight - scrollTop - clientHeight < threshold &&
hasMore.value &&
!loading.value
) {
loadMore()
}
}
// 新增:加载更多
const loadMore = () => {
if (!loading.value && hasMore.value) {
const nextPage = currentPage.value + 1
fetchImages(nextPage, selectedCategory.value, false)
}
}
// 检查是否需要自动加载更多数据
const checkAndLoadMore = () => {
const scrollContainer = document.querySelector('.image-list-content')
if (!scrollContainer) return
const { scrollHeight, clientHeight } = scrollContainer
// 如果内容高度小于等于容器高度,且还有更多数据,自动加载
if (scrollHeight <= clientHeight && hasMore.value && !loading.value) {
console.log('内容不够滚动,自动加载更多数据')
loadMore()
}
}
// 新增:重置并加载
const resetAndLoad = (category = selectedCategory.value) => {
list.value = []
hasMore.value = true
currentPage.value = 0
fetchImages(1, category, true)
// 检查是否需要自动加载更多数据
nextTick(() => {
checkAndLoadMore()
})
}
watch(
() => showPanel.value,
val => {
if (val) {
resetAndLoad()
}
}
)
// 处理图片点击
const handleImageClick = item => {
// 已选中,取消选中
if (selectList.value.includes(item.url)) {
selectList.value = selectList.value.filter(url => url !== item.url)
} else {
selectList.value.push(item.url)
}
}
// 处理分类切换
const handleChangeCategory = category => {
selectedCategory.value = category
// 如果提供了API则重新加载数据
if (props.api) {
resetAndLoad(category)
}
}
// 处理图片加载错误
const handleImageError = event => {
event.target.src =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjVmNWY1Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPuWbvueJh+WKoOi9veWksei0pe+8jOivt+ajgOafpeWbvueJh+i3r+W+hDwvdGV4dD48L3N2Zz4='
event.target.alt = '图片加载失败'
}
const confirm = () => {
emits('select', selectList.value)
showPanel.value = false
}
const init = () => {
showPanel.value = true
}
// 暴露给父组件的方法
defineExpose({
resetAndLoad,
fetchImages,
init
})
</script>
<style scoped lang="less">
// .image-list-container {
// position: relative;
// display: inline-block;
// }
.image-list-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease;
color: #666;
&:hover {
background-color: rgba(66, 133, 244, 0.1);
border-color: rgba(66, 133, 244, 0.2);
color: #4285f4;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
}
/* 弹窗遮罩层 */
.image-list-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 弹窗主体 */
.image-list-modal {
background-color: #fff;
border-radius: 12px;
width: 90%;
max-width: 1200px;
height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
animation: modalSlideUp 0.3s ease;
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 弹窗头部 */
.modal-header {
padding: 16px 20px;
background-color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 18px;
color: #333;
font-weight: 600;
}
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
opacity: 0.7;
transition: all 0.2s;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
opacity: 1;
color: #333;
transform: scale(1.1);
}
}
/* 弹窗内容 */
.modal-content {
padding: 20px;
// overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
background-color: #fff;
-webkit-overflow-scrolling: touch;
}
/* 分类标签 */
.image-categories {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 12px;
flex-wrap: wrap;
scrollbar-width: thin;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
}
.category-btn {
padding: 8px 16px;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.05);
color: #333;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
transition: all 0.2s;
height: 36px;
display: flex;
align-items: center;
font-weight: 500;
&.active {
background-color: rgba(66, 133, 244, 0.1);
border-color: rgba(66, 133, 244, 0.2);
color: #4285f4;
}
&:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.05);
transform: translateY(-1px);
}
}
/* 图片网格 */
.image-grid {
display: grid;
overflow-y: auto;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 16px;
min-height: 20rem;
max-height: 50rem;
@media screen and (max-width: 768px) {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 12px;
}
@media screen and (max-width: 48rem) {
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 10px;
}
}
.image-item {
cursor: pointer;
border-radius: 8px;
// overflow: hidden;
transition: all 0.2s ease;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.05);
position: relative;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: rgba(66, 133, 244, 0.2);
.image-overlay {
opacity: 1;
}
}
}
.image-wrapper {
position: relative;
width: 100%;
height: 22rem;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 0.2s ease;
}
&:hover img {
transform: scale(1.05);
}
}
.image-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: 16px 12px 12px;
opacity: 0;
transition: opacity 0.2s ease;
}
.image-select {
position: absolute;
bottom: 0;
right: 0;
z-index: 2;
transform: translate(50%, 50%);
i {
font-size: 2.5rem;
}
}
.image-name {
font-size: 14px;
font-weight: 500;
line-height: 1.2;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #666;
min-height: 300px;
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
p {
font-size: 16px;
margin: 0;
}
}
/* 错误状态 */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #e74c3c;
min-height: 300px;
.error-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.7;
}
p {
font-size: 16px;
margin: 0 0 16px 0;
}
.retry-btn {
padding: 8px 16px;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
&:hover {
background-color: #c0392b;
}
}
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
color: #666;
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #4285f4;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
p {
font-size: 14px;
margin: 0;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 没有更多数据状态 */
.no-more-state {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #999;
font-size: 14px;
border-top: 1px solid #f0f0f0;
margin-top: 20px;
}
/* 弹窗底部 */
.modal-footer {
padding: 16px 20px;
background-color: rgba(255, 255, 255, 0.8);
border-top: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.image-count {
font-size: 14px;
color: #666;
font-weight: 500;
}
/* 响应式设计 */
@media screen and (max-width: 1024px) {
.image-list-modal {
width: 95%;
max-width: 900px;
}
.modal-content {
padding: 16px;
}
.modal-header,
.modal-footer {
padding: 14px 16px;
}
}
@media screen and (max-width: 640px) {
.image-list-modal {
width: 98%;
margin: 10px;
max-height: 90vh;
}
.modal-content {
padding: 12px;
gap: 16px;
}
.modal-header h3 {
font-size: 16px;
}
.image-categories {
gap: 8px;
padding-bottom: 8px;
}
.category-btn {
padding: 6px 12px;
font-size: 13px;
height: 32px;
}
}
</style>

View File

@@ -54,6 +54,7 @@
<div class="canvas" ref="canvasBox">
<editCanvas
@changeCanvas="changeCanvas"
@trigger-library="triggerLibrary"
:canvasJSON="canvasJSON"
ref="editCanvas">
<template #existsImageList>
@@ -76,6 +77,12 @@
<a-spin size="large" />
</div>
</a-modal>
<SelectImages
ref="selectImages"
@select="handleImageSelect"
:api="Https.httpUrls.queryLibraryPage"
isLibrary
/>
</template>
<script lang="ts">
import { defineComponent,computed,ref,provide,nextTick,inject,toRefs, reactive, onBeforeMount} from 'vue'
@@ -92,11 +99,12 @@ import ExistsImageList from "@/component/Canvas/ExistsImageList/index.vue";
import JSZip, { forEach } from "jszip";
import publish from "@/component/WorksPage/publish.vue";
import canvasAA from '@/component/Canvas/canvasExample.vue'
import SelectImages from '@/component/common/SelectImages.vue'
export default defineComponent({
components:{
toProductRelight,poseTransfer,editCanvas,ExistsImageList,publish,canvasAA
toProductRelight,poseTransfer,editCanvas,ExistsImageList,publish,canvasAA,SelectImages
},
props:{
source:{
@@ -303,6 +311,10 @@ export default defineComponent({
data.canvasSelectList.push(obj)
}
}
const selectImages = ref(null)
const triggerLibrary = ()=>{
selectImages.value.init()
}
const handleImageSelect = (list:any)=>{
list.forEach(item => {
dataDom.editCanvas.addImageToLayer(item)
@@ -445,6 +457,9 @@ export default defineComponent({
share,
setPublish,
unLike,
triggerLibrary,
Https,
selectImages
}
},
provide() {