add modal login/signup and unified UserMenu component for consistent user

This commit is contained in:
jingrow 2025-12-21 17:13:51 +08:00
parent 3cfb936b54
commit f8da7e84b6
3 changed files with 575 additions and 71 deletions

View File

@ -52,39 +52,23 @@
</n-button>
<!-- 用户菜单 -->
<n-dropdown
trigger="click"
:options="userMenuOptions"
@select="handleUserMenuSelect"
>
<n-button quaternary>
<n-avatar
round
size="small"
:src="user?.avatar"
>
{{ user?.username?.charAt(0).toUpperCase() }}
</n-avatar>
<span class="username">{{ user?.username }}</span>
<Icon icon="tabler:chevron-down" />
</n-button>
</n-dropdown>
<UserMenu />
</n-space>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NButton, NBreadcrumb, NBreadcrumbItem, NSpace, NDropdown, NAvatar, NInput, useMessage } from 'naive-ui'
import { NButton, NBreadcrumb, NBreadcrumbItem, NSpace, NInput } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../../shared/stores/auth'
import { t } from '../../shared/i18n'
import UserMenu from '../../shared/components/UserMenu.vue'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const authStore = useAuthStore()
//
@ -93,7 +77,6 @@ const searchQuery = ref('')
// localStorage
const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
const user = computed(() => authStore.user)
const isSystemUser = computed(() => authStore.user?.user_type === 'System User')
const breadcrumbItems = computed(() => {
@ -148,43 +131,6 @@ const breadcrumbItems = computed(() => {
return items
})
const userMenuOptions = computed(() => {
const options: any[] = [
//
{
label: t('Menu Management'),
key: 'menu-manager',
icon: () => h(Icon, { icon: 'tabler:menu-2' })
},
{
label: t('Settings'),
key: 'settings',
icon: () => h(Icon, { icon: 'tabler:settings' })
},
{
type: 'divider'
},
{
label: t('Logout'),
key: 'logout',
icon: () => h(Icon, { icon: 'tabler:logout' })
}
]
return options
})
const handleUserMenuSelect = async (key: string) => {
if (key === 'logout') {
await authStore.logout()
message.success(t('Logged out'))
router.push('/login')
} else if (key === 'menu-manager') {
router.push({ name: 'MenuManager' })
} else if (key === 'settings') {
router.push({ name: 'Settings' })
}
}
//
const handleSearch = () => {
@ -224,10 +170,6 @@ const handleSearchClear = () => {
min-width: 0; /* 允许flex子项收缩 */
}
.username {
margin-left: 8px;
font-weight: 500;
}
/* 搜索框响应式样式 */
.search-input {

View File

@ -0,0 +1,86 @@
<template>
<n-dropdown
trigger="click"
:options="userMenuOptions"
@select="handleUserMenuSelect"
>
<n-button quaternary>
<n-avatar
round
size="small"
:src="user?.avatar"
>
{{ user?.username?.charAt(0).toUpperCase() }}
</n-avatar>
<span class="username">{{ user?.username }}</span>
<Icon icon="tabler:chevron-down" />
</n-button>
</n-dropdown>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NDropdown, NAvatar, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../stores/auth'
import { t } from '../i18n'
const router = useRouter()
const message = useMessage()
const authStore = useAuthStore()
const user = computed(() => authStore.user)
const userMenuOptions = computed(() => {
const options: any[] = [
{
label: t('Menu Management'),
key: 'menu-manager',
icon: () => h(Icon, { icon: 'tabler:menu-2' })
},
{
label: t('Settings'),
key: 'settings',
icon: () => h(Icon, { icon: 'tabler:settings' })
},
{
type: 'divider'
},
{
label: t('Logout'),
key: 'logout',
icon: () => h(Icon, { icon: 'tabler:logout' })
}
]
return options
})
const handleUserMenuSelect = async (key: string) => {
if (key === 'logout') {
await authStore.logout()
message.success(t('Logged out'))
router.push('/')
} else if (key === 'menu-manager') {
router.push({ name: 'MenuManager' })
} else if (key === 'settings') {
router.push({ name: 'Settings' })
}
}
</script>
<style scoped>
.username {
margin-left: 8px;
font-weight: 500;
}
/* 移动端隐藏用户名文字 */
@media (max-width: 768px) {
.username {
display: none;
}
}
</style>

View File

@ -1,19 +1,193 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NSpace, useMessage } from 'naive-ui'
import { computed, ref, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue'
import { NButton, NSpace, useMessage, NModal, NForm, NFormItem, NInput, NText } 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 axios from 'axios'
const router = useRouter()
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 handleLogin = () => router.push('/login')
const handleSignup = () => router.push('/signup')
// /
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(false)
//
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 signupRules = {
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: [
{
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'
}
],
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'
}
]
}
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: 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('注册成功,但自动登录失败,请手动登录'))
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)
useSEO({
title: t('Remove Background - Free AI Background Removal Tool'),
@ -30,6 +204,8 @@ interface HistoryItem {
}
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>('')
@ -497,9 +673,25 @@ const removeHistoryItem = (index: number) => {
}
}
onMounted(() => {
onMounted(async () => {
window.addEventListener('resize', handleResize)
window.addEventListener('paste', handlePaste)
//
await authStore.initAuth()
//
try {
const response = await fetch('/jingrow/server-config')
if (response.ok) {
const data = await response.json()
if (data.success && data.jingrow_server_url === 'https://cloud.jingrow.com') {
showSignupLink.value = true
}
}
} catch (error) {
console.error('Failed to get server config:', error)
}
})
onUnmounted(() => {
@ -524,8 +716,20 @@ onUnmounted(() => {
</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 v-if="!isLoggedIn">
<n-button quaternary @click="handleSignup">注册</n-button>
<n-button type="primary" @click="handleLogin" class="login-btn">登录</n-button>
</template>
<!-- 已登录状态显示通知和用户菜单 -->
<template v-else>
<n-button quaternary circle>
<template #icon>
<Icon icon="tabler:bell" />
</template>
</n-button>
<UserMenu />
</template>
</n-space>
</div>
</div>
@ -711,6 +915,182 @@ onUnmounted(() => {
</div>
</div>
</footer>
<!-- 登录弹窗 -->
<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="t('Email (Optional)')"
:input-props="{ autocomplete: 'email', type: 'email' }"
>
<template #prefix>
<Icon icon="tabler:mail" />
</template>
</n-input>
</n-form-item>
<n-form-item path="phoneNumber">
<n-input
v-model:value="signupFormData.phoneNumber"
:placeholder="t('Phone Number')"
: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>
@ -1649,4 +2029,100 @@ onUnmounted(() => {
padding: 40px 0 20px;
}
}
/* 登录/注册弹窗样式 */
: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>