feat: login

This commit is contained in:
2024-03-04 09:20:35 +08:00
parent ea2d53bb44
commit de336dbc72
12 changed files with 409 additions and 41 deletions

10
app.vue
View File

@@ -1,3 +1,11 @@
<script setup lang="ts">
const loginState = useLoginState()
onMounted(() => {
loginState.checkSession()
})
</script>
<template> <template>
<div> <div>
<NuxtLayout> <NuxtLayout>
@@ -5,6 +13,6 @@
</NuxtLayout> </NuxtLayout>
<UModals/> <UModals/>
<UNotifications /> <UNotifications/>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
const loginState = useLoginState()
defineProps({
contentClass: {
type: String,
default: ''
}
})
</script>
<template>
<div v-if="!loginState.is_logged_in"
class="w-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-user-circle" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton class="mt-2 font-bold" to="/login" color="black" variant="solid" size="xs">登录</UButton>
</div>
<div :class="contentClass" v-else>
<slot/>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import {Label, PinInputInput, PinInputRoot} from 'radix-vue' import {Label, PinInputInput, PinInputRoot} from 'radix-vue'
import {useFetchWrapped} from "~/composables/useFetchWrapped";
const toast = useToast() const toast = useToast()
const modal = useModal() const modal = useModal()
const loginState = useLoginState()
const sms_triggered = ref(false) const sms_triggered = ref(false)
const sms_sending = ref(false) const sms_sending = ref(false)
const sms_counting_down = ref(0) const sms_counting_down = ref(0)
const final_loading = ref(false) const final_loading = ref(false)
const handleComplete = (e: string[]) => toast.add({title: `提交验证码 ${e.join('')}`, color: 'indigo', icon: 'i-tabler-circle-check'})
const items = [ const items = [
{ {
@@ -25,20 +26,85 @@ const items = [
}, },
] ]
const accountForm = reactive({username: '', password: ''}) const accountForm = reactive<req.user.Login>({username: '', password: ''})
const smsForm = reactive({mobile: '', sms_code: []}) const smsForm = reactive({mobile: '', sms_code: []})
function onSubmit(form: any) { function onSubmit(form: req.user.Login) {
console.log('Submitted form:', form) console.log('Submitted form:', form)
final_loading.value = true
useFetchWrapped<req.user.Login, BaseResponse<resp.user.Login>>('App.User_User.Login', {
username: form.username,
password: form.password
}).then(res => {
final_loading.value = false
if (res.ret !== 200) {
toast.add({
title: '登录失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x'
})
return
}
if (!res.data.is_login) {
toast.add({
title: '登录失败',
description: res.msg || '账号或密码错误',
color: 'red',
icon: 'i-tabler-circle-x'
})
return
}
if (!res.data.token || !res.data.user_id) {
toast.add({
title: '登录失败',
description: res.msg || '无法获取登录状态',
color: 'red',
icon: 'i-tabler-circle-x'
})
return
}
loginState.token = res.data.token
loginState.user.id = res.data.user_id
loginState.updateProfile().then(() => {
loginState.checkSession()
modal.close()
toast.add({
title: '登录成功',
description: `${loginState.user.username}, 欢迎回来`,
color: 'primary',
icon: 'i-tabler-login-2'
})
}).catch(err => {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x'
})
}).finally(() => {
final_loading.value = false
})
}).catch(err => {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x'
})
})
} }
const obtainSmsCode = () => { const obtainSmsCode = () => {
smsForm.sms_code = [] smsForm.sms_code = []
sms_sending.value = true sms_sending.value = true
setTimeout(() => {
useFetchWrapped<req.user.SmsLogin, BaseResponse<resp.user.SmsLogin>>('App.User_User.MobileLogin', {
mobile: smsForm.mobile
}).then(res => {
sms_triggered.value = true sms_triggered.value = true
sms_sending.value = false sms_sending.value = false
sms_counting_down.value = 15 // TODO: modify this to change the countdown time sms_counting_down.value = 60 // TODO: modify this to change the countdown time
toast.add({title: '短信验证码已发送', color: 'indigo', icon: 'i-tabler-circle-check'}) toast.add({title: '短信验证码已发送', color: 'indigo', icon: 'i-tabler-circle-check'})
const interval = setInterval(() => { const interval = setInterval(() => {
sms_counting_down.value-- sms_counting_down.value--
@@ -46,7 +112,64 @@ const obtainSmsCode = () => {
clearInterval(interval) clearInterval(interval)
} }
}, 1000) }, 1000)
}, 2000) }).catch(err => {
toast.add({
title: '验证码发送失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x'
})
})
}
const handle_sms_verify = (e: string[]) => {
final_loading.value = true
useFetchWrapped<req.user.SmsLoginVerify, BaseResponse<resp.user.SmsLoginVerify>>('App.User_User.MobileLoginVerify', {
mobile: smsForm.mobile,
sms_code: e.join('')
}).then(res => {
if (res.ret !== 200) {
smsForm.sms_code = []
toast.add({
title: '登录失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x'
})
return
}
if (!res.data.token || !res.data.person_id) {
smsForm.sms_code = []
toast.add({
title: '登录失败',
description: res.msg || '无法获取登录状态',
color: 'red',
icon: 'i-tabler-circle-x'
})
return
}
loginState.token = res.data.token
loginState.user.id = res.data.person_id
loginState.updateProfile().then(() => {
loginState.checkSession()
modal.close()
toast.add({
title: '登录成功',
description: `${loginState.user.username}, 欢迎回来`,
color: 'primary',
icon: 'i-tabler-login-2'
})
}).catch(err => {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x'
})
}).finally(() => {
final_loading.value = false
})
}).finally(() => final_loading.value = false)
} }
</script> </script>
@@ -58,7 +181,8 @@ const obtainSmsCode = () => {
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
登录眩生花 AI 助手 登录眩生花 AI 助手
</h3> </h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()"/> <UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1"
@click="modal.close()"/>
</div> </div>
</template> </template>
@@ -71,7 +195,7 @@ const obtainSmsCode = () => {
</div> </div>
</template> </template>
<template #item="{ item }"> <template #item="{ item }">
<UCard @submit.prevent="() => onSubmit(item.key === 'account' ? accountForm : smsForm)"> <UCard @submit.prevent="() => onSubmit(accountForm)">
<template #header> <template #header>
<p class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <p class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
{{ item.label }} {{ item.label }}
@@ -93,14 +217,14 @@ const obtainSmsCode = () => {
<div v-else-if="item.key === 'sms'" class="space-y-3"> <div v-else-if="item.key === 'sms'" class="space-y-3">
<UFormGroup label="手机号" name="mobile" required> <UFormGroup label="手机号" name="mobile" required>
<UButtonGroup class="w-full"> <UButtonGroup class="w-full">
<UInput v-model="smsForm.mobile" type="sms" class="w-full" required> <UInput v-model="smsForm.mobile" :disabled="final_loading" type="sms" class="w-full" required>
<template #leading> <template #leading>
<span class="text-gray-500 dark:text-gray-400 text-xs">+86</span> <span class="text-gray-500 dark:text-gray-400 text-xs">+86</span>
</template> </template>
</UInput> </UInput>
<UButton :label="sms_counting_down ? `${sms_counting_down}秒后重发` : '获取验证码'" <UButton :label="sms_counting_down ? `${sms_counting_down}秒后重发` : '获取验证码'"
@click="obtainSmsCode" @click="obtainSmsCode"
:loading="sms_sending" :disabled="!!sms_counting_down" :loading="sms_sending" :disabled="!!sms_counting_down || final_loading"
class="text-xs font-bold" color="gray"/> class="text-xs font-bold" color="gray"/>
</UButtonGroup> </UButtonGroup>
</UFormGroup> </UFormGroup>
@@ -110,17 +234,18 @@ const obtainSmsCode = () => {
<PinInputRoot <PinInputRoot
id="sms-input" id="sms-input"
v-model="smsForm.sms_code" v-model="smsForm.sms_code"
:disabled="sms_sending" :disabled="sms_sending || final_loading"
placeholder="○" placeholder="○"
class="w-full flex gap-2 justify-between items-center mt-1" class="w-full flex gap-2 justify-between md:justify-start items-center mt-1"
@complete="handleComplete" @complete="handle_sms_verify"
type="number" otp required type="number" otp required
> >
<PinInputInput <PinInputInput
v-for="(id, index) in 6" v-for="(id, index) in 4"
:key="id" :key="id"
:index="index" :index="index"
class="pin-input" class="pin-input"
:autofocus="index === 0"
/> />
</PinInputRoot> </PinInputRoot>
</div> </div>
@@ -128,7 +253,7 @@ const obtainSmsCode = () => {
</div> </div>
<template #footer v-if="item.key !== 'sms'"> <template #footer v-if="item.key !== 'sms'">
<UButton type="submit" color="black" :disabled="final_loading"> <UButton type="submit" color="black" :loading="final_loading">
登录 登录
</UButton> </UButton>
</template> </template>
@@ -152,7 +277,7 @@ const obtainSmsCode = () => {
} }
.pin-input { .pin-input {
@apply w-full aspect-square rounded text-center shadow caret-transparent; @apply w-full md:w-16 aspect-square rounded text-center shadow caret-transparent;
@apply outline-0 ring-indigo-500 focus:ring font-bold; @apply outline-0 ring-indigo-500 focus:ring font-bold;
} }

22
composables/useDefer.ts Normal file
View File

@@ -0,0 +1,22 @@
export const useDefer = (maxFrame: number = 1000) => {
const frame = ref(1)
let rafId: number
function updateFrame() {
rafId = requestAnimationFrame(() => {
frame.value++
if (frame.value > maxFrame) return
updateFrame()
})
}
onMounted(() => {
updateFrame()
})
onUnmounted(() => {
cancelAnimationFrame(rafId)
})
return (n: number) => {
return frame.value >= n
}
}

View File

@@ -0,0 +1,21 @@
import {useFormPayload} from "~/composables/useFormPayload";
export const useFetchWrapped = <TypeReq, TypeResp>(
action: string,
payload?: TypeReq,
options?: {
method?: 'GET' | 'POST'
headers?: Record<string, string>
baseURL?: string
}
) => {
const runtimeConfig = useRuntimeConfig()
return $fetch<TypeResp>('/', {
baseURL: options?.baseURL || runtimeConfig.public.API_BASE,
method: options?.method || 'POST',
query: {
s: action
},
body: useFormPayload(payload as object)
})
}

View File

@@ -0,0 +1,10 @@
export const useFormPayload = (payload: object) => {
const formData = new FormData()
for (const dataKey in payload) {
if (payload.hasOwnProperty(dataKey)) {
// @ts-ignore
formData.append(dataKey, payload[dataKey])
}
}
return formData
}

View File

@@ -0,0 +1,64 @@
import {useFetchWrapped} from "~/composables/useFetchWrapped";
export const useLoginState = defineStore('loginState', () => {
const is_logged_in = ref(false)
const token = ref<string | null>(null)
const user = ref<UserSchema>({} as UserSchema)
const checkSession = () => {
return new Promise<boolean>(resolve => {
if (!token.value) return resolve(false)
useFetchWrapped<AuthedRequest, BaseResponse<resp.user.CheckSession>>('App.User_User.CheckSession', {
token: token.value,
user_id: user.value.id
}).then(res => {
if (res.ret !== 200) {
resolve(false)
return
}
resolve(res.data.is_login)
// update global state
is_logged_in.value = res.data.is_login
}).catch(err => resolve(false))
})
}
const updateProfile = () => {
return new Promise<UserSchema>((resolve, reject) => {
if (!token.value) return reject('token is empty')
useFetchWrapped<AuthedRequest, BaseResponse<resp.user.Profile>>('App.User_User.Profile', {
token: token.value,
user_id: user.value.id
}).then(res => {
if (res.ret !== 200) {
reject(res.msg || '未知错误')
return
}
user.value = res.data.profile
resolve(res.data.profile)
}).catch(err => reject(err || '未知错误'))
})
}
const logout = () => new Promise<void>(resolve => {
token.value = null
user.value = {} as UserSchema
is_logged_in.value = false
resolve()
})
return {
is_logged_in,
token,
user,
checkSession,
updateProfile,
logout
}
}, {
persist: {
key: 'xsh_assistant_persisted_state',
storage: persistedState.localStorage,
paths: ['token', 'user']
}
})

View File

@@ -4,6 +4,8 @@ import ModalAuthentication from "~/components/ModalAuthentication.vue";
const colorMode = useColorMode() const colorMode = useColorMode()
const dayjs = useDayjs() const dayjs = useDayjs()
const modal = useModal() const modal = useModal()
const toast = useToast()
const loginState = useLoginState()
const isDark = computed({ const isDark = computed({
get() { get() {
@@ -40,7 +42,13 @@ const items = [
icon: 'i-tabler-user-circle' icon: 'i-tabler-user-circle'
}], [{ }], [{
label: '注销登录', label: '注销登录',
icon: 'i-tabler-logout' icon: 'i-tabler-logout',
click: () => loginState.logout().then(() => toast.add({
title: '退出登录',
description: `您已成功退出登录账号`,
color: 'indigo',
icon: 'i-tabler-logout-2'
}))
}] }]
] ]
@@ -68,9 +76,10 @@ const open_login_modal = () => {
aria-label="Theme" aria-label="Theme"
@click="isDark = !isDark" @click="isDark = !isDark"
/> />
<UButton label="登录或注册" size="xs" class="font-bold" color="indigo" @click="open_login_modal"/> <UButton v-if="!loginState.is_logged_in" label="登录或注册" size="xs" class="font-bold" color="indigo"
@click="open_login_modal"/>
</ClientOnly> </ClientOnly>
<UDropdown :items="items" :popper="{ placement: 'bottom-start' }" <UDropdown v-if="loginState.is_logged_in" :items="items" :popper="{ placement: 'bottom-start' }"
:ui="{ item: { disabled: 'cursor-text select-text' } }"> :ui="{ item: { disabled: 'cursor-text select-text' } }">
<UAvatar :src="void 0" icon="i-tabler-user" size="md"/> <UAvatar :src="void 0" icon="i-tabler-user" size="md"/>
@@ -78,10 +87,12 @@ const open_login_modal = () => {
<div class="text-left"> <div class="text-left">
<p class="flex items-center gap-1"> <p class="flex items-center gap-1">
已登录为 已登录为
<UBadge color="amber" size="xs" variant="subtle">OP</UBadge> <UBadge v-if="loginState.user.auth_code === 2" color="amber" size="xs" variant="subtle">
OP
</UBadge>
</p> </p>
<p class="truncate whitespace-nowrap max-w-40 font-medium text-gray-900 dark:text-white"> <p class="truncate whitespace-nowrap max-w-40 font-medium text-gray-900 dark:text-white">
{{ item.label }} {{ loginState.user?.username }}
</p> </p>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,11 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
devtools: {enabled: true}, devtools: {enabled: true},
runtimeConfig: {
public: {
API_BASE: 'https://service1.fenshenzhike.com/'
}
},
modules: [ modules: [
'@nuxt/ui', '@nuxt/ui',
'radix-vue/nuxt', 'radix-vue/nuxt',

View File

@@ -24,11 +24,11 @@ const props = defineProps({
}) })
const expand_prompt = ref(false) const expand_prompt = ref(false)
const show_meta = ref(false) const show_meta = ref(true)
</script> </script>
<template> <template>
<div class=""> <div class="w-full">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<UIcon :name="icon"/> <UIcon :name="icon"/>
<h1 class="text-sm font-semibold"> <h1 class="text-sm font-semibold">

View File

@@ -4,11 +4,16 @@ import image2 from '~/assets/example/2.jpg';
import image3 from '~/assets/example/3.jpg'; import image3 from '~/assets/example/3.jpg';
import OptionBlock from "~/pages/aigc/drawing/components/OptionBlock.vue"; import OptionBlock from "~/pages/aigc/drawing/components/OptionBlock.vue";
import ResultBlock from "~/pages/aigc/drawing/components/ResultBlock.vue"; import ResultBlock from "~/pages/aigc/drawing/components/ResultBlock.vue";
import {useLoginState} from "~/composables/useLoginState";
import ModalAuthentication from "~/components/ModalAuthentication.vue";
useHead({ useHead({
title: '绘画 | XSH AI' title: '绘画 | XSH AI'
}) })
const modal = useModal()
const loginState = useLoginState()
const leftSection = ref<HTMLElement | null>(null) const leftSection = ref<HTMLElement | null>(null)
const leftHandler = ref<HTMLElement | null>(null) const leftHandler = ref<HTMLElement | null>(null)
@@ -54,7 +59,7 @@ const images2 = [
<template> <template>
<div class="w-full flex"> <div class="w-full flex">
<div ref="leftSection" <div ref="leftSection"
class="hidden md:block relative h-[calc(100vh-4rem)] overflow-hidden bg-neutral-200 dark:bg-neutral-800 transition-all" class="sticky hidden md:block h-[calc(100vh-4rem)] overflow-hidden bg-neutral-200 dark:bg-neutral-800 transition-all"
style="width: 320px"> style="width: 320px">
<div ref="leftHandler" <div ref="leftHandler"
class="absolute inset-0 left-auto hidden xl:flex flex-col justify-center items-center cursor-ew-resize px-1 group" class="absolute inset-0 left-auto hidden xl:flex flex-col justify-center items-center cursor-ew-resize px-1 group"
@@ -77,8 +82,16 @@ const images2 = [
</OptionBlock> </OptionBlock>
</div> </div>
</div> </div>
<div class="flex-1 h-screen flex flex-col gap-4 bg-neutral-100 dark:bg-neutral-900 p-4 overflow-y-auto"> <div class="flex-1 h-screen flex flex-col gap-4 bg-neutral-100 dark:bg-neutral-900 p-4 pb-20 overflow-y-auto">
<ResultBlock :images="images" <div v-if="!loginState.is_logged_in"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-user-circle" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton class="mt-2 font-bold" @click="modal.open(ModalAuthentication)" color="black" variant="solid"
size="xs">登录
</UButton>
</div>
<ResultBlock v-else :images="images" v-for="i in 12" :key="i"
title="XX大模型 · 文生图" :meta="{ title="XX大模型 · 文生图" :meta="{
id: 'd166429411dfc6722e54c032cdba97a2', id: 'd166429411dfc6722e54c032cdba97a2',
aspect: '9:16', aspect: '9:16',
@@ -92,13 +105,6 @@ const images2 = [
<UButton color="gray" size="xs" icon="i-tabler-trash" variant="ghost"></UButton> <UButton color="gray" size="xs" icon="i-tabler-trash" variant="ghost"></UButton>
</template> </template>
</ResultBlock> </ResultBlock>
<ResultBlock :images="images2"
title="XX大模型 · 图生图"
prompt="这是, 一组, 测试用的, 提示词">
<template #header-right>
<UButton color="gray" size="xs" icon="i-tabler-trash" variant="ghost"></UButton>
</template>
</ResultBlock>
</div> </div>
</div> </div>
</template> </template>

70
typings/schema.d.ts vendored Normal file
View File

@@ -0,0 +1,70 @@
interface AuthedRequest {
token?: string
user_id?: number
}
interface BaseResponse<T> {
ret: number
msg: string
data: T
}
interface UserSchema {
id: number
username: string
nickname: string
avatar: string
sex: 0 | 1 | 2 // 0: 未知, 1: 男, 2: 女
email: string
mobile: string
auth_code: 0 | 1 | 2 // 0: Banned, 1: User, 2: Operator
}
namespace req {
namespace user {
/**
* @description 用户登录
* @param username 用户名或手机号
* @param password 密码
*/
interface Login {
username: string
password: string
}
interface SmsLogin {
mobile: string
}
interface SmsLoginVerify {
mobile: string
sms_code: string
}
}
}
namespace resp {
namespace user {
interface CheckSession {
is_login: boolean
}
interface Login {
is_login: boolean
token?: string
user_id?: number
}
interface Profile {
profile: UserSchema
}
interface SmsLogin {
message: string
}
interface SmsLoginVerify {
token: string
person_id: number
}
}
}