wechat login is ready

This commit is contained in:
RockYang 2024-07-04 15:34:32 +08:00
parent b399fc557a
commit bddd611cc1
15 changed files with 416 additions and 238 deletions

View File

@ -7,7 +7,7 @@
* 功能新增:**支持AI解读 PDF, Word, Excel等文件** * 功能新增:**支持AI解读 PDF, Word, Excel等文件**
* 功能优化:优化聊天界面的用户上传文件的列表样式 * 功能优化:优化聊天界面的用户上传文件的列表样式
* 功能优化:优化聊天页面对话样式,支持列表样式和对话样式切换 * 功能优化:优化聊天页面对话样式,支持列表样式和对话样式切换
* 功能新增:支持微信等社交媒体登录 * 功能新增:支持微信扫码登录,未注册用户微信扫码后会自动注册并登录。移动使用微信浏览器打开可以实现无感登录。
## v4.0.9 ## v4.0.9

View File

@ -446,15 +446,10 @@ func (h *MidJourneyHandler) getData(finish bool, userId uint, page int, pageSize
} }
if item.Progress < 100 && item.ImgURL == "" && item.OrgURL != "" { if item.Progress < 100 && item.ImgURL == "" && item.OrgURL != "" {
// discord 服务器图片需要使用代理转发图片数据流
if strings.HasPrefix(item.OrgURL, "https://cdn.discordapp.com") {
image, err := utils.DownloadImage(item.OrgURL, h.App.Config.ProxyURL) image, err := utils.DownloadImage(item.OrgURL, h.App.Config.ProxyURL)
if err == nil { if err == nil {
job.ImgURL = "data:image/png;base64," + base64.StdEncoding.EncodeToString(image) job.ImgURL = "data:image/png;base64," + base64.StdEncoding.EncodeToString(image)
} }
} else {
job.ImgURL = job.OrgURL
}
} }
jobs = append(jobs, job) jobs = append(jobs, job)

View File

@ -285,9 +285,99 @@ func (h *UserHandler) CLoginRequest(c *gin.Context) {
// CLoginCallback 第三方登录回调 // CLoginCallback 第三方登录回调
func (h *UserHandler) CLoginCallback(c *gin.Context) { func (h *UserHandler) CLoginCallback(c *gin.Context) {
//platform := h.GetTrim(c, "type") loginType := h.GetTrim(c, "login_type")
//code := h.GetTrim(c, "code") code := h.GetTrim(c, "code")
var res types.BizVo
apiURL := fmt.Sprintf("%s/api/clogin/info", h.App.Config.ApiConfig.ApiURL)
r, err := req.C().R().SetBody(gin.H{"login_type": loginType, "code": code}).
SetHeader("AppId", h.App.Config.ApiConfig.AppId).
SetHeader("Authorization", fmt.Sprintf("Bearer %s", h.App.Config.ApiConfig.Token)).
SetSuccessResult(&res).
Post(apiURL)
if err != nil {
resp.ERROR(c, err.Error())
return
}
if r.IsErrorState() {
resp.ERROR(c, "error with login http status: "+r.Status)
return
}
if res.Code != types.Success {
resp.ERROR(c, "error with http response: "+res.Message)
return
}
// login successfully
data := res.Data.(map[string]interface{})
session := gin.H{}
var user model.User
tx := h.DB.Debug().Where("openid", data["openid"]).First(&user)
if tx.Error != nil { // user not exist, create new user
// 检测最大注册人数
var totalUser int64
h.DB.Model(&model.User{}).Count(&totalUser)
if h.licenseService.GetLicense().Configs.UserNum > 0 && int(totalUser) >= h.licenseService.GetLicense().Configs.UserNum {
resp.ERROR(c, "当前注册用户数已达上限,请请升级 License")
return
}
salt := utils.RandString(8)
password := fmt.Sprintf("%d", utils.RandomNumber(8))
user = model.User{
Username: fmt.Sprintf("%s@%d", loginType, utils.RandomNumber(10)),
Password: utils.GenPassword(password, salt),
Avatar: fmt.Sprintf("%s", data["avatar"]),
Salt: salt,
Status: true,
ChatRoles: utils.JsonEncode([]string{"gpt"}), // 默认只订阅通用助手角色
ChatModels: utils.JsonEncode(h.App.SysConfig.DefaultModels), // 默认开通的模型
Power: h.App.SysConfig.InitPower,
OpenId: fmt.Sprintf("%s", data["openid"]),
Nickname: fmt.Sprintf("%s", data["nickname"]),
}
tx = h.DB.Create(&user)
if tx.Error != nil {
resp.ERROR(c, "保存数据失败")
logger.Error(tx.Error)
return
}
session["username"] = user.Username
session["password"] = password
} else { // login directly
// 更新最后登录时间和IP
user.LastLoginIp = c.ClientIP()
user.LastLoginAt = time.Now().Unix()
h.DB.Model(&user).Updates(user)
h.DB.Create(&model.UserLoginLog{
UserId: user.Id,
Username: user.Username,
LoginIp: c.ClientIP(),
LoginAddress: utils.Ip2Region(h.searcher, c.ClientIP()),
})
}
// 创建 token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.Id,
"expired": time.Now().Add(time.Second * time.Duration(h.App.Config.Session.MaxAge)).Unix(),
})
tokenString, err := token.SignedString([]byte(h.App.Config.Session.SecretKey))
if err != nil {
resp.ERROR(c, "Failed to generate token, "+err.Error())
return
}
// 保存到 redis
key := fmt.Sprintf("users/%d", user.Id)
if _, err := h.redis.Set(c, key, tokenString, 0).Result(); err != nil {
resp.ERROR(c, "error with save token: "+err.Error())
return
}
session["token"] = tokenString
resp.SUCCESS(c, session)
} }
// Session 获取/验证会话 // Session 获取/验证会话

