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

7
components.d.ts vendored
View File

@@ -71,7 +71,6 @@ declare module 'vue' {
BgColorsOutlined: typeof import('@ant-design/icons-vue')['BgColorsOutlined']
BlockOutlined: typeof import('@ant-design/icons-vue')['BlockOutlined']
BoxDog: typeof import('./src/components/BoxDog/BoxDog.vue')['default']
CanvasEditor: typeof import('./src/components/ImageEditor/CanvasEditor.vue')['default']
Card3D: typeof import('./src/components/Card3D/Card3D.vue')['default']
CheckCard: typeof import('./src/components/CheckCard/CheckCard.vue')['default']
CheckCircleOutlined: typeof import('@ant-design/icons-vue')['CheckCircleOutlined']
@@ -95,10 +94,10 @@ declare module 'vue' {
EyeInvisibleOutlined: typeof import('@ant-design/icons-vue')['EyeInvisibleOutlined']
EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
FileImageOutlined: typeof import('@ant-design/icons-vue')['FileImageOutlined']
FilerobotImageEditor: typeof import('./src/components/FilerobotImageEditor/FilerobotImageEditor.vue')['default']
Folder: typeof import('./src/components/Folder/Folder.vue')['default']
ForgetPage: typeof import('./src/views/Forget/ForgetPage.vue')['default']
GradientText: typeof import('./src/components/MyUI/GradientText/GradientText.vue')['default']
ImageEditor: typeof import('./src/components/ImageEditor/ImageEditor.vue')['default']
ImageShare: typeof import('./src/views/Share/ImageShare/ImageShare.vue')['default']
ImageToolbar: typeof import('./src/components/ImageToolbar/ImageToolbar.vue')['default']
ImageUpload: typeof import('./src/components/ImageUpload/ImageUpload.vue')['default']
@@ -107,7 +106,6 @@ declare module 'vue' {
LeftOutlined: typeof import('@ant-design/icons-vue')['LeftOutlined']
LinkOutlined: typeof import('@ant-design/icons-vue')['LinkOutlined']
LoadingGraphic: typeof import('./src/components/LoadingGraphic/LoadingGraphic.vue')['default']
LoadingOutlined: typeof import('@ant-design/icons-vue')['LoadingOutlined']
LocationAlbumDetail: typeof import('./src/views/Album/LocationAlbum/LocationAlbumDetail.vue')['default']
LocationAlbumIndex: typeof import('./src/views/Album/LocationAlbum/LocationAlbumIndex.vue')['default']
LocationAlbumList: typeof import('./src/views/Album/LocationAlbum/LocationAlbumList.vue')['default']
@@ -145,8 +143,8 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
SafetyOutlined: typeof import('@ant-design/icons-vue')['SafetyOutlined']
SearchOutlined: typeof import('@ant-design/icons-vue')['SearchOutlined']
SearchResult: typeof import('./src/views/Photograph/SearchResult/SearchResult.vue')['default']
SendOutlined: typeof import('@ant-design/icons-vue')['SendOutlined']
ShareAltOutlined: typeof import('@ant-design/icons-vue')['ShareAltOutlined']
SharePhoneUpload: typeof import('./src/views/Phone/SharePhoneUpload/SharePhoneUpload.vue')['default']
ShareSidebar: typeof import('./src/views/Share/ShareViewList/ShareSidebar.vue')['default']
ShareUpload: typeof import('./src/views/Share/ImageShare/ShareUpload.vue')['default']
@@ -157,7 +155,6 @@ declare module 'vue' {
ThingAlbumDetail: typeof import('./src/views/Album/ThingAlbum/ThingAlbumDetail.vue')['default']
ThingAlbumIndex: typeof import('./src/views/Album/ThingAlbum/ThingAlbumIndex.vue')['default']
ThingAlbumList: typeof import('./src/views/Album/ThingAlbum/ThingAlbumList.vue')['default']
ToolPanel: typeof import('./src/components/ImageEditor/ToolPanel.vue')['default']
Tooltip: typeof import('./src/components/MyUI/Tooltip/Tooltip.vue')['default']
UploadImage: typeof import('./src/views/Upscale/UploadImage.vue')['default']
UploadSetting: typeof import('./src/components/ImageUpload/UploadSetting.vue')['default']

View File

@@ -29,7 +29,7 @@
"@tensorflow/tfjs-core": "^4.22.0",
"@types/animejs": "^3.1.13",
"@types/crypto-js": "^4.2.2",
"@types/fabric": "^5.3.10",
"@types/file-saver": "^2.0.7",
"@types/json-stringify-safe": "^5.0.3",
"@types/node": "^22.13.5",
"@types/nprogress": "^0.2.3",
@@ -47,11 +47,12 @@
"echarts": "^5.6.0",
"eslint": "9.21.0",
"exifr": "^7.1.3",
"fabric": "^6.6.1",
"file-saver": "^2.0.5",
"go-captcha-vue": "^2.0.6",
"gsap": "^3.12.7",
"jsencrypt": "^3.3.2",
"json-stringify-safe": "^5.0.1",
"jszip": "^3.10.1",
"less": "^4.2.2",
"localforage": "^1.10.0",
"moment": "^2.30.1",

View File

@@ -454,3 +454,58 @@ export const imageSearchApi = (data: any) => {
name: "image-search",
});
};
/**
* 搜索相册
* @param keyword
*/
export const albumSearchApi = (keyword: string) => {
return service.Post('/api/auth/storage/album/search', {
keyword: keyword,
}, {
meta: {
ignoreToken: false,
signature: false,
},
name: "album-search",
});
};
/**
* 批量添加照片到相册
* @param ids
* @param album_id
* @param provider
* @param bucket
*/
export const imageToAlbumApi = (ids: number[], album_id: number, provider: string, bucket: string) => {
return service.Post('/api/auth/storage/album/add/image', {
ids: ids,
album_id: album_id,
provider: provider,
bucket: bucket,
}, {
meta: {
ignoreToken: false,
signature: false,
},
name: "add-image-to-album",
});
};
/**
* 下载相册图片
* @param id
* @param provider
* @param bucket
*/
export const downloadAlbumImagesApi = (id: number, provider: string, bucket: string) => {
return service.Post('/api/auth/storage/album/download', {
id: id,
provider: provider,
bucket: bucket,
}, {
meta: {
ignoreToken: false,
signature: false,
},
name: "download-album-images",
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -90,3 +90,7 @@ html {
height: 100%;
width: 100%;
}
//:not(button) > svg:not([color]) {
// color: var(--text-color) !important;
//}

1
src/assets/svgs/stop.svg Normal file
View File

@@ -0,0 +1 @@
<svg t="1741019346125" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15349" width="200" height="200"><path d="M512.128 0C230.528 0 0 229.056 0 512c0 281.6 228.928 512 512 512s512-228.928 512-512c0.128-281.472-228.8-512-511.872-512z m160.256 657.984a18.368 18.368 0 0 1-18.368 18.368H372.736a18.368 18.368 0 0 1-18.368-18.368V376.704c0-10.112 8.256-18.368 18.368-18.368h281.408c10.112 0 18.368 8.256 18.368 18.368v281.28h-0.128z" fill="#3E81E9" p-id="15350"></path></svg>

After

Width:  |  Height:  |  Size: 517 B

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>

View File

@@ -85,7 +85,7 @@ const borderRadius = ref('20px');
const boxShadow = ref('none');
const searchStore = useStore().search;
const uploadStore = useStore().upload;
const router = useRouter();
/**
* 监听输入框聚焦事件
*/
@@ -143,7 +143,9 @@ const onCalendarChange = (val: RangeValue) => {
dates.value = val;
};
/**
* 搜索事件
*/
async function search() {
const params: any = {
type: searchStore.searchOption[0],
@@ -153,7 +155,15 @@ async function search() {
input_image: "123"
};
const res: any = await imageSearchApi(params);
console.log(res);
if (res && res.code === 200) {
searchStore.searchResult = res.data.records;
router.push({
path: '/main/photo/search/list', query: {
type: searchStore.searchOption[0],
keyword: searchStore.searchValue,
}
});
}
}
</script>

View File

@@ -20,6 +20,15 @@ export default [
...recycling_bin,
...share,
...upscale,
{
path: '/main/photo/search/list',
name: 'photo-search-list',
component: () => import('@/views/Photograph/SearchResult/SearchResult.vue'),
meta: {
requiresAuth: true,
title: '搜索结果'
}
}
]
}, {
path: '/main/share/list/:id',

View File

@@ -54,6 +54,8 @@ export const useImageStore = defineStore(
// 图片编辑
const imageEditVisible = ref<boolean>(false);
/**
* 获取人脸列表
*/

View File

@@ -41,10 +41,14 @@ export const useSearchStore = defineStore(
const option = options.find(option => option.value === value);
return option ? option.icon : undefined;
};
// 图片搜索结果
const searchResult = ref<any[]>([]);
return {
searchOption,
options,
searchValue,
searchResult,
getIconByValue
};
},

View File

@@ -0,0 +1,329 @@
import type {FunctionalComponent} from 'vue';
// Define constants
export const TABS = {
FINETUNE: 'Finetune',
FILTERS: 'Filters',
ADJUST: 'Adjust',
WATERMARK: 'Watermark',
ANNOTATE: 'Annotate',
RESIZE: 'Resize',
} as const;
export const TOOLS = {
CROP: 'Crop',
ROTATE: 'Rotate',
FLIP_X: 'Flip_X',
FLIP_Y: 'Flip_Y',
BRIGHTNESS: 'Brightness',
CONTRAST: 'Contrast',
HSV: 'HueSaturationValue',
WARMTH: 'Warmth',
BLUR: 'Blur',
THRESHOLD: 'Threshold',
POSTERIZE: 'Posterize',
PIXELATE: 'Pixelate',
NOISE: 'Noise',
FILTERS: 'Filters',
RECT: 'Rect',
ELLIPSE: 'Ellipse',
POLYGON: 'Polygon',
TEXT: 'Text',
LINE: 'Line',
IMAGE: 'Image',
ARROW: 'Arrow',
WATERMARK: 'Watermark',
PEN: 'Pen',
RESIZE: 'Resize',
} as const;
// Define types
type AvailableTabs = (typeof TABS)[keyof typeof TABS]
type AvailableTools = (typeof TOOLS)[keyof typeof TOOLS]
type LineCap = 'butt' | 'round' | 'square'
type ClosingReasons = 'after-saving' | 'close-button-clicked' | 'back-button-clicked' | string
type SavedImageData = {
name: string
extension: string
mimeType: string
fullName?: string
height?: number
width?: number
imageBase64?: string
imageCanvas?: HTMLCanvasElement
quality?: number
cloudimageUrl?: string
}
type AnnotationsCommon = {
fill?: string
stroke?: string
strokeWidth?: number
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
shadowOpacity?: number
opacity?: number
}
// Define annotations types
type TextAnnotation = AnnotationsCommon & {
text?: string
fontFamily?: string
fontSize?: number
letterSpacing?: number
lineHeight?: number
align?: 'left' | 'center' | 'right'
fontStyle?: 'normal' | 'bold' | 'italic' | 'bold italic'
}
type ImageAnnotation = AnnotationsCommon & {
disableUpload?: boolean
gallery?: {
originalUrl: string
previewUrl: string
}[]
}
type RectAnnotation = AnnotationsCommon & {
cornerRadius?: number
}
type PolygonAnnotation = AnnotationsCommon & {
sides?: number
}
type PenAnnotation = AnnotationsCommon & {
tension?: number
lineCap?: LineCap
selectAnnotationAfterDrawing?: boolean
}
type LineAnnotation = AnnotationsCommon & {
lineCap?: LineCap
}
type ArrowAnnotation = AnnotationsCommon & {
lineCap?: LineCap
pointerLength?: number
pointerWidth?: number
}
type RotateAnnotation = {
angle?: number
componentType?: 'slider' | 'buttons'
}
// Crop Preset Types
type CropPresetItem = {
titleKey: string
width?: number
height?: number
ratio?: string | number
descriptionKey?: string
icon?: string | HTMLElement | FunctionalComponent
disableManualResize?: boolean
noEffect?: boolean
}
type CropPresetGroup = {
titleKey: string
items: CropPresetItem[]
}
type CropPresetFolder = {
titleKey: string
groups: CropPresetGroup[]
icon?: string | HTMLElement | FunctionalComponent
}
export type onSaveFunction = (
savedImageData: SavedImageData,
imageDesignState: ImageDesignState,
) => void | Promise<void>
type triggerSaveModalFn = (arg0: onSaveFunction) => void
type triggerSavingFn = (arg0: onSaveFunction) => void
type SaveOption = {
label: string
icon: string | HTMLElement | FunctionalComponent
onClick: (arg0: triggerSaveModalFn, arg1: triggerSavingFn) => void
}
// Image Design State
type ImageDesignState = {
imgSrc?: string
finetunes?: string[]
finetunesProps?: {
brightness?: number
contrast?: number
hue?: number
saturation?: number
value?: number
blurRadius?: number
warmth?: number
}
filter?: string
adjustments?: {
crop: {
ratio: string | number
width?: number
height?: number
x?: number
y?: number
ratioFolderKey?: string
ratioGroupKey?: string
ratioTitleKey?: string
}
isFlippedX?: boolean
isFlippedY?: boolean
rotation?: number
}
annotations?: {
[key: string]: AnnotationsCommon &
(
| TextAnnotation
| RectAnnotation
| PolygonAnnotation
| PenAnnotation
| LineAnnotation
| ArrowAnnotation
) & {
id: string
name: string
x: number
y: number
scaleX?: number
scaleY?: number
width?: number
height?: number
radius?: number
radiusX?: number
radiusY?: number
points?: number[]
image?: string | HTMLElement
}
}
resize?: {
width?: number
height?: number
manualChangeDisabled?: boolean
}
shownImageDimensions?: {
width: number
height: number
scaledBy: number
}
}
export interface IFilerobotImageEditorConfig {
theme?: "light"
source: string | HTMLImageElement
annotationsCommon?: AnnotationsCommon
// [TOOLS_IDS.TEXT]
Text?: TextAnnotation & {
fonts?: (string | { label: string; value: string })[]
onFontChange?: (newFontFamily: string, reRenderCanvasFn: () => void) => void
}
// [TOOLS_IDS.IMAGE]
Image?: ImageAnnotation
// [TOOLS_IDS.ELLIPSE]
Ellipse?: AnnotationsCommon
// [TOOLS_IDS.RECT]
Rect?: RectAnnotation
// [TOOLS_IDS.POLYGON]
Polygon?: PolygonAnnotation
// [TOOLS_IDS.PEN]
Pen?: PenAnnotation
// [TOOLS_IDS.LINE]: {
Line?: LineAnnotation
// [TOOLS_IDS.ARROW]: {
Arrow?: ArrowAnnotation
// [TOOLS_IDS.ROTATE]:
Rotate?: RotateAnnotation
// [TOOLS_IDS.WATERMARK]
Watermark?: {
gallery?: string[] | { url: string; previewUrl: string }[] | []
onUploadWatermarkImgClick?: (
loadAndSetWatermarkImg: (imgUrl: string, revokeObjectUrl: boolean) => void,
) => Promise<{ url: string; revokeObjectUrl?: boolean }> | void
textScalingRatio?: number
imageScalingRatio?: number
hideTextWatermark?: boolean
}
// [TOOLS_IDS.CROP]
Crop?: {
minWidth?: number
minHeight?: number
maxWidth?: null
maxHeight?: null
ratio?: 'original' | 'custom' | 'ellipse' | number
noPresets?: boolean
ratioTitleKey?: string
presetsItems?: CropPresetItem[]
presetsFolders?: CropPresetFolder[]
autoResize?: boolean
lockCropAreaAt?:
| 'top-left'
| 'top-center'
| 'top-right'
| 'center-left'
| 'center-center'
| 'center-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right'
}
// TABS_IDS
tabsIds?: AvailableTabs[] | []
defaultTabId?: AvailableTabs
defaultToolId?: AvailableTools
onBeforeSave?: (savedImageData: SavedImageData) => void | boolean
onSave?: onSaveFunction
onClose?: (closeReason: ClosingReasons, haveNotSavedChanges: boolean) => void
closeAfterSave?: boolean
defaultSavedImageName?: string
defaultSavedImageType?: 'png' | 'jpeg' | 'jpg' | 'webp'
defaultSavedImageQuality?: number
forceToPngInEllipticalCrop?: boolean
useBackendTranslations?: boolean
translations?: object
language?: 'en' | 'fr' | 'de' | 'it' | 'pt' | 'es' | 'nl' | 'pl' | 'ro' | string
avoidChangesNotSavedAlertOnLeave?: boolean
loadableDesignState?: ImageDesignState
showBackButton?: boolean
savingPixelRatio?: number
previewPixelRatio?: number
moreSaveOptions?: SaveOption[]
useCloudimage?: boolean
cloudimage?: {
token: string
dontPrefixUrl?: boolean
domain?: string
version?: string
secureProtocol?: boolean
loadableQuery?: string
imageSealing?: {
enable?: boolean
salt?: string
charCount?: number
includeParams?: string[]
}
}
observePluginContainerSize?: boolean
showCanvasOnly?: boolean
onModify?: (currentImageDesignState: ImageDesignState) => void
useZoomPresetsMenu?: boolean
disableZooming?: boolean
noCrossOrigin?: boolean
showSaveButton?: boolean
disableSaveIfNoChanges?: boolean
removeSaveButton?: boolean
resetOnImageSourceChange?: boolean
backgroundColor?: string
backgroundImage?: HTMLImageElement
}

View File

@@ -0,0 +1,65 @@
import JSZip from 'jszip';
interface ImageData {
blob: Blob;
url: string;
index: number;
}
/**
* 打包下载图片工具方法
* @param urls 图片URL数组
*/
export async function downloadImagesAsZip(urls: string[]): Promise<void> {
const zip = new JSZip();
// 1. 并行获取所有图片资源(添加明确的类型声明)
const imagePromises = urls.map(async (url, index): Promise<ImageData | null> => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
return { blob, url, index };
} catch (error) {
console.error(`Failed to download ${url}:`, error);
return null;
}
});
// 2. 使用类型守卫过滤null值
const images = (await Promise.all(imagePromises))
.filter((img): img is ImageData => img !== null); // 类型谓词
if (images.length === 0) {
throw new Error('No images were successfully downloaded');
}
// 3. 将图片添加到压缩包
images.forEach(({ blob, url, index }) => {
const urlObj = new URL(url);
let filename = urlObj.pathname.split('/').pop() || '';
// 清理文件名中的查询参数
filename = filename.split('?')[0];
// 处理空文件名或无效文件名
if (!filename || !/\.[a-z0-9]+$/i.test(filename)) {
const extension = blob.type.split('/')[1] || 'bin';
filename = `image-${index + 1}.${extension}`;
}
zip.file(filename, blob);
});
// 4. 生成ZIP文件并触发下载
const blob = await zip.generateAsync({ type: 'blob' });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `images-${Date.now()}.zip`;
document.body.appendChild(link);
link.click();
// 5. 清理资源
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
}

View File

@@ -0,0 +1,29 @@
export async function urlToBase64(url: string): Promise<string> {
try {
const response = await fetch(url, {
method: 'GET',
mode: 'no-cors', // 确保请求模式为cors
headers: {
'Content-Type': 'image/*' // 根据实际情况调整类型
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result?.toString();
resolve(base64data || '');
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error('Error converting URL to Base64:', error);
return Promise.reject(error);
}
}

View File

@@ -0,0 +1,13 @@
export function urlToBlob(url: string): Promise<Blob> {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
return response.blob();
})
.catch(error => {
console.error(error);
throw error;
});
}

View File

@@ -51,6 +51,7 @@
:maxlength="10"
@click.stop
:placeholder="item.face_name"
autofocus
class="people-album-add-input">
<template #suffix>
<AButton type="link" style="font-size: 12px;" size="small" @mousedown.prevent

View File

@@ -42,7 +42,7 @@
分享相册
</AMenuItem>
<AMenuItem key="3" @click.prevent="deleteAlbum(album.id)">删除相册</AMenuItem>
<AMenuItem key="4">下载相册</AMenuItem>
<AMenuItem key="4" @click.prevent="downloadAlbumImage(album.id)">下载相册</AMenuItem>
</AMenu>
</template>
</ADropdown>
@@ -67,9 +67,11 @@ import more from "@/assets/svgs/more.svg";
import empty from "@/assets/svgs/empty.svg";
import useStore from "@/store";
import {message} from "ant-design-vue";
import {deleteAlbumApi, renameAlbumApi} from "@/api/storage";
import {deleteAlbumApi, downloadAlbumImagesApi, renameAlbumApi} from "@/api/storage";
import {downloadImagesAsZip} from "@/utils/imageUtils/downloadImagesAsZip.ts";
const imageStore = useStore().image;
const uploadStore = useStore().upload;
const isHovered = ref<number | null>(null);
const router = useRouter();
const route = useRoute();
@@ -118,6 +120,25 @@ async function deleteAlbum(id: number) {
}
}
/**
* 下载相册图片
* @param id
*/
async function downloadAlbumImage(id: number) {
if (!id) {
return;
}
const res: any = await downloadAlbumImagesApi(id, uploadStore.storageSelected?.[0], uploadStore.storageSelected?.[1]);
if (res && res.code === 200) {
if (!res.data.records) {
message.warning("相册中没有图片");
return;
}
await downloadImagesAsZip(res.data.records);
}
}
</script>
<style scoped lang="scss">
.phoalbum-item-container {

View File

@@ -36,9 +36,10 @@
</AMenu>
</template>
</ADropdown>
<AInput class="phoalbum-search" placeholder="搜索相册">
<AInput class="phoalbum-search" placeholder="搜索相册" v-model:value="searchValue"
@pressEnter="(e)=>searchAlbum(e.target.value)">
<template #suffix>
<AButton size="small" type="text" shape="circle" @click.prevent>
<AButton size="small" type="text" shape="circle" @click.stop="searchAlbum(searchValue)">
<template #icon>
<SearchOutlined/>
</template>
@@ -75,7 +76,7 @@
</div>
</template>
<script setup lang="ts">
import {createAlbumApi} from "@/api/storage";
import {albumSearchApi, createAlbumApi} from "@/api/storage";
import {message} from "ant-design-vue";
import useStore from "@/store";
@@ -89,7 +90,6 @@ const albumNameValue = ref<string>("未命名相册");
const imageStore = useStore().image;
/**
* 创建相册
*/
@@ -125,6 +125,24 @@ async function handleTabChange(activeKey: string) {
await imageStore.getAlbumList();
}
const searchValue = ref<string>("");
/**
* 搜索相册
* @param keyword
*/
async function searchAlbum(keyword: string) {
if (keyword.trim() === "") {
return;
}
const res: any = await albumSearchApi(keyword);
if (res && res.code === 200) {
imageStore.albumList = res.data.albums;
}
}
onMounted(() => {
imageStore.getAlbumList();

View File

@@ -0,0 +1,85 @@
<template>
<div class="search-upload">
<div class="photo-header">
<AButton type="text" shape="default" size="large" style="padding: 5px;" @click="router.go(-1)">
<template #icon>
<LeftOutlined/>
</template>
返回主页
</AButton>
<AButton type="link" size="large" class="search-btn">搜索结果</AButton>
</div>
<image-toolbar :selected="imageStore.selected" :image-list="searchStore.searchResult"/>
<div class="photo-list">
<div class="photo-list-container">
<ImageWaterfallList :image-list="searchStore.searchResult"/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import useStore from "@/store";
import ImageToolbar from "@/components/ImageToolbar/ImageToolbar.vue";
import ImageWaterfallList from "@/components/ImageWaterfallList/ImageWaterfallList.vue";
const searchStore = useStore().search;
const imageStore = useStore().image;
const router = useRouter();
onBeforeUnmount(() => {
searchStore.searchResult = [];
});
</script>
<style scoped lang="scss">
.search-upload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
.photo-header {
width: 100%;
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
border-bottom: 1px solid #e2e2e2;
.search-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
color: #333;
}
}
.photo-list {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
height: calc(100% - 65px);
margin-top: 10px;
.photo-list-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
}
}
}
</style>

View File

@@ -4,6 +4,7 @@
<AButton type="link" size="large" class="recycling-bin-title">回收站</AButton>
<span class="recycling-bin-desc">保存最近10天从云端删除的内容</span>
</div>
<image-toolbar :selected="imageStore.selected" :image-list="imageList"/>
<div class="photo-list">
<ImageWaterfallList :image-list="imageList"/>
</div>
@@ -13,10 +14,12 @@
import {getDeletedRecordApi} from "@/api/storage";
import useStore from "@/store";
import ImageWaterfallList from "@/components/ImageWaterfallList/ImageWaterfallList.vue";
import ImageToolbar from "@/components/ImageToolbar/ImageToolbar.vue";
const imageList = ref<any[]>([]);
const upload = useStore().upload;
const imageStore = useStore().image;
/**
* 查询回收站
*/
@@ -42,6 +45,7 @@ onMounted(() => {
justify-content: flex-start;
width: 100%;
height: 100%;
gap: 10px;
.recycling-bin-header {
width: 100%;

12
src/vite-env.d.ts vendored
View File

@@ -27,4 +27,16 @@ declare module '*.svg' {
export default content;
}
import type {IFilerobotImageEditorConfig} from '@/types/imageEditorConfig.ts';
declare module '@/assets/lib/ImageEditor/filerobot-image-editor.min.js' {
export default class ImageEditor {
constructor(config: IFilerobotImageEditorConfig)
render(): void
terminate(): void
}
}