Add sidebar layout for authenticated homepage users

This commit is contained in:
jingrow 2025-12-21 21:25:37 +08:00
parent 4370f1a8d7
commit 0a23ee2587
2 changed files with 340 additions and 36 deletions

View File

@ -172,7 +172,7 @@ const handleMenuSelect = (key: string) => {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-size: 16px;
font-weight: 700;
color: #1f2937;
}

View File

@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue'
import { NButton, NSpace, useMessage, NModal, NForm, NFormItem, NInput, NText } from 'naive-ui'
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 } from '@/shared/i18n'
import { useAuthStore } from '@/shared/stores/auth'
import { signupApi } from '@/shared/api/auth'
import UserMenu from '@/shared/components/UserMenu.vue'
import AppHeader from '@/app/layouts/AppHeader.vue'
import AppSidebar from '@/app/layouts/AppSidebar.vue'
import { compressImageFile } from '@/shared/utils/imageResize'
import axios from 'axios'
@ -190,6 +191,51 @@ const switchToLogin = () => {
//
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.'),
@ -667,10 +713,6 @@ const adjustContainerSize = async () => {
watch([uploadedImageUrl, resultImageUrl], adjustContainerSize, { immediate: true })
const handleResize = () => {
adjustContainerSize()
}
const handleSplitLineMouseDown = (e: MouseEvent) => {
e.preventDefault()
isDraggingSplitLine.value = true
@ -860,7 +902,7 @@ const removeHistoryItem = (index: number) => {
}
onMounted(async () => {
window.addEventListener('resize', handleResize)
window.addEventListener('resize', handleWindowResize)
window.addEventListener('paste', handlePaste)
//
@ -878,10 +920,13 @@ onMounted(async () => {
} catch (error) {
console.error('Failed to get server config:', error)
}
//
checkIsMobile()
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('paste', handlePaste)
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(uploadedImageUrl.value)
@ -895,33 +940,28 @@ onUnmounted(() => {
<template>
<div class="home-page" @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop">
<!-- 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">
<!-- 未登录状态显示登录/注册按钮 -->
<template v-if="!isLoggedIn">
<!-- 未登录状态显示营销页面布局 -->
<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">注册</n-button>
<n-button type="primary" @click="handleLogin" class="login-btn">登录</n-button>
</template>
<!-- 已登录状态显示用户菜单 -->
<template v-else>
<UserMenu />
</template>
</n-space>
</n-space>
</div>
</div>
</div>
</header>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Main Content -->
<main class="main-content">
<div v-if="isDragging" class="global-drag-overlay">
<div class="overlay-content">
<p>拖放图片到任意位置去除背景</p>
@ -1073,10 +1113,10 @@ onUnmounted(() => {
</div>
</div>
</div>
</main>
</main>
<!-- Footer -->
<footer class="marketing-footer">
<!-- Footer -->
<footer class="marketing-footer">
<div class="footer-container">
<div class="footer-grid">
<div class="footer-column">
@ -1120,7 +1160,201 @@ onUnmounted(() => {
<p class="copyright">© {{ currentYear }} {{ appName }}. 版权所有</p>
</div>
</div>
</footer>
</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>拖放图片到任意位置去除背景</p>
</div>
</div>
<input ref="fileInputRef" type="file" accept="image/*" style="display: none" @change="handleFileSelect" />
<div class="tool-page">
<div class="page-header">
<h2>去除背景</h2>
<div v-if="uploadedImage" class="toolbar-actions">
<button v-if="resultImage" class="toolbar-btn" @click="handleDownload" title="下载">
<i class="fa fa-download"></i>
</button>
<button class="toolbar-btn" @click="resetUpload" title="更换图片">
<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>上传图片</span>
</button>
<div class="divider">
<span></span>
</div>
<div class="url-input-wrapper" @click.stop>
<input
ref="urlInputRef"
v-model="imageUrl"
type="text"
class="url-input"
placeholder="粘贴图片URL"
@keyup.enter="handleUrlSubmit"
@paste="handleUrlPaste"
:disabled="processing"
/>
</div>
<p class="upload-hint">拖放图片到任意位置或直接粘贴图片</p>
<p class="upload-format-hint">支持 JPGPNGWebP 格式</p>
</div>
<div v-if="processing" class="upload-processing-overlay">
<div class="spinner"></div>
<p>加载图片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="原图" @load="adjustContainerSize" />
</div>
<div class="comparison-image result-image" :style="{ clipPath: `inset(0 0 0 ${splitPosition}%)` }">
<img ref="resultImageRef" :src="resultImageUrl" alt="结果" @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="原图" @load="adjustContainerSize" />
</div>
<div v-if="processing" class="processing-overlay">
<div class="spinner"></div>
<p>处理中...</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="添加新图片"
>
<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="删除"
:aria-label="'删除'"
></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
@ -1310,6 +1544,69 @@ onUnmounted(() => {
overflow: visible; /* 改为 visible 允许内容溢出 */
}
/* 应用布局样式(登录后) */
.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;
@ -1400,6 +1697,13 @@ onUnmounted(() => {
height: 0; /* 强制 flex 子元素计算高度 */
}
/* 登录后的 main-content 样式覆盖 */
.content-wrapper .main-content {
height: auto; /* 登录后恢复自动高度 */
min-height: auto;
overflow: visible; /* 登录后允许内容显示 */
}
.global-drag-overlay {
position: fixed;
inset: 0;