This commit is contained in:
11
web/src/App.vue
Normal file
11
web/src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<AppProvider>
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
</AppProvider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppProvider from '@/components/common/AppProvider.vue'
|
||||
</script>
|
9
web/src/api/user.js
Normal file
9
web/src/api/user.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
getUser: () => request.get('/user/info'), // 获取当前登陆用户信息
|
||||
logout: () => request.post('/user/logout'), // 退出登陆
|
||||
changePassword: (data) => request.put('/user/change-password',data), // 修改密码
|
||||
generateAvatar: () => request.post('/user/generate-avatar'), // 生成头像
|
||||
addOrUpdateUser: (data) => request.post('/user',data), // 新增/编辑用户信息
|
||||
}
|
BIN
web/src/assets/images/404.webp
Normal file
BIN
web/src/assets/images/404.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
web/src/assets/images/login_banner.webp
Normal file
BIN
web/src/assets/images/login_banner.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
BIN
web/src/assets/images/login_bg.webp
Normal file
BIN
web/src/assets/images/login_bg.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.8 KiB |
BIN
web/src/assets/images/logo.png
Normal file
BIN
web/src/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.4 KiB |
23
web/src/components/common/AppFooter.vue
Normal file
23
web/src/components/common/AppFooter.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<footer f-c-c flex-col text-14 color="#6a6a6a">
|
||||
<p>
|
||||
Copyright © 2022-present
|
||||
<!-- <a-->
|
||||
<!-- href="https://github.com/zclzone"-->
|
||||
<!-- target="__blank"-->
|
||||
<!-- hover="decoration-underline color-primary"-->
|
||||
<!-- >-->
|
||||
<!-- Ronnie Zhang-->
|
||||
<!-- </a>-->
|
||||
</p>
|
||||
<p>
|
||||
<!-- <a-->
|
||||
<!-- href="http://beian.miit.gov.cn/"-->
|
||||
<!-- target="__blank"-->
|
||||
<!-- hover="decoration-underline color-primary"-->
|
||||
<!-- >-->
|
||||
<!-- 赣ICP备2020015008号-2-->
|
||||
<!-- </a>-->
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
30
web/src/components/common/AppProvider.vue
Normal file
30
web/src/components/common/AppProvider.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<n-config-provider
|
||||
wh-full
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:theme="appStore.isDark ? darkTheme : undefined"
|
||||
:theme-overrides="naiveThemeOverrides"
|
||||
>
|
||||
<slot />
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { zhCN, dateZhCN, darkTheme } from 'naive-ui'
|
||||
import { useCssVar } from '@vueuse/core'
|
||||
import { kebabCase } from 'lodash-es'
|
||||
import { naiveThemeOverrides } from '~/settings'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
function setupCssVar() {
|
||||
const common = naiveThemeOverrides.common
|
||||
for (const key in common) {
|
||||
useCssVar(`--${kebabCase(key)}`, document.documentElement).value = common[key] || ''
|
||||
if (key === 'primaryColor') window.localStorage.setItem('__THEME_COLOR__', common[key] || '')
|
||||
}
|
||||
}
|
||||
setupCssVar()
|
||||
</script>
|
162
web/src/components/common/ScrollX.vue
Normal file
162
web/src/components/common/ScrollX.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="wrapper" @mousewheel.prevent="handleMouseWheel">
|
||||
<template v-if="showArrow && isOverflow">
|
||||
<div class="left dark:bg-dark!" @click="handleMouseWheel({ wheelDelta: 120 })">
|
||||
<icon-ic:baseline-keyboard-arrow-left />
|
||||
</div>
|
||||
<div class="right dark:bg-dark!" @click="handleMouseWheel({ wheelDelta: -120 })">
|
||||
<icon-ic:baseline-keyboard-arrow-right />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
ref="content"
|
||||
class="content"
|
||||
:class="{ overflow: isOverflow && showArrow }"
|
||||
:style="{
|
||||
transform: `translateX(${translateX}px)`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { debounce, useResize } from '@/utils'
|
||||
|
||||
defineProps({
|
||||
showArrow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const translateX = ref(0)
|
||||
const content = ref(null)
|
||||
const wrapper = ref(null)
|
||||
const isOverflow = ref(false)
|
||||
|
||||
const refreshIsOverflow = debounce(() => {
|
||||
const wrapperWidth = wrapper.value?.offsetWidth
|
||||
const contentWidth = content.value?.offsetWidth
|
||||
isOverflow.value = contentWidth > wrapperWidth
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}, 200)
|
||||
|
||||
function handleMouseWheel(e) {
|
||||
const { wheelDelta } = e
|
||||
const wrapperWidth = wrapper.value?.offsetWidth
|
||||
const contentWidth = content.value?.offsetWidth
|
||||
/**
|
||||
* @wheelDelta 平行滚动的值 >0: 右移 <0: 左移
|
||||
* @translateX 内容translateX的值
|
||||
* @wrapperWidth 容器的宽度
|
||||
* @contentWidth 内容的宽度
|
||||
*/
|
||||
if (wheelDelta < 0) {
|
||||
if (wrapperWidth > contentWidth && translateX.value < -10) return
|
||||
if (wrapperWidth <= contentWidth && contentWidth + translateX.value - wrapperWidth < -10) return
|
||||
}
|
||||
if (wheelDelta > 0 && translateX.value > 10) {
|
||||
return
|
||||
}
|
||||
|
||||
translateX.value += wheelDelta
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}
|
||||
|
||||
const resetTranslateX = debounce(function (wrapperWidth, contentWidth) {
|
||||
if (!isOverflow.value) {
|
||||
translateX.value = 0
|
||||
} else if (-translateX.value > contentWidth - wrapperWidth) {
|
||||
translateX.value = wrapperWidth - contentWidth
|
||||
} else if (translateX.value > 0) {
|
||||
translateX.value = 0
|
||||
}
|
||||
}, 200)
|
||||
|
||||
const observers = ref([])
|
||||
onMounted(() => {
|
||||
refreshIsOverflow()
|
||||
|
||||
observers.value.push(useResize(document.body, refreshIsOverflow))
|
||||
observers.value.push(useResize(content.value, refreshIsOverflow))
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
observers.value.forEach((item) => {
|
||||
item?.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
function handleScroll(x, width) {
|
||||
const wrapperWidth = wrapper.value?.offsetWidth
|
||||
const contentWidth = content.value?.offsetWidth
|
||||
if (contentWidth <= wrapperWidth) return
|
||||
|
||||
// 当 x 小于可视范围的最小值时
|
||||
if (x < -translateX.value + 150) {
|
||||
translateX.value = -(x - 150)
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}
|
||||
|
||||
// 当 x 大于可视范围的最大值时
|
||||
if (x + width > -translateX.value + wrapperWidth) {
|
||||
translateX.value = wrapperWidth - (x + width)
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
handleScroll,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
|
||||
z-index: 9;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.content {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
transition: transform 0.5s;
|
||||
&.overflow {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
background-color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
|
||||
width: 20px;
|
||||
height: 35px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 18px;
|
||||
border: 1px solid #e0e0e6;
|
||||
border-radius: 2px;
|
||||
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
.left {
|
||||
left: 0;
|
||||
}
|
||||
.right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
22
web/src/components/icon/CustomIcon.vue
Normal file
22
web/src/components/icon/CustomIcon.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
/** 自定义图标 */
|
||||
const props = defineProps({
|
||||
/** 图标名称(assets/svg下的文件名) */
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 14,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TheIcon type="custom" v-bind="props" />
|
||||
</template>
|
24
web/src/components/icon/SvgIcon.vue
Normal file
24
web/src/components/icon/SvgIcon.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'icon-custom',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'currentColor',
|
||||
},
|
||||
})
|
||||
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.icon}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg aria-hidden="true" width="1em" height="1em">
|
||||
<use :xlink:href="symbolId" :fill="color" />
|
||||
</svg>
|
||||
</template>
|
33
web/src/components/icon/TheIcon.vue
Normal file
33
web/src/components/icon/TheIcon.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { renderIcon, renderCustomIcon } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 14,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
/** iconify | custom */
|
||||
type: {
|
||||
type: String,
|
||||
default: 'iconify',
|
||||
},
|
||||
})
|
||||
|
||||
const iconCom = computed(() =>
|
||||
props.type === 'iconify'
|
||||
? renderIcon(props.icon, { size: props.size, color: props.color })
|
||||
: renderCustomIcon(props.icon, { size: props.size, color: props.color })
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="iconCom" />
|
||||
</template>
|
18
web/src/components/page/AppPage.vue
Normal file
18
web/src/components/page/AppPage.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<transition name="fade-slide" mode="out-in" appear>
|
||||
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15 dark:bg-hex-121212">
|
||||
<slot />
|
||||
<AppFooter v-if="showFooter" mt-15 />
|
||||
<n-back-top :bottom="20" />
|
||||
</section>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
229
web/src/components/page/ClientListHeaderPage.vue
Normal file
229
web/src/components/page/ClientListHeaderPage.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<AppPage :show-footer="showFooter">
|
||||
<header v-if="showHeader" mt-10 mb-20 min-h-45 flex items-center justify-between px-15>
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<template v-else>
|
||||
<n-card style="margin-left: 1.65%; width: 97.25%">
|
||||
<n-space justify="end">
|
||||
<n-button size="small" style="float: right" type="info" @click="addClient">添加</n-button>
|
||||
<n-button size="small" style="float: right" type="primary" @click="refreshList">刷新</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<slot name="action" />
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</AppPage>
|
||||
<n-modal
|
||||
style="width: 25%"
|
||||
v-model:show="addModalShow"
|
||||
preset="card"
|
||||
title="添加"
|
||||
header-style="margin-left: 40%"
|
||||
>
|
||||
<n-form
|
||||
ref="addModalFormRef"
|
||||
:rules="addModalFormRules"
|
||||
:model="addModalForm"
|
||||
label-placement="left"
|
||||
label-width="auto"
|
||||
label-align="left"
|
||||
require-mark-placement="right"
|
||||
>
|
||||
<n-form-item label="名称" path="name">
|
||||
<n-input v-model:value="addModalForm.name"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱" path="email">
|
||||
<n-input v-model:value="addModalForm.email"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="IP" path="addModalForm.ipAllocation">
|
||||
<n-select
|
||||
v-model:value="addModalForm.ipAllocation"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可访问IP" path="allowedIps">
|
||||
<n-select
|
||||
v-model:value="addModalForm.allowedIps"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可访问IP(扩展)" path="extraAllowedIps">
|
||||
<n-select
|
||||
v-model:value="addModalForm.extraAllowedIps"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="服务端DNS" path="useServerDns">
|
||||
<n-radio value="1" :checked="addModalForm.useServerDns === 1" @change="addModalForm.useServerDns = 1">是</n-radio>
|
||||
<n-radio value="0" :checked="addModalForm.useServerDns === 0" @change="addModalForm.useServerDns = 0">否</n-radio>
|
||||
</n-form-item>
|
||||
<n-form-item label="公钥" path="keys.publicKey">
|
||||
<n-input v-model:value="addModalForm.keys.publicKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="私钥" path="keys.privateKey">
|
||||
<n-input v-model:value="addModalForm.keys.privateKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="共享密钥" path="keys.presharedKey">
|
||||
<n-input v-model:value="addModalForm.keys.presharedKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button style="margin-left: 28%" size="small" type="info" @click="generateKeys">生成密钥对</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态" path="editModalForm.enabled">
|
||||
<n-radio-group :value="addModalForm.enabled">
|
||||
<n-radio :value="1" :checked="addModalForm.enabled === 1" @change="addModalForm.enabled = 1">启用</n-radio>
|
||||
<n-radio :value="0" :checked="addModalForm.enabled === 0" @change="addModalForm.enabled = 0">禁用</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="离线监听" path="offlineMonitoring">
|
||||
<n-radio-group :value="addModalForm.offlineMonitoring">
|
||||
<n-radio :value="1" :checked="addModalForm.offlineMonitoring === 1" @change="addModalForm.offlineMonitoring = 1">启用</n-radio>
|
||||
<n-radio :value="0" :checked="addModalForm.offlineMonitoring === 0" @change="addModalForm.offlineMonitoring = 0">禁用</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-button type="info" style="margin-left: 40%" @click="confirmAddClient()">确认</n-button>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import clientApi from '@/views/client/api'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { debounce } from '@/utils'
|
||||
defineProps({
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
const route = useRoute()
|
||||
|
||||
const addModalShow = ref(false)
|
||||
|
||||
|
||||
// 添加模态框的form ref
|
||||
const addModalFormRef = ref()
|
||||
|
||||
// 添加模态框的form rules
|
||||
const addModalFormRules = {
|
||||
name: [
|
||||
{ required: true, message: '名称不能为空', trigger: 'blur' }
|
||||
],
|
||||
ipAllocation: [
|
||||
{ required: true, message: '客户端IP不能为空', trigger: 'change' }
|
||||
],
|
||||
keys: {
|
||||
privateKey: [
|
||||
{ required: true, message: '密钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
publicKey: [
|
||||
{ required: true, message: '公钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
presharedKey: [
|
||||
{ required: true, message: '共享密钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
},
|
||||
enabled: [
|
||||
{ required: true, message: '状态不能为空', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 添加模态框的数据集
|
||||
const addModalForm = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
ipAllocation: [],
|
||||
allowedIps: [],
|
||||
extraAllowedIps: [],
|
||||
useServerDns: 0,
|
||||
keys: {
|
||||
privateKey: '',
|
||||
publicKey: '',
|
||||
presharedKey: ''
|
||||
},
|
||||
enabled: 1,
|
||||
offlineMonitoring: 1
|
||||
})
|
||||
|
||||
// 点击确认按钮
|
||||
async function confirmAddClient() {
|
||||
addModalFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
return valid
|
||||
}
|
||||
|
||||
clientApi.saveClient(addModalForm.value).then((response) => {
|
||||
if (response.data.code === 200) {
|
||||
refreshList()
|
||||
addModalShow.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 添加客户端
|
||||
const addClient = debounce(() => {
|
||||
generateClientIPS()
|
||||
addModalShow.value = true
|
||||
},300)
|
||||
|
||||
// 生成密钥对
|
||||
function generateKeys() {
|
||||
clientApi.generateClientKeys().then(res => {
|
||||
if (res.data.code === 200) {
|
||||
addModalForm.value.keys.privateKey = res.data.data.privateKey
|
||||
addModalForm.value.keys.publicKey = res.data.data.publicKey
|
||||
addModalForm.value.keys.presharedKey = res.data.data.presharedKey
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生成客户端IP
|
||||
function generateClientIPS() {
|
||||
clientApi.generateClientIP().then(res => {
|
||||
if (res.data.code === 200) {
|
||||
addModalForm.value.ipAllocation = res.data.data.clientIPS
|
||||
addModalForm.value.allowedIps = res.data.data.serverIPS
|
||||
generateKeys()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
function refreshList() {
|
||||
clientApi.clientList({
|
||||
current: 1,
|
||||
size: 8
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
</script>
|
33
web/src/components/page/CommonPage.vue
Normal file
33
web/src/components/page/CommonPage.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<AppPage :show-footer="showFooter">
|
||||
<header v-if="showHeader" mt-10 mb-20 min-h-45 flex items-center justify-between px-15>
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<template v-else>
|
||||
<h3>{{ title }}</h3>
|
||||
<slot name="action" />
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
const route = useRoute()
|
||||
</script>
|
27
web/src/components/query-bar/QueryBar.vue
Normal file
27
web/src/components/query-bar/QueryBar.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
bg="#fafafc"
|
||||
min-h-60
|
||||
flex
|
||||
items-start
|
||||
justify-between
|
||||
b-1
|
||||
rounded-8
|
||||
p-15
|
||||
bc-ccc
|
||||
dark:bg-black
|
||||
h-60
|
||||
>
|
||||
<n-space wrap :size="[35, 15]">
|
||||
<slot />
|
||||
</n-space>
|
||||
|
||||
<div flex-shrink-0>
|
||||
<n-button ml-20 size="small" type="primary" @click="emit('search')">搜索</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['search', 'reset'])
|
||||
</script>
|
34
web/src/components/query-bar/QueryBarItem.vue
Normal file
34
web/src/components/query-bar/QueryBarItem.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div flex items-center>
|
||||
<label
|
||||
v-if="!isNullOrWhitespace(label)"
|
||||
w-80
|
||||
flex-shrink-0
|
||||
:style="{ width: labelWidth + 'px' }"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<div :style="{ width: contentWidth + 'px' }" flex-shrink-0>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { isNullOrWhitespace } from '@/utils'
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
labelWidth: {
|
||||
type: Number,
|
||||
default: 80,
|
||||
},
|
||||
contentWidth: {
|
||||
type: Number,
|
||||
default: 220,
|
||||
},
|
||||
})
|
||||
</script>
|
55
web/src/components/table/CrudModal.vue
Normal file
55
web/src/components/table/CrudModal.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
:style="{ width }"
|
||||
preset="card"
|
||||
:title="title"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
>
|
||||
<slot />
|
||||
<template v-if="showFooter" #footer>
|
||||
<footer flex justify-end>
|
||||
<slot name="footer">
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button :loading="loading" ml-20 type="primary" @click="emit('onSave')">保存</n-button>
|
||||
</slot>
|
||||
</footer>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
width: {
|
||||
type: String,
|
||||
default: '600px',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'onSave'])
|
||||
const show = computed({
|
||||
get() {
|
||||
return props.visible
|
||||
},
|
||||
set(v) {
|
||||
emit('update:visible', v)
|
||||
},
|
||||
})
|
||||
</script>
|
149
web/src/components/table/CrudTable.vue
Normal file
149
web/src/components/table/CrudTable.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
|
||||
<slot name="queryBar" />
|
||||
</QueryBar>
|
||||
|
||||
<n-data-table
|
||||
:remote="remote"
|
||||
:loading="loading"
|
||||
:scroll-x="scrollX"
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:row-key="(row) => row[rowKey]"
|
||||
:pagination="isPagination ? pagination : false"
|
||||
@update:checked-row-keys="onChecked"
|
||||
@update:page="onPageChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { utils, writeFile } from 'xlsx'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* @remote true: 后端分页 false: 前端分页
|
||||
*/
|
||||
remote: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* @remote 是否分页
|
||||
*/
|
||||
isPagination: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
scrollX: {
|
||||
type: Number,
|
||||
default: 1200,
|
||||
},
|
||||
rowKey: {
|
||||
type: String,
|
||||
default: 'id',
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
/** queryBar中的参数 */
|
||||
queryItems: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
/** 补充参数(可选) */
|
||||
extraParams: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
/**
|
||||
* ! 约定接口入参出参
|
||||
* * 分页模式需约定分页接口入参
|
||||
* @pageSize 分页参数:一页展示多少条,默认10
|
||||
* @pageNo 分页参数:页码,默认1
|
||||
* * 需约定接口出参
|
||||
* @pageData 分页模式必须,非分页模式如果没有pageData则取上一层data
|
||||
* @total 分页模式必须,非分页模式如果没有total则取上一层data.length
|
||||
*/
|
||||
getData: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:queryItems', 'onChecked', 'onDataChange'])
|
||||
const loading = ref(false)
|
||||
const initQuery = { ...props.queryItems }
|
||||
const tableData = ref([])
|
||||
const pagination = reactive({ page: 1, pageSize: 10 })
|
||||
|
||||
async function handleQuery() {
|
||||
try {
|
||||
loading.value = true
|
||||
let paginationParams = {}
|
||||
// 如果非分页模式或者使用前端分页,则无需传分页参数
|
||||
if (props.isPagination && props.remote) {
|
||||
paginationParams = { pageNo: pagination.page, pageSize: pagination.pageSize }
|
||||
}
|
||||
const { data } = await props.getData({
|
||||
...props.queryItems,
|
||||
...props.extraParams,
|
||||
...paginationParams,
|
||||
})
|
||||
tableData.value = data?.pageData || data
|
||||
pagination.itemCount = data.total ?? data.length
|
||||
} catch (error) {
|
||||
tableData.value = []
|
||||
pagination.itemCount = 0
|
||||
} finally {
|
||||
emit('onDataChange', tableData.value)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
handleQuery()
|
||||
}
|
||||
async function handleReset() {
|
||||
const queryItems = { ...props.queryItems }
|
||||
for (const key in queryItems) {
|
||||
queryItems[key] = ''
|
||||
}
|
||||
emit('update:queryItems', { ...queryItems, ...initQuery })
|
||||
await nextTick()
|
||||
pagination.page = 1
|
||||
handleQuery()
|
||||
}
|
||||
function onPageChange(currentPage) {
|
||||
pagination.page = currentPage
|
||||
if (props.remote) {
|
||||
handleQuery()
|
||||
}
|
||||
}
|
||||
function onChecked(rowKeys) {
|
||||
if (props.columns.some((item) => item.type === 'selection')) {
|
||||
emit('onChecked', rowKeys)
|
||||
}
|
||||
}
|
||||
function handleExport(columns = props.columns, data = tableData.value) {
|
||||
if (!data?.length) return $message.warning('没有数据')
|
||||
const columnsData = columns.filter((item) => !!item.title && !item.hideInExcel)
|
||||
const thKeys = columnsData.map((item) => item.key)
|
||||
const thData = columnsData.map((item) => item.title)
|
||||
const trData = data.map((item) => thKeys.map((key) => item[key]))
|
||||
const sheet = utils.aoa_to_sheet([thData, ...trData])
|
||||
const workBook = utils.book_new()
|
||||
utils.book_append_sheet(workBook, sheet, '数据报表')
|
||||
writeFile(workBook, '数据报表.xlsx')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handleExport,
|
||||
})
|
||||
</script>
|
1
web/src/composables/index.js
Normal file
1
web/src/composables/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useCRUD } from './useCRUD'
|
103
web/src/composables/useCRUD.js
Normal file
103
web/src/composables/useCRUD.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { isNullOrWhitespace } from '@/utils'
|
||||
|
||||
const ACTIONS = {
|
||||
view: '查看',
|
||||
edit: '编辑',
|
||||
add: '新增',
|
||||
}
|
||||
|
||||
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
|
||||
const modalVisible = ref(false)
|
||||
const modalAction = ref('')
|
||||
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
|
||||
const modalLoading = ref(false)
|
||||
const modalFormRef = ref(null)
|
||||
const modalForm = ref({ ...initForm })
|
||||
|
||||
/** 新增 */
|
||||
function handleAdd() {
|
||||
modalAction.value = 'add'
|
||||
modalVisible.value = true
|
||||
modalForm.value = { ...initForm }
|
||||
}
|
||||
|
||||
/** 修改 */
|
||||
function handleEdit(row) {
|
||||
modalAction.value = 'edit'
|
||||
modalVisible.value = true
|
||||
modalForm.value = { ...row }
|
||||
}
|
||||
|
||||
/** 查看 */
|
||||
function handleView(row) {
|
||||
modalAction.value = 'view'
|
||||
modalVisible.value = true
|
||||
modalForm.value = { ...row }
|
||||
}
|
||||
|
||||
/** 保存 */
|
||||
function handleSave() {
|
||||
if (!['edit', 'add'].includes(modalAction.value)) {
|
||||
modalVisible.value = false
|
||||
return
|
||||
}
|
||||
modalFormRef.value?.validate(async (err) => {
|
||||
if (err) return
|
||||
const actions = {
|
||||
add: {
|
||||
api: () => doCreate(modalForm.value),
|
||||
cb: () => $message.success('新增成功'),
|
||||
},
|
||||
edit: {
|
||||
api: () => doUpdate(modalForm.value),
|
||||
cb: () => $message.success('编辑成功'),
|
||||
},
|
||||
}
|
||||
const action = actions[modalAction.value]
|
||||
|
||||
try {
|
||||
modalLoading.value = true
|
||||
const data = await action.api()
|
||||
action.cb()
|
||||
modalLoading.value = modalVisible.value = false
|
||||
data && refresh(data)
|
||||
} catch (error) {
|
||||
modalLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除 */
|
||||
function handleDelete(id, confirmOptions) {
|
||||
if (isNullOrWhitespace(id)) return
|
||||
$dialog.confirm({
|
||||
content: '确定删除?',
|
||||
async confirm() {
|
||||
try {
|
||||
modalLoading.value = true
|
||||
const data = await doDelete(id)
|
||||
$message.success('删除成功')
|
||||
modalLoading.value = false
|
||||
refresh(data)
|
||||
} catch (error) {
|
||||
modalLoading.value = false
|
||||
}
|
||||
},
|
||||
...confirmOptions,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
modalVisible,
|
||||
modalAction,
|
||||
modalTitle,
|
||||
modalLoading,
|
||||
handleAdd,
|
||||
handleDelete,
|
||||
handleEdit,
|
||||
handleView,
|
||||
handleSave,
|
||||
modalForm,
|
||||
modalFormRef,
|
||||
}
|
||||
}
|
16
web/src/layout/components/AppMain.vue
Normal file
16
web/src/layout/components/AppMain.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<KeepAlive :include="keepAliveNames">
|
||||
<component :is="Component" v-if="!tagStore.reloading" :key="route.fullPath" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useTagsStore } from '@/store'
|
||||
const tagStore = useTagsStore()
|
||||
|
||||
const keepAliveNames = computed(() => {
|
||||
return tagStore.tags.filter((item) => item.keepAlive).map((item) => item.name)
|
||||
})
|
||||
</script>
|
30
web/src/layout/components/header/components/BreadCrumb.vue
Normal file
30
web/src/layout/components/header/components/BreadCrumb.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<n-breadcrumb>
|
||||
<n-breadcrumb-item
|
||||
v-for="item in route.matched.filter((item) => !!item.meta?.title)"
|
||||
:key="item.path"
|
||||
@click="handleBreadClick(item.path)"
|
||||
>
|
||||
<component :is="getIcon(item.meta)" />
|
||||
{{ item.meta.title }}
|
||||
</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { renderCustomIcon, renderIcon } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
function handleBreadClick(path) {
|
||||
if (path === route.path) return
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function getIcon(meta) {
|
||||
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
|
||||
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
|
||||
return null
|
||||
}
|
||||
</script>
|
12
web/src/layout/components/header/components/FullScreen.vue
Normal file
12
web/src/layout/components/header/components/FullScreen.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<n-icon mr20 size="18" style="cursor: pointer" @click="toggle">
|
||||
<icon-ant-design:fullscreen-exit-outlined v-if="isFullscreen" />
|
||||
<icon-ant-design:fullscreen-outlined v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
|
||||
const { isFullscreen, toggle } = useFullscreen()
|
||||
</script>
|
11
web/src/layout/components/header/components/GiteeSite.vue
Normal file
11
web/src/layout/components/header/components/GiteeSite.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<n-icon mr-20 size="18" style="cursor: pointer" @click="handleLinkClick">
|
||||
<icon-simple-icons:gitee />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function handleLinkClick() {
|
||||
window.open('https://gitee.com/isme-admin/vue-naive-admin')
|
||||
}
|
||||
</script>
|
11
web/src/layout/components/header/components/GithubSite.vue
Normal file
11
web/src/layout/components/header/components/GithubSite.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<n-icon mr-20 size="18" style="cursor: pointer" @click="handleLinkClick">
|
||||
<icon-mdi:github />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function handleLinkClick() {
|
||||
window.open('https://github.com/zclzone/vue-naive-admin')
|
||||
}
|
||||
</script>
|
12
web/src/layout/components/header/components/MenuCollapse.vue
Normal file
12
web/src/layout/components/header/components/MenuCollapse.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<n-icon size="20" cursor-pointer @click="appStore.switchCollapsed">
|
||||
<icon-mdi:format-indent-increase v-if="appStore.collapsed" />
|
||||
<icon-mdi:format-indent-decrease v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<n-popover trigger="click" placement="bottom" @update:show="handlePopoverShow">
|
||||
<template #trigger>
|
||||
<n-badge :value="count" mr-20 cursor-pointer>
|
||||
<n-icon size="18" color-black dark="color-hex-fff">
|
||||
<icon-material-symbols:notifications-outline />
|
||||
</n-icon>
|
||||
</n-badge>
|
||||
</template>
|
||||
<n-tabs v-model:value="activeTab" type="line" justify-content="space-around" animated>
|
||||
<n-tab-pane
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:name="tab.name"
|
||||
:tab="tab.title + `(${tab.messages.length})`"
|
||||
>
|
||||
<ul class="cus-scroll-y max-h-200 w-220">
|
||||
<li
|
||||
v-for="(item, index) in tab.messages"
|
||||
:key="index"
|
||||
class="flex-col py-12"
|
||||
border-t="1 solid gray-200"
|
||||
:style="index > 0 ? '' : 'border: none;'"
|
||||
>
|
||||
<span mb-4 text-ellipsis>{{ item.content }}</span>
|
||||
<span text-hex-bbb>{{ item.time }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatDateTime } from '@/utils'
|
||||
|
||||
const activeTab = ref('')
|
||||
const tabs = [
|
||||
{
|
||||
name: 'zan',
|
||||
title: '点赞',
|
||||
messages: [
|
||||
{ content: '你的文章《XX》收到一条点赞', time: formatDateTime() },
|
||||
{ content: '你的文章《YY》收到一条点赞', time: formatDateTime() },
|
||||
{ content: '你的文章《AA》收到一条点赞', time: formatDateTime() },
|
||||
{ content: '你的文章《BB》收到一条点赞', time: formatDateTime() },
|
||||
{ content: '你的文章《CC》收到一条点赞', time: formatDateTime() },
|
||||
{ content: '你的文章《DD》收到一条点赞', time: formatDateTime() },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'star',
|
||||
title: '关注',
|
||||
messages: [
|
||||
{ content: '张三 关注了你', time: formatDateTime() },
|
||||
{ content: '王五 关注了你', time: formatDateTime() },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'comment',
|
||||
title: '评论',
|
||||
messages: [
|
||||
{ content: '张三 评论了你的文章《XX》"学到了"', time: formatDateTime() },
|
||||
{ content: '李四 评论了你的文章《YY》"不如Vue"', time: formatDateTime() },
|
||||
],
|
||||
},
|
||||
]
|
||||
const count = ref(tabs.map((item) => item.messages).flat().length)
|
||||
|
||||
watch(activeTab, (v) => {
|
||||
if (count === 0) return
|
||||
const tabIndex = tabs.findIndex((item) => item.name === v)
|
||||
count.value -= tabs[tabIndex].messages.length
|
||||
if (count.value < 0) count.value = 0
|
||||
})
|
||||
|
||||
function handlePopoverShow(show) {
|
||||
if (show && !activeTab.value) {
|
||||
activeTab.value = tabs[0]?.name
|
||||
}
|
||||
}
|
||||
</script>
|
18
web/src/layout/components/header/components/ThemeMode.vue
Normal file
18
web/src/layout/components/header/components/ThemeMode.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const isDark = useDark()
|
||||
const toggleDark = () => {
|
||||
appStore.toggleDark()
|
||||
useToggle(isDark)()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-icon mr-20 cursor-pointer size="18" @click="toggleDark">
|
||||
<icon-mdi-moon-waning-crescent v-if="isDark" />
|
||||
<icon-mdi-white-balance-sunny v-else />
|
||||
</n-icon>
|
||||
</template>
|
218
web/src/layout/components/header/components/UserAvatar.vue
Normal file
218
web/src/layout/components/header/components/UserAvatar.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<n-dropdown :options="options" @select="handleSelect">
|
||||
<div flex cursor-pointer items-center>
|
||||
<img :src="userStore.avatar" mr10 h-35 w-35 rounded-full />
|
||||
<span>{{ userStore.nickname }}</span>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
<n-modal
|
||||
v-model:show="showChangePwdModel"
|
||||
transform-origin="center"
|
||||
preset="card"
|
||||
title="修改密码"
|
||||
:bordered="false"
|
||||
size="large"
|
||||
style="width: 400px"
|
||||
header-style="text-align: center"
|
||||
>
|
||||
<n-form label-placement="top"
|
||||
ref="changePasswordFormRef"
|
||||
:rules="changePasswordFormRules"
|
||||
:model="changePasswordFormModel">
|
||||
<n-form-item label="原密码" path="originalPassword">
|
||||
<n-input type="password" show-password-on="click" v-model:value="changePasswordFormModel.originalPassword"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="新密码" path="newPassword">
|
||||
<n-input type="password" show-password-on="click" v-model:value="changePasswordFormModel.newPassword"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="确认密码" path="confirmPassword">
|
||||
<n-input type="password" show-password-on="click" v-model:value="changePasswordFormModel.confirmPassword"></n-input>
|
||||
</n-form-item>
|
||||
<n-button type="primary" @click="changePasswordHandle()">确认</n-button>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="showInfoModel"
|
||||
transform-origin="center"
|
||||
preset="card"
|
||||
:title="infoFormModel.nickname || '个人资料'"
|
||||
:bordered="false"
|
||||
size="large"
|
||||
style="width: 400px"
|
||||
header-style="text-align: center"
|
||||
>
|
||||
<n-form label-placement="top"
|
||||
ref="infoFormRef"
|
||||
:rules="infoFormRules"
|
||||
:model="infoFormModel">
|
||||
<n-form-item path="avatar">
|
||||
<n-avatar
|
||||
round
|
||||
style="cursor: pointer;margin-left: 36%"
|
||||
:size="78"
|
||||
:src="showAvatar"
|
||||
@click="changeAvatar()"
|
||||
></n-avatar>
|
||||
</n-form-item>
|
||||
<n-form-item label="账号" path="account">
|
||||
<n-input disabled v-model:value="infoFormModel.account"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="昵称" path="nickname">
|
||||
<n-input v-model:value="infoFormModel.nickname"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="联系方式" path="contact">
|
||||
<n-input v-model:value="infoFormModel.contact"></n-input>
|
||||
</n-form-item>
|
||||
<n-button type="primary" @click="updateInfo()">确认</n-button>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/store'
|
||||
import { renderIcon } from '@/utils'
|
||||
import api from '@/api/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: "个人信息",
|
||||
key: 'info',
|
||||
icon: renderIcon('streamline:information-desk-solid', { size: '14px' })
|
||||
},
|
||||
{
|
||||
label: "修改密码",
|
||||
key: 'change-password',
|
||||
icon: renderIcon('mdi:lock', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '退出登录',
|
||||
key: 'logout',
|
||||
icon: renderIcon('mdi:exit-to-app', { size: '14px' }),
|
||||
}
|
||||
]
|
||||
const showAvatar = ref('')
|
||||
|
||||
const showChangePwdModel = ref(false)
|
||||
const showInfoModel = ref(false)
|
||||
|
||||
const changePasswordFormRef = ref()
|
||||
const infoFormRef = ref()
|
||||
|
||||
// 表单数据值
|
||||
const changePasswordFormModel = ref({
|
||||
originalPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 用户信息表单
|
||||
const infoFormModel = ref({
|
||||
id: '',
|
||||
account: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
contact: '',
|
||||
isAdmin: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 表单数据验证规则
|
||||
const changePasswordFormRules = {
|
||||
originalPassword: [
|
||||
{ required: true, message: '原密码不能为空', trigger: 'blur' },
|
||||
{ min: 8, message: '密码最低8位', trigger: 'blur' },
|
||||
{ max: 32, message: '密码最长32位', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '新密码不能为空', trigger: 'blur' },
|
||||
{ min: 8, message: '密码最低8位', trigger: 'blur' },
|
||||
{ max: 32, message: '密码最长32位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ validator: validatePasswordSame, message: '密码不一致', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 个人信息表单验证规则
|
||||
const infoFormRules = {
|
||||
avatar: [
|
||||
{ required: true, message: '头像不能为空', trigger: 'blur' }
|
||||
],
|
||||
nickname: [
|
||||
{ required: true, message: '昵称不能为空', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 验证密码是否一致
|
||||
function validatePasswordSame(rule, value) {
|
||||
return value === changePasswordFormModel.value.newPassword;
|
||||
}
|
||||
|
||||
// 根据不同点击事件触发不同
|
||||
function handleSelect(key) {
|
||||
switch (key) {
|
||||
case 'logout':
|
||||
$dialog.confirm({
|
||||
title: '提示',
|
||||
type: 'info',
|
||||
content: '确认退出?',
|
||||
confirm() {
|
||||
userStore.logout()
|
||||
$message.success('已退出登录')
|
||||
},
|
||||
})
|
||||
break;
|
||||
case 'change-password':
|
||||
showChangePwdModel.value = true
|
||||
break;
|
||||
case 'info':
|
||||
infoFormModel.value = useUserStore().localUserInfo
|
||||
showAvatar.value = infoFormModel.value.avatar
|
||||
showInfoModel.value = true
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码逻辑
|
||||
async function changePasswordHandle() {
|
||||
changePasswordFormRef.value.validate(async (errors) => {
|
||||
if (errors) {
|
||||
return errors
|
||||
}
|
||||
const res = await api.changePassword(changePasswordFormModel.value)
|
||||
if (res.data.code === 200) {
|
||||
changePasswordFormModel.value = ref(null)
|
||||
showChangePwdModel.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生成头像
|
||||
async function changeAvatar() {
|
||||
const res = await api.generateAvatar()
|
||||
if (res.data.code === 200) {
|
||||
showAvatar.value = res.data.data
|
||||
}
|
||||
}
|
||||
|
||||
async function updateInfo() {
|
||||
infoFormRef.value.validate(async (errors) => {
|
||||
if (errors) {
|
||||
return errors
|
||||
}
|
||||
|
||||
if (showAvatar.value !== infoFormModel.value.avatar) {
|
||||
infoFormModel.value.avatar = showAvatar.value
|
||||
}
|
||||
|
||||
const res = await api.addOrUpdateUser(infoFormModel.value)
|
||||
if (res.data.code === 200) {
|
||||
infoFormModel.value = ref(null)
|
||||
showInfoModel.value = false
|
||||
await useUserStore().getUserInfo()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
17
web/src/layout/components/header/index.vue
Normal file
17
web/src/layout/components/header/index.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div flex items-center>
|
||||
<MenuCollapse />
|
||||
<BreadCrumb ml-15 hidden sm:block />
|
||||
</div>
|
||||
<div ml-auto flex items-center>
|
||||
<FullScreen />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BreadCrumb from './components/BreadCrumb.vue'
|
||||
import MenuCollapse from './components/MenuCollapse.vue'
|
||||
import FullScreen from './components/FullScreen.vue'
|
||||
import UserAvatar from './components/UserAvatar.vue'
|
||||
</script>
|
15
web/src/layout/components/sidebar/components/SideLogo.vue
Normal file
15
web/src/layout/components/sidebar/components/SideLogo.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<router-link h-60 f-c-c to="/">
|
||||
<img src="@/assets/images/logo.png" height="42" />
|
||||
<h2 v-show="!appStore.collapsed" ml-10 max-w-140 flex-shrink-0 text-16 font-bold color-primary>
|
||||
{{ title }}
|
||||
</h2>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store'
|
||||
const title = import.meta.env.VITE_TITLE
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
120
web/src/layout/components/sidebar/components/SideMenu.vue
Normal file
120
web/src/layout/components/sidebar/components/SideMenu.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<n-menu
|
||||
ref="menu"
|
||||
class="side-menu"
|
||||
accordion
|
||||
:indent="18"
|
||||
:collapsed-icon-size="22"
|
||||
:collapsed-width="64"
|
||||
:options="menuOptions"
|
||||
:value="activeKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePermissionStore } from '@/store'
|
||||
import { renderCustomIcon, renderIcon, isExternal } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const curRoute = useRoute()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const activeKey = computed(() => curRoute.meta?.activeMenu || curRoute.name)
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
|
||||
})
|
||||
|
||||
const menu = ref(null)
|
||||
watch(curRoute, async () => {
|
||||
await nextTick()
|
||||
menu.value?.showOption()
|
||||
})
|
||||
|
||||
function resolvePath(basePath, path) {
|
||||
if (isExternal(path)) return path
|
||||
return (
|
||||
'/' +
|
||||
[basePath, path]
|
||||
.filter((path) => !!path && path !== '/')
|
||||
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
|
||||
.join('/')
|
||||
)
|
||||
}
|
||||
|
||||
function getMenuItem(route, basePath = '') {
|
||||
let menuItem = {
|
||||
label: (route.meta && route.meta.title) || route.name,
|
||||
key: route.name,
|
||||
path: resolvePath(basePath, route.path),
|
||||
icon: getIcon(route.meta),
|
||||
order: route.meta?.order || 0,
|
||||
}
|
||||
|
||||
const visibleChildren = route.children
|
||||
? route.children.filter((item) => item.name && !item.isHidden)
|
||||
: []
|
||||
|
||||
if (!visibleChildren.length) return menuItem
|
||||
|
||||
if (visibleChildren.length === 1) {
|
||||
// 单个子路由处理
|
||||
const singleRoute = visibleChildren[0]
|
||||
menuItem = {
|
||||
...menuItem,
|
||||
label: singleRoute.meta?.title || singleRoute.name,
|
||||
key: singleRoute.name,
|
||||
path: resolvePath(menuItem.path, singleRoute.path),
|
||||
icon: getIcon(singleRoute.meta),
|
||||
}
|
||||
const visibleItems = singleRoute.children
|
||||
? singleRoute.children.filter((item) => item.name && !item.isHidden)
|
||||
: []
|
||||
|
||||
if (visibleItems.length === 1) {
|
||||
menuItem = getMenuItem(visibleItems[0], menuItem.path)
|
||||
} else if (visibleItems.length > 1) {
|
||||
menuItem.children = visibleItems
|
||||
.map((item) => getMenuItem(item, menuItem.path))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
} else {
|
||||
menuItem.children = visibleChildren
|
||||
.map((item) => getMenuItem(item, menuItem.path))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return menuItem
|
||||
}
|
||||
|
||||
function getIcon(meta) {
|
||||
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
|
||||
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
|
||||
return null
|
||||
}
|
||||
|
||||
function handleMenuSelect(key, item) {
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path)
|
||||
} else {
|
||||
router.push(item.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.side-menu:not(.n-menu--collapsed) {
|
||||
.n-menu-item-content {
|
||||
&::before {
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
&.n-menu-item-content--selected,
|
||||
&:hover {
|
||||
&::before {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
9
web/src/layout/components/sidebar/index.vue
Normal file
9
web/src/layout/components/sidebar/index.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import SideLogo from './components/SideLogo.vue'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SideLogo />
|
||||
<SideMenu />
|
||||
</template>
|
118
web/src/layout/components/tags/ContextMenu.vue
Normal file
118
web/src/layout/components/tags/ContextMenu.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<n-dropdown
|
||||
:show="show"
|
||||
:options="options"
|
||||
:x="x"
|
||||
:y="y"
|
||||
placement="bottom-start"
|
||||
@clickoutside="handleHideDropdown"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useTagsStore } from '@/store'
|
||||
import { renderIcon } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
|
||||
const tagsStore = useTagsStore()
|
||||
|
||||
const options = computed(() => [
|
||||
{
|
||||
label: '重新加载',
|
||||
key: 'reload',
|
||||
disabled: props.currentPath !== tagsStore.activeTag,
|
||||
icon: renderIcon('mdi:refresh', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭',
|
||||
key: 'close',
|
||||
disabled: tagsStore.tags.length <= 1,
|
||||
icon: renderIcon('mdi:close', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭其他',
|
||||
key: 'close-other',
|
||||
disabled: tagsStore.tags.length <= 1,
|
||||
icon: renderIcon('mdi:arrow-expand-horizontal', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭左侧',
|
||||
key: 'close-left',
|
||||
disabled: tagsStore.tags.length <= 1 || props.currentPath === tagsStore.tags[0].path,
|
||||
icon: renderIcon('mdi:arrow-expand-left', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭右侧',
|
||||
key: 'close-right',
|
||||
disabled:
|
||||
tagsStore.tags.length <= 1 ||
|
||||
props.currentPath === tagsStore.tags[tagsStore.tags.length - 1].path,
|
||||
icon: renderIcon('mdi:arrow-expand-right', { size: '14px' }),
|
||||
},
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
const actionMap = new Map([
|
||||
[
|
||||
'reload',
|
||||
() => {
|
||||
tagsStore.reloadTag(route.path, route.meta?.keepAlive)
|
||||
},
|
||||
],
|
||||
[
|
||||
'close',
|
||||
() => {
|
||||
tagsStore.removeTag(props.currentPath)
|
||||
},
|
||||
],
|
||||
[
|
||||
'close-other',
|
||||
() => {
|
||||
tagsStore.removeOther(props.currentPath)
|
||||
},
|
||||
],
|
||||
[
|
||||
'close-left',
|
||||
() => {
|
||||
tagsStore.removeLeft(props.currentPath)
|
||||
},
|
||||
],
|
||||
[
|
||||
'close-right',
|
||||
() => {
|
||||
tagsStore.removeRight(props.currentPath)
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
function handleHideDropdown() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function handleSelect(key) {
|
||||
const actionFn = actionMap.get(key)
|
||||
actionFn && actionFn()
|
||||
handleHideDropdown()
|
||||
}
|
||||
</script>
|
106
web/src/layout/components/tags/index.vue
Normal file
106
web/src/layout/components/tags/index.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<ScrollX ref="scrollXRef" class="bg-white dark:bg-dark!">
|
||||
<n-tag
|
||||
v-for="tag in tagsStore.tags"
|
||||
ref="tabRefs"
|
||||
:key="tag.path"
|
||||
class="mx-5 cursor-pointer rounded-4 px-15 hover:color-primary"
|
||||
:type="tagsStore.activeTag === tag.path ? 'primary' : 'default'"
|
||||
:closable="tagsStore.tags.length > 1"
|
||||
@click="handleTagClick(tag.path)"
|
||||
@close.stop="tagsStore.removeTag(tag.path)"
|
||||
@contextmenu.prevent="handleContextMenu($event, tag)"
|
||||
>
|
||||
<template v-if="tag.icon" #icon>
|
||||
<TheIcon :icon="tag.icon" class="mr-4" />
|
||||
</template>
|
||||
{{ tag.title }}
|
||||
</n-tag>
|
||||
<ContextMenu
|
||||
v-if="contextMenuOption.show"
|
||||
v-model:show="contextMenuOption.show"
|
||||
:current-path="contextMenuOption.currentPath"
|
||||
:x="contextMenuOption.x"
|
||||
:y="contextMenuOption.y"
|
||||
/>
|
||||
</ScrollX>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import { useTagsStore } from '@/store'
|
||||
import ScrollX from '@/components/common/ScrollX.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsStore = useTagsStore()
|
||||
const tabRefs = ref([])
|
||||
const scrollXRef = ref(null)
|
||||
|
||||
const contextMenuOption = reactive({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
currentPath: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
const { name, fullPath: path } = route
|
||||
const title = route.meta?.title
|
||||
const icon = route.meta?.icon
|
||||
const keepAlive = route.meta?.keepAlive
|
||||
tagsStore.addTag({ name, path, title, icon, keepAlive })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => tagsStore.activeIndex,
|
||||
async (activeIndex) => {
|
||||
await nextTick()
|
||||
const activeTabElement = tabRefs.value[activeIndex]?.$el
|
||||
if (!activeTabElement) return
|
||||
const { offsetLeft: x, offsetWidth: width } = activeTabElement
|
||||
scrollXRef.value?.handleScroll(x + width, width)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleTagClick = (path) => {
|
||||
tagsStore.setActiveTag(path)
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function showContextMenu() {
|
||||
contextMenuOption.show = true
|
||||
}
|
||||
function hideContextMenu() {
|
||||
contextMenuOption.show = false
|
||||
}
|
||||
function setContextMenu(x, y, currentPath) {
|
||||
Object.assign(contextMenuOption, { x, y, currentPath })
|
||||
}
|
||||
|
||||
// 右击菜单
|
||||
async function handleContextMenu(e, tagItem) {
|
||||
const { clientX, clientY } = e
|
||||
hideContextMenu()
|
||||
setContextMenu(clientX, clientY, tagItem.path)
|
||||
await nextTick()
|
||||
showContextMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.n-tag__close {
|
||||
box-sizing: content-box;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
transform: scale(0.9);
|
||||
transform: translateX(5px);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
</style>
|
42
web/src/layout/index.vue
Normal file
42
web/src/layout/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<n-layout has-sider wh-full>
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="200"
|
||||
:native-scrollbar="false"
|
||||
:collapsed="appStore.collapsed"
|
||||
>
|
||||
<SideBar />
|
||||
</n-layout-sider>
|
||||
|
||||
<article flex-col flex-1 overflow-hidden>
|
||||
<header
|
||||
border-b="1 solid #eee"
|
||||
class="flex items-center bg-white px-15"
|
||||
dark="bg-dark border-0"
|
||||
:style="`height: ${header.height}px`"
|
||||
>
|
||||
<AppHeader />
|
||||
</header>
|
||||
<section v-if="tags.visible" hidden border-b bc-eee sm:block dark:border-0>
|
||||
<AppTags :style="{ height: `${tags.height}px` }" />
|
||||
</section>
|
||||
<section flex-1 overflow-hidden bg-hex-f5f6fb dark:bg-hex-101014>
|
||||
<AppMain />
|
||||
</section>
|
||||
</article>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppHeader from './components/header/index.vue'
|
||||
import SideBar from './components/sidebar/index.vue'
|
||||
import AppMain from './components/AppMain.vue'
|
||||
import AppTags from './components/tags/index.vue'
|
||||
import { useAppStore } from '@/store'
|
||||
import { header, tags } from '~/settings'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
21
web/src/main.js
Normal file
21
web/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** 重置样式 */
|
||||
import '@/styles/reset.css'
|
||||
import 'uno.css'
|
||||
import '@/styles/global.scss'
|
||||
import 'virtual:svg-icons-register'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { setupRouter } from '@/router'
|
||||
import { setupStore } from '@/store'
|
||||
import App from './App.vue'
|
||||
import { setupNaiveDiscreteApi } from './utils'
|
||||
|
||||
async function setupApp() {
|
||||
const app = createApp(App)
|
||||
setupStore(app)
|
||||
await setupRouter(app)
|
||||
app.mount('#app')
|
||||
setupNaiveDiscreteApi()
|
||||
}
|
||||
|
||||
setupApp()
|
9
web/src/router/guard/index.js
Normal file
9
web/src/router/guard/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createPageLoadingGuard } from './page-loading-guard'
|
||||
import { createPageTitleGuard } from './page-title-guard'
|
||||
import { createPermissionGuard } from './permission-guard'
|
||||
|
||||
export function setupRouterGuard(router) {
|
||||
createPageLoadingGuard(router)
|
||||
createPermissionGuard(router)
|
||||
createPageTitleGuard(router)
|
||||
}
|
15
web/src/router/guard/page-loading-guard.js
Normal file
15
web/src/router/guard/page-loading-guard.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export function createPageLoadingGuard(router) {
|
||||
router.beforeEach(() => {
|
||||
window.$loadingBar?.start()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
setTimeout(() => {
|
||||
window.$loadingBar?.finish()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
window.$loadingBar?.error()
|
||||
})
|
||||
}
|
12
web/src/router/guard/page-title-guard.js
Normal file
12
web/src/router/guard/page-title-guard.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const baseTitle = import.meta.env.VITE_TITLE
|
||||
|
||||
export function createPageTitleGuard(router) {
|
||||
router.afterEach((to) => {
|
||||
const pageTitle = to.meta?.title
|
||||
if (pageTitle) {
|
||||
document.title = `${pageTitle} | ${baseTitle}`
|
||||
} else {
|
||||
document.title = baseTitle
|
||||
}
|
||||
})
|
||||
}
|
19
web/src/router/guard/permission-guard.js
Normal file
19
web/src/router/guard/permission-guard.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getToken, isNullOrWhitespace } from '@/utils'
|
||||
|
||||
const WHITE_LIST = ['/login', '/404']
|
||||
export function createPermissionGuard(router) {
|
||||
router.beforeEach(async (to) => {
|
||||
const token = getToken()
|
||||
|
||||
/** 没有token的情况 */
|
||||
if (isNullOrWhitespace(token)) {
|
||||
if (WHITE_LIST.includes(to.path)) return true
|
||||
return { path: 'login', query: { ...to.query, redirect: to.path } }
|
||||
}
|
||||
|
||||
/** 有token的情况 */
|
||||
if (to.path === '/login') return { path: '/' }
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
67
web/src/router/index.js
Normal file
67
web/src/router/index.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import { setupRouterGuard } from './guard'
|
||||
import { basicRoutes, EMPTY_ROUTE, NOT_FOUND_ROUTE } from './routes'
|
||||
import { getToken, isNullOrWhitespace } from '@/utils'
|
||||
import { useUserStore, usePermissionStore } from '@/store'
|
||||
|
||||
const isHash = import.meta.env.VITE_USE_HASH === 'true'
|
||||
export const router = createRouter({
|
||||
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
|
||||
routes: basicRoutes,
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
})
|
||||
|
||||
export async function setupRouter(app) {
|
||||
await addDynamicRoutes()
|
||||
setupRouterGuard(router)
|
||||
app.use(router)
|
||||
}
|
||||
|
||||
export async function resetRouter() {
|
||||
const basicRouteNames = getRouteNames(basicRoutes)
|
||||
router.getRoutes().forEach((route) => {
|
||||
const name = route.name
|
||||
if (!basicRouteNames.includes(name)) {
|
||||
router.removeRoute(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function addDynamicRoutes() {
|
||||
const token = getToken()
|
||||
// 没有token情况
|
||||
if (isNullOrWhitespace(token)) {
|
||||
router.addRoute(EMPTY_ROUTE)
|
||||
return
|
||||
}
|
||||
// 有token的情况
|
||||
const userStore = useUserStore()
|
||||
try {
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
!userStore.id && (await userStore.getUserInfo())
|
||||
|
||||
const accessRoutes = permissionStore.generateRoutes(userStore.role)
|
||||
accessRoutes.forEach((route) => {
|
||||
!router.hasRoute(route.name) && router.addRoute(route)
|
||||
})
|
||||
router.hasRoute(EMPTY_ROUTE.name) && router.removeRoute(EMPTY_ROUTE.name)
|
||||
router.addRoute(NOT_FOUND_ROUTE)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$message.error('初始化用户信息失败: ' + error)
|
||||
userStore.logout()
|
||||
}
|
||||
}
|
||||
|
||||
export function getRouteNames(routes) {
|
||||
return routes.map((route) => getRouteName(route)).flat(1)
|
||||
}
|
||||
|
||||
function getRouteName(route) {
|
||||
const names = [route.name]
|
||||
if (route.children && route.children.length) {
|
||||
names.push(...route.children.map((item) => getRouteName(item)).flat(1))
|
||||
}
|
||||
return names
|
||||
}
|
34
web/src/router/routes/index.js
Normal file
34
web/src/router/routes/index.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
export const basicRoutes = [
|
||||
{
|
||||
name: 'Login',
|
||||
path: '/login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
isHidden: true,
|
||||
meta: {
|
||||
title: '登录页',
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
export const NOT_FOUND_ROUTE = {
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404',
|
||||
isHidden: true,
|
||||
}
|
||||
|
||||
export const EMPTY_ROUTE = {
|
||||
name: 'Empty',
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: null,
|
||||
}
|
||||
|
||||
const modules = import.meta.glob('@/views/**/route.js', { eager: true })
|
||||
const asyncRoutes = []
|
||||
Object.keys(modules).forEach((key) => {
|
||||
asyncRoutes.push(modules[key].default)
|
||||
})
|
||||
|
||||
export { asyncRoutes }
|
7
web/src/store/index.js
Normal file
7
web/src/store/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export function setupStore(app) {
|
||||
app.use(createPinia())
|
||||
}
|
||||
|
||||
export * from './modules'
|
28
web/src/store/modules/app/index.js
Normal file
28
web/src/store/modules/app/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { useDark } from '@vueuse/core'
|
||||
|
||||
const isDark = useDark()
|
||||
export const useAppStore = defineStore('app', {
|
||||
state() {
|
||||
return {
|
||||
collapsed: false,
|
||||
isDark,
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
switchCollapsed() {
|
||||
this.collapsed = !this.collapsed
|
||||
},
|
||||
setCollapsed(collapsed) {
|
||||
this.collapsed = collapsed
|
||||
},
|
||||
/** 设置暗黑模式 */
|
||||
setDark(isDark) {
|
||||
this.isDark = isDark
|
||||
},
|
||||
/** 切换/关闭 暗黑模式 */
|
||||
toggleDark() {
|
||||
this.isDark = !this.isDark
|
||||
},
|
||||
},
|
||||
})
|
4
web/src/store/modules/index.js
Normal file
4
web/src/store/modules/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './app'
|
||||
export * from './permission'
|
||||
export * from './tags'
|
||||
export * from './user'
|
60
web/src/store/modules/permission/index.js
Normal file
60
web/src/store/modules/permission/index.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { asyncRoutes, basicRoutes } from '@/router/routes'
|
||||
|
||||
function hasPermission(route, role) {
|
||||
// * 不需要权限直接返回true
|
||||
if (!route.meta?.requireAuth) return true
|
||||
|
||||
const routeRole = route.meta?.role ? route.meta.role : []
|
||||
|
||||
// * 登录用户没有角色或者路由没有设置角色判定为没有权限
|
||||
if (!role.length || !routeRole.length) return false
|
||||
|
||||
// * 路由指定的角色包含任一登录用户角色则判定有权限
|
||||
return role.some((item) => routeRole.includes(item))
|
||||
}
|
||||
|
||||
function filterAsyncRoutes(routes = [], role) {
|
||||
const ret = []
|
||||
routes.forEach((route) => {
|
||||
if (hasPermission(route, role)) {
|
||||
const curRoute = {
|
||||
...route,
|
||||
children: [],
|
||||
}
|
||||
if (route.children && route.children.length) {
|
||||
curRoute.children = filterAsyncRoutes(route.children, role)
|
||||
} else {
|
||||
Reflect.deleteProperty(curRoute, 'children')
|
||||
}
|
||||
ret.push(curRoute)
|
||||
}
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
export const usePermissionStore = defineStore('permission', {
|
||||
state() {
|
||||
return {
|
||||
accessRoutes: [],
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
routes() {
|
||||
return basicRoutes.concat(this.accessRoutes)
|
||||
},
|
||||
menus() {
|
||||
return this.routes.filter((route) => route.name && !route.isHidden)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
generateRoutes(role = []) {
|
||||
const accessRoutes = filterAsyncRoutes(asyncRoutes, role)
|
||||
this.accessRoutes = accessRoutes
|
||||
return accessRoutes
|
||||
},
|
||||
resetPermission() {
|
||||
this.$reset()
|
||||
},
|
||||
},
|
||||
})
|
6
web/src/store/modules/tags/helpers.js
Normal file
6
web/src/store/modules/tags/helpers.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sStorage } from '@/utils'
|
||||
|
||||
export const activeTag = sStorage.get('activeTag')
|
||||
export const tags = sStorage.get('tags')
|
||||
|
||||
export const WITHOUT_TAG_PATHS = ['/404', '/login']
|
83
web/src/store/modules/tags/index.js
Normal file
83
web/src/store/modules/tags/index.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { activeTag, tags, WITHOUT_TAG_PATHS } from './helpers'
|
||||
import { router } from '@/router'
|
||||
import { sStorage } from '@/utils'
|
||||
|
||||
export const useTagsStore = defineStore('tag', {
|
||||
state() {
|
||||
return {
|
||||
tags: tags || [],
|
||||
activeTag: activeTag || '',
|
||||
reloading: false,
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
activeIndex() {
|
||||
return this.tags.findIndex((item) => item.path === this.activeTag)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setActiveTag(path) {
|
||||
this.activeTag = path
|
||||
sStorage.set('activeTag', path)
|
||||
},
|
||||
setTags(tags) {
|
||||
this.tags = tags
|
||||
sStorage.set('tags', tags)
|
||||
},
|
||||
addTag(tag = {}) {
|
||||
if (WITHOUT_TAG_PATHS.includes(tag.path)) return
|
||||
let findItem = this.tags.find((item) => item.path === tag.path)
|
||||
if (findItem) findItem = tag
|
||||
else this.setTags([...this.tags, tag])
|
||||
this.setActiveTag(tag.path)
|
||||
},
|
||||
async reloadTag(path, keepAlive) {
|
||||
const findItem = this.tags.find((item) => item.path === path)
|
||||
// 更新key可让keepAlive失效
|
||||
if (findItem && keepAlive) findItem.keepAlive = false
|
||||
|
||||
$loadingBar.start()
|
||||
this.reloading = true
|
||||
await nextTick()
|
||||
this.reloading = false
|
||||
findItem.keepAlive = keepAlive
|
||||
setTimeout(() => {
|
||||
document.documentElement.scrollTo({ left: 0, top: 0 })
|
||||
$loadingBar.finish()
|
||||
}, 100)
|
||||
},
|
||||
removeTag(path) {
|
||||
this.setTags(this.tags.filter((tag) => tag.path !== path))
|
||||
if (path === this.activeTag) {
|
||||
router.push(this.tags[this.tags.length - 1].path)
|
||||
}
|
||||
},
|
||||
removeOther(curPath = this.activeTag) {
|
||||
this.setTags(this.tags.filter((tag) => tag.path === curPath))
|
||||
if (curPath !== this.activeTag) {
|
||||
router.push(this.tags[this.tags.length - 1].path)
|
||||
}
|
||||
},
|
||||
removeLeft(curPath) {
|
||||
const curIndex = this.tags.findIndex((item) => item.path === curPath)
|
||||
const filterTags = this.tags.filter((item, index) => index >= curIndex)
|
||||
this.setTags(filterTags)
|
||||
if (!filterTags.find((item) => item.path === this.activeTag)) {
|
||||
router.push(filterTags[filterTags.length - 1].path)
|
||||
}
|
||||
},
|
||||
removeRight(curPath) {
|
||||
const curIndex = this.tags.findIndex((item) => item.path === curPath)
|
||||
const filterTags = this.tags.filter((item, index) => index <= curIndex)
|
||||
this.setTags(filterTags)
|
||||
if (!filterTags.find((item) => item.path === this.activeTag)) {
|
||||
router.push(filterTags[filterTags.length - 1].path)
|
||||
}
|
||||
},
|
||||
resetTags() {
|
||||
this.setTags([])
|
||||
this.setActiveTag('')
|
||||
},
|
||||
},
|
||||
})
|
72
web/src/store/modules/user/index.js
Normal file
72
web/src/store/modules/user/index.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { resetRouter } from '@/router'
|
||||
import { useTagsStore, usePermissionStore } from '@/store'
|
||||
import { removeToken, toLogin } from '@/utils'
|
||||
import api from '@/api/user'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state() {
|
||||
return {
|
||||
userInfo: {
|
||||
id: '',
|
||||
account: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
contact: '',
|
||||
isAdmin: 0,
|
||||
status: 1
|
||||
},
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
id() {
|
||||
return this.userInfo?.id
|
||||
},
|
||||
account() {
|
||||
return this.userInfo?.account
|
||||
},
|
||||
nickname() {
|
||||
return this.userInfo?.nickname
|
||||
},
|
||||
avatar() {
|
||||
return this.userInfo?.avatar
|
||||
},
|
||||
contact() {
|
||||
return this.userInfo?.contact
|
||||
},
|
||||
isAdmin() {
|
||||
return this.userInfo?.isAdmin
|
||||
},
|
||||
status() {
|
||||
return this.userInfo?.status
|
||||
},
|
||||
localUserInfo() {
|
||||
return this.userInfo
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async getUserInfo() {
|
||||
try {
|
||||
const res = await api.getUser()
|
||||
const { id, account,nickname, avatar,contact,isAdmin,status } = res.data.data
|
||||
this.userInfo = { id, account,nickname, avatar, contact,isAdmin,status }
|
||||
return Promise.resolve(res.data)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
const { resetTags } = useTagsStore()
|
||||
const { resetPermission } = usePermissionStore()
|
||||
removeToken()
|
||||
resetTags()
|
||||
resetPermission()
|
||||
resetRouter()
|
||||
this.$reset()
|
||||
toLogin()
|
||||
},
|
||||
setUserInfo(userInfo = {}) {
|
||||
this.userInfo = { ...this.userInfo, ...userInfo }
|
||||
},
|
||||
},
|
||||
})
|
66
web/src/styles/global.scss
Normal file
66
web/src/styles/global.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* transition fade-slide */
|
||||
.fade-slide-leave-active,
|
||||
.fade-slide-enter-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.cus-scroll {
|
||||
overflow: auto;
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
.cus-scroll-x {
|
||||
overflow-x: auto;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
.cus-scroll-y {
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
.cus-scroll,
|
||||
.cus-scroll-x,
|
||||
.cus-scroll-y {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
&:hover {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
35
web/src/styles/reset.css
Normal file
35
web/src/styles/reset.css
Normal file
@@ -0,0 +1,35 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:link,
|
||||
a:visited,
|
||||
a:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
outline: none;
|
||||
border: none;
|
||||
resize: none;
|
||||
}
|
17
web/src/utils/auth/auth.js
Normal file
17
web/src/utils/auth/auth.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { router } from '@/router'
|
||||
|
||||
export function toLogin() {
|
||||
const currentRoute = unref(router.currentRoute)
|
||||
const needRedirect =
|
||||
!currentRoute.meta.requireAuth && !['/404', '/login'].includes(router.currentRoute.value.path)
|
||||
router.replace({
|
||||
path: '/login',
|
||||
query: needRedirect ? { ...currentRoute.query, redirect: currentRoute.path } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function toFourZeroFour() {
|
||||
router.replace({
|
||||
path: '/404',
|
||||
})
|
||||
}
|
2
web/src/utils/auth/index.js
Normal file
2
web/src/utils/auth/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './auth'
|
||||
export * from './token'
|
29
web/src/utils/auth/token.js
Normal file
29
web/src/utils/auth/token.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { lStorage } from '@/utils'
|
||||
|
||||
const TOKEN_CODE = 'token'
|
||||
const X_TOKEN_CODE = 'X_TOKEN_CODE'
|
||||
|
||||
export function getToken() {
|
||||
return lStorage.get(TOKEN_CODE)
|
||||
}
|
||||
|
||||
export function getTokenAll() {
|
||||
return lStorage.getItem(TOKEN_CODE)
|
||||
}
|
||||
|
||||
export function getXToken() {
|
||||
return lStorage.get(X_TOKEN_CODE)
|
||||
}
|
||||
|
||||
export function setXToken(token) {
|
||||
lStorage.set(X_TOKEN_CODE,token)
|
||||
}
|
||||
|
||||
export function setToken(token,expireAt) {
|
||||
lStorage.set(TOKEN_CODE, token,expireAt)
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
lStorage.remove(TOKEN_CODE)
|
||||
}
|
||||
|
99
web/src/utils/common/common.js
Normal file
99
web/src/utils/common/common.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/**
|
||||
* @desc 格式化时间
|
||||
* @param {(Object|string|number)} time
|
||||
* @param {string} format
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function formatDateTime(time = undefined, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
return dayjs(time).format(format)
|
||||
}
|
||||
|
||||
export function formatDate(date = undefined, format = 'YYYY-MM-DD') {
|
||||
return formatDateTime(date, format)
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc 函数节流
|
||||
* @param {Function} fn
|
||||
* @param {Number} wait
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function throttle(fn, wait) {
|
||||
var context, args
|
||||
var previous = 0
|
||||
|
||||
return function () {
|
||||
var now = +new Date()
|
||||
context = this
|
||||
args = arguments
|
||||
if (now - previous > wait) {
|
||||
fn.apply(context, args)
|
||||
previous = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc 函数防抖
|
||||
* @param {Function} func
|
||||
* @param {number} wait
|
||||
* @param {boolean} immediate
|
||||
* @return {*}
|
||||
*/
|
||||
export function debounce(method, wait, immediate) {
|
||||
let timeout
|
||||
return function (...args) {
|
||||
let context = this
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
// 立即执行需要两个条件,一是immediate为true,二是timeout未被赋值或被置为null
|
||||
if (immediate) {
|
||||
/**
|
||||
* 如果定时器不存在,则立即执行,并设置一个定时器,wait毫秒后将定时器置为null
|
||||
* 这样确保立即执行后wait毫秒内不会被再次触发
|
||||
*/
|
||||
let callNow = !timeout
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null
|
||||
}, wait)
|
||||
if (callNow) {
|
||||
method.apply(context, args)
|
||||
}
|
||||
} else {
|
||||
// 如果immediate为false,则函数wait毫秒后执行
|
||||
timeout = setTimeout(() => {
|
||||
/**
|
||||
* args是一个类数组对象,所以使用fn.apply
|
||||
* 也可写作method.call(context, ...args)
|
||||
*/
|
||||
method.apply(context, args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
* @param {Function} cb
|
||||
* @return {ResizeObserver}
|
||||
*/
|
||||
export function useResize(el, cb) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
cb(entries[0].contentRect)
|
||||
})
|
||||
observer.observe(el)
|
||||
return observer
|
||||
}
|
||||
|
||||
// 超长省略号显示
|
||||
export function ellipsis(str) {
|
||||
if (!str) return "";
|
||||
if (str.length >= 10) {
|
||||
return str.slice(0, 10) + "...";
|
||||
}
|
||||
return str;
|
||||
}
|
12
web/src/utils/common/icon.js
Normal file
12
web/src/utils/common/icon.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { h } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import SvgIcon from '@/components/icon/SvgIcon.vue'
|
||||
|
||||
export function renderIcon(icon, props = { size: 12 }) {
|
||||
return () => h(NIcon, props, { default: () => h(Icon, { icon }) })
|
||||
}
|
||||
|
||||
export function renderCustomIcon(icon, props = { size: 12 }) {
|
||||
return () => h(NIcon, props, { default: () => h(SvgIcon, { icon }) })
|
||||
}
|
4
web/src/utils/common/index.js
Normal file
4
web/src/utils/common/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './common'
|
||||
export * from './is'
|
||||
export * from './icon'
|
||||
export * from './naiveTools'
|
119
web/src/utils/common/is.js
Normal file
119
web/src/utils/common/is.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const toString = Object.prototype.toString
|
||||
|
||||
export function is(val, type) {
|
||||
return toString.call(val) === `[object ${type}]`
|
||||
}
|
||||
|
||||
export function isDef(val) {
|
||||
return typeof val !== 'undefined'
|
||||
}
|
||||
|
||||
export function isUndef(val) {
|
||||
return typeof val === 'undefined'
|
||||
}
|
||||
|
||||
export function isNull(val) {
|
||||
return val === null
|
||||
}
|
||||
|
||||
export function isWhitespace(val) {
|
||||
return val === ''
|
||||
}
|
||||
|
||||
export function isObject(val) {
|
||||
return !isNull(val) && is(val, 'Object')
|
||||
}
|
||||
|
||||
export function isArray(val) {
|
||||
return val && Array.isArray(val)
|
||||
}
|
||||
|
||||
export function isString(val) {
|
||||
return is(val, 'String')
|
||||
}
|
||||
|
||||
export function isNumber(val) {
|
||||
return is(val, 'Number')
|
||||
}
|
||||
|
||||
export function isBoolean(val) {
|
||||
return is(val, 'Boolean')
|
||||
}
|
||||
|
||||
export function isDate(val) {
|
||||
return is(val, 'Date')
|
||||
}
|
||||
|
||||
export function isRegExp(val) {
|
||||
return is(val, 'RegExp')
|
||||
}
|
||||
|
||||
export function isFunction(val) {
|
||||
return typeof val === 'function'
|
||||
}
|
||||
|
||||
export function isPromise(val) {
|
||||
return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
|
||||
}
|
||||
|
||||
export function isElement(val) {
|
||||
return isObject(val) && !!val.tagName
|
||||
}
|
||||
|
||||
export function isWindow(val) {
|
||||
return typeof window !== 'undefined' && isDef(window) && is(val, 'Window')
|
||||
}
|
||||
|
||||
export function isNullOrUndef(val) {
|
||||
return isNull(val) || isUndef(val)
|
||||
}
|
||||
|
||||
export function isNullOrWhitespace(val) {
|
||||
return isNullOrUndef(val) || isWhitespace(val)
|
||||
}
|
||||
|
||||
/** 空数组 | 空字符串 | 空对象 | 空Map | 空Set */
|
||||
export function isEmpty(val) {
|
||||
if (isArray(val) || isString(val)) {
|
||||
return val.length === 0
|
||||
}
|
||||
|
||||
if (val instanceof Map || val instanceof Set) {
|
||||
return val.size === 0
|
||||
}
|
||||
|
||||
if (isObject(val)) {
|
||||
return Object.keys(val).length === 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* * 类似mysql的IFNULL函数
|
||||
* * 第一个参数为null/undefined/'' 则返回第二个参数作为备用值,否则返回第一个参数
|
||||
* @param {Number|Boolean|String} val
|
||||
* @param {Number|Boolean|String} def
|
||||
* @returns
|
||||
*/
|
||||
export function ifNull(val, def = '') {
|
||||
return isNullOrWhitespace(val) ? def : val
|
||||
}
|
||||
|
||||
export function isUrl(path) {
|
||||
const reg =
|
||||
/(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)$/
|
||||
return reg.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isExternal(path) {
|
||||
return /^(https?:|mailto:|tel:)/.test(path)
|
||||
}
|
||||
|
||||
export const isServer = typeof window === 'undefined'
|
||||
|
||||
export const isClient = !isServer
|
99
web/src/utils/common/naiveTools.js
Normal file
99
web/src/utils/common/naiveTools.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as NaiveUI from 'naive-ui'
|
||||
import { isNullOrUndef } from '@/utils'
|
||||
import { naiveThemeOverrides as themeOverrides } from '~/settings'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
export function setupMessage(NMessage) {
|
||||
let loadingMessage = null
|
||||
class Message {
|
||||
/**
|
||||
* 规则:
|
||||
* * loading message只显示一个,新的message会替换正在显示的loading message
|
||||
* * loading message不会自动清除,除非被替换成非loading message,非loading message默认2秒后自动清除
|
||||
*/
|
||||
|
||||
removeMessage(message = loadingMessage, duration = 2000) {
|
||||
setTimeout(() => {
|
||||
if (message) {
|
||||
message.destroy()
|
||||
message = null
|
||||
}
|
||||
}, duration)
|
||||
}
|
||||
|
||||
showMessage(type, content, option = {}) {
|
||||
if (loadingMessage && loadingMessage.type === 'loading') {
|
||||
// 如果存在则替换正在显示的loading message
|
||||
loadingMessage.type = type
|
||||
loadingMessage.content = content
|
||||
|
||||
if (type !== 'loading') {
|
||||
// 非loading message需设置自动清除
|
||||
this.removeMessage(loadingMessage, option.duration)
|
||||
}
|
||||
} else {
|
||||
// 不存在正在显示的loading则新建一个message,如果新建的message是loading message则将message赋值存储下来
|
||||
let message = NMessage[type](content, option)
|
||||
if (type === 'loading') {
|
||||
loadingMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loading(content) {
|
||||
this.showMessage('loading', content, { duration: 0 })
|
||||
}
|
||||
|
||||
success(content, option = {}) {
|
||||
this.showMessage('success', content, option)
|
||||
}
|
||||
|
||||
error(content, option = {}) {
|
||||
this.showMessage('error', content, option)
|
||||
}
|
||||
|
||||
info(content, option = {}) {
|
||||
this.showMessage('info', content, option)
|
||||
}
|
||||
|
||||
warning(content, option = {}) {
|
||||
this.showMessage('warning', content, option)
|
||||
}
|
||||
}
|
||||
|
||||
return new Message()
|
||||
}
|
||||
|
||||
export function setupDialog(NDialog) {
|
||||
NDialog.confirm = function (option = {}) {
|
||||
const showIcon = !isNullOrUndef(option.title)
|
||||
return NDialog[option.type || 'warning']({
|
||||
showIcon,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: option.confirm,
|
||||
onNegativeClick: option.cancel,
|
||||
onMaskClick: option.cancel,
|
||||
...option,
|
||||
})
|
||||
}
|
||||
|
||||
return NDialog
|
||||
}
|
||||
|
||||
export function setupNaiveDiscreteApi() {
|
||||
const appStore = useAppStore()
|
||||
const configProviderProps = computed(() => ({
|
||||
theme: appStore.isDark ? NaiveUI.darkTheme : undefined,
|
||||
themeOverrides,
|
||||
}))
|
||||
const { message, dialog, notification, loadingBar } = NaiveUI.createDiscreteApi(
|
||||
['message', 'dialog', 'notification', 'loadingBar'],
|
||||
{ configProviderProps }
|
||||
)
|
||||
|
||||
window.$loadingBar = loadingBar
|
||||
window.$notification = notification
|
||||
window.$message = setupMessage(message)
|
||||
window.$dialog = setupDialog(dialog)
|
||||
}
|
33
web/src/utils/http/helpers.js
Normal file
33
web/src/utils/http/helpers.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useUserStore } from '@/store'
|
||||
|
||||
export function addBaseParams(params) {
|
||||
if (!params.userId) {
|
||||
params.userId = useUserStore().id
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveResError(code, message) {
|
||||
switch (code) {
|
||||
case 400:
|
||||
message = message ?? '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
message = message ?? '登录已过期'
|
||||
useUserStore().logout()
|
||||
break
|
||||
case 403:
|
||||
message = message ?? '没有权限'
|
||||
break
|
||||
case 404:
|
||||
message = message ?? '资源或接口不存在'
|
||||
break
|
||||
case 500:
|
||||
message = message ?? '服务器异常'
|
||||
useUserStore().logout()
|
||||
break
|
||||
default:
|
||||
message = message ?? `【${code}】: 未知异常!`
|
||||
break
|
||||
}
|
||||
return message
|
||||
}
|
19
web/src/utils/http/index.js
Normal file
19
web/src/utils/http/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios'
|
||||
import { resReject, resResolve, reqReject, reqResolve } from './interceptors'
|
||||
|
||||
export function createAxios(options = {}) {
|
||||
const defaultOptions = {
|
||||
timeout: 120000,
|
||||
}
|
||||
const service = axios.create({
|
||||
...defaultOptions,
|
||||
...options,
|
||||
})
|
||||
service.interceptors.request.use(reqResolve, reqReject)
|
||||
service.interceptors.response.use(resResolve, resReject)
|
||||
return service
|
||||
}
|
||||
|
||||
export const request = createAxios({
|
||||
baseURL: import.meta.env.VITE_BASE_API,
|
||||
})
|
75
web/src/utils/http/interceptors.js
Normal file
75
web/src/utils/http/interceptors.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { getToken, getTokenAll, getXToken, setToken, setXToken } from '@/utils'
|
||||
import { resolveResError } from './helpers'
|
||||
|
||||
export function reqResolve(config) {
|
||||
// 处理不需要token的请求
|
||||
if (config.noNeedToken) {
|
||||
return config
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
return Promise.reject({ code: 401, message: '登录已过期,请重新登录!' })
|
||||
}
|
||||
|
||||
// if (expire < Date.now()) {
|
||||
// return Promise.reject({ code: 401, message: '登录已过期,请重新登录!' })
|
||||
// }
|
||||
|
||||
/**
|
||||
* * 加上 token
|
||||
* ! 认证方案: JWT Bearer
|
||||
*/
|
||||
config.headers.Authorization = config.headers.Authorization || token
|
||||
config.headers.set('X-TOKEN', getXToken())
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function reqReject(error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
export function resResolve(response) {
|
||||
// 设置token
|
||||
if (response.headers.get('Authorization')) {
|
||||
const { expire } = getTokenAll()
|
||||
setToken(response.headers.get('Authorization'),expire / 1000)
|
||||
setXToken(response.headers.get('X-TOKEN'))
|
||||
}
|
||||
// TODO: 处理不同的 response.headers
|
||||
const { data, status, config, statusText } = response
|
||||
if (data?.code !== 200 && status !== 200) {
|
||||
const code = data?.code ?? status
|
||||
|
||||
/** 根据code处理对应的操作,并返回处理后的message */
|
||||
const message = resolveResError(code, data?.message ?? statusText)
|
||||
|
||||
/** 需要错误提醒 */
|
||||
!config.noNeedTip && window.$message?.error(message)
|
||||
return Promise.reject({ code, message, error: data || response })
|
||||
}
|
||||
|
||||
return Promise.resolve(response)
|
||||
}
|
||||
|
||||
export function resReject(error) {
|
||||
if (!error || !error.response) {
|
||||
const code = error?.code
|
||||
/** 根据code处理对应的操作,并返回处理后的message */
|
||||
const message = resolveResError(code, error.message)
|
||||
window.$message?.error(message)
|
||||
return Promise.reject({ code, message, error })
|
||||
}
|
||||
if (error.response.headers.get('Authorization')) {
|
||||
const { expire } = getTokenAll()
|
||||
setToken(error.response.headers.get('Authorization'),expire / 1000)
|
||||
setXToken(error.response.headers.get('X-TOKEN'))
|
||||
}
|
||||
const { data, status, config } = error.response
|
||||
const code = data?.code ?? status
|
||||
const message = resolveResError(code, data?.message ?? error.message)
|
||||
/** 需要错误提醒 */
|
||||
!config?.noNeedTip && window.$message?.error(message)
|
||||
return Promise.reject({ code, message, error: error.response?.data || error.response })
|
||||
}
|
4
web/src/utils/index.js
Normal file
4
web/src/utils/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './common'
|
||||
export * from './storage'
|
||||
export * from './http'
|
||||
export * from './auth'
|
21
web/src/utils/storage/index.js
Normal file
21
web/src/utils/storage/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createStorage } from './storage'
|
||||
|
||||
const prefixKey = 'Vue_Naive_Admin_'
|
||||
|
||||
export const createLocalStorage = function (option = {}) {
|
||||
return createStorage({
|
||||
prefixKey: option.prefixKey || '',
|
||||
storage: localStorage,
|
||||
})
|
||||
}
|
||||
|
||||
export const createSessionStorage = function (option = {}) {
|
||||
return createStorage({
|
||||
prefixKey: option.prefixKey || '',
|
||||
storage: sessionStorage,
|
||||
})
|
||||
}
|
||||
|
||||
export const lStorage = createLocalStorage({ prefixKey })
|
||||
|
||||
export const sStorage = createSessionStorage({ prefixKey })
|
55
web/src/utils/storage/storage.js
Normal file
55
web/src/utils/storage/storage.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { isNullOrUndef } from '@/utils'
|
||||
|
||||
class Storage {
|
||||
constructor(option) {
|
||||
this.storage = option.storage
|
||||
this.prefixKey = option.prefixKey
|
||||
}
|
||||
|
||||
getKey(key) {
|
||||
return `${this.prefixKey}${key}`.toUpperCase()
|
||||
}
|
||||
|
||||
set(key, value, expire) {
|
||||
const stringData = JSON.stringify({
|
||||
value,
|
||||
time: Date.now(),
|
||||
expire: !isNullOrUndef(expire) ? expire * 1000 : null,
|
||||
})
|
||||
this.storage.setItem(this.getKey(key), stringData)
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const { value } = this.getItem(key, {})
|
||||
return value
|
||||
}
|
||||
|
||||
getItem(key, def = null) {
|
||||
const val = this.storage.getItem(this.getKey(key))
|
||||
if (!val) return def
|
||||
try {
|
||||
const data = JSON.parse(val)
|
||||
const { value, time, expire } = data
|
||||
if (isNullOrUndef(expire) || expire > Date.now()) {
|
||||
return { value, time, expire }
|
||||
}
|
||||
this.remove(key)
|
||||
return def
|
||||
} catch (error) {
|
||||
this.remove(key)
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
remove(key) {
|
||||
this.storage.removeItem(this.getKey(key))
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.storage.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function createStorage({ prefixKey = '', storage = sessionStorage }) {
|
||||
return new Storage({ prefixKey, storage })
|
||||
}
|
10
web/src/views/client/api.js
Normal file
10
web/src/views/client/api.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
clientList: (params) => request.get('/client/list',{ params }), // 客户端列表
|
||||
downloadClient: (type,id) => request.get(`/client/download/${id}/${type}`), // 下载客户端配置文件
|
||||
deleteClient: (id) => request.delete(`/client/${id}`), // 删除客户端
|
||||
saveClient: (data) => request.post(`/client`, data), // 新增/编辑客户端
|
||||
generateClientKeys: () => request.post(`/client/generate-keys`), // 生成密钥对
|
||||
generateClientIP: () => request.post(`/client/generate-ip`), // 生成客户端IP
|
||||
}
|
669
web/src/views/client/index.vue
Normal file
669
web/src/views/client/index.vue
Normal file
@@ -0,0 +1,669 @@
|
||||
<template>
|
||||
<AppPage>
|
||||
<header mt-10 mb-35 min-h-190 flex items-center justify-between px-15>
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<template v-else>
|
||||
<n-card style="margin-left: 1.65%; width: 97.25%">
|
||||
<QueryBar @search="search">
|
||||
<n-form label-placement="left"
|
||||
ref="listQueryRef"
|
||||
:model="searchParam"
|
||||
>
|
||||
<n-space justify="start">
|
||||
<n-form-item style="margin-top: 3px" size="small" label="名称" path="name">
|
||||
<n-input size="small" v-model:value="searchParam.name"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱" path="email">
|
||||
<n-input size="small" v-model:value="searchParam.email"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="IP" path="ipAllocation">
|
||||
<n-input size="small" v-model:value="searchParam.ipAllocation"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态" path="enabled" style="width: 150px">
|
||||
<n-select
|
||||
size="small"
|
||||
v-model:value="searchParam.enabled"
|
||||
placeholder="请选择"
|
||||
clearable
|
||||
:options="selOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</QueryBar>
|
||||
<n-divider/>
|
||||
<n-space justify="end">
|
||||
<n-button size="small" style="float: right" type="info" @click="addClient">添加</n-button>
|
||||
<n-button size="small" style="float: right" type="primary" @click="refreshList">刷新</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<slot name="action" />
|
||||
</template>
|
||||
</header>
|
||||
<div>
|
||||
<n-card
|
||||
v-for="row in listData.data"
|
||||
:hoverable="true"
|
||||
style="margin-left: 2.5%;margin-bottom: 10px;float: left;width: 22%;"
|
||||
>
|
||||
<template #header>
|
||||
<n-popover trigger="hover">
|
||||
<template #trigger>
|
||||
<n-tag type="info" size="small" :bordered="false">
|
||||
{{ ellipsis(row.name) }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<span>{{ row.name }}</span>
|
||||
</n-popover>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-button size="small" type="info" @click="openEditModal(row)">详情</n-button>
|
||||
<n-button size="small" ml-5 color="#8F930B" @click="openQrCode(row.id,row.name)">二维码</n-button>
|
||||
<n-dropdown
|
||||
trigger="click"
|
||||
:options="dropMenuOpts"
|
||||
:show-arrow="true"
|
||||
@select="moreSelectHandle"
|
||||
>
|
||||
<n-button size="small" ml-5 type="primary" @click="saveRows(row)">更多</n-button>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
<div>
|
||||
<n-form label-placement="top" label-width="auto">
|
||||
<n-form-item label="名称:">
|
||||
<n-button color="#60688C" dashed size="small">
|
||||
{{ row.name }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱:">
|
||||
<n-button color="#60688C" dashed size="small">
|
||||
{{ !row.email ? '--' : row.email }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="客户端IP:">
|
||||
<n-button mr-3="" color="#0519F8" dashed size="small" v-for="cip in row.ipAllocation">
|
||||
{{ cip }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="可访问IP:">
|
||||
<n-button v-if="row.allowedIps.length <= 0" dashed size="small">
|
||||
-
|
||||
</n-button>
|
||||
<n-button v-else dashed mr-2 type="warning" v-for="aip in row.allowedIps" size="small">
|
||||
{{ aip }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="创建人:">
|
||||
<n-button color="#54150F" dashed size="small">
|
||||
{{ row.createUser }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态:">
|
||||
<n-button v-if="row.enabled === 1" color="#067748" round :bordered="false" size="small">
|
||||
启用
|
||||
</n-button>
|
||||
<n-button v-else color="#BA090C" round :bordered="false" size="small">
|
||||
禁用
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="离线监听:">
|
||||
<n-button v-if="row.offlineMonitoring === 1" color="#067748" round :bordered="false" size="small">
|
||||
启用
|
||||
</n-button>
|
||||
<n-button v-else color="#BA090C" round :bordered="false" size="small">
|
||||
禁用
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item class="timeItem" label="时间:">
|
||||
<n-space vertical>
|
||||
<span> 创建时间: {{ row.createdAt }}</span>
|
||||
<span> 更新时间: {{ row.updatedAt }}</span>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
<div mt-10 mb-20 min-h-45 flex items-center justify-between px-15>
|
||||
<n-card v-if="listData.total > 8" style="margin-left: 1.65%; width: 97.25%">
|
||||
<n-pagination
|
||||
:item-count="listData.total"
|
||||
v-model:page="search.page"
|
||||
@update-page="pageChange"
|
||||
:default-page-size="8"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
</AppPage>
|
||||
<n-modal
|
||||
style="width: 16%"
|
||||
v-model:show="showQrCode"
|
||||
preset="card"
|
||||
:title="qrCodeData.title"
|
||||
header-style="text-align: center"
|
||||
content-style="text-align: center"
|
||||
>
|
||||
<n-image
|
||||
:src="qrCodeData.data"
|
||||
preview-disabled
|
||||
alt="请使用手机Wireguard软件扫码"
|
||||
/>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
style="width: 25%"
|
||||
v-model:show="editModalShow"
|
||||
preset="card"
|
||||
:title="editModalTitle"
|
||||
header-style="text-align: center"
|
||||
>
|
||||
<n-form
|
||||
ref="editModalFormRef"
|
||||
:rules="editModalFormRules"
|
||||
:model="editModalForm"
|
||||
label-placement="left"
|
||||
label-width="auto"
|
||||
label-align="left"
|
||||
require-mark-placement="right"
|
||||
>
|
||||
<n-form-item label="名称" path="name">
|
||||
<n-input v-model:value="editModalForm.name"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱" path="email">
|
||||
<n-input v-model:value="editModalForm.email"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="IP" path="ipAllocation" :rule="{
|
||||
required: true,
|
||||
type: 'array',
|
||||
message: 'IP不能为空',
|
||||
trigger: ['blur','change']
|
||||
}">
|
||||
<n-select
|
||||
v-model:value="editModalForm.ipAllocation"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可访问IP" path="allowedIps">
|
||||
<n-select
|
||||
v-model:value="editModalForm.allowedIps"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可访问IP(扩展)" path="extraAllowedIps">
|
||||
<n-select
|
||||
v-model:value="editModalForm.extraAllowedIps"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="服务端DNS" path="useServerDns">
|
||||
<n-radio value="1" :checked="editModalForm.useServerDns === 1" @change="editModalForm.useServerDns = 1">是</n-radio>
|
||||
<n-radio value="0" :checked="editModalForm.useServerDns === 0" @change="editModalForm.useServerDns = 0">否</n-radio>
|
||||
</n-form-item>
|
||||
<n-form-item label="公钥" path="keys.publicKey">
|
||||
<n-input disabled v-model:value="editModalForm.keys.publicKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="私钥" path="keys.privateKey">
|
||||
<n-input disabled v-model:value="editModalForm.keys.privateKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="共享密钥" path="keys.presharedKey">
|
||||
<n-input disabled v-model:value="editModalForm.keys.presharedKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态" path="editModalForm.enabled">
|
||||
<n-radio-group :value="editModalForm.enabled">
|
||||
<n-radio :value="1" :checked="editModalForm.enabled === 1" @change="editModalForm.enabled = 1">启用</n-radio>
|
||||
<n-radio :value="0" :checked="editModalForm.enabled === 0" @change="editModalForm.enabled = 0">禁用</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="离线监听" path="offlineMonitoring">
|
||||
<n-radio-group :value="editModalForm.offlineMonitoring">
|
||||
<n-radio :value="1" :checked="editModalForm.offlineMonitoring === 1" @change="editModalForm.offlineMonitoring = 1">启用</n-radio>
|
||||
<n-radio :value="0" :checked="editModalForm.offlineMonitoring === 0" @change="editModalForm.offlineMonitoring = 0">禁用</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-button type="info" style="margin-left: 40%" @click="updateClient()">确认</n-button>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
style="width: 25%"
|
||||
v-model:show="addModalShow"
|
||||
preset="card"
|
||||
title="添加"
|
||||
header-style="text-align: center"
|
||||
>
|
||||
<n-form
|
||||
ref="addModalFormRef"
|
||||
:rules="addModalFormRules"
|
||||
:model="addModalForm"
|
||||
label-placement="left"
|
||||
label-width="auto"
|
||||
label-align="left"
|
||||
require-mark-placement="right"
|
||||
>
|
||||
<n-form-item label="名称" path="name">
|
||||
<n-input v-model:value="addModalForm.name"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱" path="email">
|
||||
<n-input v-model:value="addModalForm.email"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="IP" path="ipAllocation" :rule="{
|
||||
required: true,
|
||||
type: 'array',
|
||||
message: 'IP不能为空',
|
||||
trigger: ['blur','change']
|
||||
}">
|
||||
<n-select
|
||||
v-model:value="addModalForm.ipAllocation"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可访问IP" path="allowedIps">
|
||||
<n-select
|
||||
v-model:value="addModalForm.allowedIps"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可访问IP(扩展)" path="extraAllowedIps">
|
||||
<n-select
|
||||
v-model:value="addModalForm.extraAllowedIps"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="服务端DNS" path="useServerDns">
|
||||
<n-radio value="1" :checked="addModalForm.useServerDns === 1" @change="addModalForm.useServerDns = 1">是</n-radio>
|
||||
<n-radio value="0" :checked="addModalForm.useServerDns === 0" @change="addModalForm.useServerDns = 0">否</n-radio>
|
||||
</n-form-item>
|
||||
<n-form-item label="公钥" path="keys.publicKey">
|
||||
<n-input v-model:value="addModalForm.keys.publicKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="私钥" path="keys.privateKey">
|
||||
<n-input v-model:value="addModalForm.keys.privateKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="共享密钥" path="keys.presharedKey">
|
||||
<n-input v-model:value="addModalForm.keys.presharedKey"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button style="margin-left: 28%" size="small" type="info" @click="generateKeys">生成密钥对</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态" path="editModalForm.enabled">
|
||||
<n-radio-group :value="addModalForm.enabled">
|
||||
<n-radio :value="1" :checked="addModalForm.enabled === 1" @change="addModalForm.enabled = 1">启用</n-radio>
|
||||
<n-radio :value="0" :checked="addModalForm.enabled === 0" @change="addModalForm.enabled = 0">禁用</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="离线监听" path="offlineMonitoring">
|
||||
<n-radio-group :value="addModalForm.offlineMonitoring">
|
||||
<n-radio :value="1" :checked="addModalForm.offlineMonitoring === 1" @change="addModalForm.offlineMonitoring = 1">启用</n-radio>
|
||||
<n-radio :value="0" :checked="addModalForm.offlineMonitoring === 0" @change="addModalForm.offlineMonitoring = 0">禁用</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-button type="info" style="margin-left: 40%" @click="confirmAddClient()">确认</n-button>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import api from '@/views/client/api'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { debounce, ellipsis } from '@/utils'
|
||||
import clientApi from '@/views/client/api'
|
||||
import QueryBar from '@/components/query-bar/QueryBar.vue'
|
||||
|
||||
const selOptions = [
|
||||
{
|
||||
label: '启用',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '禁用',
|
||||
value: 0
|
||||
}
|
||||
]
|
||||
|
||||
const listQueryRef = ref()
|
||||
|
||||
// 卡片数据表
|
||||
const listData = reactive({
|
||||
data: [],
|
||||
total: 0,
|
||||
totalPage: 0,
|
||||
})
|
||||
|
||||
// 是否打开二维码模态框
|
||||
const showQrCode = ref(false)
|
||||
|
||||
// 编辑模块框
|
||||
const editModalShow = ref(false)
|
||||
const addModalShow = ref(false)
|
||||
const editModalTitle = ref('')
|
||||
|
||||
// 二维码的数据
|
||||
const qrCodeData = {
|
||||
title: '',
|
||||
data: '',
|
||||
}
|
||||
|
||||
// 点击更多按钮时候保存的当前客户端信息
|
||||
const getMoreRowData = ref({
|
||||
id: '',
|
||||
name: ''
|
||||
})
|
||||
|
||||
// 编辑模态框的form ref
|
||||
const editModalFormRef = ref()
|
||||
const addModalFormRef = ref()
|
||||
|
||||
// 编辑模态框的form rules
|
||||
const editModalFormRules = {
|
||||
name: [
|
||||
{ required: true, message: '名称不能为空', trigger: 'blur' }
|
||||
],
|
||||
keys: {
|
||||
privateKey: [
|
||||
{ required: true, message: '密钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
publicKey: [
|
||||
{ required: true, message: '公钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
presharedKey: [
|
||||
{ required: true, message: '共享密钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
},
|
||||
enabled: [
|
||||
{ required: true, message: '状态不能为空', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 添加模态框的form rules
|
||||
const addModalFormRules = {
|
||||
name: [
|
||||
{ required: true, message: '名称不能为空', trigger: 'blur' }
|
||||
],
|
||||
keys: {
|
||||
privateKey: [
|
||||
{ required: true, message: '密钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
publicKey: [
|
||||
{ required: true, message: '公钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
presharedKey: [
|
||||
{ required: true, message: '共享密钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
},
|
||||
enabled: [
|
||||
{ required: true, message: '状态不能为空', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 编辑模态框的数据集
|
||||
const editModalForm = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
ipAllocation: [],
|
||||
allowedIps: [],
|
||||
extraAllowedIps: [],
|
||||
useServerDns: 0,
|
||||
keys: {
|
||||
privateKey: '',
|
||||
publicKey: '',
|
||||
presharedKey: ''
|
||||
},
|
||||
enabled: 1,
|
||||
offlineMonitoring: 1
|
||||
})
|
||||
|
||||
// 添加模态框的数据集
|
||||
const addModalForm = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
ipAllocation: [],
|
||||
allowedIps: [],
|
||||
extraAllowedIps: [],
|
||||
useServerDns: 0,
|
||||
keys: {
|
||||
privateKey: '',
|
||||
publicKey: '',
|
||||
presharedKey: ''
|
||||
},
|
||||
enabled: 1,
|
||||
offlineMonitoring: 1
|
||||
})
|
||||
|
||||
// 下拉菜单选项
|
||||
const dropMenuOpts = [
|
||||
{
|
||||
key: 'download',
|
||||
label: '下载',
|
||||
},
|
||||
{
|
||||
key: 'mail',
|
||||
label: '邮件',
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
}
|
||||
]
|
||||
|
||||
// 页码参数
|
||||
const searchParam = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
ipAllocation: '',
|
||||
enabled: undefined,
|
||||
current: 1,
|
||||
size: 8,
|
||||
})
|
||||
|
||||
// 获取客户端列表
|
||||
function getClientList() {
|
||||
api.clientList(searchParam).then((res) => {
|
||||
if (res.data.code === 200) {
|
||||
listData.data = res.data.data.records
|
||||
listData.total = res.data.data.total;
|
||||
listData.totalPage = res.data.data.totalPage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 打开二维码
|
||||
function openQrCode(clientID,title) {
|
||||
api.downloadClient("QRCODE",clientID).then((res) => {
|
||||
if (res.data.code === 200) {
|
||||
qrCodeData.data = res.data.data.qrCode
|
||||
qrCodeData.title = title
|
||||
showQrCode.value = true
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// 更多按钮下的操作逻辑
|
||||
function moreSelectHandle(key) {
|
||||
switch (key) {
|
||||
case "download": // 下载文件
|
||||
api.downloadClient("FILE",getMoreRowData.value.id).then((response) => {
|
||||
if (response && response.status === 200) {
|
||||
const blob = new Blob([response.data], {
|
||||
type: "text/plain"
|
||||
});
|
||||
const link = document.createElement("a"); // 创建a标签
|
||||
link.download = getMoreRowData.value.name + ".conf"; // a标签添加属性
|
||||
link.style.display = "none";
|
||||
link.href = URL.createObjectURL(blob);
|
||||
document.body.appendChild(link);
|
||||
link.click(); // 执行下载
|
||||
URL.revokeObjectURL(link.href); // 释放url
|
||||
document.body.removeChild(link); // 释放标签
|
||||
}
|
||||
})
|
||||
break;
|
||||
case "mail": // 发送邮件
|
||||
api.downloadClient("EMAIL",getMoreRowData.value.id).then((response) => {
|
||||
if (response.data.code === 200) {
|
||||
$message.success("发送邮件成功,请注意查收!")
|
||||
}
|
||||
})
|
||||
break;
|
||||
case "delete": // 删除客户端
|
||||
$dialog.confirm({
|
||||
type: 'warning',
|
||||
title: '删除',
|
||||
content: `是否删除客户端:【${getMoreRowData.value.name}】?`,
|
||||
async confirm() {
|
||||
api.deleteClient(getMoreRowData.value.id).then(res => {
|
||||
if (res.data.code === 200) {
|
||||
$message.success("操作成功!")
|
||||
getClientList()
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 点击更多按钮传递数据
|
||||
function saveRows(row) {
|
||||
getMoreRowData.value = row
|
||||
}
|
||||
|
||||
// 打开编辑模态框
|
||||
function openEditModal(row) {
|
||||
editModalShow.value = true
|
||||
editModalTitle.value = row.name
|
||||
editModalForm.value.id = row.id
|
||||
editModalForm.value.name = row.name
|
||||
editModalForm.value.email = row.email
|
||||
editModalForm.value.ipAllocation = row.ipAllocation
|
||||
editModalForm.value.allowedIps = row.allowedIps
|
||||
editModalForm.value.extraAllowedIps = row.extraAllowedIps
|
||||
editModalForm.value.useServerDns = row.useServerDns
|
||||
editModalForm.value.keys.privateKey = row.keys.privateKey
|
||||
editModalForm.value.keys.publicKey = row.keys.publicKey
|
||||
editModalForm.value.keys.presharedKey = row.keys.presharedKey
|
||||
editModalForm.value.enabled = row.enabled
|
||||
editModalForm.value.offlineMonitoring = row.offlineMonitoring
|
||||
}
|
||||
|
||||
// 更改客户端信息
|
||||
function updateClient() {
|
||||
editModalFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
return valid
|
||||
}
|
||||
|
||||
api.saveClient(editModalForm.value).then((res) => {
|
||||
if (res.data.code === 200) {
|
||||
editModalShow.value = false
|
||||
getClientList()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// 点击确认按钮
|
||||
async function confirmAddClient() {
|
||||
addModalFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
return valid
|
||||
}
|
||||
|
||||
clientApi.saveClient(addModalForm.value).then((response) => {
|
||||
if (response.data.code === 200) {
|
||||
refreshList()
|
||||
addModalShow.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 添加客户端
|
||||
const addClient = debounce(() => {
|
||||
generateClientIPS()
|
||||
addModalForm.value.name = ""
|
||||
addModalShow.value = true
|
||||
},300)
|
||||
|
||||
// 生成密钥对
|
||||
function generateKeys() {
|
||||
clientApi.generateClientKeys().then(res => {
|
||||
if (res.data.code === 200) {
|
||||
addModalForm.value.keys.privateKey = res.data.data.privateKey
|
||||
addModalForm.value.keys.publicKey = res.data.data.publicKey
|
||||
addModalForm.value.keys.presharedKey = res.data.data.presharedKey
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生成客户端IP
|
||||
function generateClientIPS() {
|
||||
clientApi.generateClientIP().then(res => {
|
||||
if (res.data.code === 200) {
|
||||
addModalForm.value.ipAllocation = res.data.data.clientIPS
|
||||
addModalForm.value.allowedIps = res.data.data.serverIPS
|
||||
generateKeys()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
function refreshList() {
|
||||
searchParam.name = ""
|
||||
searchParam.email = ""
|
||||
searchParam.enabled = undefined
|
||||
searchParam.ipAllocation = ""
|
||||
getClientList()
|
||||
}
|
||||
|
||||
// 页码变化
|
||||
function pageChange(page) {
|
||||
searchParam.current = page
|
||||
getClientList()
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function search() {
|
||||
getClientList()
|
||||
}
|
||||
|
||||
getClientList()
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.timeItem {
|
||||
.n-form-item-blank {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
23
web/src/views/client/route.js
Normal file
23
web/src/views/client/route.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
export default {
|
||||
name: 'Client',
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/client',
|
||||
meta: {
|
||||
order: 2,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'Client',
|
||||
path: 'client',
|
||||
component: () => import('./index.vue'),
|
||||
meta: {
|
||||
title: '客户端',
|
||||
icon: 'ph:network-duotone',
|
||||
order: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
7
web/src/views/login/api.js
Normal file
7
web/src/views/login/api.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
// 获取验证码
|
||||
captcha: () => request.get('/login/captcha',{ noNeedToken: true }),
|
||||
login: (data) => request.post('/login', data, { noNeedToken: true }),
|
||||
}
|
150
web/src/views/login/index.vue
Normal file
150
web/src/views/login/index.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<AppPage :show-footer="true" bg-cover :style="{ backgroundImage: `url(${bgImg})` }">
|
||||
<div
|
||||
style="transform: translateY(25px)"
|
||||
class="m-auto max-w-700 min-w-345 f-c-c rounded-10 bg-white bg-opacity-60 p-15 card-shadow"
|
||||
dark:bg-dark
|
||||
>
|
||||
<div hidden w-380 flex-col px-20 py-35 md:block>
|
||||
<img src="@/assets/images/login_banner.webp" w-full alt="login_banner" />
|
||||
</div>
|
||||
|
||||
<div w-320 flex-col px-20 py-35>
|
||||
<h5 f-c-c text-24 font-normal color="#6a6a6a">
|
||||
<img src="@/assets/images/logo.png" height="50" class="mr-10" />
|
||||
{{ title }}
|
||||
</h5>
|
||||
<div mt-20>
|
||||
<n-input
|
||||
v-model:value="loginInfo.account"
|
||||
autofocus
|
||||
class="h-45 items-center text-16"
|
||||
placeholder="账号"
|
||||
:maxlength="20"
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-material-symbols:account-circle-outline class="mr-8 text-20 opacity-40" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<div mt-20>
|
||||
<n-input
|
||||
v-model:value="loginInfo.password"
|
||||
class="h-45 items-center text-16"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="密码"
|
||||
:maxlength="20"
|
||||
@keydown.enter="handleLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-ri:lock-password-line class="mr-8 text-20 opacity-40" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
|
||||
<div class="mt-20 flex items-center">
|
||||
<n-input
|
||||
v-model:value="loginInfo.captchaCode"
|
||||
class="h-45 items-center w-8"
|
||||
palceholder="请输入验证码"
|
||||
:maxlength="4"
|
||||
@keydown.enter="handleLogin()"
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-material-symbols:keyboard-outline class="mr-8 text-20 opacity-40" />
|
||||
</template>
|
||||
</n-input>
|
||||
<img
|
||||
v-if="captchaUrl"
|
||||
:src="captchaUrl"
|
||||
alt="验证码"
|
||||
height="38"
|
||||
class="ml-12 w-120 cursor-pointer"
|
||||
@click="getCaptcha()"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div mt-20>
|
||||
<n-button
|
||||
h-50
|
||||
w-full
|
||||
rounded-5
|
||||
text-16
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { setToken, setXToken } from '@/utils'
|
||||
import bgImg from '@/assets/images/login_bg.webp'
|
||||
import api from './api'
|
||||
import { addDynamicRoutes } from '@/router'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const title = import.meta.env.VITE_TITLE
|
||||
|
||||
const router = useRouter()
|
||||
const { query } = useRoute()
|
||||
|
||||
const captchaUrl = ref('')
|
||||
const loginInfo = ref({
|
||||
account: '',
|
||||
password: '',
|
||||
captchaId: '',
|
||||
captchaCode: ''
|
||||
})
|
||||
|
||||
// 进入系统初始化验证码
|
||||
getCaptcha()
|
||||
|
||||
// 获取验证码
|
||||
async function getCaptcha() {
|
||||
const res = await api.captcha()
|
||||
captchaUrl.value = res.data.data.captcha
|
||||
loginInfo.value.captchaId = res.data.data.id
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
async function handleLogin() {
|
||||
const { account, password, captchaCode } = loginInfo.value
|
||||
if (!account || !password) {
|
||||
$message.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
if (!captchaCode) {
|
||||
$message.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
$message.loading('正在验证...')
|
||||
const res = await api.login(loginInfo.value)
|
||||
$message.success('登录成功')
|
||||
const tokenStr = `${res.data.data.type} ${res.data.data.token}`
|
||||
setToken(tokenStr,res.data.data.expireAt)
|
||||
setXToken(res.headers.get('X-Token'))
|
||||
await addDynamicRoutes()
|
||||
if (query.redirect) {
|
||||
const path = query.redirect
|
||||
Reflect.deleteProperty(query, 'redirect')
|
||||
router.push({ path, query })
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
} catch (error) {
|
||||
await getCaptcha()
|
||||
console.error(error)
|
||||
$message.removeMessage()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
9
web/src/views/setting/api.js
Normal file
9
web/src/views/setting/api.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
getSetting: (params) => request.get('/setting',{params}), // 获取配置
|
||||
setSetting: (data) => request.post('/setting', data), // 设置配置
|
||||
delSetting: (code) => request.delete(`/setting/${code}`), // 删除配置
|
||||
allSettings: () => request.get('/setting/all'), // 所有配置
|
||||
publicAddr: () => request.get('/setting/public-addr'), // 获取公网地址
|
||||
}
|
489
web/src/views/setting/index.vue
Normal file
489
web/src/views/setting/index.vue
Normal file
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<AppPage>
|
||||
<n-card>
|
||||
<n-tabs default-value="Server" justify-content="space-evenly" type="line" @update-value="tabChange">
|
||||
<n-tab-pane name="Server" tab="服务端">
|
||||
<n-form
|
||||
ref="serverFormRef"
|
||||
:model="serverFormModel"
|
||||
:rules="serverFormRules"
|
||||
>
|
||||
<n-form-item label="IP段" path="ipScope" :rule="{
|
||||
required: true,
|
||||
type: 'array',
|
||||
message: 'IP段不能为空',
|
||||
trigger: ['change','blur']
|
||||
}">
|
||||
<n-select
|
||||
v-model:value="serverFormModel.ipScope"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="监听端口" path="listenPort" :rule="[
|
||||
{
|
||||
required: true,
|
||||
type: 'number',
|
||||
message: '监听端口不能为空',
|
||||
trigger: ['change','blur']
|
||||
},
|
||||
{
|
||||
min: 1120,
|
||||
type: 'number',
|
||||
message: '端口最低设置为1120',
|
||||
trigger: ['change','blur']
|
||||
},
|
||||
{
|
||||
max: 65535,
|
||||
type: 'number',
|
||||
message: '端口最高设置为65535',
|
||||
trigger: ['change','blur']
|
||||
}
|
||||
]">
|
||||
<n-input-number :min="1120" :max="65535" v-model:value="serverFormModel.listenPort"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="私钥" path="privateKey">
|
||||
<n-input v-model:value="serverFormModel.privateKey"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="公钥" path="publicKey">
|
||||
<n-input v-model:value="serverFormModel.publicKey"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="上行脚本" path="postUpScript">
|
||||
<n-input v-model:value="serverFormModel.postUpScript"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="下行脚本" path="postDownScript">
|
||||
<n-input v-model:value="serverFormModel.postDownScript"/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="info" @click="updateServerConf">确认</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="Global" tab="全局">
|
||||
<n-form
|
||||
ref="globalFormRef"
|
||||
:model="globalFormModel"
|
||||
:rules="globalFormRules"
|
||||
>
|
||||
<n-form-item label="公网IP" path="endpointAddress" class="pid">
|
||||
<n-input v-model:value="globalFormModel.endpointAddress"/>
|
||||
<n-button style="margin-top: 5px" size="small" type="warning" @click="getPublicAddr">获取地址</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="DNS" path="dnsServer" :rule="{
|
||||
required: true,
|
||||
type: 'array',
|
||||
message: 'dns不能为空',
|
||||
trigger: ['change','blur']
|
||||
}">
|
||||
<n-select
|
||||
v-model:value="globalFormModel.dnsServer"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
placeholder="输入,按回车确认"
|
||||
:show-arrow="false"
|
||||
:show="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="MTU" path="MTU" :rule="[
|
||||
{
|
||||
required: true,
|
||||
type: 'number',
|
||||
message: 'MTU不能为空',
|
||||
trigger: ['change','blur']
|
||||
},
|
||||
{
|
||||
min: 1120,
|
||||
type: 'number',
|
||||
message: 'MTU最低设置为100',
|
||||
trigger: ['change','blur']
|
||||
},
|
||||
{
|
||||
max: 3000,
|
||||
type: 'number',
|
||||
message: 'MTU最高设置为3000',
|
||||
trigger: ['change','blur']
|
||||
}
|
||||
]">
|
||||
<n-input-number :min="100" :max="3000" v-model:value="globalFormModel.MTU"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="persistentKeepalive" path="persistentKeepalive" :rule="[
|
||||
{
|
||||
required: true,
|
||||
type: 'number',
|
||||
message: 'persistentKeepalive不能为空',
|
||||
trigger: ['change','blur']
|
||||
},
|
||||
{
|
||||
min: 15,
|
||||
type: 'number',
|
||||
message: 'persistentKeepalive最低设置为15',
|
||||
trigger: ['change','blur']
|
||||
},
|
||||
{
|
||||
max: 300,
|
||||
type: 'number',
|
||||
message: 'persistentKeepalive最高设置为300',
|
||||
trigger: ['change','blur']
|
||||
}
|
||||
]">
|
||||
<n-input-number :min="15" :max="300" v-model:value="globalFormModel.persistentKeepalive"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="firewallMark" path="firewallMark">
|
||||
<n-input v-model:value="globalFormModel.firewallMark"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="table" path="table">
|
||||
<n-input v-model:value="globalFormModel.table"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="configPath" path="configFilePath">
|
||||
<n-input v-model:value="globalFormModel.configFilePath"/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="info" @click="updateGlobalConf">确认</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="Other" tab="其他">
|
||||
<n-button style="float:right;margin-bottom: 10px" size="small" type="info" @click="showAddModal = !showAddModal">添加</n-button>
|
||||
<n-data-table
|
||||
:columns="tableColumns"
|
||||
:data="taleData.data"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
<n-modal
|
||||
:title="editFormModel.describe"
|
||||
v-model:show="showEditModal"
|
||||
preset="card"
|
||||
style="width: 30%"
|
||||
>
|
||||
<n-form
|
||||
ref="editFormRef"
|
||||
:model="editFormModel"
|
||||
>
|
||||
<n-form-item v-for="(item,index) in editFormModel.data" :label="index">
|
||||
<n-input v-if="typeof item === 'string'" v-model:value="editFormModel.data[index]"/>
|
||||
<n-input-number v-else-if="typeof item === 'number'" v-model:value="editFormModel.data[index]"/>
|
||||
<n-radio-group v-else-if="typeof item === 'boolean'" :value="editFormModel.data[index]">
|
||||
<n-radio :value="true" :checked="editFormModel.data[index] === true" @change="editFormModel.data[index] = true">是</n-radio>
|
||||
<n-radio :value="false" :checked="editFormModel.data[index] === false" @change="editFormModel.data[index] = false">否</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="info" @click="updateSetting">确认</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
title="添加"
|
||||
v-model:show="showAddModal"
|
||||
preset="card"
|
||||
style="width: 30%"
|
||||
>
|
||||
<n-form :model="addFormModel" ref="addFormRef">
|
||||
<n-form-item label="Code">
|
||||
<n-input v-model:value="addFormModel.code"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="选项">
|
||||
<n-dynamic-input v-model:value="addFormModel.data" preset="pair" key-placeholder="键" value-placeholder="值"/>
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="addFormModel.describe"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="info" @click="addSetting">确认</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</AppPage>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
// 服务端配置表单ref
|
||||
import AppPage from '@/components/page/AppPage.vue'
|
||||
import api from '@/views/setting/api'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { renderIcon } from '@/utils'
|
||||
|
||||
// 表头
|
||||
const tableColumns = [
|
||||
{
|
||||
title: 'code',
|
||||
key: 'code',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
key: "describe",
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row) => {
|
||||
return [
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
color: '#4389DA',
|
||||
onClick: () => listBtn(row, "EDIT")
|
||||
}, {
|
||||
icon: renderIcon('mingcute:edit-line', { size: 14 }),
|
||||
}),
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
style: 'margin-left: 5px',
|
||||
color: '#ED1518',
|
||||
onClick: () => listBtn(row, "DELETE")
|
||||
}, {
|
||||
icon: renderIcon('icon-park-outline:delete', { size: 14 }),
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const taleData = ref({
|
||||
data: []
|
||||
})
|
||||
|
||||
// 服务端表单ref
|
||||
const serverFormRef = ref(null)
|
||||
// 全局设置表单ref
|
||||
const globalFormRef = ref(null)
|
||||
// 编辑模态框表单ref
|
||||
const editFormRef = ref(null)
|
||||
// 添加模态框表单ref
|
||||
const addFormRef = ref(null)
|
||||
// 编辑模态框展示
|
||||
const showEditModal = ref(false)
|
||||
// 添加模态框展示
|
||||
const showAddModal = ref(false)
|
||||
|
||||
// 服务端表单数据
|
||||
const serverFormModel = ref({
|
||||
ipScope: [],
|
||||
listenPort: 51820,
|
||||
privateKey: '',
|
||||
publicKey: '',
|
||||
postUpScript: '',
|
||||
postDownScript: ''
|
||||
})
|
||||
// 全局设置表单数据
|
||||
const globalFormModel = ref({
|
||||
MTU: 1450,
|
||||
configFilePath: '',
|
||||
dnsServer: [],
|
||||
endpointAddress: '',
|
||||
firewallMark: '',
|
||||
persistentKeepalive: 15,
|
||||
table: ''
|
||||
})
|
||||
// 编辑表单数据
|
||||
const editFormModel = ref({
|
||||
code: '',
|
||||
data: '',
|
||||
describe: ''
|
||||
})
|
||||
// 添加表单数据
|
||||
const addFormModel = ref({
|
||||
code: '',
|
||||
data: [
|
||||
{
|
||||
key: '',
|
||||
value: ''
|
||||
}
|
||||
],
|
||||
describe: ''
|
||||
})
|
||||
|
||||
// 服务端表单数据验证
|
||||
const serverFormRules = {
|
||||
privateKey: [
|
||||
{ required: true, message: '密钥不能为空', trigger: 'blur' }
|
||||
],
|
||||
publicKey: [
|
||||
{ required: true, message: '公钥不能为空', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
// 全局表单数据验证
|
||||
const globalFormRules = {
|
||||
endpointAddress: [
|
||||
{ required: true, message: '公网IP不能为空', trigger: 'blur' }
|
||||
],
|
||||
configFilePath: [
|
||||
{ required: true, message: '文件地址不能为空', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取服务端配置
|
||||
async function getServerConfig () {
|
||||
const res = await api.getSetting({
|
||||
code: "WG_SERVER"
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
serverFormModel.value = JSON.parse(res.data.data)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全局配置
|
||||
async function getGlobalConfig () {
|
||||
const res = await api.getSetting({
|
||||
code: "WG_SETTING"
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
globalFormModel.value = JSON.parse(res.data.data)
|
||||
}
|
||||
}
|
||||
|
||||
// 更改服务端配置
|
||||
async function updateServerConf() {
|
||||
serverFormRef.value?.validate(async (errors) => {
|
||||
if (errors) {
|
||||
return errors
|
||||
}
|
||||
const res = await api.setSetting({
|
||||
code: "WG_SERVER",
|
||||
data: JSON.stringify(serverFormModel.value)
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
await getServerConfig()
|
||||
$message.success("操作成功")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// 更改全局配置
|
||||
async function updateGlobalConf() {
|
||||
globalFormRef.value?.validate(async (errors) => {
|
||||
if (errors) {
|
||||
return errors
|
||||
}
|
||||
const res = await api.setSetting({
|
||||
code: "WG_SETTING",
|
||||
data: JSON.stringify(globalFormModel.value)
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
await getGlobalConfig()
|
||||
$message.success("操作成功")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取公网IP地址
|
||||
async function getPublicAddr() {
|
||||
const res = await api.publicAddr()
|
||||
if (res.data.code === 200) {
|
||||
globalFormModel.value.endpointAddress = res.data.data
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全部配置
|
||||
function allSetting() {
|
||||
api.allSettings().then(res => {
|
||||
if (res.data.code === 200) {
|
||||
taleData.value.data = res.data.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// tab切换事件
|
||||
function tabChange(code) {
|
||||
switch (code) {
|
||||
case "Server":
|
||||
getServerConfig()
|
||||
break;
|
||||
case "Global":
|
||||
getGlobalConfig()
|
||||
break;
|
||||
case "Other":
|
||||
allSetting()
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 列表操作按钮
|
||||
function listBtn(row,code) {
|
||||
switch (code) {
|
||||
case "EDIT":
|
||||
showEditModal.value = true
|
||||
editFormModel.value.describe = row.describe
|
||||
editFormModel.value.data = JSON.parse(row.data)
|
||||
editFormModel.value.code = row.code
|
||||
break;
|
||||
case "DELETE":
|
||||
$dialog.confirm({
|
||||
type: 'warning',
|
||||
title: '删除',
|
||||
content: `是否删除设置:【${row.describe}】?`,
|
||||
async confirm() {
|
||||
const res = await api.delSetting(row.code)
|
||||
if (res.data.code === 200) {
|
||||
$message.success("操作成功")
|
||||
allSetting()
|
||||
}
|
||||
},
|
||||
})
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 更改配置信息
|
||||
async function updateSetting() {
|
||||
const res = await api.setSetting({
|
||||
code: editFormModel.value.code,
|
||||
data: JSON.stringify(editFormModel.value.data),
|
||||
describe: editFormModel.value.describe
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
showEditModal.value = false
|
||||
allSetting()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加设置
|
||||
async function addSetting() {
|
||||
const dataObj = {}
|
||||
for (const item of addFormModel.value.data) {
|
||||
dataObj[item.key] = item.value
|
||||
}
|
||||
|
||||
const addFormData = {
|
||||
code: addFormModel.value.code,
|
||||
data: JSON.stringify(dataObj),
|
||||
describe: addFormModel.value.describe
|
||||
}
|
||||
|
||||
const res = await api.setSetting(addFormData)
|
||||
if (res.data.code === 200) {
|
||||
showAddModal.value = false
|
||||
addFormModel.value = {
|
||||
code: '',
|
||||
data: [
|
||||
{
|
||||
key: '',
|
||||
value: ''
|
||||
}
|
||||
],
|
||||
describe: ''
|
||||
}
|
||||
allSetting()
|
||||
}
|
||||
}
|
||||
|
||||
getServerConfig()
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.pid .n-form-item-blank {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
23
web/src/views/setting/route.js
Normal file
23
web/src/views/setting/route.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
export default {
|
||||
name: 'Setting',
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/setting',
|
||||
meta: {
|
||||
order: 3,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'Setting',
|
||||
path: 'setting',
|
||||
component: () => import('./index.vue'),
|
||||
meta: {
|
||||
title: '设置',
|
||||
icon: 'ant-design:setting-outlined',
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
7
web/src/views/user/api.js
Normal file
7
web/src/views/user/api.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
userList: (params = {}) => request.get('/user/list',{ params }), // 用户列表
|
||||
deleteUser: (id) => request.delete(`/user/${id}`,), // 删除用户
|
||||
resetPassword: (id) => request.put(`/user/reset-password/${id}`), // 重置用户密码
|
||||
}
|
405
web/src/views/user/index.vue
Normal file
405
web/src/views/user/index.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<AppPage>
|
||||
<n-card
|
||||
:segmented="{
|
||||
content: true,
|
||||
footer: 'soft'
|
||||
}"
|
||||
header-style="font-size: 15px"
|
||||
:hoverable="true"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #header>
|
||||
{{ $route.meta.title }}
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-button v-if="useUserStore().isAdmin === 1" size="small" type="info" @click="addUser()">添加</n-button>
|
||||
<n-button style="margin-left: 5px" size="small" type="primary" @click="getUserList()">刷新</n-button>
|
||||
</template>
|
||||
<n-data-table
|
||||
remote
|
||||
ref="table"
|
||||
:columns="tableColumns"
|
||||
:data="tableData.data"
|
||||
:pagination="paginate"
|
||||
:paginate-single-page="false"
|
||||
></n-data-table>
|
||||
</n-card>
|
||||
</AppPage>
|
||||
<n-modal
|
||||
v-model:show="showInfoModel"
|
||||
transform-origin="center"
|
||||
preset="card"
|
||||
:title="infoFormModel.nickname || '个人资料'"
|
||||
:bordered="false"
|
||||
size="large"
|
||||
style="width: 400px"
|
||||
header-style="text-align: center"
|
||||
>
|
||||
<n-form
|
||||
ref="infoFormRef"
|
||||
:rules="infoFormRules"
|
||||
:model="infoFormModel"
|
||||
label-placement="left"
|
||||
label-width="auto"
|
||||
label-align="right"
|
||||
require-mark-placement="left"
|
||||
>
|
||||
<n-form-item label="账号" path="account">
|
||||
<n-input v-if="infoFormModel.id !== ''" disabled v-model:value="infoFormModel.account"></n-input>
|
||||
<n-input v-else v-model:value="infoFormModel.account"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="infoFormModel.id === ''" label="密码" path="password">
|
||||
<n-input type="password" v-model:value="infoFormModel.password"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="昵称" path="nickname">
|
||||
<n-input v-model:value="infoFormModel.nickname"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="联系方式" path="contact">
|
||||
<n-input v-model:value="infoFormModel.contact"></n-input>
|
||||
</n-form-item>
|
||||
<n-form-item label="管理员">
|
||||
<n-space>
|
||||
<n-radio
|
||||
:checked="infoFormModel.isAdmin === 1"
|
||||
value="1"
|
||||
@change="infoFormModel.isAdmin = 1"
|
||||
>
|
||||
是
|
||||
</n-radio>
|
||||
<n-radio
|
||||
:checked="infoFormModel.isAdmin === 0"
|
||||
value="0"
|
||||
@change="infoFormModel.isAdmin = 0"
|
||||
>
|
||||
否
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-space>
|
||||
<n-radio
|
||||
:checked="infoFormModel.status === 1"
|
||||
value="1"
|
||||
@change="infoFormModel.status = 1"
|
||||
>
|
||||
是
|
||||
</n-radio>
|
||||
<n-radio
|
||||
:checked="infoFormModel.status === 0"
|
||||
value="0"
|
||||
@change="infoFormModel.status = 0"
|
||||
>
|
||||
否
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-button style="margin-left: 20%" type="primary" @click="SaveUser(infoFormModel)">确认</n-button>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppPage from '@/components/page/AppPage.vue'
|
||||
import api from '@/views/user/api'
|
||||
import userApi from '@/api/user'
|
||||
import { NAvatar,NTag,NButton } from 'naive-ui'
|
||||
import { renderIcon } from '@/utils'
|
||||
import { useUserStore } from '@/store'
|
||||
|
||||
const infoFormRef = ref()
|
||||
|
||||
// 用户信息model的展示控制
|
||||
const showInfoModel = ref(false)
|
||||
// 表单表头
|
||||
const tableColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'index',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (_, index) => {
|
||||
return `${index + 1}`
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
key: 'account',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
key: 'nickname',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '头像',
|
||||
key: 'avatar',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row) => {
|
||||
return h(NAvatar,{
|
||||
src: row.avatar,
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '联系方式',
|
||||
key: 'contact',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '管理员',
|
||||
key: 'isAdmin',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row) => {
|
||||
switch (row.isAdmin) {
|
||||
case 1:
|
||||
return h(NTag,{
|
||||
type: 'info',
|
||||
},{
|
||||
default: () => '是'
|
||||
})
|
||||
case 0:
|
||||
return h(NTag,{
|
||||
type: 'success',
|
||||
},{
|
||||
default: () => '否'
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row) => {
|
||||
switch (row.status) {
|
||||
case 1:
|
||||
return h(NTag,{
|
||||
type: 'info',
|
||||
},{
|
||||
default: () => '启用'
|
||||
})
|
||||
case 0:
|
||||
return h(NTag,{
|
||||
type: 'success',
|
||||
},{
|
||||
default: () => '禁用'
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createdAt',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
key: 'updatedAt',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row) => {
|
||||
let components = [
|
||||
h(NButton,{
|
||||
size: 'small',
|
||||
color: '#4389DA',
|
||||
onClick: () => listBtn(row,"EDIT")
|
||||
},{
|
||||
icon: renderIcon('mingcute:edit-line', { size: 14 }),
|
||||
}),
|
||||
h(NButton,{
|
||||
size: 'small',
|
||||
style: 'margin-left: 5px',
|
||||
color: '#0BE170',
|
||||
onClick: () => listBtn(row,"RESET")
|
||||
},{
|
||||
icon: renderIcon('ic:baseline-lock-reset', { size: 14 }),
|
||||
}),
|
||||
h(NButton,{
|
||||
size: 'small',
|
||||
style: 'margin-left: 5px',
|
||||
color: '#ED1518',
|
||||
onClick: () => listBtn(row,"DELETE")
|
||||
},{
|
||||
icon: renderIcon('icon-park-outline:delete', { size: 14 }),
|
||||
})
|
||||
]
|
||||
const currentLoginUser = useUserStore().localUserInfo
|
||||
if (row.account === 'admin') {
|
||||
components = []
|
||||
}
|
||||
if (currentLoginUser.isAdmin !== 1) {
|
||||
components = []
|
||||
}
|
||||
if (currentLoginUser.id === row.id) {
|
||||
components = []
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const tableData = ref({
|
||||
data: []
|
||||
})
|
||||
|
||||
// 页码控件
|
||||
const paginate = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
itemCount: 0,
|
||||
onChange: (page) => {
|
||||
paginate.page = page;
|
||||
pageParam.current = page
|
||||
pageParam.size = paginate.pageSize
|
||||
getUserList()
|
||||
}
|
||||
})
|
||||
|
||||
// 用户信息的表单数据
|
||||
const infoFormModel = ref({
|
||||
id: '',
|
||||
account: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
contact: '',
|
||||
isAdmin: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 页码参数
|
||||
const pageParam = {
|
||||
current: 1,
|
||||
size: 10
|
||||
}
|
||||
|
||||
// 个人信息表单验证规则
|
||||
const infoFormRules = {
|
||||
account: [
|
||||
{ required: true, message: '账号不能为空', trigger: 'blur' },
|
||||
{ min: 2, message: '账号长度最短2位', trigger: 'blur' },
|
||||
{ max: 20, message: '账号长度最长20位', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ min: 8, message: '密码至少8位', trigger: 'blur' },
|
||||
{ max: 32, message: '密码最多32位', trigger: 'blur' }
|
||||
],
|
||||
nickname: [
|
||||
{ required: true, message: '昵称不能为空', trigger: 'blur' }
|
||||
],
|
||||
isAdmin: [
|
||||
{ required: true, message: '管理员不能为空', trigger: 'blur' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '状态不能为空', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取用户列表信息
|
||||
async function getUserList() {
|
||||
const res = await api.userList(pageParam)
|
||||
if (res.data.code === 200) {
|
||||
tableData.value.data = res.data.data.records;
|
||||
paginate.itemCount = res.data.data.total;
|
||||
}
|
||||
}
|
||||
|
||||
// 列表操作按钮
|
||||
async function listBtn(row,type) {
|
||||
switch (type) {
|
||||
case 'EDIT':
|
||||
showInfoModel.value = true
|
||||
infoFormModel.value.id = row.id;
|
||||
infoFormModel.value.account = row.account;
|
||||
infoFormModel.value.nickname = row.nickname;
|
||||
infoFormModel.value.avatar = row.avatar;
|
||||
infoFormModel.value.contact = row.contact;
|
||||
infoFormModel.value.isAdmin = row.isAdmin;
|
||||
infoFormModel.value.status = row.status;
|
||||
break;
|
||||
case 'RESET':
|
||||
$dialog.confirm({
|
||||
type: 'info',
|
||||
title: '重置密码',
|
||||
content: `是否重置用户:【${row.nickname}】的密码?`,
|
||||
async confirm() {
|
||||
const res = await api.resetPassword(row.id)
|
||||
if (res.data.code === 200) {
|
||||
$message.success("操作成功")
|
||||
await getUserList()
|
||||
}
|
||||
},
|
||||
})
|
||||
break;
|
||||
case 'DELETE':
|
||||
$dialog.confirm({
|
||||
type: 'warning',
|
||||
title: '删除',
|
||||
content: `是否删除用户:【${row.nickname}】?`,
|
||||
async confirm() {
|
||||
const res = await api.deleteUser(row.id)
|
||||
if (res.data.code === 200) {
|
||||
$message.success("操作成功")
|
||||
await getUserList()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 新增/编辑用户
|
||||
async function SaveUser(user) {
|
||||
if (!user) {
|
||||
showInfoModel.value = true
|
||||
}
|
||||
infoFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
return valid
|
||||
}
|
||||
const res = await userApi.addOrUpdateUser(user)
|
||||
if (res.data.code === 200) {
|
||||
await getUserList()
|
||||
showInfoModel.value = false
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
function addUser() {
|
||||
infoFormModel.value = {
|
||||
id: '',
|
||||
account: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
contact: '',
|
||||
isAdmin: 0,
|
||||
status: 1
|
||||
}
|
||||
showInfoModel.value = true
|
||||
}
|
||||
|
||||
getUserList()
|
||||
</script>
|
||||
<style></style>
|
23
web/src/views/user/route.js
Normal file
23
web/src/views/user/route.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
export default {
|
||||
name: 'User',
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/user',
|
||||
meta: {
|
||||
order: 1,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'User',
|
||||
path: 'user',
|
||||
component: () => import('./index.vue'),
|
||||
meta: {
|
||||
title: '管理员',
|
||||
icon: 'ph:users-bold',
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
7
web/src/views/workbench/api.js
Normal file
7
web/src/views/workbench/api.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
logsList: (params) => request.get('/dashboard/request/list',{ params }), // 操作日志列表
|
||||
dailyPoetry: () => request.get('/dashboard/daily-poetry'), // 每日诗词
|
||||
clientConnections: () => request.get('/dashboard/connections') // 客户端链接信息
|
||||
}
|
242
web/src/views/workbench/index.vue
Normal file
242
web/src/views/workbench/index.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<AppPage :show-footer="true">
|
||||
<div class="flex">
|
||||
<n-card class="w-30%">
|
||||
<div class="flex items-center">
|
||||
<n-avatar round :size="60" :src="userStore.avatar" />
|
||||
<div class="ml-20 flex-col">
|
||||
<span class="text-20 opacity-80">Hello, {{ userStore.nickname }}</span>
|
||||
<span class="mt-4 opacity-50">今日事,今日毕。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-40 text-14 opacity-60" style="cursor: pointer" @click="dailyPoe">{{ dailyPoetry.content || '莫向外求,但从心觅,行有不得,反求诸己。' }}</p>
|
||||
<p class="mt-32 text-right text-12 opacity-40">—— {{ dailyPoetry.author || '佚名' }}</p>
|
||||
</n-card>
|
||||
<n-card class="ml-12 w-70%">
|
||||
<n-data-table
|
||||
remote
|
||||
:columns="tableColumns"
|
||||
:data="tableData.data"
|
||||
:pagination="paginate"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-card>
|
||||
<n-data-table
|
||||
remote
|
||||
:columns="connectionsColumns"
|
||||
:data="connectionsData.data"
|
||||
/>
|
||||
</n-card>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/store'
|
||||
import api from '@/views/workbench/api'
|
||||
import { debounce } from '@/utils'
|
||||
import { NTag } from 'naive-ui'
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表格表头
|
||||
const tableColumns = [
|
||||
{
|
||||
title: '用户名称',
|
||||
key: 'username',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '客户端IP',
|
||||
key: 'clientIP',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '请求方法',
|
||||
key: 'method',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '请求主机',
|
||||
key: 'host',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '请求地址',
|
||||
key: 'uri',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '状态码',
|
||||
key: 'statusCode',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '请求时间',
|
||||
key: 'createdAt',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
]
|
||||
// 链接信息列表
|
||||
const connectionsColumns = [
|
||||
{
|
||||
title: '客户端名称',
|
||||
key: 'name',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '联系邮箱',
|
||||
key: 'email',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '客户端IP',
|
||||
key: 'ipAllocation',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '端点',
|
||||
key: 'connectEndpoint',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '是否在线',
|
||||
key: 'online',
|
||||
align: 'center',
|
||||
titleAlign: 'center',
|
||||
render: (row) => {
|
||||
switch (row.online) {
|
||||
case true:
|
||||
return h(NTag,{
|
||||
type: 'info',
|
||||
},{
|
||||
default: () => '在线'
|
||||
})
|
||||
case false:
|
||||
return h(NTag,{
|
||||
type: 'warning',
|
||||
},{
|
||||
default: () => '离线'
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '接受流量',
|
||||
key: 'receiveBytes',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '传输流量',
|
||||
key: 'transmitBytes',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
},
|
||||
{
|
||||
title: '最后握手时间',
|
||||
key: 'lastHandAt',
|
||||
align: 'center',
|
||||
titleAlign: 'center'
|
||||
}
|
||||
]
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref({
|
||||
data: []
|
||||
})
|
||||
|
||||
// 链接数据
|
||||
const connectionsData = ref({
|
||||
data: []
|
||||
})
|
||||
|
||||
const dailyPoetry = ref({
|
||||
author: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
// 页码控件
|
||||
const paginate = reactive({
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
itemCount: 0,
|
||||
pageCount: 0,
|
||||
onChange: (page) => {
|
||||
paginate.page = page;
|
||||
getLogsList()
|
||||
}
|
||||
})
|
||||
|
||||
// 获取操作日志列表
|
||||
async function getLogsList() {
|
||||
try {
|
||||
const res = await api.logsList({
|
||||
current: paginate.page,
|
||||
size: paginate.pageSize,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
tableData.value.data = res.data.data.records;
|
||||
paginate.itemCount = res.data.data.total;
|
||||
paginate.pageCount = res.data.data.totalPage;
|
||||
}
|
||||
}catch (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
// 每日诗词
|
||||
const dailyPoe = debounce(() => {
|
||||
getDailyPoetry()
|
||||
},800)
|
||||
|
||||
// 获取每日诗词
|
||||
function getDailyPoetry() {
|
||||
try {
|
||||
api.dailyPoetry().then(res => {
|
||||
if (res.data.code === 200) {
|
||||
dailyPoetry.value.author = res.data.data.author;
|
||||
dailyPoetry.value.content = res.data.data.content;
|
||||
}
|
||||
})
|
||||
}catch (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
const connectionList = debounce(() => {
|
||||
getClientConnections()
|
||||
},300)
|
||||
|
||||
// 获取客户端链接列表
|
||||
async function getClientConnections() {
|
||||
try {
|
||||
const res = await api.clientConnections()
|
||||
if (res.data.code === 200) {
|
||||
connectionsData.value.data = res.data.data;
|
||||
}
|
||||
}catch (e) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
const initFunc = debounce(() => {
|
||||
getClientConnections()
|
||||
dailyPoe()
|
||||
// connectionList()
|
||||
},500)
|
||||
|
||||
getLogsList()
|
||||
initFunc()
|
||||
</script>
|
23
web/src/views/workbench/route.js
Normal file
23
web/src/views/workbench/route.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/workbench',
|
||||
meta: {
|
||||
order: 0,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'Workbench',
|
||||
path: 'workbench',
|
||||
component: () => import('./index.vue'),
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'mdi:home',
|
||||
order: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
Reference in New Issue
Block a user