jingrowtools/src/views/HomePage.vue
jingrow 91be186013 refactor: 删除服务器配置检查,注册链接始终显示
- 删除 /jingrow/server-config API 调用
- 移除服务器配置检查逻辑
- 设置注册链接始终显示(showSignupLink 默认为 true)
- 消除 404 错误
2026-01-02 20:27:42 +08:00

3087 lines
78 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue'
import { NButton, NSpace, useMessage, NModal, NForm, NFormItem, NInput, NText, NLayout, NLayoutSider, NLayoutHeader, NLayoutContent } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { useSEO } from '@/shared/composables/useSEO'
import { t, getCurrentLocale } from '@/shared/i18n'
import { useAuthStore } from '@/shared/stores/auth'
import { signupApi } from '@/shared/api/auth'
import AppHeader from '@/app/layouts/AppHeader.vue'
import AppSidebar from '@/app/layouts/AppSidebar.vue'
import { compressImageFile } from '@/shared/utils/imageResize'
import axios from 'axios'
const message = useMessage()
const authStore = useAuthStore()
const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
const currentYear = computed(() => new Date().getFullYear())
const logoUrl = computed(() => '/logo.svg')
// 登录/注册弹窗状态
const showLoginModal = ref(false)
const showSignupModal = ref(false)
const loginFormRef = ref()
const signupFormRef = ref()
const loginLoading = ref(false)
const signupLoading = ref(false)
const showSignupLink = ref(true)
// 登录表单
const loginFormData = reactive({
username: '',
password: ''
})
const loginRules = {
username: [
{ required: true, message: t('Please enter username'), trigger: 'blur' }
],
password: [
{ required: true, message: t('Please enter password'), trigger: 'blur' },
{ min: 6, message: t('Password must be at least 6 characters'), trigger: 'blur' }
]
}
// 注册表单
const signupFormData = reactive({
username: '',
password: '',
confirmPassword: '',
email: '',
phoneNumber: ''
})
const validatePasswordMatch = (_rule: any, value: string) => {
if (value !== signupFormData.password) {
return new Error(t('Passwords do not match'))
}
return true
}
const isEnglish = computed(() => getCurrentLocale() === 'en-US')
const signupRules = computed(() => {
const rules: any = {
username: [
{ required: true, message: t('Please enter username'), trigger: 'blur' },
{ min: 3, message: t('Username must be at least 3 characters'), trigger: 'blur' }
],
password: [
{ required: true, message: t('Please enter password'), trigger: 'blur' },
{ min: 6, message: t('Password must be at least 6 characters'), trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: t('Please confirm password'), trigger: 'blur' },
{ validator: validatePasswordMatch, trigger: 'blur' }
]
}
// 英文版email必填手机号可选
if (isEnglish.value) {
rules.email = [
{ required: true, message: t('Please enter email'), trigger: 'blur' },
{
validator: (_rule: any, value: string) => {
// required规则已处理空值这里只验证格式
if (!value) return true
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
return new Error(t('Please enter a valid email address'))
}
return true
},
trigger: 'blur'
}
]
rules.phoneNumber = [
{
validator: (_rule: any, value: string) => {
if (!value) return true
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(value)) {
return new Error(t('Please enter a valid phone number'))
}
return true
},
trigger: 'blur'
}
]
} else {
// 中文版email可选手机号必填
rules.email = [
{
validator: (_rule: any, value: string) => {
if (!value) return true
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
return new Error(t('Please enter a valid email address'))
}
return true
},
trigger: 'blur'
}
]
rules.phoneNumber = [
{ required: true, message: t('Please enter phone number'), trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: t('Please enter a valid phone number'),
trigger: 'blur'
}
]
}
return rules
})
const handleLogin = () => {
showLoginModal.value = true
}
const handleSignup = () => {
showSignupModal.value = true
}
const handleLoginSubmit = async () => {
try {
await loginFormRef.value?.validate()
loginLoading.value = true
const result = await authStore.login(loginFormData.username, loginFormData.password)
if (result.success) {
message.success(t('Login successful'))
showLoginModal.value = false
loginFormData.username = ''
loginFormData.password = ''
} else {
message.error(result.error || t('Login failed'))
}
} catch (error) {
console.error('Login error:', error)
message.error(t('Login failed, please check username and password'))
} finally {
loginLoading.value = false
}
}
const handleSignupSubmit = async () => {
try {
await signupFormRef.value?.validate()
signupLoading.value = true
const result = await signupApi({
username: signupFormData.username,
password: signupFormData.password,
email: signupFormData.email || undefined,
phone_number: isEnglish.value ? (signupFormData.phoneNumber || undefined) : signupFormData.phoneNumber
})
if (result.success) {
message.success(t('Sign up successful'))
if (result.user) {
authStore.user = result.user
authStore.isAuthenticated = true
localStorage.setItem('jingrow_user', JSON.stringify(result.user))
localStorage.setItem('jingrow_authenticated', 'true')
showSignupModal.value = false
signupFormData.username = ''
signupFormData.password = ''
signupFormData.confirmPassword = ''
signupFormData.email = ''
signupFormData.phoneNumber = ''
} else {
const loginResult = await authStore.login(signupFormData.username, signupFormData.password)
if (loginResult.success) {
showSignupModal.value = false
signupFormData.username = ''
signupFormData.password = ''
signupFormData.confirmPassword = ''
signupFormData.email = ''
signupFormData.phoneNumber = ''
} else {
message.warning(loginResult.error || t('Sign up successful, but auto login failed. Please login manually'))
showSignupModal.value = false
showLoginModal.value = true
}
}
} else {
const errorMsg = result.error || t('Sign up failed')
console.error('注册失败:', errorMsg, result)
message.error(errorMsg)
}
} catch (error: any) {
console.error('注册异常:', error)
message.error(error.message || t('Sign up failed, please try again'))
} finally {
signupLoading.value = false
}
}
const switchToSignup = () => {
showLoginModal.value = false
showSignupModal.value = true
}
const switchToLogin = () => {
showSignupModal.value = false
showLoginModal.value = true
}
// 登录状态
const isLoggedIn = computed(() => authStore.isLoggedIn)
// Sidebar 折叠状态
const SIDEBAR_COLLAPSE_KEY = 'app.sidebar.collapsed'
const collapsed = ref(localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true')
const isMobile = ref(false)
// 检测屏幕尺寸
const checkIsMobile = () => {
isMobile.value = window.innerWidth < 768
if (isMobile.value) {
collapsed.value = true
}
}
// 切换侧边栏
const toggleSidebar = () => {
collapsed.value = !collapsed.value
}
// 侧边栏折叠事件
const onSidebarCollapse = () => {
collapsed.value = true
}
// 侧边栏展开事件
const onSidebarExpand = () => {
collapsed.value = false
}
// 菜单选择事件 - 移动端自动关闭
const onMenuSelect = () => {
if (isMobile.value) {
collapsed.value = true
}
}
// 监听窗口大小变化
const handleWindowResize = () => {
checkIsMobile()
adjustContainerSize()
}
watch(collapsed, (val) => {
localStorage.setItem(SIDEBAR_COLLAPSE_KEY, String(val))
})
useSEO({
title: t('Remove Background - Free AI Background Removal Tool'),
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')
})
interface HistoryItem {
id: string
originalImageUrl: string
originalImageFile: File | null
resultImage: string
timestamp: number
}
const fileInputRef = ref<HTMLInputElement | null>(null)
// urlInputRef 在模板中使用lint 警告是误报
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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 ''
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 sampleImages = [
{
id: 'sample-1',
url: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1024&h=1024&fit=crop&q=80',
name: '人物示例'
},
{
id: 'sample-2',
url: 'https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e?w=1024&h=1024&fit=crop&q=80',
name: '人物示例'
},
{
id: 'sample-3',
url: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=1024&h=1024&fit=crop&q=80',
name: '产品示例'
},
{
id: 'sample-4',
url: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=1024&h=1024&fit=crop&q=80',
name: '产品示例'
},
{
id: 'sample-5',
url: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1024&h=1024&fit=crop&q=80',
name: '动物示例'
},
{
id: 'sample-6',
url: 'https://images.unsplash.com/photo-1551963831-b3b1ca40c98e?w=1024&h=1024&fit=crop&q=80',
name: '物品示例'
},
{
id: 'sample-7',
url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=1024&h=1024&fit=crop&q=80',
name: '人物示例'
},
{
id: 'sample-8',
url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=1024&h=1024&fit=crop&q=80',
name: '人物示例'
},
{
id: 'sample-9',
url: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=1024&h=1024&fit=crop&q=80',
name: '人物示例'
},
{
id: 'sample-10',
url: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=1024&h=1024&fit=crop&q=80',
name: '人物示例'
}
]
const comparisonContainerRef = ref<HTMLElement | null>(null)
const originalImageRef = ref<HTMLImageElement | null>(null)
const resultImageRef = ref<HTMLImageElement | null>(null)
const singleImageWrapperRef = ref<HTMLElement | null>(null)
const singleImageRef = ref<HTMLImageElement | null>(null)
const isDraggingSplitLine = ref(false)
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 triggerFileInput = () => {
fileInputRef.value?.click()
}
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
processFile(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
}
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(t('Image processing failed, please try again'))
}
}
// 处理示例图片点击
const handleSampleImageClick = async (imageUrl: string) => {
if (processing.value) return
processing.value = true
try {
const response = await fetch(imageUrl, { 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 file = new File([blob], 'sample-image.jpg', { type: blob.type })
await processFile(file)
} catch (error: any) {
console.error('加载示例图片失败:', error)
let errorMessage = t('Failed to load sample image')
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
}
}
const handleRemoveBackground = async () => {
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('/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
// 检查登录状态
if (!isLoggedIn.value) {
message.warning(t('Please login to download'))
showLoginModal.value = true
return
}
try {
// 优先使用缓存的 blob URL
let blobUrl = resultImageBlobUrl.value
// 如果没有缓存,则获取并缓存
if (!blobUrl) {
const response = await fetch(resultImage.value)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${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.click()
// 不立即清理 blob URL保留缓存供后续下载使用
} catch (error) {
console.error('下载失败:', error)
message.error(t('Failed to download image'))
}
}
const resetUpload = () => {
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 = ''
}
if (singleImageWrapperRef.value) {
singleImageWrapperRef.value.style.width = ''
singleImageWrapperRef.value.style.height = ''
}
}
const adjustContainerSize = async () => {
await nextTick()
// 等待多个渲染周期确保 DOM 完全更新
await new Promise(resolve => setTimeout(resolve, 0))
// 处理对比视图容器
const container = comparisonContainerRef.value
if (container) {
const img = originalImageRef.value || resultImageRef.value
if (img) {
const previewSection = container.closest('.preview-section') as HTMLElement
if (previewSection) {
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 && maxAvailableHeight >= 100) {
if (img.complete) {
const { naturalWidth, naturalHeight } = img
if (naturalWidth > 0 && naturalHeight > 0) {
const scale = Math.min(
maxAvailableWidth / naturalWidth,
maxAvailableHeight / naturalHeight,
1
)
container.style.width = `${naturalWidth * scale}px`
container.style.height = `${naturalHeight * scale}px`
}
} else {
img.addEventListener('load', adjustContainerSize, { once: true })
}
} else {
setTimeout(adjustContainerSize, 100)
}
}
}
}
// 处理单图视图容器
const singleWrapper = singleImageWrapperRef.value
if (singleWrapper) {
const img = singleImageRef.value
if (img) {
const previewSection = singleWrapper.closest('.preview-section') as HTMLElement
if (previewSection) {
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 && maxAvailableHeight >= 100) {
if (img.complete) {
const { naturalWidth, naturalHeight } = img
if (naturalWidth > 0 && naturalHeight > 0) {
const scale = Math.min(
maxAvailableWidth / naturalWidth,
maxAvailableHeight / naturalHeight,
1
)
singleWrapper.style.width = `${naturalWidth * scale}px`
singleWrapper.style.height = `${naturalHeight * scale}px`
}
} else {
img.addEventListener('load', adjustContainerSize, { once: true })
}
} else {
setTimeout(adjustContainerSize, 100)
}
} else {
setTimeout(adjustContainerSize, 100)
}
} else {
setTimeout(adjustContainerSize, 100)
}
}
}
watch([uploadedImageUrl, resultImageUrl], adjustContainerSize, { immediate: true })
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 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
}
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(t('Image processing failed, please try again'))
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
}
}
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 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
}
}
onMounted(async () => {
window.addEventListener('resize', handleWindowResize)
window.addEventListener('paste', handlePaste)
// 初始化认证状态
await authStore.initAuth()
// 检测移动端
checkIsMobile()
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('paste', handlePaste)
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(uploadedImageUrl.value)
}
// 清理结果图片的 blob URL 缓存
if (resultImageBlobUrl.value) {
URL.revokeObjectURL(resultImageBlobUrl.value)
}
})
</script>
<template>
<div class="home-page" @dragenter.prevent="handleDragEnter" @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
<!-- 未登录状态显示营销页面布局 -->
<template v-if="!isLoggedIn">
<!-- Header -->
<header class="marketing-header">
<div class="header-container">
<div class="header-left">
<router-link to="/" class="logo-link">
<img v-if="logoUrl" :src="logoUrl" :alt="appName" class="logo-img" />
<span class="logo-text">{{ appName }}</span>
</router-link>
</div>
<div class="header-right">
<n-space :size="12">
<n-button quaternary @click="handleSignup">{{ t('Sign up') }}</n-button>
<n-button type="primary" @click="handleLogin" class="login-btn">{{ t('Login') }}</n-button>
</n-space>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<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="tool-page">
<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="t('Original')" @load="adjustContainerSize" />
</div>
<div class="comparison-image result-image" :style="{ clipPath: `inset(0 0 0 ${splitPosition}%)` }">
<img ref="resultImageRef" :src="resultImageUrl" :alt="t('Background Removed')" @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" ref="singleImageWrapperRef">
<div class="single-image-container">
<img ref="singleImageRef" :src="uploadedImageUrl" :alt="t('Original')" @load="adjustContainerSize" />
</div>
<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 v-if="!uploadedImage" class="sample-images-section">
<p class="sample-images-title">{{ t('Click image to try') }}</p>
<div class="sample-images-container">
<div
v-for="sample in sampleImages"
:key="sample.id"
class="sample-image-item"
@click="handleSampleImageClick(sample.url)"
:class="{ loading: processing }"
>
<div class="sample-image-wrapper">
<img :src="sample.url" :alt="sample.name" loading="lazy" />
<div class="sample-image-overlay">
<Icon icon="tabler:wand" class="overlay-icon" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="marketing-footer">
<div class="footer-container">
<div class="footer-content">
<!-- 左侧Logo 和社交媒体 -->
<div class="footer-left">
<div class="footer-logo">
<router-link to="/" class="logo-link">
<img v-if="logoUrl" :src="logoUrl" :alt="appName" class="logo-img" />
<span class="logo-text">{{ appName }}</span>
</router-link>
</div>
<div class="footer-social">
<a href="#" class="social-icon" :title="t('WeChat')">
<Icon icon="tabler:brand-wechat" />
</a>
<a href="#" class="social-icon" :title="t('Weibo')">
<Icon icon="ant-design:weibo-outlined" />
</a>
<a href="#" class="social-icon" :title="t('TikTok')">
<Icon icon="tabler:brand-tiktok" />
</a>
<a href="#" class="social-icon" :title="t('Zhihu')">
<Icon icon="ant-design:zhihu-square-filled" />
</a>
</div>
<div class="footer-copyright">
<p class="copyright-text">© {{ currentYear }} {{ appName }} {{ t('All Rights Reserved') }}</p>
<p class="icp-text">
<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">粤ICP备20016818号</a>
</p>
</div>
</div>
<!-- 右侧:三列链接 -->
<div class="footer-right">
<div class="footer-column">
<h3 class="footer-title">{{ t('Products & Services') }}</h3>
<ul class="footer-links">
<li><a href="https://jingrow.com" target="_blank">Jingrow</a></li>
<li><a href="https://jingrowtools.com" target="_blank">{{ t('Jingrow Tools') }}</a></li>
</ul>
</div>
<div class="footer-column">
<h3 class="footer-title">{{ t('Resources') }}</h3>
<ul class="footer-links">
<li><a href="#">{{ t('Use Cases') }}</a></li>
<li><a href="#">{{ t('API & Pricing') }}</a></li>
<li><a href="#">{{ t('Developer Documentation') }}</a></li>
<li><a href="#">{{ t('Research & Exploration') }}</a></li>
</ul>
</div>
<div class="footer-column">
<h3 class="footer-title">{{ t('Support & Help') }}</h3>
<ul class="footer-links">
<li><a href="#">{{ t('Tutorials') }}</a></li>
<li><a href="#">{{ t('FAQ') }}</a></li>
<li><a href="#">{{ t('Online Support') }}</a></li>
<li><a href="#">{{ t('Feedback & Suggestions') }}</a></li>
</ul>
</div>
</div>
</div>
</div>
</footer>
</template>
<!-- 已登录状态:显示应用布局(带 sidebar 和 header -->
<template v-else>
<n-layout has-sider class="app-layout">
<!-- 侧边栏 -->
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
v-model:collapsed="collapsed"
:show-trigger="!isMobile"
:responsive="true"
:breakpoint="768"
@collapse="onSidebarCollapse"
@expand="onSidebarExpand"
>
<AppSidebar :collapsed="collapsed" @menu-select="onMenuSelect" />
</n-layout-sider>
<!-- 主内容区 -->
<n-layout>
<!-- 顶部导航 -->
<n-layout-header bordered>
<AppHeader @toggle-sidebar="toggleSidebar" />
</n-layout-header>
<!-- 内容区域 -->
<n-layout-content>
<div class="content-wrapper">
<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="tool-page">
<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="t('Original')" @load="adjustContainerSize" />
</div>
<div class="comparison-image result-image" :style="{ clipPath: `inset(0 0 0 ${splitPosition}%)` }">
<img ref="resultImageRef" :src="resultImageUrl" :alt="t('Background Removed')" @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" ref="singleImageWrapperRef">
<div class="single-image-container">
<img ref="singleImageRef" :src="uploadedImageUrl" :alt="t('Original')" @load="adjustContainerSize" />
</div>
<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 v-if="!uploadedImage" class="sample-images-section">
<p class="sample-images-title">{{ t('Click image to try') }}</p>
<div class="sample-images-container">
<div
v-for="sample in sampleImages"
:key="sample.id"
class="sample-image-item"
@click="handleSampleImageClick(sample.url)"
:class="{ loading: processing }"
>
<div class="sample-image-wrapper">
<img :src="sample.url" :alt="sample.name" loading="lazy" />
<div class="sample-image-overlay">
<Icon icon="tabler:wand" class="overlay-icon" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</n-layout-content>
</n-layout>
<!-- 移动端遮罩层 -->
<div
v-if="isMobile && !collapsed"
class="mobile-overlay"
@click="toggleSidebar"
></div>
</n-layout>
</template>
<!-- 登录弹窗 -->
<n-modal
v-model:show="showLoginModal"
preset="card"
:title="appName"
size="large"
:bordered="false"
:mask-closable="true"
style="max-width: 500px;"
class="auth-modal"
>
<n-form
ref="loginFormRef"
:model="loginFormData"
:rules="loginRules"
size="medium"
:show-label="false"
@keyup.enter="handleLoginSubmit"
>
<n-form-item path="username">
<n-input
v-model:value="loginFormData.username"
:placeholder="t('Username')"
:input-props="{ autocomplete: 'username' }"
>
<template #prefix>
<Icon icon="tabler:user" />
</template>
</n-input>
</n-form-item>
<n-form-item path="password">
<n-input
v-model:value="loginFormData.password"
type="password"
:placeholder="t('Password')"
:input-props="{ autocomplete: 'current-password' }"
show-password-on="click"
>
<template #prefix>
<Icon icon="tabler:lock" />
</template>
</n-input>
</n-form-item>
<n-form-item>
<n-button
type="primary"
size="large"
block
:loading="loginLoading"
@click="handleLoginSubmit"
class="brand-button"
>
{{ t('Login') }}
</n-button>
</n-form-item>
</n-form>
<div class="auth-footer" v-if="showSignupLink">
<n-text depth="3">
{{ t("Don't have an account?") }}
<a href="javascript:void(0)" class="auth-link" @click="switchToSignup">
{{ t('Sign up') }}
</a>
</n-text>
</div>
</n-modal>
<!-- 注册弹窗 -->
<n-modal
v-model:show="showSignupModal"
preset="card"
:title="appName"
size="large"
:bordered="false"
:mask-closable="true"
style="max-width: 500px;"
class="auth-modal"
>
<n-form
ref="signupFormRef"
:model="signupFormData"
:rules="signupRules"
size="medium"
:show-label="false"
@keyup.enter="handleSignupSubmit"
>
<n-form-item path="username">
<n-input
v-model:value="signupFormData.username"
:placeholder="t('Username')"
:input-props="{ autocomplete: 'username' }"
>
<template #prefix>
<Icon icon="tabler:user" />
</template>
</n-input>
</n-form-item>
<n-form-item path="password">
<n-input
v-model:value="signupFormData.password"
type="password"
:placeholder="t('Password')"
:input-props="{ autocomplete: 'new-password' }"
show-password-on="click"
>
<template #prefix>
<Icon icon="tabler:lock" />
</template>
</n-input>
</n-form-item>
<n-form-item path="confirmPassword">
<n-input
v-model:value="signupFormData.confirmPassword"
type="password"
:placeholder="t('Confirm Password')"
:input-props="{ autocomplete: 'new-password' }"
show-password-on="click"
>
<template #prefix>
<Icon icon="tabler:lock" />
</template>
</n-input>
</n-form-item>
<n-form-item path="email">
<n-input
v-model:value="signupFormData.email"
:placeholder="isEnglish ? t('Email') : t('Email (Optional)')"
:input-props="{ autocomplete: 'email', type: 'email' }"
>
<template #prefix>
<Icon icon="tabler:mail" />
</template>
</n-input>
</n-form-item>
<n-form-item v-if="!isEnglish" path="phoneNumber">
<n-input
v-model:value="signupFormData.phoneNumber"
:placeholder="t('Mobile')"
:input-props="{ autocomplete: 'tel' }"
>
<template #prefix>
<Icon icon="tabler:phone" />
</template>
</n-input>
</n-form-item>
<n-form-item>
<n-button
type="primary"
size="medium"
block
:loading="signupLoading"
@click="handleSignupSubmit"
class="brand-button"
>
{{ t('Sign up') }}
</n-button>
</n-form-item>
</n-form>
<div class="auth-footer">
<n-text depth="3">
{{ t('Already have an account?') }}
<a href="javascript:void(0)" class="auth-link" @click="switchToLogin">
{{ t('Login') }}
</a>
</n-text>
</div>
</n-modal>
</div>
</template>
<style scoped lang="scss">
.home-page {
min-height: 100vh; /* 使用 min-height 而不是固定 height */
display: flex;
flex-direction: column;
background: white;
position: relative;
overflow: hidden;
}
/* 应用布局样式(登录后) */
.app-layout {
height: 100vh;
}
.content-wrapper {
padding: 20px;
min-height: calc(100vh - 64px);
overflow-y: auto;
}
/* 使用Naive UI内置的sticky功能 */
:deep(.n-layout-header) {
position: sticky;
top: 0;
z-index: 1000;
}
/* 移动端遮罩层 */
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
/* 移动端样式 */
@media (max-width: 767px) {
/* 移动端时完全隐藏侧边栏 */
:deep(.n-layout-sider) {
position: fixed !important;
top: 0;
left: 0;
height: 100vh;
z-index: 1000;
width: 280px !important;
max-width: 80vw;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
/* 移动端侧边栏打开时的样式 */
:deep(.n-layout-sider:not(.n-layout-sider--collapsed)) {
transform: translateX(0) !important;
}
/* 移动端主内容区域占满全宽 */
:deep(.n-layout) {
margin-left: 0 !important;
}
}
/* 桌面端保持原有样式 */
@media (min-width: 768px) {
:deep(.n-layout-sider) {
position: relative !important;
transform: none !important;
}
}
/* Header */
.marketing-header {
flex-shrink: 0;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: white;
border-bottom: 1px solid var(--n-border-color, #efeff5);
position: sticky;
top: 0;
z-index: 1000;
}
.header-container {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.logo-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: #1f2937;
font-size: 16px;
font-weight: 700;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.logo-img {
height: 32px;
width: auto;
}
.logo-text {
font-size: 16px;
font-weight: 700;
white-space: nowrap;
}
.header-right {
display: flex;
align-items: center;
}
:deep(.login-btn) {
background: #e6f8f0 !important;
color: #0d684b !important;
&:hover {
background: #dcfce7 !important;
color: #166534 !important;
}
&:active {
background: #1fc76f !important;
color: white !important;
}
}
/* Main Content */
.main-content {
flex: 1;
width: 100%;
padding: 24px 0;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
height: 0;
}
.content-wrapper .main-content {
height: auto;
min-height: auto;
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;
}
}
.tool-page {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 600px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.page-header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 12px 36px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.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;
&:hover {
border-color: #1fc76f;
color: #1fc76f;
background: #f0fdf4;
}
}
.page-content {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 600px; /* 设置最小高度确保容器足够大 */
padding: 12px 16px;
}
.tool-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 600px; /* 设置最小高度确保容器足够大 */
}
/* 示例图片区块 */
.sample-images-section {
margin-top: 24px;
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
}
.sample-images-title {
font-size: 14px;
font-weight: 600;
color: #64748b;
margin: 0 0 16px 0;
letter-spacing: 0.05em;
}
.sample-images-container {
display: flex;
gap: 16px;
overflow-x: auto;
overflow-y: hidden;
padding: 0;
padding-bottom: 8px;
justify-content: center;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
&:hover {
background: #94a3b8;
}
}
}
.sample-image-item {
flex-shrink: 0;
width: 100px;
height: 100px;
cursor: pointer;
position: relative;
padding: 4px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-4px);
.sample-image-overlay {
opacity: 1;
}
}
&.loading {
pointer-events: none;
opacity: 0.6;
}
}
.sample-image-wrapper {
width: 100%;
height: 100%;
position: relative;
background: #f1f5f9;
border: 2px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sample-image-item:hover & {
border-color: #1fc76f;
box-shadow: 0 8px 16px rgba(31, 199, 111, 0.2);
}
}
.sample-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(31, 199, 111, 0.9), rgba(13, 104, 75, 0.9));
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
.overlay-icon {
font-size: 32px;
color: white;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
}
.upload-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 600px; /* 设置最小高度 */
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;
min-height: 600px; /* 确保预览区域有足够高度 */
height: auto;
}
.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;
}
.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;
min-width: 620px;
min-height: 620px;
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;
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;
}
.single-image-container {
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;
}
}
.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);
}
}
/* Footer */
.marketing-footer {
background: white;
margin-top: auto;
padding: 64px 0;
}
.footer-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 64px;
}
/* 左侧区域 */
.footer-left {
display: flex;
flex-direction: column;
gap: 24px;
flex-shrink: 0;
}
.footer-logo {
.logo-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: #1f2937;
font-size: 18px;
font-weight: 700;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.logo-img {
height: 32px;
width: auto;
}
.logo-text {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
}
}
.footer-social {
display: flex;
gap: 16px;
align-items: center;
}
.social-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: #94a3b8;
transition: all 0.2s ease;
text-decoration: none;
:deep(svg) {
width: 32px;
height: 32px;
}
&:hover {
color: #1fc76f;
transform: translateY(-2px);
}
}
.footer-copyright {
display: flex;
flex-direction: column;
gap: 8px;
}
.copyright-text {
margin: 0;
color: #6b7280;
font-size: 13px;
line-height: 1.5;
}
.icp-text {
margin: 0;
color: #9ca3af;
font-size: 12px;
line-height: 1.5;
a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: #1fc76f;
}
}
}
/* 右侧区域 */
.footer-right {
display: flex;
gap: 64px;
flex: 1;
justify-content: flex-end;
}
.footer-column {
display: flex;
flex-direction: column;
min-width: 140px;
}
.footer-title {
font-size: 13px;
font-weight: 600;
color: #111827;
margin: 0 0 16px 0;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.footer-links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.footer-links li {
margin: 0;
}
.footer-links a {
color: #6b7280;
text-decoration: none;
font-size: 14px;
line-height: 1.5;
transition: all 0.2s ease;
display: inline-block;
&:hover {
color: #1fc76f;
transform: translateX(2px);
}
}
@media (max-width: 1024px) {
.footer-content {
gap: 48px;
}
.footer-right {
gap: 48px;
}
}
@media (max-width: 768px) {
.marketing-header {
padding: 0 12px;
height: 56px;
}
.logo-text {
font-size: 20px;
}
.main-content {
padding: 12px;
}
.page-header {
padding: 10px 12px;
}
.page-header h2 {
font-size: 18px;
}
.page-content {
padding: 8px 12px;
}
.sample-images-section {
padding: 16px;
margin-top: 16px;
}
.sample-images-title {
font-size: 12px;
margin-bottom: 12px;
}
.sample-image-item {
width: 100px;
height: 100px;
}
.sample-image-overlay .overlay-icon {
font-size: 24px;
}
.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;
}
.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;
}
}
.marketing-footer {
padding: 40px 0 24px;
}
.footer-container {
padding: 0 16px;
}
.footer-content {
flex-direction: column;
gap: 32px;
}
.footer-right {
flex-direction: column;
gap: 32px;
width: 100%;
}
.footer-column {
min-width: auto;
}
.footer-social {
gap: 12px;
}
.social-icon {
width: 28px;
height: 28px;
:deep(svg) {
width: 18px;
height: 18px;
}
}
}
@media (max-width: 480px) {
.marketing-footer {
padding: 32px 0 20px;
}
.footer-content {
gap: 24px;
}
.footer-right {
gap: 24px;
}
.footer-title {
font-size: 12px;
margin-bottom: 12px;
}
.footer-links {
gap: 10px;
}
.footer-links a {
font-size: 13px;
}
.copyright-text {
font-size: 12px;
}
.icp-text {
font-size: 11px;
}
}
/* 登录/注册弹窗样式 */
:deep(.auth-modal) {
.n-card {
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.n-card-header {
text-align: center;
padding: 24px 24px 16px;
border-bottom: none;
}
.n-card__content {
padding: 0 24px 24px;
}
.n-form-item {
margin-bottom: 6px !important;
}
.n-form-item:last-child {
margin-bottom: 0 !important;
}
.n-form-item:not(.n-form-item--error) .n-form-item__feedback-wrapper {
min-height: 0 !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
.n-form-item--error .n-form-item__feedback-wrapper {
margin-top: 4px !important;
min-height: auto !important;
}
}
.auth-footer {
text-align: center;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.auth-link {
color: #1fc76f;
text-decoration: none;
font-weight: 500;
margin-left: 4px;
transition: color 0.2s;
cursor: pointer;
&:hover {
color: #1fc76f;
text-decoration: underline;
}
}
:deep(.brand-button) {
background: #e6f8f0 !important;
border: 1px solid #1fc76f !important;
color: #0d684b !important;
}
:deep(.brand-button .n-button__border),
:deep(.brand-button .n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
:deep(.brand-button:hover) {
background: #dcfce7 !important;
border-color: #1fc76f !important;
color: #166534 !important;
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15) !important;
}
:deep(.brand-button:hover .n-button__border),
:deep(.brand-button:hover .n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
:deep(.brand-button:active) {
background: #1fc76f !important;
border-color: #1fc76f !important;
color: white !important;
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2) !important;
}
:deep(.brand-button:active .n-button__border),
:deep(.brand-button:active .n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
</style>