add image editing features

This commit is contained in:
2025-03-04 01:29:29 +08:00
parent 3f4bf14533
commit 08d4bbfbf9
29 changed files with 45044 additions and 297 deletions

View File

@@ -0,0 +1,74 @@
<template>
<div ref="fileRobotImageRef" class="editor-container"></div>
</template>
<script lang="ts" setup>
import {computed, onMounted, ref} from 'vue';
import FilerobotImageEditor from '@/assets/lib/ImageEditor/filerobot-image-editor.min.js';
import type {IFilerobotImageEditorConfig} from '@/types/imageEditorConfig.ts';
import fr from './lang/fr';
import en from './lang/en';
import zh from './lang/zh';
const fileRobotImageRef = ref(null);
interface IProps {
config: IFilerobotImageEditorConfig
}
const props = defineProps<IProps>();
/**
* Helper: Determines translations based on language or passed configuration.
* @returns Translation strings object.
*/
const getTranslations = () => {
if (props.config?.translations) {
return props.config.translations;
}
if (!props.config?.translations && props.config?.language === 'fr') {
return fr;
}
if (!props.config?.translations && props.config?.language === 'en') {
return en;
}
return zh;
};
/**
* Lifecycle: Mounts the editor instance on the container.
*/
const filerobotImageEditorInstance = ref(null);
const initializeEditor = () => {
try {
if (!fileRobotImageRef.value) {
throw new Error('fileRobotImageRef is not available');
}
const instance = new FilerobotImageEditor(fileRobotImageRef.value, {
...props.config,
translations: getTranslations(),
});
filerobotImageEditorInstance.value = instance;
instance.render();
} catch (error) {
console.error('Failed to initialize FilerobotImageEditor:', error);
}
};
onMounted(() => {
initializeEditor();
});
defineExpose({
filerobotImageEditorInstance: computed(() => filerobotImageEditorInstance.value),
});
</script>
<style scoped lang="scss">
.editor-container {
height: 75vh;
width: 100%;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,96 @@
export default {
name: 'Name',
save: 'Save',
saveAs: 'Save as',
back: 'Back',
loading: 'Loading...',
resetOperations: 'Reset/delete all operations',
changesLoseConfirmation: 'All changes will be lost',
changesLoseConfirmationHint: 'Are you sure you want to continue?',
cancel: 'Cancel',
continue: 'Continue',
undoTitle: 'Undo last operation',
redoTitle: 'Redo last operation',
showImageTitle: 'Show original image',
zoomInTitle: 'Zoom in',
zoomOutTitle: 'Zoom out',
toggleZoomMenuTitle: 'Toggle zoom menu',
adjustTab: 'Adjust',
finetuneTab: 'Finetune',
filtersTab: 'Filters',
watermarkTab: 'Watermark',
annotateTab: 'Draw',
resize: 'Resize',
resizeTab: 'Resize',
invalidImageError: 'Invalid image provided.',
uploadImageError: 'Error while uploading the image.',
areNotImages: 'are not images',
isNotImage: 'is not image',
toBeUploaded: 'to be uploaded',
cropTool: 'Crop',
original: 'Original',
custom: 'Custom',
square: 'Square',
landscape: 'Landscape',
portrait: 'Portrait',
ellipse: 'Ellipse',
classicTv: 'Classic TV',
cinemascope: 'Cinemascope',
arrowTool: 'Arrow',
blurTool: 'Blur',
brightnessTool: 'Brightness',
contrastTool: 'Contrast',
ellipseTool: 'Ellipse',
unFlipX: 'Un-Flip X',
flipX: 'Flip X',
unFlipY: 'Un-Flip Y',
flipY: 'Flip Y',
hsvTool: 'HSV',
hue: 'Hue',
saturation: 'Saturation',
value: 'Value',
imageTool: 'Image',
importing: 'Importing...',
addImage: '+ Add image',
lineTool: 'Line',
penTool: 'Pen',
polygonTool: 'Polygon',
sides: 'Sides',
rectangleTool: 'Rectangle',
cornerRadius: 'Corner Radius',
resizeWidthTitle: 'Width in pixels',
resizeHeightTitle: 'Height in pixels',
toggleRatioLockTitle: 'Toggle ratio lock',
reset: 'Reset',
resetSize: 'Reset to original image size',
rotateTool: 'Rotate',
textTool: 'Text',
textSpacings: 'Text spacings',
textAlignment: 'Text alignment',
fontFamily: 'Font family',
size: 'Size',
letterSpacing: 'Letter Spacing',
lineHeight: 'Line height',
warmthTool: 'Warmth',
addWatermark: '+ Add watermark',
addWatermarkTitle: 'Choose the watermark type',
uploadWatermark: 'Upload watermark',
addWatermarkAsText: 'Add as text',
padding: 'Padding',
shadow: 'Shadow',
horizontal: 'Horizontal',
vertical: 'Vertical',
blur: 'Blur',
opacity: 'Opacity',
position: 'Position',
stroke: 'Stroke',
saveAsModalLabel: 'Save the image as',
extension: 'Extension',
nameIsRequired: 'Name is required.',
quality: 'Quality',
imageDimensionsHoverTitle: 'Saved image size (width x height)',
cropSizeLowerThanResizedWarning:
'Note, the selected crop area is lower than the applied resize which might cause quality decrease',
actualSize: 'Actual size (100%)',
fitSize: 'Fit size',
};

View File

@@ -0,0 +1,96 @@
export default {
name: 'Nom',
save: 'Enregistrer',
saveAs: 'Enregistrer sous',
back: 'Retour',
loading: 'Chargement...',
resetOperations: 'Réinitialiser/supprimer toutes les opérations',
changesLoseConfirmation: 'Tous les changements seront perdus',
changesLoseConfirmationHint: 'Êtes-vous sûr de vouloir continuer ?',
cancel: 'Annuler',
continue: 'Continuer',
undoTitle: 'Annuler la dernière opération',
redoTitle: 'Refaire la dernière opération',
showImageTitle: "Afficher l'image originale",
zoomInTitle: 'Zoom avant',
zoomOutTitle: 'Zoom arrière',
toggleZoomMenuTitle: 'Afficher/masquer le menu de zoom',
adjustTab: 'Ajuster',
finetuneTab: 'Affiner',
filtersTab: 'Filtres',
watermarkTab: 'Filigrane',
annotateTab: 'Dessiner',
resize: 'Rétréci',
resizeTab: 'Rétréci',
invalidImageError: 'Image non valide fournie.',
uploadImageError: "Erreur lors du téléchargement de l'image.",
areNotImages: 'ne sont pas des images',
isNotImage: "n'est pas une image",
toBeUploaded: 'à télécharger',
cropTool: 'Rogner',
original: 'Original',
custom: 'Personnalisé',
square: 'Carré',
landscape: 'Paysage',
portrait: 'Portrait',
ellipse: 'Ellipse',
classicTv: 'TV classique',
cinemascope: 'Cinémascope',
arrowTool: 'Flèche',
blurTool: 'Flou',
brightnessTool: 'Luminosité',
contrastTool: 'Contraste',
ellipseTool: 'Ellipse',
unFlipX: 'Annuler le retournement X',
flipX: 'Retourner X',
unFlipY: 'Annuler le retournement Y',
flipY: 'Retourner Y',
hsvTool: 'HSV',
hue: 'Teinte',
saturation: 'Saturation',
value: 'Valeur',
imageTool: 'Image',
importing: 'Importation...',
addImage: '+ Ajouter une image',
lineTool: 'Ligne',
penTool: 'Stylo',
polygonTool: 'Polygone',
sides: 'Côtés',
rectangleTool: 'Rectangle',
cornerRadius: 'Rayon des coins',
resizeWidthTitle: 'Largeur en pixels',
resizeHeightTitle: 'Hauteur en pixels',
toggleRatioLockTitle: 'Activer/désactiver le verrouillage des proportions',
reset: 'Réinitialiser',
resetSize: "Réinitialiser à la taille originale de l'image",
rotateTool: 'Pivoter',
textTool: 'Texte',
textSpacings: 'Espacement du texte',
textAlignment: 'Alignement du texte',
fontFamily: 'Famille de police',
size: 'Taille',
letterSpacing: 'Espacement des lettres',
lineHeight: 'Interligne',
warmthTool: 'Chaleur',
addWatermark: '+ Ajouter un filigrane',
addWatermarkTitle: 'Choisir le type de filigrane',
uploadWatermark: 'Télécharger un filigrane',
addWatermarkAsText: 'Ajouter en tant que texte',
padding: 'Marge intérieure',
shadow: 'Ombre',
horizontal: 'Horizontal',
vertical: 'Vertical',
blur: 'Flou',
opacity: 'Opacité',
position: 'Position',
stroke: 'Contour',
saveAsModalLabel: "Enregistrer l'image sous",
extension: 'Extension',
nameIsRequired: 'Le nom est requis.',
quality: 'Qualité',
imageDimensionsHoverTitle: "Taille de l'image sauvegardée (largeur x hauteur)",
cropSizeLowerThanResizedWarning:
'Attention, la zone de recadrage sélectionnée est inférieure au redimensionnement appliqué, ce qui pourrait entraîner une perte de qualité.',
actualSize: 'Taille réelle (100%)',
fitSize: 'Adapter à la taille',
};

View File

@@ -0,0 +1,95 @@
export default {
name: '名称',
save: '保存',
saveAs: '另存为',
back: '返回',
loading: '加载中...',
resetOperations: '重置/删除所有操作',
changesLoseConfirmation: '所有更改将会丢失',
changesLoseConfirmationHint: '确定要继续吗?',
cancel: '取消',
continue: '继续',
undoTitle: '撤销上一步操作',
redoTitle: '重做上一步操作',
showImageTitle: '显示原始图片',
zoomInTitle: '放大',
zoomOutTitle: '缩小',
toggleZoomMenuTitle: '切换缩放菜单',
adjustTab: '基础调整',
finetuneTab: '精细调整',
filtersTab: '滤镜',
watermarkTab: '水印',
annotateTab: '标注',
resize: '调整尺寸',
resizeTab: '尺寸调整',
invalidImageError: '提供的图片无效',
uploadImageError: '图片上传时发生错误',
areNotImages: '不是图片文件',
isNotImage: '非图片文件',
toBeUploaded: '待上传',
cropTool: '裁剪工具',
original: '原始比例',
custom: '自定义',
square: '正方形',
landscape: '横屏比例',
portrait: '竖屏比例',
ellipse: '椭圆形',
classicTv: '经典电视比例',
cinemascope: '宽银幕比例',
arrowTool: '箭头工具',
blurTool: '模糊工具',
brightnessTool: '亮度',
contrastTool: '对比度',
ellipseTool: '椭圆工具',
unFlipX: '取消水平翻转',
flipX: '水平翻转',
unFlipY: '取消垂直翻转',
flipY: '垂直翻转',
hsvTool: 'HSV调整',
hue: '色相',
saturation: '饱和度',
value: '明度',
imageTool: '图片工具',
importing: '导入中...',
addImage: '+ 添加图片',
lineTool: '直线工具',
penTool: '画笔工具',
polygonTool: '多边形工具',
sides: '边数',
rectangleTool: '矩形工具',
cornerRadius: '圆角半径',
resizeWidthTitle: '宽度(像素)',
resizeHeightTitle: '高度(像素)',
toggleRatioLockTitle: '锁定宽高比',
reset: '重置',
resetSize: '恢复原始尺寸',
rotateTool: '旋转工具',
textTool: '文本工具',
textSpacings: '文字间距',
textAlignment: '文字对齐',
fontFamily: '字体',
size: '字号',
letterSpacing: '字间距',
lineHeight: '行高',
warmthTool: '色温调整',
addWatermark: '+ 添加水印',
addWatermarkTitle: '选择水印类型',
uploadWatermark: '上传水印图片',
addWatermarkAsText: '添加文字水印',
padding: '内边距',
shadow: '阴影',
horizontal: '水平',
vertical: '垂直',
blur: '模糊度',
opacity: '不透明度',
position: '位置',
stroke: '描边',
saveAsModalLabel: '将图片另存为',
extension: '扩展名',
nameIsRequired: '名称不能为空',
quality: '画质',
imageDimensionsHoverTitle: '保存图片尺寸(宽 x 高)',
cropSizeLowerThanResizedWarning: '注意:所选裁剪区域小于调整后的尺寸,可能会导致画质下降',
actualSize: '实际尺寸100%',
fitSize: '适应尺寸',
};

View File

@@ -1,245 +0,0 @@
<template>
<div class="editor-container">
<a-upload
:before-upload="handleBeforeUpload"
:show-upload-list="false"
>
<a-button icon="upload" class="upload-button">Upload Image</a-button>
</a-upload>
<div class="canvas-container">
<canvas ref="canvas" width="600" height="400"></canvas>
</div>
<div class="controls">
<a-button @click="undo">撤消</a-button>
<a-button @click="redo">重做</a-button>
<a-button @click="flipHorizontal">水平翻转</a-button>
<a-button @click="rotate">旋转</a-button>
<a-button @click="addText">添加文本</a-button>
<a-button @click="applyFilter">应用过滤器</a-button>
<!-- Add more buttons for other features -->
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, watch } from 'vue';
import * as fabric from 'fabric';
export default defineComponent({
name: 'ImageEditor',
props: {
imageUrl: {
type: String,
required: true,
},
},
setup(props) {
const canvas = ref<HTMLCanvasElement | null>(null);
let fabricCanvas: fabric.Canvas;
const history = ref<any[]>([]);
const redoStack = ref<any[]>([]);
onMounted(() => {
if (canvas.value) {
fabricCanvas = new fabric.Canvas(canvas.value);
fabricCanvas.on('object:modified', saveState);
fabricCanvas.on('object:added', saveState);
// Add mouse wheel zoom functionality
fabricCanvas.on('mouse:wheel', (opt) => {
const delta = opt.e.deltaY;
let zoom = fabricCanvas.getZoom();
zoom *= 0.999 ** delta;
if (zoom > 20) zoom = 20;
if (zoom < 0.01) zoom = 0.01;
fabricCanvas.zoomToPoint(new fabric.Point(opt.e.offsetX, opt.e.offsetY), zoom);
opt.e.preventDefault();
opt.e.stopPropagation();
});
loadImage(props.imageUrl);
}
});
watch(() => props.imageUrl, (newUrl) => {
loadImage(newUrl);
});
const loadImage = async (url: string) => {
try {
const img = await fabric.Image.fromURL(url, { crossOrigin: 'anonymous' });
fabricCanvas.clear();
img.set({
left: fabricCanvas.getWidth() / 2,
top: fabricCanvas.getHeight() / 2,
originX: 'center',
originY: 'center',
scaleX: fabricCanvas.getWidth() / img.width!,
scaleY: fabricCanvas.getHeight() / img.height!,
});
fabricCanvas.add(img);
fabricCanvas.renderAll();
saveState();
} catch (error) {
console.error('Error loading image:', error);
}
};
const saveState = () => {
redoStack.value = [];
history.value.push(JSON.stringify(fabricCanvas));
};
const handleBeforeUpload = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const imgObj = new Image();
imgObj.src = e.target?.result as string;
imgObj.onload = () => {
fabricCanvas.clear();
const img = new fabric.Image(imgObj, {
left: fabricCanvas.getWidth() / 2,
top: fabricCanvas.getHeight() / 2,
originX: 'center',
originY: 'center',
scaleX: fabricCanvas.getWidth() / imgObj.width,
scaleY: fabricCanvas.getHeight() / imgObj.height,
});
fabricCanvas.add(img);
fabricCanvas.renderAll();
saveState();
};
};
reader.readAsDataURL(file);
return false;
};
const undo = () => {
if (history.value.length > 1) {
redoStack.value.push(history.value.pop()!);
fabricCanvas.loadFromJSON(history.value[history.value.length - 1], () => {
fabricCanvas.renderAll();
});
}
};
const redo = () => {
if (redoStack.value.length > 0) {
const state = redoStack.value.pop()!;
history.value.push(state);
fabricCanvas.loadFromJSON(state, () => {
fabricCanvas.renderAll();
});
}
};
const flipHorizontal = () => {
const activeObject = fabricCanvas.getActiveObject();
if (activeObject) {
activeObject.toggle('flipX');
fabricCanvas.renderAll();
saveState();
}
};
const rotate = () => {
const activeObject = fabricCanvas.getActiveObject();
if (activeObject) {
activeObject.rotate((activeObject.angle || 0) + 90);
fabricCanvas.renderAll();
saveState();
}
};
const addText = () => {
const text = new fabric.Textbox('Edit me', {
left: fabricCanvas.getWidth() / 2,
top: fabricCanvas.getHeight() / 2,
width: 200,
fontSize: 20,
originX: 'center',
originY: 'center',
editable: true,
});
fabricCanvas.add(text);
fabricCanvas.setActiveObject(text);
fabricCanvas.renderAll();
saveState();
};
const applyFilter = () => {
const activeObject = fabricCanvas.getActiveObject();
if (activeObject && activeObject instanceof fabric.Image) {
activeObject.filters = activeObject.filters || [];
activeObject.filters.push(new fabric.Image.filters.Grayscale());
activeObject.applyFilters();
fabricCanvas.renderAll();
saveState();
}
};
return {
canvas,
handleBeforeUpload,
undo,
redo,
flipHorizontal,
rotate,
addText,
applyFilter,
};
},
});
</script>
<style scoped>
.editor-container {
display: flex;
flex-direction: column;
align-items: center;
background-color: #f0f2f5;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.upload-button {
margin-bottom: 20px;
background-color: #1890ff;
color: white;
border: none;
transition: background-color 0.3s;
}
.upload-button:hover {
background-color: #40a9ff;
}
.canvas-container {
border: 2px dashed #d9d9d9;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
}
canvas {
display: block;
}
.controls {
display: flex;
gap: 10px;
}
.controls a-button {
background-color: #1890ff;
color: white;
border: none;
transition: background-color 0.3s;
}
.controls a-button:hover {
background-color: #40a9ff;
}
</style>

