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>
<div>
<NuxtLayout>
@@ -5,6 +13,6 @@
</NuxtLayout>
<UModals/>
<UNotifications />
<UNotifications/>
</div>
</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">
import {Label, PinInputInput, PinInputRoot} from 'radix-vue'
import {useFetchWrapped} from "~/composables/useFetchWrapped";
const toast = useToast()
const modal = useModal()
const loginState = useLoginState()
const sms_triggered = ref(false)
const sms_sending = ref(false)
const sms_counting_down = ref(0)
const final_loading = ref(false)
const handleComplete = (e: string[]) => toast.add({title: `提交验证码 ${e.join('')}`, color: 'indigo', icon: 'i-tabler-circle-check'})
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: []})
function onSubmit(form: any) {
function onSubmit(form: req.user.Login) {
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 = () => {
smsForm.sms_code = []
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_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'})
const interval = setInterval(() => {
sms_counting_down.value--
@@ -46,7 +112,64 @@ const obtainSmsCode = () => {
clearInterval(interval)
}
}, 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>
@@ -58,7 +181,8 @@ const obtainSmsCode = () => {
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
登录眩生花 AI 助手
</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>
</template>
@@ -71,7 +195,7 @@ const obtainSmsCode = () => {
</div>
</template>
<template #item="{ item }">
<UCard @submit.prevent="() => onSubmit(item.key === 'account' ? accountForm : smsForm)">
<UCard @submit.prevent="() => onSubmit(accountForm)">
<template #header>
<p class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
{{ item.label }}
@@ -93,14 +217,14 @@ const obtainSmsCode = () => {
<div v-else-if="item.key === 'sms'" class="space-y-3">
<UFormGroup label="手机号" name="mobile" required>
<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>
<span class="text-gray-500 dark:text-gray-400 text-xs">+86</span>
</template>
</UInput>
<UButton :label="sms_counting_down ? `${sms_counting_down}秒后重发` : '获取验证码'"
@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"/>
</UButtonGroup>
</UFormGroup>
@@ -110,17 +234,18 @@ const obtainSmsCode = () => {
<PinInputRoot
id="sms-input"
v-model="smsForm.sms_code"
:disabled="sms_sending"
:disabled="sms_sending || final_loading"
placeholder="○"
class="w-full flex gap-2 justify-between items-center mt-1"
@complete="handleComplete"
class="w-full flex gap-2 justify-between md:justify-start items-center mt-1"
@complete="handle_sms_verify"
type="number" otp required
>
<PinInputInput
v-for="(id, index) in 6"
v-for="(id, index) in 4"
:key="id"
:index="index"
class="pin-input"
:autofocus="index === 0"
/>
</PinInputRoot>
</div>
@@ -128,7 +253,7 @@ const obtainSmsCode = () => {
</div>
<template #footer v-if="item.key !== 'sms'">
<UButton type="submit" color="black" :disabled="final_loading">
<UButton type="submit" color="black" :loading="final_loading">
登录
</UButton>
</template>
@@ -152,7 +277,7 @@ const obtainSmsCode = () => {
}
.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;
}

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 dayjs = useDayjs()
const modal = useModal()
const toast = useToast()
const loginState = useLoginState()
const isDark = computed({
get() {
@@ -40,7 +42,13 @@ const items = [
icon: 'i-tabler-user-circle'
}], [{
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"
@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>
<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' } }">
<UAvatar :src="void 0" icon="i-tabler-user" size="md"/>
@@ -78,10 +87,12 @@ const open_login_modal = () => {
<div class="text-left">
<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 class="truncate whitespace-nowrap max-w-40 font-medium text-gray-900 dark:text-white">
{{ item.label }}
{{ loginState.user?.username }}
</p>
</div>
</template>

View File

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

View File

@@ -24,11 +24,11 @@ const props = defineProps({
})
const expand_prompt = ref(false)
const show_meta = ref(false)
const show_meta = ref(true)
</script>
<template>
<div class="">
<div class="w-full">
<div class="flex items-center gap-1">
<UIcon :name="icon"/>
<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 OptionBlock from "~/pages/aigc/drawing/components/OptionBlock.vue";
import ResultBlock from "~/pages/aigc/drawing/components/ResultBlock.vue";
import {useLoginState} from "~/composables/useLoginState";
import ModalAuthentication from "~/components/ModalAuthentication.vue";
useHead({
title: '绘画 | XSH AI'
})
const modal = useModal()
const loginState = useLoginState()
const leftSection = ref<HTMLElement | null>(null)
const leftHandler = ref<HTMLElement | null>(null)
@@ -54,7 +59,7 @@ const images2 = [
<template>
<div class="w-full flex">
<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">
<div ref="leftHandler"
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>
</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">
<ResultBlock :images="images"
<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">
<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="{
id: 'd166429411dfc6722e54c032cdba97a2',
aspect: '9:16',
@@ -92,13 +105,6 @@ const images2 = [
<UButton color="gray" size="xs" icon="i-tabler-trash" variant="ghost"></UButton>
</template>
</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>
</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
}
}
}