mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 06:46:02 +00:00
feat: 用户账户系统
This commit is contained in:
248
web/src/App.vue
248
web/src/App.vue
@@ -1,104 +1,123 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-snackbar v-model="snackbar" :color="color" :timeout="timeout" :location="location">
|
||||
{{ text }}
|
||||
</v-snackbar>
|
||||
<v-snackbar v-model="snackbar" :color="color" :timeout="timeout" :location="location">
|
||||
{{ text }}
|
||||
</v-snackbar>
|
||||
|
||||
<v-layout>
|
||||
<v-navigation-drawer id="navigation-drawer" :width="160" app permanent rail>
|
||||
<v-list-item id="logo-list-item">
|
||||
<template v-slot:prepend>
|
||||
<div id="logo-container">
|
||||
<v-img id="logo-img" src="@/assets/langbot-logo-block.png" height="32" width="32"></v-img>
|
||||
<div id="app-container" v-if="proxy.$store.state.user.tokenChecked && proxy.$store.state.user.tokenValid">
|
||||
<v-layout>
|
||||
<v-navigation-drawer id="navigation-drawer" :width="160" app permanent rail>
|
||||
<v-list-item id="logo-list-item">
|
||||
<template v-slot:prepend>
|
||||
<div id="logo-container">
|
||||
<v-img id="logo-img" src="@/assets/langbot-logo-block.png" height="32" width="32"></v-img>
|
||||
|
||||
<div id="version-chip"
|
||||
v-tooltip="proxy.$store.state.version + (proxy.$store.state.debug ? ' (调试模式已启用)' : '')"
|
||||
:style="{ 'background-color': proxy.$store.state.debug ? '#27aa27' : '#1e9ae2' }">
|
||||
{{ proxy.$store.state.version }}
|
||||
<div id="version-chip"
|
||||
v-tooltip="proxy.$store.state.version + (proxy.$store.state.debug ? ' (调试模式已启用)' : '')"
|
||||
:style="{ 'background-color': proxy.$store.state.debug ? '#27aa27' : '#1e9ae2' }">
|
||||
{{ proxy.$store.state.version }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item to="/" title="仪表盘" value="dashboard" prepend-icon="mdi-speedometer" v-tooltip="仪表盘">
|
||||
</v-list-item>
|
||||
<v-list-item to="/settings" title="设置" value="settings" prepend-icon="mdi-toggle-switch-outline"
|
||||
v-tooltip="设置">
|
||||
</v-list-item>
|
||||
<v-list-item to="/logs" title="日志" value="logs" prepend-icon="mdi-file-outline" v-tooltip="日志">
|
||||
</v-list-item>
|
||||
<v-list-item to="/plugins" title="插件" value="plugins" prepend-icon="mdi-puzzle-outline" v-tooltip="插件">
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<template v-slot:append>
|
||||
<div>
|
||||
<v-list density="compact" nav>
|
||||
<v-dialog max-width="500" persistent v-model="taskDialogShow">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-list-item id="system-tasks-list-item" title="任务列表" prepend-icon="mdi-align-horizontal-left"
|
||||
v-tooltip="任务列表" v-bind="activatorProps">
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<template v-slot:default="{ isActive }">
|
||||
<TaskDialog :dialog="{ show: isActive }" @close="closeTaskDialog" />
|
||||
</template>
|
||||
</v-dialog>
|
||||
|
||||
<v-list-item id="about-list-item" title="系统信息" prepend-icon="mdi-cog-outline" v-tooltip="系统信息">
|
||||
<v-menu activator="parent" :close-on-content-click="false" location="end">
|
||||
<v-list>
|
||||
<v-list-item @click="showAboutDialog">
|
||||
<v-list-item-title>
|
||||
关于 LangBot
|
||||
|
||||
<v-dialog max-width="400" persistent v-model="aboutDialogShow">
|
||||
<template v-slot:default="{ isActive }">
|
||||
<AboutDialog :dialog="{ show: isActive }" @close="closeAboutDialog" />
|
||||
</template>
|
||||
</v-dialog>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="openDocs">
|
||||
<v-list-item-title>
|
||||
查看文档
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="reload('platform')">
|
||||
<v-list-item-title>
|
||||
重载消息平台
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="reload('plugin')">
|
||||
<v-list-item-title>
|
||||
重载插件
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item to="/" title="仪表盘" value="dashboard" prepend-icon="mdi-speedometer" v-tooltip="仪表盘">
|
||||
</v-list-item>
|
||||
<v-list-item to="/settings" title="设置" value="settings" prepend-icon="mdi-toggle-switch-outline"
|
||||
v-tooltip="设置">
|
||||
</v-list-item>
|
||||
<v-list-item to="/logs" title="日志" value="logs" prepend-icon="mdi-file-outline" v-tooltip="日志">
|
||||
</v-list-item>
|
||||
<v-list-item to="/plugins" title="插件" value="plugins" prepend-icon="mdi-puzzle-outline" v-tooltip="插件">
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<template v-slot:append>
|
||||
<div>
|
||||
<v-list density="compact" nav>
|
||||
<v-dialog max-width="500" persistent v-model="taskDialogShow">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-list-item id="system-tasks-list-item" title="任务列表" prepend-icon="mdi-align-horizontal-left"
|
||||
v-tooltip="任务列表" v-bind="activatorProps">
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<template v-slot:default="{ isActive }">
|
||||
<TaskDialog :dialog="{ show: isActive }" @close="closeTaskDialog" />
|
||||
</template>
|
||||
</v-dialog>
|
||||
<v-main style="background-color: #f6f6f6;">
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-layout>
|
||||
</div>
|
||||
|
||||
<v-list-item id="about-list-item" title="系统信息" prepend-icon="mdi-cog-outline" v-tooltip="系统信息">
|
||||
<v-menu activator="parent" :close-on-content-click="false" location="end">
|
||||
<v-list>
|
||||
<v-list-item @click="showAboutDialog">
|
||||
<v-list-item-title>
|
||||
关于 LangBot
|
||||
<div id="loading-container" v-if="!proxy.$store.state.user.tokenChecked">
|
||||
<div id="loading-text">
|
||||
<img src="@/assets/langbot-logo.png" height="32" width="32" />
|
||||
<span id="loading-text-span">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-dialog max-width="400" persistent v-model="aboutDialogShow">
|
||||
<template v-slot:default="{ isActive }">
|
||||
<AboutDialog :dialog="{ show: isActive }" @close="closeAboutDialog" />
|
||||
</template>
|
||||
</v-dialog>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<div id="login-container" v-if="proxy.$store.state.user.tokenChecked && !proxy.$store.state.user.tokenValid && proxy.$store.state.user.systemInitialized">
|
||||
<LoginDialog @error="error" @success="success" @checkToken="checkToken" />
|
||||
</div>
|
||||
|
||||
<v-list-item @click="openDocs">
|
||||
<v-list-item-title>
|
||||
查看文档
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="reload('platform')">
|
||||
<v-list-item-title>
|
||||
重载消息平台
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="reload('plugin')">
|
||||
<v-list-item-title>
|
||||
重载插件
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main style="background-color: #f6f6f6;">
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-layout>
|
||||
<div id="uninitialized-container" v-if="proxy.$store.state.user.tokenChecked && !proxy.$store.state.user.systemInitialized">
|
||||
<InitDialog @error="error" @success="success" @checkSystemInitialized="checkSystemInitialized" />
|
||||
</div>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { provide, ref, watch } from 'vue';
|
||||
import { provide, ref, watch, onMounted } from 'vue';
|
||||
|
||||
import TaskDialog from '@/components/TaskListDialog.vue'
|
||||
import AboutDialog from '@/components/AboutDialog.vue'
|
||||
import InitDialog from '@/components/InitDialog.vue'
|
||||
import LoginDialog from '@/components/LoginDialog.vue'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
@@ -160,14 +179,14 @@ function reload(scope) {
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
).then(response => {
|
||||
if (response.data.code === 0) {
|
||||
success(label+'已重载')
|
||||
success(label + '已重载')
|
||||
|
||||
// 关闭菜单
|
||||
} else {
|
||||
error(label+'重载失败:' + response.data.message)
|
||||
error(label + '重载失败:' + response.data.message)
|
||||
}
|
||||
}).catch(err => {
|
||||
error(label+'重载失败:' + err)
|
||||
error(label + '重载失败:' + err)
|
||||
})
|
||||
|
||||
}
|
||||
@@ -181,9 +200,68 @@ function showAboutDialog() {
|
||||
function closeAboutDialog() {
|
||||
aboutDialogShow.value = false
|
||||
}
|
||||
|
||||
function checkSystemInitialized() {
|
||||
proxy.$axios.get('/user/init').then(response => {
|
||||
if (response.data.code === 0) {
|
||||
proxy.$store.state.user.systemInitialized = response.data.data.initialized
|
||||
} else {
|
||||
error('系统初始化状态检查失败:' + response.data.message)
|
||||
proxy.$store.state.user.systemInitialized = true
|
||||
}
|
||||
|
||||
checkToken()
|
||||
}).catch(err => {
|
||||
error('系统初始化状态检查失败:' + err)
|
||||
proxy.$store.state.user.systemInitialized = true
|
||||
})
|
||||
}
|
||||
|
||||
function checkToken() {
|
||||
proxy.$axios.get('/user/check-token').then(response => {
|
||||
if (response.data.code === 0) {
|
||||
proxy.$store.state.user.tokenValid = true
|
||||
} else {
|
||||
proxy.$store.state.user.tokenValid = false
|
||||
}
|
||||
}).catch(err => {
|
||||
proxy.$store.state.user.tokenValid = false
|
||||
}).finally(() => {
|
||||
proxy.$store.state.user.tokenChecked = true
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkSystemInitialized()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#loading-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#loading-text-span {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
padding-top: 0.2rem;
|
||||
}
|
||||
|
||||
#navigation-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
88
web/src/components/InitDialog.vue
Normal file
88
web/src/components/InitDialog.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialog" width="500" persistent>
|
||||
<v-card id="init-dialog">
|
||||
<v-card-title class="d-flex align-center" style="gap: 0.5rem;">
|
||||
<img src="@/assets/langbot-logo.png" height="32" width="32" />
|
||||
<span>系统初始化</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p>请输入初始管理员邮箱和密码。</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="d-flex flex-column" style="gap: 0.5rem;">
|
||||
<v-text-field v-model="user" variant="outlined" label="管理员邮箱" :rules="[rules.required, rules.email]"
|
||||
clearable />
|
||||
<v-text-field v-model="password" variant="outlined" label="管理员密码" :rules="[rules.required]"
|
||||
type="password" clearable />
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn color="primary" variant="flat" @click="initialize" prepend-icon="mdi-check">初始化</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject, getCurrentInstance } from 'vue'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const emit = defineEmits(['error', 'success', 'checkSystemInitialized'])
|
||||
|
||||
const dialog = ref(true)
|
||||
|
||||
const user = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const snackbar = inject('snackbar')
|
||||
|
||||
const rules = {
|
||||
required: value => !!value || '必填项',
|
||||
email: value => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(value) || '请输入有效的邮箱地址'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function checkEmailValid(email) {
|
||||
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return regex.test(email)
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
// 检查邮箱和密码是否为空
|
||||
if (user.value == undefined || password.value == undefined) {
|
||||
emit('error', '邮箱和密码不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (user.value == '' || password.value == '') {
|
||||
emit('error', '邮箱和密码不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (!checkEmailValid(user.value)) {
|
||||
emit('error', '请输入有效的邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
proxy.$axios.post('/user/init', {
|
||||
user: user.value,
|
||||
password: password.value
|
||||
}).then(res => {
|
||||
emit('success', '系统初始化成功')
|
||||
|
||||
emit('checkSystemInitialized')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#init-dialog {
|
||||
padding-top: 0.8rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
72
web/src/components/LoginDialog.vue
Normal file
72
web/src/components/LoginDialog.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialog" width="350" persistent>
|
||||
<v-card id="login-dialog">
|
||||
<v-card-title class="d-flex align-center" style="gap: 0.5rem;">
|
||||
<img src="@/assets/langbot-logo.png" height="32" width="32" />
|
||||
<span>登录 LangBot</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="d-flex flex-column" style="gap: 0.5rem;margin-bottom: -2rem;margin-top: 1rem;">
|
||||
|
||||
<v-text-field v-model="user" variant="outlined" label="邮箱" :rules="[rules.required, rules.email]"
|
||||
clearable />
|
||||
<v-text-field v-model="password" variant="outlined" label="密码" :rules="[rules.required]"
|
||||
type="password" clearable />
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn color="primary" variant="flat" @click="login">登录</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance } from 'vue'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const emit = defineEmits(['error', 'success', 'checkToken'])
|
||||
|
||||
const dialog = ref(true)
|
||||
|
||||
const user = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const rules = {
|
||||
required: value => !!value || '必填项',
|
||||
email: value => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(value) || '请输入有效的邮箱地址'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const login = () => {
|
||||
proxy.$axios.post('/user/auth', {
|
||||
user: user.value,
|
||||
password: password.value
|
||||
}).then(res => {
|
||||
if (res.data.data.token) {
|
||||
emit('success', '登录成功')
|
||||
localStorage.setItem('user-token', res.data.data.token)
|
||||
setTimeout(() => {
|
||||
location.reload()
|
||||
}, 1000)
|
||||
} else {
|
||||
emit('error', '登录失败')
|
||||
}
|
||||
}).catch(err => {
|
||||
emit('error', err.response.data.message)
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#login-dialog {
|
||||
padding-top: 0.8rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,16 @@ export function registerPlugins (app) {
|
||||
.use(router)
|
||||
.use(store)
|
||||
|
||||
// 读取用户令牌
|
||||
const token = localStorage.getItem('user-token')
|
||||
|
||||
if (token) {
|
||||
store.state.user.jwtToken = token
|
||||
}
|
||||
|
||||
// 所有axios请求均携带用户令牌
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${store.state.user.jwtToken}`
|
||||
|
||||
app.config.globalProperties.$axios = axios
|
||||
store.commit('initializeFetch')
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ export default createStore({
|
||||
version: 'v0.0.0',
|
||||
debug: false,
|
||||
enabledPlatformCount: 0,
|
||||
user: {
|
||||
tokenChecked: false,
|
||||
tokenValid: false,
|
||||
systemInitialized: true,
|
||||
jwtToken: '',
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
initializeFetch() {
|
||||
|
||||
Reference in New Issue
Block a user