添加背景页面右边栏增加图片标签页,支持添加背景图片
This commit is contained in:
parent
42563f67a8
commit
4c17007f33
@ -1202,6 +1202,22 @@
|
|||||||
"Auto remove background failed, using original image": "自动去除背景失败,使用原始图片",
|
"Auto remove background failed, using original image": "自动去除背景失败,使用原始图片",
|
||||||
"Please login first to use auto remove background feature": "请先登录以使用自动去除背景功能",
|
"Please login first to use auto remove background feature": "请先登录以使用自动去除背景功能",
|
||||||
"Background removed successfully": "背景去除成功",
|
"Background removed successfully": "背景去除成功",
|
||||||
|
"Color": "颜色",
|
||||||
|
"Background Image": "背景图片",
|
||||||
|
"Upload Custom Background": "上传自定义背景",
|
||||||
|
"Click or drag to upload": "点击或拖拽上传",
|
||||||
|
"My Backgrounds": "我的背景图",
|
||||||
|
"Pexels Backgrounds": "Pexels 背景图",
|
||||||
|
"No images found": "未找到图片",
|
||||||
|
"Failed to load background images": "加载背景图片失败",
|
||||||
|
"Background image applied": "背景图片已应用",
|
||||||
|
"Failed to apply background image": "应用背景图片失败",
|
||||||
|
"Background uploaded successfully": "背景上传成功",
|
||||||
|
"Failed to upload background": "上传背景失败",
|
||||||
|
"Remove": "移除",
|
||||||
|
"Upload": "上传",
|
||||||
|
"Background Settings": "背景设置",
|
||||||
|
"Close": "关闭",
|
||||||
"Sign up successful, but auto login failed. Please login manually": "注册成功,但自动登录失败,请手动登录",
|
"Sign up successful, but auto login failed. Please login manually": "注册成功,但自动登录失败,请手动登录",
|
||||||
"WeChat": "微信",
|
"WeChat": "微信",
|
||||||
"Weibo": "微博",
|
"Weibo": "微博",
|
||||||
|
|||||||
@ -259,85 +259,300 @@ favorite-colors-grid<template>
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<!-- Right Sidebar for Common Colors and Color Picker (Desktop Only) -->
|
<!-- Mobile Bottom Sheet Color Picker -->
|
||||||
<div v-if="uploadedImage" class="right-sidebar desktop-only">
|
<transition name="slide-up">
|
||||||
<div class="sidebar-content">
|
<div
|
||||||
<div class="color-picker-section">
|
v-if="uploadedImage && showMobileColorPicker"
|
||||||
<h4 class="section-title">{{ t('Background Color') }}</h4>
|
class="mobile-color-sheet-overlay"
|
||||||
<div class="color-picker-container">
|
@click="closeMobileColorPicker"
|
||||||
<input
|
>
|
||||||
v-model="backgroundColor"
|
<div
|
||||||
type="color"
|
class="mobile-color-sheet"
|
||||||
class="sidebar-color-picker"
|
@click.stop
|
||||||
@input="onColorChange"
|
>
|
||||||
/>
|
<div class="sheet-handle"></div>
|
||||||
<div class="hex-input-wrapper">
|
<div class="sheet-header">
|
||||||
<input
|
<h4>{{ t('Background Settings') }}</h4>
|
||||||
v-model="backgroundColor"
|
<button
|
||||||
type="text"
|
class="sheet-close-btn"
|
||||||
class="sidebar-hex-input"
|
@click="closeMobileColorPicker"
|
||||||
placeholder="#FFFFFF"
|
:title="t('Close')"
|
||||||
@input="onHexInputChange"
|
|
||||||
@blur="onHexInputBlur"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="add-favorite-btn"
|
|
||||||
@click="addToFavorites"
|
|
||||||
:title="t('Add to favorites')"
|
|
||||||
:disabled="favoriteColors.length >= MAX_FAVORITE_COLORS"
|
|
||||||
>
|
|
||||||
<i class="fa fa-star"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="favoriteColors.length > 0" class="favorite-colors-section">
|
|
||||||
<h4 class="section-title">{{ t('Favorite Colors') }}</h4>
|
|
||||||
<div class="favorite-colors-grid">
|
|
||||||
<div
|
|
||||||
v-for="(color, index) in favoriteColors"
|
|
||||||
:key="index"
|
|
||||||
class="favorite-color-item"
|
|
||||||
:class="{ 'active': color === backgroundColor }"
|
|
||||||
:style="{ backgroundColor: color }"
|
|
||||||
@click="selectCommonColor(color)"
|
|
||||||
:title="color"
|
|
||||||
>
|
>
|
||||||
<button
|
<i class="fa fa-times"></i>
|
||||||
type="button"
|
</button>
|
||||||
class="remove-favorite-btn"
|
|
||||||
@click.stop="removeFavoriteColor(color)"
|
|
||||||
:title="t('Remove from favorites')"
|
|
||||||
:aria-label="t('Remove from favorites')"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="sheet-content">
|
||||||
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||||
<div class="common-colors-section">
|
<n-tab-pane name="color" :tab="t('Color')">
|
||||||
<h4 class="section-title">{{ t('Common Colors') }}</h4>
|
<div class="color-picker-section">
|
||||||
<div class="common-colors-grid">
|
<div class="color-picker-container">
|
||||||
<div
|
<input
|
||||||
v-for="(color, index) in commonColors"
|
v-model="backgroundColor"
|
||||||
:key="index"
|
type="color"
|
||||||
class="common-color-item"
|
class="sidebar-color-picker"
|
||||||
:class="{ 'active': color === backgroundColor }"
|
@input="onColorChange"
|
||||||
:style="{ backgroundColor: color }"
|
/>
|
||||||
@click="selectCommonColor(color)"
|
<div class="hex-input-wrapper">
|
||||||
:title="color"
|
<input
|
||||||
>
|
v-model="backgroundColor"
|
||||||
</div>
|
type="text"
|
||||||
|
class="sidebar-hex-input"
|
||||||
|
placeholder="#FFFFFF"
|
||||||
|
@input="onHexInputChange"
|
||||||
|
@blur="onHexInputBlur"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="add-favorite-btn"
|
||||||
|
@click="addToFavorites"
|
||||||
|
:title="t('Add to favorites')"
|
||||||
|
:disabled="favoriteColors.length >= MAX_FAVORITE_COLORS"
|
||||||
|
>
|
||||||
|
<i class="fa fa-star"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="favoriteColors.length > 0" class="favorite-colors-section">
|
||||||
|
<h4 class="section-title">{{ t('Favorite Colors') }}</h4>
|
||||||
|
<div class="favorite-colors-grid">
|
||||||
|
<div
|
||||||
|
v-for="(color, index) in favoriteColors"
|
||||||
|
:key="index"
|
||||||
|
class="favorite-color-item"
|
||||||
|
:class="{ 'active': color === backgroundColor }"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="selectCommonColor(color)"
|
||||||
|
:title="color"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-favorite-btn"
|
||||||
|
@click.stop="removeFavoriteColor(color)"
|
||||||
|
:title="t('Remove from favorites')"
|
||||||
|
:aria-label="t('Remove from favorites')"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="common-colors-section">
|
||||||
|
<h4 class="section-title">{{ t('Common Colors') }}</h4>
|
||||||
|
<div class="common-colors-grid">
|
||||||
|
<div
|
||||||
|
v-for="(color, index) in commonColors"
|
||||||
|
:key="index"
|
||||||
|
class="common-color-item"
|
||||||
|
:class="{ 'active': color === backgroundColor }"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="selectCommonColor(color)"
|
||||||
|
:title="color"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="image" :tab="t('Background')">
|
||||||
|
<div class="background-images-section">
|
||||||
|
<h4 class="section-title">{{ t('Upload Custom Background') }}</h4>
|
||||||
|
<n-upload
|
||||||
|
:custom-request="handleCustomBackgroundUpload"
|
||||||
|
accept="image/*"
|
||||||
|
:show-file-list="false"
|
||||||
|
>
|
||||||
|
<n-upload-dragger>
|
||||||
|
<div class="upload-dragger-content">
|
||||||
|
<i class="fa fa-cloud-upload" style="font-size: 32px; color: #1fc76f;"></i>
|
||||||
|
<div class="upload-text">{{ t('Click or drag to upload') }}</div>
|
||||||
|
</div>
|
||||||
|
</n-upload-dragger>
|
||||||
|
</n-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="customBackgrounds.length > 0" class="custom-backgrounds-section">
|
||||||
|
<h4 class="section-title">{{ t('My Backgrounds') }}</h4>
|
||||||
|
<div class="background-images-grid">
|
||||||
|
<div
|
||||||
|
v-for="(bgImage, index) in customBackgrounds"
|
||||||
|
:key="index"
|
||||||
|
class="background-image-item"
|
||||||
|
@click="applyBackgroundImage(bgImage)"
|
||||||
|
>
|
||||||
|
<img :src="bgImage" alt="Background" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-bg-btn"
|
||||||
|
@click.stop="removeCustomBackground(index)"
|
||||||
|
:title="t('Remove')"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pexels-images-section">
|
||||||
|
<h4 class="section-title">{{ t('Pexels Backgrounds') }}</h4>
|
||||||
|
<n-spin :show="pexelsLoading">
|
||||||
|
<div v-if="pexelsImages.length > 0" class="background-images-grid">
|
||||||
|
<div
|
||||||
|
v-for="image in pexelsImages"
|
||||||
|
:key="image.id"
|
||||||
|
class="background-image-item"
|
||||||
|
@click="applyBackgroundImage(image.src.medium)"
|
||||||
|
>
|
||||||
|
<img :src="image.src.tiny" :alt="image.alt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-empty v-else :description="t('No images found')" size="small" />
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Right Sidebar for Common Colors and Color Picker (Desktop Only) -->
|
||||||
|
<div v-if="uploadedImage" class="right-sidebar desktop-only">
|
||||||
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||||
|
<n-tab-pane name="color" :tab="t('Color')">
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="color-picker-section">
|
||||||
|
<h4 class="section-title">{{ t('Background Color') }}</h4>
|
||||||
|
<div class="color-picker-container">
|
||||||
|
<input
|
||||||
|
v-model="backgroundColor"
|
||||||
|
type="color"
|
||||||
|
class="sidebar-color-picker"
|
||||||
|
@input="onColorChange"
|
||||||
|
/>
|
||||||
|
<div class="hex-input-wrapper">
|
||||||
|
<input
|
||||||
|
v-model="backgroundColor"
|
||||||
|
type="text"
|
||||||
|
class="sidebar-hex-input"
|
||||||
|
placeholder="#FFFFFF"
|
||||||
|
@input="onHexInputChange"
|
||||||
|
@blur="onHexInputBlur"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="add-favorite-btn"
|
||||||
|
@click="addToFavorites"
|
||||||
|
:title="t('Add to favorites')"
|
||||||
|
:disabled="favoriteColors.length >= MAX_FAVORITE_COLORS"
|
||||||
|
>
|
||||||
|
<i class="fa fa-star"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="favoriteColors.length > 0" class="favorite-colors-section">
|
||||||
|
<h4 class="section-title">{{ t('Favorite Colors') }}</h4>
|
||||||
|
<div class="favorite-colors-grid">
|
||||||
|
<div
|
||||||
|
v-for="(color, index) in favoriteColors"
|
||||||
|
:key="index"
|
||||||
|
class="favorite-color-item"
|
||||||
|
:class="{ 'active': color === backgroundColor }"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="selectCommonColor(color)"
|
||||||
|
:title="color"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-favorite-btn"
|
||||||
|
@click.stop="removeFavoriteColor(color)"
|
||||||
|
:title="t('Remove from favorites')"
|
||||||
|
:aria-label="t('Remove from favorites')"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="common-colors-section">
|
||||||
|
<h4 class="section-title">{{ t('Common Colors') }}</h4>
|
||||||
|
<div class="common-colors-grid">
|
||||||
|
<div
|
||||||
|
v-for="(color, index) in commonColors"
|
||||||
|
:key="index"
|
||||||
|
class="common-color-item"
|
||||||
|
:class="{ 'active': color === backgroundColor }"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="selectCommonColor(color)"
|
||||||
|
:title="color"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="image" :tab="t('Background Image')">
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="background-images-section">
|
||||||
|
<h4 class="section-title">{{ t('Upload Custom Background') }}</h4>
|
||||||
|
<n-upload
|
||||||
|
:custom-request="handleCustomBackgroundUpload"
|
||||||
|
accept="image/*"
|
||||||
|
:show-file-list="false"
|
||||||
|
>
|
||||||
|
<n-upload-dragger>
|
||||||
|
<div class="upload-dragger-content">
|
||||||
|
<i class="fa fa-cloud-upload" style="font-size: 32px; color: #1fc76f;"></i>
|
||||||
|
<div class="upload-text">{{ t('Click or drag to upload') }}</div>
|
||||||
|
</div>
|
||||||
|
</n-upload-dragger>
|
||||||
|
</n-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="customBackgrounds.length > 0" class="custom-backgrounds-section">
|
||||||
|
<h4 class="section-title">{{ t('My Backgrounds') }}</h4>
|
||||||
|
<div class="background-images-grid">
|
||||||
|
<div
|
||||||
|
v-for="(bgImage, index) in customBackgrounds"
|
||||||
|
:key="index"
|
||||||
|
class="background-image-item"
|
||||||
|
@click="applyBackgroundImage(bgImage)"
|
||||||
|
>
|
||||||
|
<img :src="bgImage" alt="Background" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-bg-btn"
|
||||||
|
@click.stop="removeCustomBackground(index)"
|
||||||
|
:title="t('Remove')"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pexels-images-section">
|
||||||
|
<h4 class="section-title">{{ t('Pexels Backgrounds') }}</h4>
|
||||||
|
<n-spin :show="pexelsLoading">
|
||||||
|
<div v-if="pexelsImages.length > 0" class="background-images-grid">
|
||||||
|
<div
|
||||||
|
v-for="image in pexelsImages"
|
||||||
|
:key="image.id"
|
||||||
|
class="background-image-item"
|
||||||
|
@click="applyBackgroundImage(image.src.medium)"
|
||||||
|
>
|
||||||
|
<img :src="image.src.tiny" :alt="image.alt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-empty v-else :description="t('No images found')" />
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage, NTabs, NTabPane, NSpin, NUpload, NUploadDragger, NButton, NEmpty } from 'naive-ui'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { t } from '@/shared/i18n'
|
import { t } from '@/shared/i18n'
|
||||||
@ -445,6 +660,203 @@ const removeFavoriteColor = (color: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch Pexels images
|
||||||
|
const fetchPexelsImages = async () => {
|
||||||
|
// Skip fetching if no API key is configured
|
||||||
|
if (!PEXELS_API_KEY) {
|
||||||
|
console.warn('Pexels API key not configured. Skipping image fetch.')
|
||||||
|
pexelsImages.value = []
|
||||||
|
pexelsLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pexelsLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('https://api.pexels.com/v1/search', {
|
||||||
|
params: {
|
||||||
|
query: pexelsQuery.value,
|
||||||
|
page: pexelsPage.value,
|
||||||
|
per_page: 20,
|
||||||
|
orientation: 'landscape'
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: PEXELS_API_KEY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pexelsImages.value = response.data.photos || []
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to fetch Pexels images:', error)
|
||||||
|
// Don't show error message if API key is not configured
|
||||||
|
if (PEXELS_API_KEY) {
|
||||||
|
message.error(t('Failed to load background images'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pexelsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply background image
|
||||||
|
const applyBackgroundImage = async (imageUrl: string) => {
|
||||||
|
if (!fabricCanvas || !uploadedImage.value || !uploadedImageUrl.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
processing.value = true
|
||||||
|
|
||||||
|
// Save the original foreground image reference before clearing
|
||||||
|
const currentObjects = fabricCanvas.getObjects()
|
||||||
|
const foregroundImg = currentObjects.length > 0 ? currentObjects[0] : null
|
||||||
|
|
||||||
|
// Load background image
|
||||||
|
const bgImg = await FabricImage.fromURL(imageUrl, {
|
||||||
|
crossOrigin: 'anonymous'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!bgImg || !fabricCanvas) {
|
||||||
|
processing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get canvas dimensions
|
||||||
|
const canvasWidth = fabricCanvas.width || 1
|
||||||
|
const canvasHeight = fabricCanvas.height || 1
|
||||||
|
|
||||||
|
// Scale background image to cover canvas
|
||||||
|
const bgScale = Math.max(
|
||||||
|
canvasWidth / (bgImg.width || 1),
|
||||||
|
canvasHeight / (bgImg.height || 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
bgImg.set({
|
||||||
|
scaleX: bgScale,
|
||||||
|
scaleY: bgScale,
|
||||||
|
left: canvasWidth / 2,
|
||||||
|
top: canvasHeight / 2,
|
||||||
|
originX: 'center',
|
||||||
|
originY: 'center',
|
||||||
|
selectable: false,
|
||||||
|
evented: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
fabricCanvas.clear()
|
||||||
|
|
||||||
|
// Add background image first (at the bottom)
|
||||||
|
fabricCanvas.add(bgImg)
|
||||||
|
|
||||||
|
// Re-load and add the foreground image (original uploaded image)
|
||||||
|
if (foregroundImg) {
|
||||||
|
// Clone the foreground image to avoid issues
|
||||||
|
const newForegroundImg = await FabricImage.fromURL(uploadedImageUrl.value, {
|
||||||
|
crossOrigin: 'anonymous'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (newForegroundImg) {
|
||||||
|
newForegroundImg.set({
|
||||||
|
left: canvasWidth / 2,
|
||||||
|
top: canvasHeight / 2,
|
||||||
|
originX: 'center',
|
||||||
|
originY: 'center',
|
||||||
|
selectable: false,
|
||||||
|
evented: false
|
||||||
|
})
|
||||||
|
|
||||||
|
fabricCanvas.add(newForegroundImg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure correct layer order: background at bottom, foreground on top
|
||||||
|
const allObjects = fabricCanvas.getObjects()
|
||||||
|
if (allObjects.length === 2) {
|
||||||
|
// Move background (first object) to back
|
||||||
|
fabricCanvas.sendObjectToBack(allObjects[0])
|
||||||
|
// Move foreground (second object) to front
|
||||||
|
fabricCanvas.bringObjectToFront(allObjects[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fabricCanvas.renderAll()
|
||||||
|
|
||||||
|
// Export result
|
||||||
|
const dataUrl = fabricCanvas.toDataURL({
|
||||||
|
format: 'png',
|
||||||
|
quality: 0.95,
|
||||||
|
multiplier: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
resultImage.value = dataUrl
|
||||||
|
await cacheResultImage(dataUrl)
|
||||||
|
|
||||||
|
// Update history
|
||||||
|
if (currentHistoryIndex.value === -1 && uploadedImage.value && uploadedImageUrl.value) {
|
||||||
|
const historyItem: HistoryItem = {
|
||||||
|
id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
originalImageUrl: uploadedImageUrl.value,
|
||||||
|
originalImageFile: uploadedImage.value,
|
||||||
|
resultImage: dataUrl,
|
||||||
|
backgroundColor: backgroundColor.value,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
historyList.value.unshift(historyItem)
|
||||||
|
currentHistoryIndex.value = 0
|
||||||
|
} else if (currentHistoryIndex.value >= 0) {
|
||||||
|
historyList.value[currentHistoryIndex.value].resultImage = dataUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
processing.value = false
|
||||||
|
|
||||||
|
// Auto-close mobile color picker on mobile
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
closeMobileColorPicker()
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Apply background image error:', error)
|
||||||
|
message.error(t('Failed to apply background image'))
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle custom background upload
|
||||||
|
const handleCustomBackgroundUpload = async (options: any) => {
|
||||||
|
const { file } = options
|
||||||
|
|
||||||
|
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||||
|
if (!validTypes.includes(file.file?.type || '')) {
|
||||||
|
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 10 * 1024 * 1024
|
||||||
|
if ((file.file?.size || 0) > maxSize) {
|
||||||
|
message.warning(t('Image size exceeds 10MB limit'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const dataUrl = e.target?.result as string
|
||||||
|
if (dataUrl) {
|
||||||
|
// Add to custom backgrounds
|
||||||
|
customBackgrounds.value.unshift(dataUrl)
|
||||||
|
// Limit to 20 custom backgrounds
|
||||||
|
if (customBackgrounds.value.length > 20) {
|
||||||
|
customBackgrounds.value = customBackgrounds.value.slice(0, 20)
|
||||||
|
}
|
||||||
|
saveCustomBackgrounds()
|
||||||
|
message.success(t('Background uploaded successfully'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file.file)
|
||||||
|
} catch (error) {
|
||||||
|
message.error(t('Failed to upload background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove custom background
|
||||||
|
const removeCustomBackground = (index: number) => {
|
||||||
|
customBackgrounds.value.splice(index, 1)
|
||||||
|
saveCustomBackgrounds()
|
||||||
|
}
|
||||||
|
|
||||||
// 36 common background colors - light to dark gradient
|
// 36 common background colors - light to dark gradient
|
||||||
const commonColors = ref([
|
const commonColors = ref([
|
||||||
// Pure Whites (3)
|
// Pure Whites (3)
|
||||||
@ -606,6 +1018,43 @@ const processing = ref(false)
|
|||||||
const showMobileColorPicker = ref(false)
|
const showMobileColorPicker = ref(false)
|
||||||
let fabricCanvas: Canvas | null = null
|
let fabricCanvas: Canvas | null = null
|
||||||
|
|
||||||
|
// Tabs state
|
||||||
|
const activeTab = ref<'color' | 'image'>('color')
|
||||||
|
|
||||||
|
// Pexels images state
|
||||||
|
const pexelsImages = ref<any[]>([])
|
||||||
|
const pexelsLoading = ref(false)
|
||||||
|
const pexelsPage = ref(1)
|
||||||
|
const pexelsQuery = ref('nature background')
|
||||||
|
const PEXELS_API_KEY = __PEXELS_API_KEY__ || ''
|
||||||
|
|
||||||
|
// Custom background images
|
||||||
|
const customBackgrounds = ref<string[]>([])
|
||||||
|
|
||||||
|
// Load custom backgrounds from localStorage
|
||||||
|
const loadCustomBackgrounds = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('add_background_custom_images')
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
customBackgrounds.value = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load custom backgrounds:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save custom backgrounds to localStorage
|
||||||
|
const saveCustomBackgrounds = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('add_background_custom_images', JSON.stringify(customBackgrounds.value))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save custom backgrounds:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const adjustCanvasSize = () => {
|
const adjustCanvasSize = () => {
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
if (!canvas || !fabricCanvas) return
|
if (!canvas || !fabricCanvas) return
|
||||||
@ -656,6 +1105,26 @@ const handleResize = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
|
// Prevent paste handling when user is in right sidebar or mobile sheet
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
|
||||||
|
// Check if paste is triggered from right sidebar or mobile sheet
|
||||||
|
if (target) {
|
||||||
|
const isInRightSidebar = target.closest('.right-sidebar')
|
||||||
|
const isInMobileSheet = target.closest('.mobile-color-sheet')
|
||||||
|
const isInUploadDragger = target.closest('.n-upload-dragger')
|
||||||
|
|
||||||
|
// Don't handle paste if it's from sidebar, mobile sheet, or upload dragger
|
||||||
|
if (isInRightSidebar || isInMobileSheet || isInUploadDragger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle paste when no image is uploaded yet (in upload area)
|
||||||
|
if (uploadedImage.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const items = event.clipboardData?.items
|
const items = event.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
|
|
||||||
@ -783,6 +1252,8 @@ onMounted(() => {
|
|||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
window.addEventListener('paste', handlePaste)
|
window.addEventListener('paste', handlePaste)
|
||||||
loadFavoriteColors()
|
loadFavoriteColors()
|
||||||
|
loadCustomBackgrounds()
|
||||||
|
fetchPexelsImages()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@ -1345,8 +1816,8 @@ const selectHistoryItem = async (index: number) => {
|
|||||||
await cacheResultImage(item.resultImage)
|
await cacheResultImage(item.resultImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize canvas and apply saved background color
|
// Re-render canvas with the saved result
|
||||||
await initializeCanvasWithBackground(item.backgroundColor)
|
await initializeCanvasFromResult(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const initializeCanvasWithBackground = async (bgColor: string) => {
|
const initializeCanvasWithBackground = async (bgColor: string) => {
|
||||||
@ -1415,6 +1886,68 @@ const initializeCanvasWithBackground = async (bgColor: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize canvas from saved result (for history items)
|
||||||
|
const initializeCanvasFromResult = async (item: HistoryItem) => {
|
||||||
|
if (!canvasRef.value || !item.resultImage) {
|
||||||
|
processing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Clean up old canvas
|
||||||
|
if (fabricCanvas) {
|
||||||
|
fabricCanvas.dispose()
|
||||||
|
fabricCanvas = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new fabric canvas
|
||||||
|
fabricCanvas = new Canvas(canvasRef.value, {
|
||||||
|
selection: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load the result image directly
|
||||||
|
const resultImg = await FabricImage.fromURL(item.resultImage, {
|
||||||
|
crossOrigin: 'anonymous'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resultImg || !fabricCanvas) {
|
||||||
|
processing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get image natural dimensions
|
||||||
|
const imgWidth = resultImg.width || 1
|
||||||
|
const imgHeight = resultImg.height || 1
|
||||||
|
|
||||||
|
// Set canvas to actual image dimensions
|
||||||
|
fabricCanvas.setDimensions({ width: imgWidth, height: imgHeight })
|
||||||
|
|
||||||
|
resultImg.set({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
fabricCanvas.add(resultImg)
|
||||||
|
fabricCanvas.centerObject(resultImg)
|
||||||
|
fabricCanvas.renderAll()
|
||||||
|
|
||||||
|
// Adjust visual display size to fit container
|
||||||
|
adjustCanvasSize()
|
||||||
|
|
||||||
|
processing.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Canvas initialization from result error:', error)
|
||||||
|
message.error(t('Failed to load image'))
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getHistoryThumbnailUrl = (item: HistoryItem): string => {
|
const getHistoryThumbnailUrl = (item: HistoryItem): string => {
|
||||||
if (item.resultImage) {
|
if (item.resultImage) {
|
||||||
return item.resultImage
|
return item.resultImage
|
||||||
@ -1986,6 +2519,25 @@ const removeHistoryItem = (index: number) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(.n-tabs) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.n-tabs-nav {
|
||||||
|
padding: 12px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-tabs-tab {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-tabs-pane-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide desktop sidebar on mobile */
|
/* Hide desktop sidebar on mobile */
|
||||||
@ -2081,6 +2633,26 @@ const removeHistoryItem = (index: number) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
:deep(.n-tabs) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.n-tabs-nav {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-tabs-tab {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-tabs-pane-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slide up animation */
|
/* Slide up animation */
|
||||||
@ -2328,6 +2900,109 @@ const removeHistoryItem = (index: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Background Images Sections */
|
||||||
|
.background-images-section,
|
||||||
|
.custom-backgrounds-section,
|
||||||
|
.pexels-images-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dragger-content {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-images-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-image-item {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3f4f6;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1fc76f;
|
||||||
|
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.3);
|
||||||
|
|
||||||
|
.remove-bg-btn {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-bg-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid white;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 2px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.9);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-processing-overlay {
|
.canvas-processing-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
1
vite-env.d.ts
vendored
1
vite-env.d.ts
vendored
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_BACKEND_SERVER_URL?: string
|
readonly VITE_BACKEND_SERVER_URL?: string
|
||||||
// 更多环境变量...
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export default defineConfig(({ mode, command }) => {
|
|||||||
const FRONTEND_HOST = env.VITE_FRONTEND_HOST || '0.0.0.0'
|
const FRONTEND_HOST = env.VITE_FRONTEND_HOST || '0.0.0.0'
|
||||||
const FRONTEND_PORT = Number(env.VITE_FRONTEND_PORT) || 3001
|
const FRONTEND_PORT = Number(env.VITE_FRONTEND_PORT) || 3001
|
||||||
const ALLOWED_HOSTS = (env.VITE_ALLOWED_HOSTS || '').split(',').map((s) => s.trim()).filter(Boolean)
|
const ALLOWED_HOSTS = (env.VITE_ALLOWED_HOSTS || '').split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
const PEXELS_API_KEY = env.VITE_PEXELS_API_KEY || ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
@ -96,7 +97,9 @@ export default defineConfig(({ mode, command }) => {
|
|||||||
// 确保环境变量在构建时可用
|
// 确保环境变量在构建时可用
|
||||||
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
|
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
|
||||||
// 应用顺序(已移除 apps.txt 支持,使用默认值)
|
// 应用顺序(已移除 apps.txt 支持,使用默认值)
|
||||||
__APPS_ORDER__: JSON.stringify(['jingrow'])
|
__APPS_ORDER__: JSON.stringify(['jingrow']),
|
||||||
|
// Pexels API Key
|
||||||
|
__PEXELS_API_KEY__: JSON.stringify(PEXELS_API_KEY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user