View File

@ -1,3 +1,6 @@
ALTER TABLE `chatgpt_chat_models` CHANGE `power` `power` SMALLINT NOT NULL COMMENT '消耗算力点数'; ALTER TABLE `chatgpt_chat_models` CHANGE `power` `power` SMALLINT NOT NULL COMMENT '消耗算力点数';
ALTER TABLE `chatgpt_users` ADD `openid` VARCHAR(100) NULL COMMENT '第三方登录账号ID' AFTER `last_login_ip`; ALTER TABLE `chatgpt_users` ADD `openid` VARCHAR(100) NULL COMMENT '第三方登录账号ID' AFTER `last_login_ip`;
ALTER TABLE `chatgpt_users` ADD UNIQUE(`openid`);
ALTER TABLE `chatgpt_users` ADD `platform` VARCHAR(30) NULL COMMENT '登录平台' AFTER `openid`; ALTER TABLE `chatgpt_users` ADD `platform` VARCHAR(30) NULL COMMENT '登录平台' AFTER `openid`;
ALTER TABLE `chatgpt_users` CHANGE `avatar` `avatar` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '头像';
ALTER TABLE `chatgpt_chat_history` CHANGE `icon` `icon` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色图标';

View File

@ -0,0 +1,115 @@
.bg {
position fixed
left 0
right 0
top 0
bottom 0
background-color #313237
background-image url("~@/assets/img/login-bg.jpg")
background-size cover
background-position center
background-repeat repeat-y
//filter: blur(10px); /* */
}
.main {
.contain {
position fixed
left 50%
top 40%
width 90%
max-width 400px;
transform translate(-50%, -50%)
padding 20px 10px;
color #ffffff
border-radius 10px;
.logo {
text-align center
.el-image {
width 120px;
cursor pointer
}
}
.header {
width 100%
margin-bottom 24px
font-size 24px
color $white_v1
letter-space 2px
text-align center
padding-top 10px
}
.content {
width 100%
height: auto
border-radius 3px
.block {
margin-bottom 16px
.el-input__inner {
border 1px solid $gray-v6 !important
.el-icon-user, .el-icon-lock {
font-size 20px
}
}
}
.btn-row {
padding-top 10px;
.login-btn {
width 100%
font-size 16px
letter-spacing 2px
}
}
.text-line {
justify-content center
padding-top 10px;
font-size 14px;
}
.opt {
padding 15px
.el-col {
text-align center
}
}
.divider {
border-top: 2px solid #c1c1c1;
}
.clogin {
padding 15px
display flex
justify-content center
.iconfont {
font-size 20px
background: #E9F1F6;
padding: 8px;
border-radius: 50%;
}
.iconfont.icon-wechat {
color #0bc15f
}
}
}
}
.footer {
color #ffffff;
.container {
padding 20px;
}
}
}

