1641 lines
37 KiB
Vue
1641 lines
37 KiB
Vue
<template>
|
||
<div
|
||
class="remove-background-page"
|
||
@dragenter.prevent="handleDragEnter"
|
||
@dragover.prevent="handleDragOver"
|
||
@dragleave="handleDragLeave"
|
||
@drop.prevent="handleDrop"
|
||
>
|
||
<div v-if="isDragging" class="global-drag-overlay">
|
||
<div class="overlay-content">
|
||
<p>{{ t('Drop image anywhere to remove background') }}</p>
|
||
</div>
|
||
</div>
|
||
<input
|
||
ref="fileInputRef"
|
||
type="file"
|
||
accept="image/*"
|
||
style="display: none"
|
||
@change="handleFileSelect"
|
||
/>
|
||
|
||
<div class="page-header">
|
||
<h2>{{ t('Remove Background') }}</h2>
|
||
<div v-if="uploadedImage" class="toolbar-actions">
|
||
<button
|
||
v-if="resultImage"
|
||
class="toolbar-btn"
|
||
@click="handleDownload"
|
||
:title="t('Download')"
|
||
>
|
||
<i class="fa fa-download"></i>
|
||
</button>
|
||
<button
|
||
class="toolbar-btn"
|
||
@click="resetUpload"
|
||
:title="t('Change Image')"
|
||
>
|
||
<i class="fa fa-refresh"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-content">
|
||
<div class="tool-container">
|
||
<div class="upload-section">
|
||
<div
|
||
v-if="!uploadedImage"
|
||
class="upload-area"
|
||
:class="{ 'dragging': isDragging }"
|
||
>
|
||
<div class="upload-content">
|
||
<button
|
||
type="button"
|
||
class="upload-btn"
|
||
@click="triggerFileInput"
|
||
:disabled="processing"
|
||
>
|
||
<i class="fa fa-upload"></i>
|
||
<span>{{ t('Upload Image') }}</span>
|
||
</button>
|
||
|
||
<div class="divider">
|
||
<span>{{ t('or') }}</span>
|
||
</div>
|
||
|
||
<div class="url-input-wrapper" @click.stop>
|
||
<input
|
||
ref="urlInputRef"
|
||
v-model="imageUrl"
|
||
type="text"
|
||
class="url-input"
|
||
:placeholder="t('Paste image URL here')"
|
||
@keyup.enter="handleUrlSubmit"
|
||
@paste="handleUrlPaste"
|
||
:disabled="processing"
|
||
/>
|
||
</div>
|
||
|
||
<p class="upload-hint">{{ t('Drag and drop your image anywhere, or paste image directly') }}</p>
|
||
<p class="upload-format-hint">{{ t('Supports JPG, PNG, WebP formats') }}</p>
|
||
</div>
|
||
<div v-if="processing" class="upload-processing-overlay">
|
||
<div class="spinner"></div>
|
||
<p>{{ t('Loading image from URL...') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="preview-section">
|
||
<div class="comparison-view" v-if="resultImage">
|
||
<div class="comparison-container" ref="comparisonContainerRef">
|
||
<div
|
||
class="comparison-image original-image"
|
||
:style="{ clipPath: `inset(0 ${100 - splitPosition}% 0 0)` }"
|
||
>
|
||
<img
|
||
ref="originalImageRef"
|
||
:src="uploadedImageUrl"
|
||
alt="Original"
|
||
@load="adjustContainerSize"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
class="comparison-image result-image"
|
||
:style="{ clipPath: `inset(0 0 0 ${splitPosition}%)` }"
|
||
>
|
||
<img
|
||
ref="resultImageRef"
|
||
:src="resultImageUrl"
|
||
alt="Result"
|
||
@load="adjustContainerSize"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
class="split-line"
|
||
:class="{ 'dragging': isDraggingSplitLine }"
|
||
:style="{ left: `${splitPosition}%` }"
|
||
@mousedown.prevent.stop="handleSplitLineMouseDown"
|
||
>
|
||
<div class="split-line-handle">
|
||
<i class="fa fa-arrows-h"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="single-image-view">
|
||
<div class="image-wrapper">
|
||
<img :src="uploadedImageUrl" alt="Original" />
|
||
<div v-if="processing" class="processing-overlay">
|
||
<div class="spinner"></div>
|
||
<p>{{ t('Processing...') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="historyList.length > 0 || uploadedImage" class="history-bar">
|
||
<div class="history-scroll-container">
|
||
<button
|
||
type="button"
|
||
class="history-item add-button"
|
||
@click.stop="triggerFileInput"
|
||
:title="t('Add New Image')"
|
||
>
|
||
<i class="fa fa-plus"></i>
|
||
</button>
|
||
|
||
<div
|
||
v-for="(item, index) in historyList"
|
||
:key="item.id"
|
||
class="history-item"
|
||
:class="{ 'active': currentHistoryIndex === index }"
|
||
@click="selectHistoryItem(index)"
|
||
>
|
||
<div class="history-thumbnail">
|
||
<img :src="getHistoryThumbnailUrl(item)" alt="History" />
|
||
<button
|
||
type="button"
|
||
class="history-delete-btn"
|
||
@click.stop="removeHistoryItem(index)"
|
||
:title="t('Delete')"
|
||
:aria-label="t('Delete')"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="uploadedImage && resultImage && currentHistoryIndex === -1"
|
||
class="history-item active"
|
||
>
|
||
<div class="history-thumbnail">
|
||
<img :src="resultImageUrl" alt="Current" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||
import { useMessage } from 'naive-ui'
|
||
import { useRoute } from 'vue-router'
|
||
import axios from 'axios'
|
||
import { t } from '@/shared/i18n'
|
||
import { get_session_api_headers } from '@/shared/api/auth'
|
||
import { useAuthStore } from '@/shared/stores/auth'
|
||
import { useSEO } from '@/shared/composables/useSEO'
|
||
import { compressImageFile } from '@/shared/utils/imageResize'
|
||
|
||
const message = useMessage()
|
||
const authStore = useAuthStore()
|
||
const route = useRoute()
|
||
|
||
// SEO配置 - 直接在组件中定义,立即同步设置到HTML
|
||
// 符合国内搜索引擎(百度、360、搜狗)和未来趋势
|
||
useSEO({
|
||
title: t('Remove Background'),
|
||
description: t('Remove background from images using AI technology. Free online tool to remove image backgrounds instantly. Supports JPG, PNG, WebP formats.'),
|
||
keywords: t('remove background, AI background removal, online background remover, image processing, background removal tool, transparent background, free tool, JPG background removal, PNG background removal')
|
||
})
|
||
|
||
interface HistoryItem {
|
||
id: string
|
||
originalImageUrl: string
|
||
originalImageFile: File | null
|
||
resultImage: string
|
||
timestamp: number
|
||
}
|
||
|
||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||
const urlInputRef = ref<HTMLInputElement | null>(null)
|
||
const uploadedImage = ref<File | null>(null)
|
||
const uploadedImageUrl = ref<string>('')
|
||
const resultImage = ref<string>('')
|
||
const resultImageBlobUrl = ref<string>('') // 缓存的 blob URL,用于下载
|
||
const imageUrl = ref<string>('')
|
||
const resultImageUrl = computed(() => {
|
||
if (!resultImage.value) return ''
|
||
// 直接返回图片URL
|
||
return resultImage.value
|
||
})
|
||
|
||
const historyList = ref<HistoryItem[]>([])
|
||
const currentHistoryIndex = ref<number>(-1)
|
||
const isDragging = ref(false)
|
||
const dragCounter = ref(0)
|
||
const processing = ref(false)
|
||
const splitPosition = ref(0)
|
||
const comparisonContainerRef = ref<HTMLElement | null>(null)
|
||
const originalImageRef = ref<HTMLImageElement | null>(null)
|
||
const resultImageRef = ref<HTMLImageElement | null>(null)
|
||
const isDraggingSplitLine = ref(false)
|
||
|
||
const adjustContainerSize = () => {
|
||
const container = comparisonContainerRef.value
|
||
if (!container) return
|
||
|
||
const img = originalImageRef.value || resultImageRef.value
|
||
if (!img) return
|
||
|
||
const previewSection = container.closest('.preview-section') as HTMLElement
|
||
if (!previewSection) return
|
||
|
||
const previewRect = previewSection.getBoundingClientRect()
|
||
const padding = 24
|
||
const maxAvailableWidth = Math.max(0, previewRect.width - padding)
|
||
const maxAvailableHeight = Math.max(0, previewRect.height - padding)
|
||
|
||
if (maxAvailableWidth <= 0 || maxAvailableHeight <= 0) return
|
||
|
||
if (!img.complete) {
|
||
img.addEventListener('load', adjustContainerSize, { once: true })
|
||
return
|
||
}
|
||
|
||
const { naturalWidth, naturalHeight } = img
|
||
if (naturalWidth === 0 || naturalHeight === 0) return
|
||
|
||
const scale = Math.min(
|
||
maxAvailableWidth / naturalWidth,
|
||
maxAvailableHeight / naturalHeight,
|
||
1
|
||
)
|
||
|
||
container.style.width = `${naturalWidth * scale}px`
|
||
container.style.height = `${naturalHeight * scale}px`
|
||
}
|
||
|
||
watch([uploadedImageUrl, resultImageUrl], adjustContainerSize, { immediate: true })
|
||
|
||
const handleResize = () => {
|
||
adjustContainerSize()
|
||
}
|
||
|
||
const handlePaste = async (event: ClipboardEvent) => {
|
||
const items = event.clipboardData?.items
|
||
if (!items) return
|
||
|
||
// 检查是否有图片数据
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
if (item.type.indexOf('image') !== -1) {
|
||
event.preventDefault()
|
||
const file = item.getAsFile()
|
||
if (file) {
|
||
processFile(file)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// 检查是否有文本URL
|
||
const text = event.clipboardData?.getData('text')
|
||
if (text && isValidImageUrl(text)) {
|
||
event.preventDefault()
|
||
imageUrl.value = text.trim()
|
||
await handleUrlSubmit()
|
||
}
|
||
}
|
||
|
||
const isValidImageUrl = (url: string): boolean => {
|
||
if (!url || typeof url !== 'string') return false
|
||
try {
|
||
const urlObj = new URL(url)
|
||
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
||
return false
|
||
}
|
||
|
||
const pathname = urlObj.pathname.toLowerCase()
|
||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']
|
||
|
||
const hasImageExtension = imageExtensions.some(ext => pathname.endsWith(ext)) ||
|
||
urlObj.pathname.match(/\.(jpg|jpeg|png|webp|gif|bmp)(\?|$)/i) !== null
|
||
|
||
return hasImageExtension || pathname.length > 0
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
const handleUrlPaste = async (event: ClipboardEvent) => {
|
||
const text = event.clipboardData?.getData('text')
|
||
if (text && isValidImageUrl(text)) {
|
||
event.preventDefault()
|
||
imageUrl.value = text.trim()
|
||
await handleUrlSubmit()
|
||
}
|
||
}
|
||
|
||
const handleUrlSubmit = async () => {
|
||
const url = imageUrl.value.trim()
|
||
if (!url) {
|
||
message.warning(t('Please enter an image URL'))
|
||
return
|
||
}
|
||
|
||
if (!isValidImageUrl(url)) {
|
||
message.warning(t('Please enter a valid image URL'))
|
||
return
|
||
}
|
||
|
||
processing.value = true
|
||
resultImage.value = ''
|
||
splitPosition.value = 0
|
||
currentHistoryIndex.value = -1
|
||
|
||
try {
|
||
// 使用代理或直接加载图片
|
||
const response = await fetch(url, { mode: 'cors' })
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load image: ${response.status}`)
|
||
}
|
||
|
||
const blob = await response.blob()
|
||
|
||
// 验证文件类型
|
||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||
if (!validTypes.includes(blob.type)) {
|
||
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
||
processing.value = false
|
||
return
|
||
}
|
||
|
||
// 验证文件大小
|
||
const maxSize = 10 * 1024 * 1024
|
||
if (blob.size > maxSize) {
|
||
message.warning(t('Image size exceeds 10MB limit'))
|
||
processing.value = false
|
||
return
|
||
}
|
||
|
||
// 转换为File对象
|
||
const originalFile = new File([blob], 'image-from-url', { type: blob.type })
|
||
|
||
// 压缩图片到 1024x1024
|
||
try {
|
||
const compressedFile = await compressImageFile(originalFile, {
|
||
maxWidth: 1024,
|
||
maxHeight: 1024,
|
||
quality: 0.92,
|
||
mode: 'contain'
|
||
})
|
||
|
||
uploadedImage.value = compressedFile
|
||
uploadedImageUrl.value = URL.createObjectURL(compressedFile)
|
||
|
||
// 开始处理
|
||
await handleRemoveBackground()
|
||
} catch (error) {
|
||
console.error('图片压缩失败:', error)
|
||
message.error('图片处理失败,请重试')
|
||
processing.value = false
|
||
}
|
||
} catch (error: any) {
|
||
console.error('加载图片URL失败:', error)
|
||
let errorMessage = t('Failed to load image from URL')
|
||
|
||
if (error.message?.includes('CORS')) {
|
||
errorMessage = t('CORS error. The image server does not allow cross-origin access.')
|
||
} else if (error.message?.includes('Failed to load')) {
|
||
errorMessage = t('Failed to load image. Please check the URL and try again.')
|
||
}
|
||
|
||
message.error(errorMessage)
|
||
processing.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('resize', handleResize)
|
||
window.addEventListener('paste', handlePaste)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
window.removeEventListener('paste', handlePaste)
|
||
// 清理对象URL
|
||
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
||
URL.revokeObjectURL(uploadedImageUrl.value)
|
||
}
|
||
// 清理结果图片的 blob URL 缓存
|
||
if (resultImageBlobUrl.value) {
|
||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||
}
|
||
})
|
||
|
||
const triggerFileInput = () => {
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
fileInputRef.value.click()
|
||
}
|
||
}
|
||
|
||
const handleFileSelect = (event: Event) => {
|
||
const target = event.target as HTMLInputElement
|
||
const file = target.files?.[0]
|
||
if (file) {
|
||
processFile(file)
|
||
}
|
||
}
|
||
|
||
const hasFiles = (event: DragEvent) => {
|
||
const types = event.dataTransfer?.types
|
||
if (!types) return false
|
||
return Array.from(types).includes('Files')
|
||
}
|
||
|
||
const handleDragEnter = (event: DragEvent) => {
|
||
if (!hasFiles(event)) return
|
||
dragCounter.value += 1
|
||
isDragging.value = true
|
||
}
|
||
|
||
const handleDragOver = (event: DragEvent) => {
|
||
if (!hasFiles(event)) return
|
||
event.dataTransfer!.dropEffect = 'copy'
|
||
isDragging.value = true
|
||
}
|
||
|
||
const handleDragLeave = (event: DragEvent) => {
|
||
if (!hasFiles(event)) return
|
||
dragCounter.value = Math.max(0, dragCounter.value - 1)
|
||
if (dragCounter.value === 0) {
|
||
isDragging.value = false
|
||
}
|
||
}
|
||
|
||
const handleDrop = (event: DragEvent) => {
|
||
if (!hasFiles(event)) return
|
||
event.preventDefault()
|
||
dragCounter.value = 0
|
||
isDragging.value = false
|
||
const file = event.dataTransfer?.files[0]
|
||
if (file && file.type.startsWith('image/')) {
|
||
processFile(file)
|
||
} else {
|
||
message.warning(t('Please upload an image file'))
|
||
}
|
||
}
|
||
|
||
const processFile = async (file: File) => {
|
||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||
if (!validTypes.includes(file.type)) {
|
||
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
||
return
|
||
}
|
||
|
||
const maxSize = 10 * 1024 * 1024
|
||
if (file.size > maxSize) {
|
||
message.warning(t('Image size exceeds 10MB limit'))
|
||
return
|
||
}
|
||
|
||
// 压缩图片到 1024x1024
|
||
try {
|
||
const compressedFile = await compressImageFile(file, {
|
||
maxWidth: 1024,
|
||
maxHeight: 1024,
|
||
quality: 0.92,
|
||
mode: 'contain'
|
||
})
|
||
|
||
uploadedImage.value = compressedFile
|
||
resultImage.value = ''
|
||
splitPosition.value = 0
|
||
currentHistoryIndex.value = -1
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
uploadedImageUrl.value = e.target?.result as string
|
||
handleRemoveBackground()
|
||
}
|
||
reader.readAsDataURL(compressedFile)
|
||
} catch (error) {
|
||
console.error('图片压缩失败:', error)
|
||
message.error('图片处理失败,请重试')
|
||
}
|
||
}
|
||
|
||
const resetUpload = () => {
|
||
// 清理对象URL
|
||
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
||
URL.revokeObjectURL(uploadedImageUrl.value)
|
||
}
|
||
// 清理结果图片的 blob URL 缓存
|
||
if (resultImageBlobUrl.value) {
|
||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||
resultImageBlobUrl.value = ''
|
||
}
|
||
uploadedImage.value = null
|
||
uploadedImageUrl.value = ''
|
||
resultImage.value = ''
|
||
imageUrl.value = ''
|
||
splitPosition.value = 0
|
||
currentHistoryIndex.value = -1
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
}
|
||
if (comparisonContainerRef.value) {
|
||
comparisonContainerRef.value.style.width = ''
|
||
comparisonContainerRef.value.style.height = ''
|
||
}
|
||
}
|
||
|
||
const selectHistoryItem = async (index: number) => {
|
||
if (index < 0 || index >= historyList.value.length) return
|
||
|
||
const item = historyList.value[index]
|
||
currentHistoryIndex.value = index
|
||
uploadedImageUrl.value = item.originalImageUrl
|
||
resultImage.value = item.resultImage
|
||
splitPosition.value = 0
|
||
uploadedImage.value = item.originalImageFile
|
||
|
||
// 缓存历史记录的结果图片
|
||
if (item.resultImage) {
|
||
await cacheResultImage(item.resultImage)
|
||
}
|
||
}
|
||
|
||
const getHistoryThumbnailUrl = (item: HistoryItem): string => {
|
||
if (item.resultImage) {
|
||
return item.resultImage
|
||
}
|
||
return item.originalImageUrl
|
||
}
|
||
|
||
const handleSplitLineMouseDown = (e: MouseEvent) => {
|
||
e.preventDefault()
|
||
isDraggingSplitLine.value = true
|
||
|
||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||
if (!comparisonContainerRef.value || !isDraggingSplitLine.value) return
|
||
|
||
const rect = comparisonContainerRef.value.getBoundingClientRect()
|
||
const x = moveEvent.clientX - rect.left
|
||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
|
||
splitPosition.value = percentage
|
||
}
|
||
|
||
const handleMouseUp = () => {
|
||
isDraggingSplitLine.value = false
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
const handleRemoveBackground = async () => {
|
||
if (!authStore.isLoggedIn) {
|
||
message.error(t('Please login first to use this feature'))
|
||
return
|
||
}
|
||
|
||
if (!uploadedImage.value) {
|
||
message.warning(t('Please upload an image first'))
|
||
return
|
||
}
|
||
|
||
processing.value = true
|
||
resultImage.value = ''
|
||
|
||
// 处理成功结果的辅助函数(文件内部使用)
|
||
const handleSuccess = async (imageUrl: string): Promise<void> => {
|
||
resultImage.value = imageUrl
|
||
await cacheResultImage(imageUrl)
|
||
|
||
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: imageUrl,
|
||
timestamp: Date.now()
|
||
}
|
||
historyList.value.unshift(historyItem)
|
||
currentHistoryIndex.value = 0
|
||
} else if (currentHistoryIndex.value >= 0) {
|
||
historyList.value[currentHistoryIndex.value].resultImage = imageUrl
|
||
}
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('file', uploadedImage.value)
|
||
|
||
const controller = new AbortController()
|
||
const timeoutId = setTimeout(() => controller.abort(), 180000)
|
||
|
||
const response = await fetch('/api/v1/tools/rmbg/file/free', {
|
||
method: 'POST',
|
||
body: formData,
|
||
signal: controller.signal
|
||
})
|
||
|
||
clearTimeout(timeoutId)
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`)
|
||
}
|
||
|
||
const reader = response.body?.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
|
||
if (!reader) {
|
||
throw new Error('Response body is not readable')
|
||
}
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop() || ''
|
||
|
||
for (const line of lines) {
|
||
if (line.trim()) {
|
||
try {
|
||
const result = JSON.parse(line.trim())
|
||
if (result.status === 'success' && result.image_url) {
|
||
await handleSuccess(result.image_url)
|
||
return
|
||
} else if (result.error) {
|
||
message.error(result.error)
|
||
}
|
||
} catch (parseError) {
|
||
console.error('Failed to parse JSON:', parseError, 'Line:', line)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理最后一行
|
||
if (buffer.trim()) {
|
||
try {
|
||
const result = JSON.parse(buffer.trim())
|
||
if (result.status === 'success' && result.image_url) {
|
||
await handleSuccess(result.image_url)
|
||
return
|
||
} else {
|
||
message.error(result.error || t('Failed to remove background'))
|
||
}
|
||
} catch (parseError) {
|
||
console.error('Failed to parse final JSON:', parseError)
|
||
message.error(t('Failed to parse response'))
|
||
}
|
||
} else {
|
||
message.error(t('No image data returned'))
|
||
}
|
||
} catch (error: any) {
|
||
let errorMessage = t('Failed to remove background')
|
||
|
||
if (error.name === 'AbortError') {
|
||
errorMessage = t('Request timeout. Please try again.')
|
||
} else if (error.message) {
|
||
errorMessage = error.message
|
||
}
|
||
|
||
message.error(errorMessage)
|
||
} finally {
|
||
processing.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 缓存结果图片为 blob URL,用于下载
|
||
*/
|
||
const cacheResultImage = async (imageUrl: string) => {
|
||
try {
|
||
// 清理旧的 blob URL
|
||
if (resultImageBlobUrl.value) {
|
||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||
resultImageBlobUrl.value = ''
|
||
}
|
||
|
||
// 获取图片并转换为 blob URL
|
||
const response = await fetch(imageUrl)
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch image: ${response.status}`)
|
||
}
|
||
const blob = await response.blob()
|
||
resultImageBlobUrl.value = URL.createObjectURL(blob)
|
||
} catch (error) {
|
||
console.error('缓存图片失败:', error)
|
||
// 缓存失败不影响显示,只是下载时需要重新获取
|
||
}
|
||
}
|
||
|
||
const handleDownload = async () => {
|
||
if (!resultImage.value) return
|
||
|
||
try {
|
||
// 优先使用缓存的 blob URL
|
||
let blobUrl = resultImageBlobUrl.value
|
||
|
||
// 如果没有缓存,则获取并缓存
|
||
if (!blobUrl) {
|
||
const response = await fetch(resultImage.value)
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`)
|
||
}
|
||
const blob = await response.blob()
|
||
blobUrl = URL.createObjectURL(blob)
|
||
resultImageBlobUrl.value = blobUrl
|
||
}
|
||
|
||
// 创建下载链接
|
||
const link = document.createElement('a')
|
||
link.href = blobUrl
|
||
link.download = `removed-background-${Date.now()}.png`
|
||
link.style.display = 'none'
|
||
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
|
||
// 清理:移除 DOM 元素,但不释放 blob URL(保留缓存供后续下载使用)
|
||
requestAnimationFrame(() => {
|
||
document.body.removeChild(link)
|
||
})
|
||
} catch (error: any) {
|
||
console.error('下载图片失败:', error)
|
||
message.error(t('Failed to download image'))
|
||
}
|
||
}
|
||
|
||
const removeHistoryItem = (index: number) => {
|
||
if (index < 0 || index >= historyList.value.length) return
|
||
|
||
const removingCurrent = currentHistoryIndex.value === index
|
||
historyList.value.splice(index, 1)
|
||
|
||
if (historyList.value.length === 0) {
|
||
currentHistoryIndex.value = -1
|
||
resetUpload()
|
||
return
|
||
}
|
||
|
||
if (removingCurrent) {
|
||
const nextIndex = Math.min(index, historyList.value.length - 1)
|
||
selectHistoryItem(nextIndex)
|
||
} else if (currentHistoryIndex.value > index) {
|
||
currentHistoryIndex.value -= 1
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.remove-background-page {
|
||
position: relative;
|
||
width: calc(100% + 40px);
|
||
height: calc(100vh - 64px - 40px);
|
||
margin: -20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.global-drag-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: radial-gradient(circle at center, rgba(248, 250, 252, 0.92), rgba(248, 250, 252, 0.82));
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
pointer-events: none;
|
||
animation: fade-in 0.15s ease;
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
width: 55vmax;
|
||
height: 55vmax;
|
||
border-radius: 35%;
|
||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||
animation: rotate 12s linear infinite;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.overlay-content {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 18px 30px;
|
||
color: #0f172a;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
letter-spacing: 0.04em;
|
||
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: -10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(79, 70, 229, 0.35);
|
||
opacity: 0.7;
|
||
animation: pulse-line 1.8s ease-out infinite;
|
||
}
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: -28px 16px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(90deg, rgba(59, 130, 246, 0), rgba(59, 130, 246, 0.24), rgba(59, 130, 246, 0));
|
||
filter: blur(10px);
|
||
opacity: 0.65;
|
||
animation: shimmer 2.4s linear infinite;
|
||
}
|
||
}
|
||
|
||
@keyframes fade-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
@keyframes rotate {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes pulse-line {
|
||
0% {
|
||
transform: scale(0.9);
|
||
opacity: 0;
|
||
}
|
||
|
||
50% {
|
||
transform: scale(1);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
100% {
|
||
transform: scale(1.05);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% {
|
||
opacity: 0.2;
|
||
}
|
||
50% {
|
||
opacity: 0.7;
|
||
}
|
||
100% {
|
||
opacity: 0.2;
|
||
}
|
||
}
|
||
|
||
.page-header {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 12px 36px;
|
||
}
|
||
|
||
.page-content {
|
||
flex: 1;
|
||
width: 100%;
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.page-header h2 {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
margin: 0;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
background: white;
|
||
color: #64748b;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
|
||
i {
|
||
font-size: 14px;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: #1fc76f;
|
||
color: #1fc76f;
|
||
background: #f0fdf4;
|
||
}
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
|
||
.tool-container {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.upload-section {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.upload-section:has(.preview-section) {
|
||
padding: 0;
|
||
border-radius: 0;
|
||
border: none;
|
||
box-shadow: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.upload-area {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 2px dashed #cbd5e1;
|
||
border-radius: 12px;
|
||
padding: 48px 32px;
|
||
text-align: center;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
background: #fafbfc;
|
||
min-height: 0;
|
||
position: relative;
|
||
|
||
&:hover {
|
||
border-color: #1fc76f;
|
||
background: #f0fdf4;
|
||
}
|
||
|
||
&.dragging {
|
||
border-color: #1fc76f;
|
||
background: #ecfdf5;
|
||
border-style: solid;
|
||
}
|
||
}
|
||
|
||
.upload-processing-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
border-radius: 12px;
|
||
backdrop-filter: blur(4px);
|
||
z-index: 10;
|
||
|
||
p {
|
||
font-size: 14px;
|
||
color: #64748b;
|
||
margin: 0;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.upload-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
width: 100%;
|
||
max-width: 500px;
|
||
}
|
||
|
||
.upload-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 20px 40px;
|
||
background: #e6f8f0;
|
||
color: #0d684b;
|
||
border: 1px solid #1fc76f;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
white-space: nowrap;
|
||
|
||
i {
|
||
font-size: 16px;
|
||
}
|
||
|
||
&:hover:not(:disabled) {
|
||
background: #dcfce7;
|
||
border-color: #1fc76f;
|
||
color: #166534;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15);
|
||
}
|
||
|
||
&:active:not(:disabled) {
|
||
background: #1fc76f;
|
||
border-color: #1fc76f;
|
||
color: white;
|
||
transform: translateY(0);
|
||
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2);
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
background: #f1f5f9;
|
||
border-color: #e2e8f0;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
&:disabled:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
}
|
||
|
||
.divider {
|
||
display: flex;
|
||
align-items: center;
|
||
color: #94a3b8;
|
||
font-size: 13px;
|
||
width: 100%;
|
||
max-width: 300px;
|
||
|
||
&::before,
|
||
&::after {
|
||
content: '';
|
||
flex: 1;
|
||
height: 1px;
|
||
background: #e5e7eb;
|
||
}
|
||
|
||
span {
|
||
padding: 0 12px;
|
||
}
|
||
}
|
||
|
||
.upload-hint {
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
margin: 0;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.upload-format-hint {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
margin: 0;
|
||
}
|
||
|
||
.url-input-wrapper {
|
||
width: 100%;
|
||
max-width: 500px;
|
||
}
|
||
|
||
.url-input {
|
||
width: 100%;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
font-size: 14px;
|
||
color: #1f2937;
|
||
background: white;
|
||
outline: none;
|
||
transition: all 0.2s ease;
|
||
|
||
&::placeholder {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
&:focus {
|
||
border-color: #1fc76f;
|
||
box-shadow: 0 0 0 3px rgba(31, 199, 111, 0.1);
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
background: #f8fafc;
|
||
}
|
||
}
|
||
|
||
.preview-section {
|
||
flex: 1;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
padding: 12px;
|
||
}
|
||
|
||
.preview-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 24px;
|
||
margin-bottom: 32px;
|
||
|
||
@media (max-width: 768px) {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.preview-card {
|
||
background: #f8fafc;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.preview-label {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
margin-bottom: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.comparison-view {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.comparison-container {
|
||
position: relative;
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
overflow: hidden;
|
||
border-radius: 8px;
|
||
border: 1px solid #e5e7eb;
|
||
background:
|
||
linear-gradient(45deg, #f1f5f9 25%, transparent 25%),
|
||
linear-gradient(-45deg, #f1f5f9 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #f1f5f9 75%),
|
||
linear-gradient(-45deg, transparent 75%, #f1f5f9 75%);
|
||
background-size: 20px 20px;
|
||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||
}
|
||
|
||
.comparison-image {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
img {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain;
|
||
}
|
||
}
|
||
|
||
.original-image {
|
||
z-index: 1;
|
||
}
|
||
|
||
.result-image {
|
||
z-index: 2;
|
||
}
|
||
|
||
.split-line {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 2px;
|
||
background: transparent;
|
||
cursor: col-resize;
|
||
z-index: 10;
|
||
transform: translateX(-50%);
|
||
transition: left 0s;
|
||
user-select: none;
|
||
pointer-events: auto;
|
||
|
||
&:hover {
|
||
width: 2px;
|
||
}
|
||
|
||
&.dragging {
|
||
transition: none;
|
||
cursor: col-resize;
|
||
}
|
||
}
|
||
|
||
.split-line-handle {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 40px;
|
||
height: 40px;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
border: 2px solid #1fc76f;
|
||
|
||
i {
|
||
color: #1fc76f;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.single-image-view {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.image-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
overflow: hidden;
|
||
border-radius: 8px;
|
||
border: 1px solid #e5e7eb;
|
||
background:
|
||
linear-gradient(45deg, #f1f5f9 25%, transparent 25%),
|
||
linear-gradient(-45deg, #f1f5f9 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #f1f5f9 75%),
|
||
linear-gradient(-45deg, transparent 75%, #f1f5f9 75%);
|
||
background-size: 20px 20px;
|
||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.processing-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
backdrop-filter: blur(4px);
|
||
|
||
p {
|
||
font-size: 14px;
|
||
color: #64748b;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 4px solid #e5e7eb;
|
||
border-top-color: #1fc76f;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.history-bar {
|
||
flex-shrink: 0;
|
||
width: 100%;
|
||
padding: 12px 0;
|
||
background: transparent;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.history-scroll-container {
|
||
display: flex;
|
||
gap: 10px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
padding: 0;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #cbd5e1 transparent;
|
||
|
||
&::-webkit-scrollbar {
|
||
height: 6px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1;
|
||
border-radius: 3px;
|
||
|
||
&:hover {
|
||
background: #94a3b8;
|
||
}
|
||
}
|
||
}
|
||
|
||
.history-item {
|
||
flex-shrink: 0;
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
position: relative;
|
||
|
||
&.add-button {
|
||
background: white;
|
||
border: 2px dashed #cbd5e1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #64748b;
|
||
transition: all 0.2s ease;
|
||
padding: 0;
|
||
margin: 0;
|
||
outline: none;
|
||
-webkit-appearance: none;
|
||
-moz-appearance: none;
|
||
appearance: none;
|
||
|
||
i {
|
||
font-size: 24px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: #1fc76f;
|
||
color: #1fc76f;
|
||
background: #f0fdf4;
|
||
}
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
&:focus {
|
||
outline: none;
|
||
border-color: #1fc76f;
|
||
}
|
||
}
|
||
|
||
&.active {
|
||
.history-thumbnail {
|
||
border-color: #1fc76f;
|
||
box-shadow: 0 0 10px rgba(31, 199, 111, 0.35);
|
||
}
|
||
}
|
||
}
|
||
|
||
.history-thumbnail {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 8px;
|
||
border: 2px solid #e5e7eb;
|
||
overflow: hidden;
|
||
position: relative;
|
||
background:
|
||
linear-gradient(45deg, #f1f5f9 25%, transparent 25%),
|
||
linear-gradient(-45deg, #f1f5f9 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #f1f5f9 75%),
|
||
linear-gradient(-45deg, transparent 75%, #f1f5f9 75%);
|
||
background-size: 10px 10px;
|
||
background-position: 0 0, 0 5px, 5px -5px, -5px 0px;
|
||
transition: all 0.2s ease;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: #1fc76f;
|
||
}
|
||
|
||
.history-delete-btn {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 999px;
|
||
border: none;
|
||
background: rgba(15, 23, 42, 0.6);
|
||
color: white;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transform: scale(0.9);
|
||
transition: all 0.2s ease;
|
||
cursor: pointer;
|
||
font-size: 0;
|
||
line-height: 1;
|
||
|
||
&::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.85);
|
||
}
|
||
}
|
||
|
||
&:hover .history-delete-btn {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.page-header {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.page-header h2 {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.page-content {
|
||
padding: 8px 12px;
|
||
}
|
||
|
||
.upload-section {
|
||
padding: 16px;
|
||
}
|
||
|
||
.upload-area {
|
||
padding: 32px 16px;
|
||
}
|
||
|
||
.upload-content {
|
||
gap: 14px;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.upload-btn {
|
||
width: 100%;
|
||
justify-content: center;
|
||
padding: 18px 32px;
|
||
font-size: 15px;
|
||
|
||
i {
|
||
font-size: 15px;
|
||
}
|
||
}
|
||
|
||
.divider {
|
||
max-width: 100%;
|
||
}
|
||
|
||
.url-input-wrapper {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.url-input {
|
||
font-size: 12px;
|
||
padding: 6px 10px;
|
||
}
|
||
|
||
.url-submit-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
|
||
i {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
.preview-section {
|
||
padding: 8px;
|
||
}
|
||
|
||
.history-bar {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.history-item {
|
||
width: 48px;
|
||
height: 48px;
|
||
}
|
||
|
||
.history-scroll-container {
|
||
gap: 8px;
|
||
padding: 0 8px;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
|
||
i {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
}
|
||
</style>
|
||
|