添加背景页面右边栏增加图片标签页,支持添加背景图片

This commit is contained in:
jingrow 2026-01-21 21:22:24 +08:00
parent 42563f67a8
commit 4c17007f33
4 changed files with 765 additions and 72 deletions

View File

@ -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": "微博",

View File

@ -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
View File

@ -2,7 +2,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_BACKEND_SERVER_URL?: string readonly VITE_BACKEND_SERVER_URL?: string
// 更多环境变量...
} }
interface ImportMeta { interface ImportMeta {

View File

@ -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)
} }
} }
}) })