View File

@ -155,6 +155,7 @@ const synthesis = (text) => {
// //
const reGenerate = (prompt) => { const reGenerate = (prompt) => {
console.log(prompt)
emits('regen', prompt) emits('regen', prompt)
} }
</script> </script>

View File

@ -0,0 +1,58 @@
<template>
<div class="running-job-list">
<div class="running-job-box" v-if="list.length > 0">
<div class="job-item" v-for="item in list">
<div v-if="item.progress > 0" class="job-item-inner">
<el-image v-if="item.img_url" :src="item['img_url']" fit="cover" loading="lazy">
<template #placeholder>
<div class="image-slot">
正在加载图片
</div>
</template>
<template #error>
<div class="image-slot">
<el-icon>
<Picture/>
</el-icon>
</div>
</template>
</el-image>
<div class="progress">
<el-progress type="circle" :percentage="item.progress" :width="100"
color="#47fff1"/>
</div>
</div>
<el-image fit="cover" v-else>
<template #error>
<div class="image-slot">
<i class="iconfont icon-quick-start"></i>
<span>任务正在排队中</span>
</div>
</template>
</el-image>
</div>
</div>
<el-empty :image-size="100" v-else/>
</div>
</template>
<script setup>
import {ref} from "vue";
import {CircleCloseFilled, Picture} from "@element-plus/icons-vue";
import {isImage, removeArrayItem, substr} from "@/utils/libs";
import {FormatFileSize, GetFileIcon, GetFileType} from "@/store/system";
const props = defineProps({
list: {
type: Array,
default:[],
}
})
</script>
<style scoped lang="stylus">
@import "~@/assets/css/running-job-list.styl"
</style>

View File

@ -101,6 +101,12 @@ const routes = [
meta: {title: '用户登录'}, meta: {title: '用户登录'},
component: () => import('@/views/Login.vue'), component: () => import('@/views/Login.vue'),
}, },
{
name: 'login-callback',
path: '/login/callback',
meta: {title: '用户登录'},
component: () => import('@/views/LoginCallback.vue'),
},
{ {
name: 'register', name: 'register',
path: '/register', path: '/register',

View File

@ -626,10 +626,12 @@ const connect = function (chat_id, role_id) {
reader.onload = () => { reader.onload = () => {
const data = JSON.parse(String(reader.result)); const data = JSON.parse(String(reader.result));
if (data.type === 'start') { if (data.type === 'start') {
const prePrompt = chatData.value[chatData.value.length-1].content
chatData.value.push({ chatData.value.push({
type: "reply", type: "reply",
id: randString(32), id: randString(32),
icon: _role['icon'], icon: _role['icon'],
prompt:prePrompt,
content: "" content: ""
}); });
} else if (data.type === 'end') { // } else if (data.type === 'end') { //
@ -864,7 +866,7 @@ const reGenerate = function (prompt) {
type: "prompt", type: "prompt",
id: randString(32), id: randString(32),
icon: loginUser.value.avatar, icon: loginUser.value.avatar,
content: md.render(text) content: text
}); });
socket.value.send(JSON.stringify({type: "chat", content: prompt})); socket.value.send(JSON.stringify({type: "chat", content: prompt}));
} }

View File

@ -86,43 +86,7 @@
<div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }"> <div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }">
<div class="job-list-box"> <div class="job-list-box">
<h2>任务列表</h2> <h2>任务列表</h2>
<div class="running-job-list"> <task-list :list="runningJobs" />
<div class="running-job-box" v-if="runningJobs.length > 0">
<div class="job-item" v-for="item in runningJobs" :key="item.id">
<div v-if="item.progress > 0" class="job-item-inner">
<el-image :src="item['img_url']" fit="cover" loading="lazy">
<template #placeholder>
<div class="image-slot">
正在加载图片
</div>
</template>
<template #error>
<div class="image-slot">
<el-icon>
<Picture/>
</el-icon>
</div>
</template>
</el-image>
<div class="progress">
<el-progress type="circle" :percentage="item.progress" :width="100"
color="#47fff1"/>
</div>
</div>
<el-image fit="cover" v-else>
<template #error>
<div class="image-slot">
<i class="iconfont icon-quick-start"></i>
<span>任务正在排队中</span>
</div>
</template>
</el-image>
</div>
</div>
<el-empty :image-size="100" v-else/>
</div>
<h2>创作记录</h2> <h2>创作记录</h2>
<div class="finish-job-list"> <div class="finish-job-list">
@ -228,6 +192,7 @@ import {ElMessage, ElMessageBox, ElNotification} from "element-plus";
import Clipboard from "clipboard"; import Clipboard from "clipboard";
import {checkSession} from "@/action/session"; import {checkSession} from "@/action/session";
import {useSharedStore} from "@/store/sharedata"; import {useSharedStore} from "@/store/sharedata";
import TaskList from "@/components/TaskList.vue";
const listBoxHeight = ref(0) const listBoxHeight = ref(0)
// const paramBoxHeight = ref(0) // const paramBoxHeight = ref(0)

View File

@ -215,10 +215,11 @@ const init = () => {
const logout = function () { const logout = function () {
httpGet('/api/user/logout').then(() => { httpGet('/api/user/logout').then(() => {
removeUserToken() removeUserToken()
store.setShowLoginDialog(true) router.push("/login")
loginUser.value = {} // store.setShowLoginDialog(true)
// // loginUser.value = {}
routerViewKey.value += 1 // //
// routerViewKey.value += 1
}).catch(() => { }).catch(() => {
ElMessage.error('注销失败!'); ElMessage.error('注销失败!');
}) })

View File

@ -163,7 +163,7 @@
</el-form> </el-form>
</div> </div>
</div> </div>
<div class="task-list-box" @scrollend="handleScrollEnd"> <div class="task-list-box">
<div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }"> <div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }">
<div class="extra-params"> <div class="extra-params">
<el-form> <el-form>
@ -450,43 +450,7 @@
<div class="job-list-box"> <div class="job-list-box">
<h2>任务列表</h2> <h2>任务列表</h2>
<div class="running-job-list"> <task-list :list="runningJobs" />
<div class="running-job-box" v-if="runningJobs.length > 0">
<div class="job-item" v-for="item in runningJobs">
<div v-if="item.progress > 0" class="job-item-inner">
<el-image :src="item['img_url']" fit="cover" loading="lazy">
<template #placeholder>
<div class="image-slot">
正在加载图片
</div>
</template>
<template #error>
<div class="image-slot">
<el-icon>
<Picture/>
</el-icon>
</div>
</template>
</el-image>
<div class="progress">
<el-progress type="circle" :percentage="item.progress" :width="100"
color="#47fff1"/>
</div>
</div>
<el-image fit="cover" v-else>
<template #error>
<div class="image-slot">
<i class="iconfont icon-quick-start"></i>
<span>任务正在排队中</span>
</div>
</template>
</el-image>
</div>
</div>
<el-empty :image-size="100" v-else/>
</div>
<h2>创作记录</h2> <h2>创作记录</h2>
<div class="finish-job-list"> <div class="finish-job-list">
@ -617,6 +581,7 @@ import {useRouter} from "vue-router";
import {getSessionId} from "@/store/session"; import {getSessionId} from "@/store/session";
import {copyObj, removeArrayItem} from "@/utils/libs"; import {copyObj, removeArrayItem} from "@/utils/libs";
import {useSharedStore} from "@/store/sharedata"; import {useSharedStore} from "@/store/sharedata";
import TaskList from "@/components/TaskList.vue";
const listBoxHeight = ref(0) const listBoxHeight = ref(0)
const paramBoxHeight = ref(0) const paramBoxHeight = ref(0)

View File

@ -296,39 +296,8 @@
<div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }"> <div class="task-list-inner" :style="{ height: listBoxHeight + 'px' }">
<div class="job-list-box"> <div class="job-list-box">
<h2>任务列表</h2> <h2>任务列表</h2>
<div class="running-job-list"> <task-list :list="runningJobs" />
<div class="running-job-box" v-if="runningJobs.length > 0">
<div class="job-item" v-for="item in runningJobs">
<div v-if="item.progress > 0" class="job-item-inner">
<el-image :src="item['img_url']" fit="cover" loading="lazy">
<template #placeholder>
<div class="image-slot">
正在加载图片
</div>
</template>
<template #error>
<div class="image-slot"></div>
</template>
</el-image>
<div class="progress">
<el-progress type="circle" :percentage="item.progress" :width="100"
color="#47fff1"/>
</div>
</div>
<el-image fit="cover" v-else>
<template #error>
<div class="image-slot">
<i class="iconfont icon-quick-start"></i>
<span>任务正在排队中</span>
</div>
</template>
</el-image>
</div>
</div>
<el-empty :image-size="100" v-else/>
</div>
<h2>创作记录</h2> <h2>创作记录</h2>
<div class="finish-job-list"> <div class="finish-job-list">
<div v-if="finishedJobs.length > 0"> <div v-if="finishedJobs.length > 0">
@ -506,6 +475,7 @@ import {checkSession} from "@/action/session";
import {useRouter} from "vue-router"; import {useRouter} from "vue-router";
import {getSessionId} from "@/store/session"; import {getSessionId} from "@/store/session";
import {useSharedStore} from "@/store/sharedata"; import {useSharedStore} from "@/store/sharedata";
import TaskList from "@/components/TaskList.vue";
const listBoxHeight = ref(0) const listBoxHeight = ref(0)
// const paramBoxHeight = ref(0) // const paramBoxHeight = ref(0)

View File

@ -35,19 +35,22 @@
</el-row> </el-row>
<el-row class="opt" :gutter="24"> <el-row class="opt" :gutter="24">
<el-col :span="span" v-if="cLoginURL !== ''"> <el-col :span="8"><el-link type="primary" @click="router.push('/register')">注册</el-link></el-col>
<el-tooltip class="item" effect="light" content="微信扫码登录" placement="top"> <el-col :span="8">
<a class="wechat-login" :href="cLoginURL"><i class="iconfont icon-wechat"></i></a>
</el-tooltip>
</el-col>
<el-col :span="span"><el-link type="primary" @click="router.push('/register')">注册</el-link></el-col>
<el-col :span="span">
<el-link type="info" @click="showResetPass = true">重置密码</el-link> <el-link type="info" @click="showResetPass = true">重置密码</el-link>
</el-col> </el-col>
<el-col :span="span"> <el-col :span="8">
<el-link type="info" @click="router.push('/')">首页</el-link> <el-link type="info" @click="router.push('/')">首页</el-link>
</el-col> </el-col>
</el-row> </el-row>
<div v-if="wechatLoginURL !== ''">
<el-divider class="divider">其他登录方式</el-divider>
<div class="clogin">
<a class="wechat-login" :href="wechatLoginURL"><i class="iconfont icon-wechat"></i></a>
</div>
</div>
</div> </div>
</div> </div>
@ -80,8 +83,7 @@ const password = ref(process.env.VUE_APP_PASS);
const showResetPass = ref(false) const showResetPass = ref(false)
const logo = ref("/images/logo.png") const logo = ref("/images/logo.png")
const licenseConfig = ref({}) const licenseConfig = ref({})
const cLoginURL = ref('') const wechatLoginURL = ref('')
const span = ref(8)
onMounted(() => { onMounted(() => {
// //
@ -94,7 +96,6 @@ onMounted(() => {
httpGet("/api/config/license").then(res => { httpGet("/api/config/license").then(res => {
licenseConfig.value = res.data licenseConfig.value = res.data
span.value = 6
}).catch(e => { }).catch(e => {
showMessageError("获取 License 配置:" + e.message) showMessageError("获取 License 配置:" + e.message)
}) })
@ -108,10 +109,9 @@ onMounted(() => {
}).catch(() => { }).catch(() => {
}) })
// const returnURL = `${location.protocol}//${location.host}/user/api/clogin/callback` const returnURL = `${location.protocol}//${location.host}/login/callback`
const returnURL = `https://ai.r9it.com/user/api/clogin/callback`
httpGet("/api/user/clogin/request?return_url="+returnURL).then(res => { httpGet("/api/user/clogin/request?return_url="+returnURL).then(res => {
cLoginURL.value = res.data.url wechatLoginURL.value = res.data.url
}).catch(e => { }).catch(e => {
console.error(e) console.error(e)
}) })
@ -147,103 +147,5 @@ const login = function () {
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
.bg { @import "@/assets/css/login.styl"
position fixed
left 0
right 0
top 0
bottom 0
background-color #313237
background-image url("~@/assets/img/login-bg.jpg")
background-size cover
background-position center
background-repeat repeat-y
//filter: blur(10px); /* */
}
.main {
.contain {
position fixed
left 50%
top 40%
width 90%
max-width 400px;
transform translate(-50%, -50%)
padding 20px 10px;
color #ffffff
border-radius 10px;
.logo {
text-align center
.el-image {
width 120px;
cursor pointer
}
}
.header {
width 100%
margin-bottom 24px
font-size 24px
color $white_v1
letter-space 2px
text-align center
padding-top 10px
}
.content {
width 100%
height: auto
border-radius 3px
.block {
margin-bottom 16px
.el-input__inner {
border 1px solid $gray-v6 !important
.el-icon-user, .el-icon-lock {
font-size 20px
}
}
}
.btn-row {
padding-top 10px;
.login-btn {
width 100%
font-size 16px
letter-spacing 2px
}
}
.text-line {
justify-content center
padding-top 10px;
font-size 14px;
}
.opt {
padding 15px
.el-col {
text-align center
}
.wechat-login {
color #0bc15f
}
}
}
}
.footer {
color #ffffff;
.container {
padding 20px;
}
}
}
</style> </style>

View File

@ -0,0 +1,105 @@
<template>
<div class="login-callback"
v-loading="loading"
element-loading-text="正在同步登录信息..."
:style="{ height: winHeight + 'px' }">
<el-dialog
v-model="show"
:close-on-click-modal="false"
:show-close="false"
style="width: 360px;"
>
<el-result
icon="success"
title="登录成功"
style="--el-result-padding:10px"
>
<template #sub-title>
<div class="user-info">
<div class="line">您的初始账户信息如下</div>
<div class="line"><span>用户名</span>{{username}}</div>
<div class="line"><span>密码</span>{{password}}</div>
<div class="line">您后期也可以通过此账号和密码登录</div>
</div>
</template>
<template #extra>
<el-button type="primary" @click="finishLogin">我知道了</el-button>
</template>
</el-result>
</el-dialog>
</div>
</template>
<script setup>
import {ref} from "vue"
import {useRouter} from "vue-router"
import {ElMessage, ElMessageBox} from "element-plus";
import {httpGet} from "@/utils/http";
import {setUserToken} from "@/store/session";
import {isMobile} from "@/utils/libs";
const winHeight = ref(window.innerHeight)
const loading = ref(true)
const router = useRouter()
const show = ref(false)
const username = ref('')
const password = ref('')
const code = router.currentRoute.value.query.code
if (code === "") {
ElMessage.error({message: "登录失败code 参数不能为空",duration: 2000, onClose: () => router.push("/")})
} else {
//
httpGet("/api/user/clogin/callback",{login_type: "wx",code: code}).then(res => {
setUserToken(res.data.token)
if (res.data.username) {
username.value = res.data.username
password.value = res.data.password
show.value = true
loading.value = false
} else {
finishLogin()
}
}).catch(e => {
ElMessageBox.alert(e.message, {
confirmButtonText: '重新登录',
type:"error",
title:"登录失败",
callback: () => {
router.push("/login")
},
})
})
}
const finishLogin = () => {
if (isMobile()) {
router.push('/mobile')
} else {
router.push('/chat')
}
}
</script>
<style lang="stylus" scoped>
.login-callback {
.user-info {
display flex
flex-direction column
padding 10px
border 1px dashed #e1e1e1
border-radius 10px
.line {
text-align left
font-size 14px
line-height 1.5
span{
font-weight bold
}
}
}
}
</style>