View File

@@ -20,9 +20,36 @@
<template #icon>
<PlusSquareOutlined class="photo-toolbar-icon"/>
</template>
添加到
<APopover placement="bottom" trigger="click">
<template #content>
<div class="add-to-popover">
<div class="add-to-popover-top">
<AInput autofocus placeholder="搜索相册" v-model:value="searchValue"
@pressEnter="(e)=>searchAlbum(e.target.value)">
<template #suffix>
<AAvatar size="small" shape="circle" :src="search" @click="searchAlbum(searchValue)"/>
</template>
</AInput>
</div>
<div class="add-to-popover-bottom">
<div class="add-to-popover-album-item"
@click="addImagesToAlbum(album.id)"
v-for="(album, index) in albumList" :key="index">
<div>
<AAvatar :size="50" shape="square" :src="album.cover_image?album.cover_image:defaultAlbumCover"/>
</div>
<div class="add-to-popover-album-item-name">
<span style="font-size: 14px;font-weight: bold">{{ album.name }}</span>
<ATag color="blue" :bordered="false">{{ album.created_at }}</ATag>
</div>
</div>
</div>
</div>
</template>
添加到
</APopover>
</AButton>
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn">
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn" @click="downloadImages">
<template #icon>
<DownloadOutlined class="photo-toolbar-icon"/>
</template>
@@ -34,12 +61,12 @@
</template>
编辑
</AButton>
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn">
<template #icon>
<ShareAltOutlined class="photo-toolbar-icon"/>
</template>
分享
</AButton>
<!-- <AButton type="text" shape="default" size="middle" class="photo-toolbar-btn">-->
<!-- <template #icon>-->
<!-- <ShareAltOutlined class="photo-toolbar-icon"/>-->
<!-- </template>-->
<!-- 分享-->
<!-- </AButton>-->
<AButton type="text" shape="default" size="middle" class="photo-toolbar-btn" @click="deleteImages">
<template #icon>
<DeleteOutlined class="photo-toolbar-icon"/>
@@ -49,18 +76,23 @@
</div>
</div>
</transition>
<AModal v-model:open="imageStore.imageEditVisible" title="编辑图片" width="50%" :mask-closable="false"
:keyboard="false" :wrap-class-name="'image-edit-modal'">
<ImageEditor :image-url="getUrlById(props.selected[0])"/>
<AModal v-model:open="imageStore.imageEditVisible" width="60%" :mask-closable="false"
:keyboard="false" wrap-class-name="full-modal" :footer="null" @cancel="handleImageEditClose">
<FilerobotImageEditor class="editor-container" :config="config"/>
</AModal>
</template>
<script setup lang="ts">
import FilerobotImageEditor from '@/components/FilerobotImageEditor/FilerobotImageEditor.vue';
import useStore from "@/store";
import {Image, ImageList, ImageRecord} from "@/types/image";
import {deletedImagesApi} from "@/api/storage";
import {ImageList, ImageRecord} from "@/types/image";
import {albumListApi, albumSearchApi, deletedImagesApi, imageToAlbumApi} from "@/api/storage";
import {message} from "ant-design-vue";
import ImageEditor from "@/components/ImageEditor/ImageEditor.vue";
import {downloadImagesAsZip} from "@/utils/imageUtils/downloadImagesAsZip.ts";
import search from "@/assets/svgs/search.svg";
import confidential from '@/assets/images/confidential.png';
import topSecret from '@/assets/images/top-secret.png';
import {type IFilerobotImageEditorConfig, TABS, TOOLS} from '@/types/imageEditorConfig.ts';
import defaultAlbumCover from "@/assets/images/default-cover.png";
const props = defineProps({
selected: {
@@ -77,11 +109,83 @@ const uploadStore = useStore().upload;
const clearSelected = () => {
imageStore.selected = [];
};
const image = new Image();
const selectAll = () => {
imageStore.selected = props.imageList.flatMap((record: ImageRecord) => record.list.map((image: Image) => image.id));
/**
* Download file
* @param base64Data
* @param fileName
*/
function uriDownload(base64Data: string, fileName: string) {
// Create a link element
const link = document.createElement('a');
link.href = base64Data;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* 根据id 列表获取url 列表
*/
const idToUrlMap = computed(() => {
const map: { [key: number]: string } = {};
props.imageList.forEach((record: ImageRecord) => {
record.list.forEach((image: any) => {
map[image.id] = image.url;
});
});
return map;
});
/**
* 根据id 获取url
* @param id
*/
const getUrlById = (id: number): string => {
return idToUrlMap.value[id];
};
const config: IFilerobotImageEditorConfig = {
source: image,
[TOOLS.WATERMARK]: {
gallery: [confidential, topSecret],
textScalingRatio: 0.33,
imageScalingRatio: 0.33,
},
[TOOLS.TEXT]: {
text: '双击编辑文本...',
},
language: 'zh',
avoidChangesNotSavedAlertOnLeave: false,
// tabsIds: [TABS.ADJUST, TABS.ANNOTATE, TABS.FINETUNE, TABS.WATERMARK],
previewPixelRatio: window.devicePixelRatio * 4,
defaultTabId: TABS.ADJUST,
onSave: (savedImageData) => {
try {
uriDownload(savedImageData.imageBase64!, savedImageData.fullName!);
} catch (error) {
console.log(error);
}
},
defaultSavedImageName: 'image',
showBackButton: false,
showSaveButton: true,
disableSaveIfNoChanges: true,
resetOnImageSourceChange: true,
closeAfterSave: true,
};
/**
* 全选
*/
const selectAll = () => {
imageStore.selected = props.imageList.flatMap((record: ImageRecord) => record.list.map((image: any) => image.id));
};
/**
* 删除图片
*/
const deleteImages = async () => {
imageStore.imageListLoading = true;
const res: any = await deletedImagesApi(props.selected, uploadStore.storageSelected?.[0], uploadStore.storageSelected?.[1]);
@@ -96,32 +200,99 @@ const isAllSelected = computed(() => {
const imageList = props.imageList || [];
return props.selected.length === imageList.flatMap((record: ImageRecord) => record.list).length;
});
/**
* 编辑图片
*/
const editImages = () => {
const editImages = async () => {
// 只能编辑一张图片
if (props.selected.length !== 1) {
message.warning("只能编辑一张图片");
return;
}
imageStore.imageEditVisible = true;
image.crossOrigin = 'Anonymous';
image.src = getUrlById(props.selected[0]);
image.onload = () => {
imageStore.imageEditVisible = true;
};
};
const idToUrlMap = computed(() => {
const map: { [key: number]: string } = {};
props.imageList.forEach((record: ImageRecord) => {
record.list.forEach((image: Image) => {
map[image.id] = image.url;
});
});
return map;
// 根据id selected 列表获取url 列表
const selectedUrlList = computed(() => {
return props.selected.map(id => idToUrlMap.value[id]);
});
/**
* 下载图片
*/
function downloadImages() {
if (selectedUrlList.value.length === 0) {
message.warning("请选择图片");
return;
}
downloadImagesAsZip(selectedUrlList.value);
return;
}
const albumList = ref<any[]>([]);
async function getAlbumList(type: number = 0, sort: boolean = true) {
const res: any = await albumListApi(type, sort);
if (res && res.code === 200) {
albumList.value = res.data.albums;
}
}
const searchValue = ref<string>("");
/**
* 搜索相册
* @param keyword
*/
async function searchAlbum(keyword: string) {
if (keyword.trim() === "") {
await getAlbumList();
return;
}
const res: any = await albumSearchApi(keyword);
if (res && res.code === 200) {
albumList.value = res.data.albums;
}
}
/**
* 添加到相册
* @param albumId
*/
async function addImagesToAlbum(albumId: number) {
if (props.selected.length === 0) {
message.warning("请选择图片");
return;
}
const res: any = await imageToAlbumApi(props.selected, albumId, uploadStore.storageSelected?.[0], uploadStore.storageSelected?.[1]);
if (res && res.code === 200) {
message.success("添加成功");
imageStore.selected = [];
} else {
message.error("添加失败");
}
}
/**
* 关闭编辑图片弹窗
*/
function handleImageEditClose() {
imageStore.selected = [];
image.src = '';
imageStore.imageEditVisible = false;
}
onMounted(() => {
getAlbumList();
});
const getUrlById = (id: number): string => {
console.log(idToUrlMap.value[id]);
return idToUrlMap.value[id];
};
onBeforeUnmount(() => {
imageStore.selected = [];
});
@@ -168,7 +339,7 @@ onBeforeUnmount(() => {
.photo-toolbar-icon {
font-size: 20px;
font-weight: bold;
color: #fff;
color: #fff !important;
}
.photo-toolbar-btn {
@@ -199,4 +370,60 @@ onBeforeUnmount(() => {
transform: translateY(0);
opacity: 1;
}
.add-to-popover {
width: 230px;
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
.add-to-popover-top {
width: 100%;
height: 40px;
}
.add-to-popover-bottom {
width: 100%;
height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow: auto;
gap: 10px;
.add-to-popover-album-item {
width: 200px;
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
cursor: pointer;
padding: 10px;
border-radius: 8px;
background-color: rgba(191, 189, 189, 0.11);
border: 1px solid #ddd; // 添加一圈线
transition: transform 0.2s, box-shadow 0.2s; // 添加过渡效果
&:hover {
transform: scale(1.05); // 鼠标悬停时放大
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); // 鼠标悬停时添加阴影
background-color: rgba(129, 193, 214, 0.11);
}
.add-to-popover-album-item-name {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
}
}
}
}
</style>

View File

@@ -1,11 +1,19 @@
<template>
<Spin size="middle" :spinning="imageStore.imageListLoading" indicator="spin-dot" tip="loading..." :rotate="true">
<div style="width:100%;height:100%;" v-if="props.imageList">
<div v-for="(itemList, indexList) in props.imageList" :key="indexList">
<span style="margin-left: 10px;font-size: 13px">{{ itemList.date }}</span>
<div v-for="(itemList, indexList) in props.imageList" :key="indexList" class="group-container"
:class="{ 'has-selected': hasSelected(itemList) }">
<div class="date-header">
<img :src="getGroupIcon(itemList)" alt="Hover" class="custom-checkbox"
:style="{ width: iconSize + 'px', height: iconSize + 'px' }"
@click.stop="toggleGroup(itemList)"
/>
<span class="date-text">{{ itemList.date }}</span>
</div>
<AImagePreviewGroup>
<div class="photo-list">
<div v-for="(item, index) in itemList.list" :key="index">
<div v-for="(item, index) in itemList.list" :key="index"
:class="{'photo-item': true, 'animate': true, [`animate-delay-${index}`]: true}">
<CheckCard :key="index"
class="photo-item"
margin="0"
@@ -51,6 +59,9 @@
<script setup lang="ts">
import empty from "@/assets/svgs/empty.svg";
import useStore from "@/store";
import complete from '@/assets/svgs/complete.svg';
import stop from '@/assets/svgs/stop.svg';
import greyComplete from '@/assets/svgs/grey-complete.svg';
const props = defineProps({
imageList: {
@@ -58,8 +69,56 @@ const props = defineProps({
default: () => []
}
});
const iconSize = ref(23);
const imageStore = useStore().image;
const toggleGroup = (group: any) => {
const currentIds = group.list.map((item: any) => item.id);
const allSelected = currentIds.every(id =>
imageStore.selected.includes(id)
);
// 创建新数组保证响应式更新
imageStore.selected = allSelected
? imageStore.selected.filter(id => !currentIds.includes(id))
: [...new Set([...imageStore.selected, ...currentIds])];
};
// 新增计算分组状态
const groupState = computed(() => (group: any) => {
const currentIds = group.list.map((item: any) => item.id);
if (currentIds.length === 0) return 'empty';
const selectedCount = currentIds.filter(id =>
imageStore.selected.includes(id)
).length;
if (selectedCount === currentIds.length) return 'all';
if (selectedCount > 0) return 'partial';
return 'none';
});
// 动态获取图标
const getGroupIcon = (group: any) => {
switch (groupState.value(group)) {
case 'all':
return complete;
case 'partial':
return stop;
default:
return greyComplete;
}
};
// 新增计算属性判断是否有选中
const hasSelected = computed(() => (group: any) => {
return group.list.some((item: any) =>
imageStore.selected.includes(item.id)
);
});
</script>
@@ -71,4 +130,161 @@ const imageStore = useStore().image;
padding: 10px;
gap: 15px;
}
.animate {
opacity: 0;
transform: translateX(-50px);
animation: fadeInRight 0.5s forwards;
}
@for $i from 0 through 30 { // 假设最多有20张图片你可以根据实际情况调整
.animate-delay-#{$i} {
animation-delay: #{$i * 0.1}s; // 每张图片延迟0.1秒显示
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(-50px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.date-text {
position: relative;
font-size: 13px;
color: #666;
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
/* 新增固定位置规则 */
.has-selected & {
transform: translateX(28px) !important;
transition: none !important;
}
}
.group-container {
position: relative;
margin-bottom: 16px;
// 有选中时的常显逻辑
&.has-selected {
.custom-checkbox {
opacity: 1 !important;
transform: translateX(8px) scale(1) !important;
}
// 保持日期位移效果
&:hover .date-text {
transform: translateX(28px);
}
}
// 无选中时的悬停逻辑
&:not(.has-selected) {
.custom-checkbox {
opacity: 0;
transform: translateX(8px) scale(0);
// 悬停显示
&:hover {
opacity: 1;
transform: translateX(8px) scale(1);
}
}
// 分组悬停时的日期位移
&:hover {
.date-text {
transform: translateX(28px);
}
.custom-checkbox {
opacity: 1;
transform: translateX(8px) scale(1);
}
}
}
}
@keyframes checkScale {
0% {
transform: translateX(8px) scale(0);
}
50% {
transform: translateX(8px) scale(1.2);
}
100% {
transform: translateX(8px) scale(1);
}
}
@keyframes pulse {
0% {
transform: translateX(8px) scale(1);
}
50% {
transform: translateX(8px) scale(1.1);
}
100% {
transform: translateX(8px) scale(1);
}
}
.date-header {
position: relative;
height: 24px;
margin: 8px 0;
padding-left: 10px;
display: flex;
align-items: center;
}
.date-text {
position: relative;
font-size: 13px;
color: #666;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
.custom-checkbox {
position: absolute;
left: 0;
width: 24px;
height: 24px;
border: 1px solid #ffffff;
border-radius: 50%;
background: transparent;
opacity: 0;
transform: translateX(8px) scale(0);
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 分开定义过渡属性 */
cursor: pointer;
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background: #1890ff;
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.2s ease 0.1s;
}
}
.date-checkbox:checked + .custom-checkbox::after {
transform: translate(-50%, -50%) scale(1);
}
</style>