🎨添加静态打包
Some checks failed
continuous-integration/drone/tag Build is failing

This commit is contained in:
coward
2024-08-21 09:10:01 +08:00
parent 941b8da804
commit 12e551b4e9
135 changed files with 15749 additions and 3 deletions

11
web/src/App.vue Normal file
View 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
View 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), // 新增/编辑用户信息
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
export { default as useCRUD } from './useCRUD'

View 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,
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,9 @@
<script setup>
import SideLogo from './components/SideLogo.vue'
import SideMenu from './components/SideMenu.vue'
</script>
<template>
<SideLogo />
<SideMenu />
</template>

View 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>

View 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
View 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
View 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()

View 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)
}

View 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()
})
}

View 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
}
})
}

View 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
View 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
}

View 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
View File

@@ -0,0 +1,7 @@
import { createPinia } from 'pinia'
export function setupStore(app) {
app.use(createPinia())
}
export * from './modules'

View 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
},
},
})

View File

@@ -0,0 +1,4 @@
export * from './app'
export * from './permission'
export * from './tags'
export * from './user'

View 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()
},
},
})

View 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']

View 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('')
},
},
})

View 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 }
},
},
})

View 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
View 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;
}

View 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',
})
}

View File

@@ -0,0 +1,2 @@
export * from './auth'
export * from './token'

View 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)
}

View 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;
}

View 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 }) })
}

View 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
View 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

View 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)
}

View 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
}

View 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,
})

View 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
View File

@@ -0,0 +1,4 @@
export * from './common'
export * from './storage'
export * from './http'
export * from './auth'

View 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 })

View 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 })
}

View 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
}

View 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>

View 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,
},
},
],
}

View 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 }),
}

View 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>

View 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'), // 获取公网地址
}

View 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>

View 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,
},
},
],
}

View 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}`), // 重置用户密码
}

View 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>

View 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,
},
},
],
}

View 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') // 客户端链接信息
}

View 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>

View 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,
},
},
],
}