- 删除所有调试日志(console.error/console.log) - 将所有中文注释改为英文注释 - 将硬编码的中文文本改为使用 t() 函数的英文,支持国际化 - 移除可能暴露项目信息的内容 - 添加新增翻译键的中文翻译(Portrait Sample, Product Sample, Animal Sample, Object Sample, Unable to get team information) 修改文件: - src/views/HomePage.vue - src/views/tools/remove_background/remove_background.vue - src/views/settings/Settings.vue - src/locales/zh-CN.json
3094 lines
79 KiB
Vue
3094 lines
79 KiB
Vue
<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')
|
|
|
|
// Login/Signup modal state
|
|
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)
|
|
|
|
// Login form
|
|
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' }
|
|
]
|
|
}
|
|
|
|
// Signup form
|
|
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' }
|
|
]
|
|
}
|
|
|
|
// English version: email required, phone optional
|
|
if (isEnglish.value) {
|
|
rules.email = [
|
|
{ required: true, message: t('Please enter email'), trigger: 'blur' },
|
|
{
|
|
validator: (_rule: any, value: string) => {
|
|
// Required rule handles empty values, only validate format here
|
|
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 {
|
|
// Chinese version: email optional, phone required
|
|
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
|
|
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')
|
|
message.error(errorMsg)
|
|
}
|
|
} catch (error: any) {
|
|
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
|
|
}
|
|
|
|
// Login state
|
|
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
|
|
|
// Sidebar collapse state
|
|
const SIDEBAR_COLLAPSE_KEY = 'app.sidebar.collapsed'
|
|
const collapsed = ref(localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true')
|
|
const isMobile = ref(false)
|
|
|
|
// Check screen size
|
|
const checkIsMobile = () => {
|
|
isMobile.value = window.innerWidth < 768
|
|
if (isMobile.value) {
|
|
collapsed.value = true
|
|
}
|
|
}
|
|
|
|
// Toggle sidebar
|
|
const toggleSidebar = () => {
|
|
collapsed.value = !collapsed.value
|
|
}
|
|
|
|
// Sidebar collapse event
|
|
const onSidebarCollapse = () => {
|
|
collapsed.value = true
|
|
}
|
|
|
|
// Sidebar expand event
|
|
const onSidebarExpand = () => {
|
|
collapsed.value = false
|
|
}
|
|
|
|
// Menu select event - auto close on mobile
|
|
const onMenuSelect = () => {
|
|
if (isMobile.value) {
|
|
collapsed.value = true
|
|
}
|
|
}
|
|
|
|
// Handle window resize
|
|
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 is used in template, lint warning is false positive
|
|
// 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>('') // Cached blob URL for download
|
|
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)
|
|
|
|
// Sample images for quick experience - suitable for background removal
|
|
const sampleImages = [
|
|
{
|
|
id: 'sample-1',
|
|
url: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Portrait Sample')
|
|
},
|
|
{
|
|
id: 'sample-2',
|
|
url: 'https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Portrait Sample')
|
|
},
|
|
{
|
|
id: 'sample-3',
|
|
url: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Product Sample')
|
|
},
|
|
{
|
|
id: 'sample-4',
|
|
url: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Product Sample')
|
|
},
|
|
{
|
|
id: 'sample-5',
|
|
url: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Animal Sample')
|
|
},
|
|
{
|
|
id: 'sample-6',
|
|
url: 'https://images.unsplash.com/photo-1551963831-b3b1ca40c98e?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Object Sample')
|
|
},
|
|
{
|
|
id: 'sample-7',
|
|
url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Portrait Sample')
|
|
},
|
|
{
|
|
id: 'sample-8',
|
|
url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Portrait Sample')
|
|
},
|
|
{
|
|
id: 'sample-9',
|
|
url: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Portrait Sample')
|
|
},
|
|
{
|
|
id: 'sample-10',
|
|
url: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=1024&h=1024&fit=crop&q=80',
|
|
name: t('Portrait Sample')
|
|
}
|
|
]
|
|
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) {
|
|
message.error(t('Image processing failed, please try again'))
|
|
}
|
|
}
|
|
|
|
// Handle sample image click
|
|
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()
|
|
|
|
// Validate file type
|
|
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
|
|
}
|
|
|
|
// Validate file size
|
|
const maxSize = 10 * 1024 * 1024
|
|
if (blob.size > maxSize) {
|
|
message.warning(t('Image size exceeds 10MB limit'))
|
|
processing.value = false
|
|
return
|
|
}
|
|
|
|
// Convert to File object
|
|
const file = new File([blob], 'sample-image.jpg', { type: blob.type })
|
|
await processFile(file)
|
|
} catch (error: any) {
|
|
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 = ''
|
|
|
|
// Helper function to handle successful result (internal use)
|
|
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) {
|
|
// Failed to parse JSON, continue processing
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process last 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) {
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache result image as blob URL for download
|
|
*/
|
|
const cacheResultImage = async (imageUrl: string) => {
|
|
try {
|
|
// Clean up old blob URL
|
|
if (resultImageBlobUrl.value) {
|
|
URL.revokeObjectURL(resultImageBlobUrl.value)
|
|
resultImageBlobUrl.value = ''
|
|
}
|
|
|
|
// Fetch image and convert to 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) {
|
|
// Cache failure doesn't affect display, only download needs to refetch
|
|
}
|
|
}
|
|
|
|
const handleDownload = async () => {
|
|
if (!resultImage.value) return
|
|
|
|
// Check login status
|
|
if (!isLoggedIn.value) {
|
|
message.warning(t('Please login to download'))
|
|
showLoginModal.value = true
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Prefer cached blob URL
|
|
let blobUrl = resultImageBlobUrl.value
|
|
|
|
// If no cache, fetch and cache
|
|
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()
|
|
// Don't immediately clean up blob URL, keep cache for subsequent downloads
|
|
} catch (error) {
|
|
message.error(t('Failed to download image'))
|
|
}
|
|
}
|
|
|
|
const resetUpload = () => {
|
|
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
|
URL.revokeObjectURL(uploadedImageUrl.value)
|
|
}
|
|
// Clean up result image blob URL cache
|
|
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()
|
|
// Wait for multiple render cycles to ensure DOM is fully updated
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
|
// Handle comparison view container
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle single image view container
|
|
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 })
|
|
|
|
// Compress image to 1024x1024
|
|
try {
|
|
const compressedFile = await compressImageFile(originalFile, {
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
quality: 0.92,
|
|
mode: 'contain' // Maintain aspect ratio
|
|
})
|
|
|
|
uploadedImage.value = compressedFile
|
|
uploadedImageUrl.value = URL.createObjectURL(compressedFile)
|
|
|
|
await handleRemoveBackground()
|
|
} catch (error) {
|
|
message.error(t('Image processing failed, please try again'))
|
|
processing.value = false
|
|
}
|
|
} catch (error: any) {
|
|
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
|
|
|
|
// Cache result image from history
|
|
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)
|
|
|
|
// Initialize auth state
|
|
await authStore.initAuth()
|
|
|
|
|
|
// Detect mobile
|
|
checkIsMobile()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleWindowResize)
|
|
window.removeEventListener('paste', handlePaste)
|
|
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
|
URL.revokeObjectURL(uploadedImageUrl.value)
|
|
}
|
|
// Clean up result image blob URL cache
|
|
if (resultImageBlobUrl.value) {
|
|
URL.revokeObjectURL(resultImageBlobUrl.value)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="home-page" @dragenter.prevent="handleDragEnter" @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
|
<!-- Not logged in: show marketing page layout -->
|
|
<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>
|
|
|
|
<!-- Sample images section -->
|
|
<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">
|
|
<!-- Left: Logo and social media -->
|
|
<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">
|
|
<!-- 中文版社交图标 -->
|
|
<template v-if="!isEnglish">
|
|
<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>
|
|
</template>
|
|
<!-- English version social icons -->
|
|
<template v-else>
|
|
<a href="#" class="social-icon" title="Twitter">
|
|
<Icon icon="tabler:brand-twitter" />
|
|
</a>
|
|
<a href="#" class="social-icon" title="Facebook">
|
|
<Icon icon="tabler:brand-facebook" />
|
|
</a>
|
|
<a href="#" class="social-icon" title="LinkedIn">
|
|
<Icon icon="tabler:brand-linkedin" />
|
|
</a>
|
|
<a href="#" class="social-icon" title="GitHub">
|
|
<Icon icon="tabler:brand-github" />
|
|
</a>
|
|
</template>
|
|
</div>
|
|
<div class="footer-copyright">
|
|
<p class="copyright-text">© {{ currentYear }} {{ appName }} {{ t('All Rights Reserved') }}</p>
|
|
<p v-if="!isEnglish" class="icp-text">
|
|
<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">粤ICP备20016818号</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: three column links -->
|
|
<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>
|
|
|
|
<!-- Logged in: show app layout (with sidebar and header) -->
|
|
<template v-else>
|
|
<n-layout has-sider class="app-layout">
|
|
<!-- Sidebar -->
|
|
<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>
|
|
|
|
<!-- Main content area -->
|
|
<n-layout>
|
|
<!-- Top navigation -->
|
|
<n-layout-header bordered>
|
|
<AppHeader @toggle-sidebar="toggleSidebar" />
|
|
</n-layout-header>
|
|
|
|
<!-- Content area -->
|
|
<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>
|
|
|
|
<!-- Sample images section -->
|
|
<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>
|
|
|
|
<!-- Mobile overlay -->
|
|
<div
|
|
v-if="isMobile && !collapsed"
|
|
class="mobile-overlay"
|
|
@click="toggleSidebar"
|
|
></div>
|
|
</n-layout>
|
|
</template>
|
|
|
|
<!-- Login modal -->
|
|
<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>
|
|
|
|
<!-- Signup 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; /* Use min-height instead of fixed height */
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: white;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* App layout styles (after login) */
|
|
.app-layout {
|
|
height: 100vh;
|
|
}
|
|
|
|
.content-wrapper {
|
|
padding: 20px;
|
|
min-height: calc(100vh - 64px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Use Naive UI built-in sticky functionality */
|
|
:deep(.n-layout-header) {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1000;
|
|
}
|
|
|
|
/* Mobile overlay */
|
|
.mobile-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
z-index: 999;
|
|
}
|
|
|
|
/* Mobile styles */
|
|
@media (max-width: 767px) {
|
|
/* Completely hide sidebar on mobile */
|
|
: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;
|
|
}
|
|
|
|
/* Mobile sidebar open styles */
|
|
:deep(.n-layout-sider:not(.n-layout-sider--collapsed)) {
|
|
transform: translateX(0) !important;
|
|
}
|
|
|
|
/* Mobile main content area takes full width */
|
|
:deep(.n-layout) {
|
|
margin-left: 0 !important;
|
|
}
|
|
}
|
|
|
|
/* Desktop maintains original styles */
|
|
@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; /* Set minimum height to ensure container is large enough */
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
.tool-container {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
min-height: 600px; /* Set minimum height to ensure container is large enough */
|
|
}
|
|
|
|
/* Sample images section */
|
|
.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; /* Set minimum height */
|
|
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; /* Ensure preview area has sufficient height */
|
|
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>
|