✨ add image editing features
This commit is contained in:
7
components.d.ts
vendored
7
components.d.ts
vendored
@@ -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']
|
||||
|
@@ -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",
|
||||
|
@@ -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",
|
||||
});
|
||||
};
|
||||
|
BIN
src/assets/images/confidential.png
Normal file
BIN
src/assets/images/confidential.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/images/top-secret.png
Normal file
BIN
src/assets/images/top-secret.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
43528
src/assets/lib/ImageEditor/filerobot-image-editor.min.js
vendored
Normal file
43528
src/assets/lib/ImageEditor/filerobot-image-editor.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1
src/assets/svgs/stop.svg
Normal 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 |
74
src/components/FilerobotImageEditor/FilerobotImageEditor.vue
Normal file
74
src/components/FilerobotImageEditor/FilerobotImageEditor.vue
Normal 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>
|
96
src/components/FilerobotImageEditor/lang/en.ts
Normal file
96
src/components/FilerobotImageEditor/lang/en.ts
Normal 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',
|
||||
};
|
96
src/components/FilerobotImageEditor/lang/fr.ts
Normal file
96
src/components/FilerobotImageEditor/lang/fr.ts
Normal 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',
|
||||
};
|
95
src/components/FilerobotImageEditor/lang/zh.ts
Normal file
95
src/components/FilerobotImageEditor/lang/zh.ts
Normal 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: '适应尺寸',
|
||||
};
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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',
|
||||
|
@@ -54,6 +54,8 @@ export const useImageStore = defineStore(
|
||||
// 图片编辑
|
||||
const imageEditVisible = ref<boolean>(false);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取人脸列表
|
||||
*/
|
||||
|
@@ -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
|
||||
};
|
||||
},
|
||||
|
329
src/types/imageEditorConfig.ts
Normal file
329
src/types/imageEditorConfig.ts
Normal 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
|
||||
}
|
65
src/utils/imageUtils/downloadImagesAsZip.ts
Normal file
65
src/utils/imageUtils/downloadImagesAsZip.ts
Normal 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);
|
||||
}
|
29
src/utils/imageUtils/urlToBase64.ts
Normal file
29
src/utils/imageUtils/urlToBase64.ts
Normal 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);
|
||||
}
|
||||
}
|
13
src/utils/imageUtils/urlToBlob.ts
Normal file
13
src/utils/imageUtils/urlToBlob.ts
Normal 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;
|
||||
});
|
||||
}
|
@@ -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
|
||||
|
@@ -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 {
|
||||
|
@@ -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();
|
||||
|
85
src/views/Photograph/SearchResult/SearchResult.vue
Normal file
85
src/views/Photograph/SearchResult/SearchResult.vue
Normal 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>
|
@@ -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
12
src/vite-env.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user