🎉新版本页面

This commit is contained in:
coward 2024-08-08 15:32:17 +08:00
parent 00f3d51c1d
commit 89c89a28ee
62 changed files with 7868 additions and 5413 deletions

2
.env
View File

@ -1,3 +1,3 @@
VITE_TITLE = 'Vue Naive Admin'
VITE_TITLE = 'Wireguard-UI'
VITE_PORT = 3100

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

65
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,65 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<editorconfig>
<option name="ENABLED" value="false" />
</editorconfig>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/git_toolbox_blame.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxBlameSettings">
<option name="version" value="2" />
</component>
</project>

View File

@ -0,0 +1,21 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="7">
<item index="0" class="java.lang.String" itemvalue="nobr" />
<item index="1" class="java.lang.String" itemvalue="noembed" />
<item index="2" class="java.lang.String" itemvalue="comment" />
<item index="3" class="java.lang.String" itemvalue="noscript" />
<item index="4" class="java.lang.String" itemvalue="embed" />
<item index="5" class="java.lang.String" itemvalue="script" />
<item index="6" class="java.lang.String" itemvalue="content" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/wireguard-ui.iml" filepath="$PROJECT_DIR$/.idea/wireguard-ui.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

12
.idea/wireguard-ui.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -7,7 +7,7 @@ export const PROXY_CONFIG = {
* @转发路径 http://localhost:8080/user
*/
'/api': {
target: 'http://localhost:8080',
target: 'http://localhost:6687/api',
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp('^/api'), ''),
},

View File

@ -66,7 +66,7 @@
"fs-extra": "^11.1.1",
"husky": "^8.0.3",
"lint-staged": "^14.0.1",
"naive-ui": "^2.35.0",
"naive-ui": "^2.39.0",
"rollup-plugin-visualizer": "^5.9.2",
"sass": "^1.69.0",
"unocss": "0.55.3",

9649
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
import { request } from '@/utils'
export default {
getUser: () => request.get('/user'),
refreshToken: () => request.post('/auth/refreshToken', null, { noNeedTip: true }),
}

9
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), // 新增/编辑用户信息
}

View File

@ -2,22 +2,22 @@
<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>
<!-- <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>
<!-- <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,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

@ -1,16 +1,16 @@
<template>
<AppPage :show-footer="showFooter">
<header v-if="showHeader" mb-15 min-h-45 flex items-center justify-between px-15>
<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>
<h2 text-22 font-normal text-hex-333 dark:text-hex-ccc>{{ title || route.meta?.title }}</h2>
<h3>{{ title }}</h3>
<slot name="action" />
</template>
</header>
<n-card flex-1 rounded-10>
<div>
<slot />
</n-card>
</div>
</AppPage>
</template>

View File

@ -10,14 +10,14 @@
p-15
bc-ccc
dark:bg-black
h-60
>
<n-space wrap :size="[35, 15]">
<slot />
</n-space>
<div flex-shrink-0>
<n-button secondary type="primary" @click="emit('reset')">重置</n-button>
<n-button ml-20 type="primary" @click="emit('search')">搜索</n-button>
<n-button ml-20 size="small" type="primary" @click="emit('search')">搜索</n-button>
</div>
</div>
</template>

View File

@ -2,36 +2,217 @@
<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.name }}</span>
<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) {
if (key === 'logout') {
$dialog.confirm({
title: '提示',
type: 'info',
content: '确认退出?',
confirm() {
userStore.logout()
$message.success('已退出登录')
},
})
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

@ -4,10 +4,6 @@
<BreadCrumb ml-15 hidden sm:block />
</div>
<div ml-auto flex items-center>
<MessageNotification />
<ThemeMode />
<GiteeSite />
<GithubSite />
<FullScreen />
<UserAvatar />
</div>
@ -18,8 +14,4 @@ import BreadCrumb from './components/BreadCrumb.vue'
import MenuCollapse from './components/MenuCollapse.vue'
import FullScreen from './components/FullScreen.vue'
import UserAvatar from './components/UserAvatar.vue'
import GithubSite from './components/GithubSite.vue'
import GiteeSite from './components/GiteeSite.vue'
import ThemeMode from './components/ThemeMode.vue'
import MessageNotification from './components/MessageNotification.vue'
</script>

View File

@ -4,7 +4,7 @@
bordered
collapse-mode="width"
:collapsed-width="64"
:width="220"
:width="200"
:native-scrollbar="false"
:collapsed="appStore.collapsed"
>

View File

@ -1,4 +1,4 @@
import { getToken, refreshAccessToken, isNullOrWhitespace } from '@/utils'
import { getToken, isNullOrWhitespace } from '@/utils'
const WHITE_LIST = ['/login', '/404']
export function createPermissionGuard(router) {
@ -14,7 +14,6 @@ export function createPermissionGuard(router) {
/** 有token的情况 */
if (to.path === '/login') return { path: '/' }
refreshAccessToken()
return true
})
}

View File

@ -28,41 +28,25 @@ export async function resetRouter() {
}
export async function addDynamicRoutes() {
// return Promise.reject('123')
const token = getToken()
// 没有token情况
if (isNullOrWhitespace(token)) {
router.addRoute(EMPTY_ROUTE)
return
}
// 有token的情况
const userStore = useUserStore()
try {
const permissionStore = usePermissionStore()
!userStore.userId && (await userStore.getUserInfo())
!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)
window.$notification?.success({
title: '🎉🎉🎉 2.0 全栈版本开源了!',
content: () =>
h(
'span',
{},
'2.0为全栈版本,提供前端+后端,全新重构,全面简化,',
h(
'a',
{ href: 'https://admin.isme.top', target: '__blank' },
'👉体验 https://admin.isme.top'
)
),
})
} catch (error) {
console.error(error)
$message.error('初始化用户信息失败: ' + error)

View File

@ -1,13 +1,6 @@
const Layout = () => import('@/layout/index.vue')
export const basicRoutes = [
{
name: '404',
path: '/404',
component: () => import('@/views/error-page/404.vue'),
isHidden: true,
},
{
name: 'Login',
path: '/login',
@ -16,44 +9,7 @@ export const basicRoutes = [
meta: {
title: '登录页',
},
},
{
name: 'ExternalLink',
path: '/external-link',
component: Layout,
meta: {
title: '外部链接',
icon: 'mdi:link-variant',
order: 4,
},
children: [
{
name: 'LinkGithubSrc',
path: 'https://github.com/zclzone/vue-naive-admin',
meta: {
title: '源码 - github',
icon: 'mdi:github',
},
},
{
name: 'LinkGiteeSrc',
path: 'https://gitee.com/zclzone/vue-naive-admin',
meta: {
title: '源码 - gitee',
icon: 'simple-icons:gitee',
},
},
{
name: 'LinkDocs',
path: 'https://zclzone.github.io/vue-naive-admin-docs',
meta: {
title: '文档 - vuepress',
icon: 'mdi:vuejs',
},
},
],
},
}
]
export const NOT_FOUND_ROUTE = {

View File

@ -2,34 +2,54 @@ import { defineStore } from 'pinia'
import { resetRouter } from '@/router'
import { useTagsStore, usePermissionStore } from '@/store'
import { removeToken, toLogin } from '@/utils'
import api from '@/api'
import api from '@/api/user'
export const useUserStore = defineStore('user', {
state() {
return {
userInfo: {},
userInfo: {
id: '',
account: '',
nickname: '',
avatar: '',
contact: '',
isAdmin: 0,
status: 1
},
}
},
getters: {
userId() {
id() {
return this.userInfo?.id
},
name() {
return this.userInfo?.name
account() {
return this.userInfo?.account
},
nickname() {
return this.userInfo?.nickname
},
avatar() {
return this.userInfo?.avatar
},
role() {
return this.userInfo?.role || []
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, name, avatar, role } = res.data
this.userInfo = { id, name, avatar, role }
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)

View File

@ -1,33 +1,29 @@
import { lStorage } from '@/utils'
import api from '@/api'
const TOKEN_CODE = 'access_token'
const DURATION = 6 * 60 * 60
const TOKEN_CODE = 'token'
const X_TOKEN_CODE = 'X_TOKEN_CODE'
export function getToken() {
return lStorage.get(TOKEN_CODE)
}
export function setToken(token) {
lStorage.set(TOKEN_CODE, token, DURATION)
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)
}
export async function refreshAccessToken() {
const tokenItem = lStorage.getItem(TOKEN_CODE)
if (!tokenItem) {
return
}
const { time } = tokenItem
// token生成或者刷新后30分钟内不执行刷新
if (Date.now() - time <= 1000 * 60 * 30) return
try {
const res = await api.refreshToken()
setToken(res.data.token)
} catch (error) {
console.error(error)
}
}

View File

@ -88,3 +88,12 @@ export function useResize(el, cb) {
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

@ -2,7 +2,7 @@ import { useUserStore } from '@/store'
export function addBaseParams(params) {
if (!params.userId) {
params.userId = useUserStore().userId
params.userId = useUserStore().id
}
}
@ -23,6 +23,7 @@ export function resolveResError(code, message) {
break
case 500:
message = message ?? '服务器异常'
useUserStore().logout()
break
default:
message = message ?? `${code}】: 未知异常!`

View File

@ -1,4 +1,4 @@
import { getToken } from '@/utils'
import { getToken, getTokenAll, getXToken, setToken, setXToken } from '@/utils'
import { resolveResError } from './helpers'
export function reqResolve(config) {
@ -12,11 +12,16 @@ export function reqResolve(config) {
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 || 'Bearer ' + token
config.headers.Authorization = config.headers.Authorization || token
config.headers.set('X-TOKEN', getXToken())
return config
}
@ -26,9 +31,15 @@ export function reqReject(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 !== 0) {
if (data?.code !== 200 && status !== 200) {
const code = data?.code ?? status
/** 根据code处理对应的操作并返回处理后的message */
@ -38,7 +49,8 @@ export function resResolve(response) {
!config.noNeedTip && window.$message?.error(message)
return Promise.reject({ code, message, error: data || response })
}
return Promise.resolve(data)
return Promise.resolve(response)
}
export function resReject(error) {
@ -49,6 +61,11 @@ export function resReject(error) {
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)

View File

@ -14,7 +14,7 @@ class Storage {
const stringData = JSON.stringify({
value,
time: Date.now(),
expire: !isNullOrUndef(expire) ? Date.now() + expire * 1000 : null,
expire: !isNullOrUndef(expire) ? expire * 1000 : null,
})
this.storage.setItem(this.getKey(key), stringData)
}
@ -31,7 +31,7 @@ class Storage {
const data = JSON.parse(val)
const { value, time, expire } = data
if (isNullOrUndef(expire) || expire > Date.now()) {
return { value, time }
return { value, time, expire }
}
this.remove(key)
return def

10
src/views/client/api.js Normal file
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
}

669
src/views/client/index.vue Normal file
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 style="margin-left: 1.65%; width: 97.25%">
<n-pagination
:item-count="listData.total"
v-model:page="search.page"
@update-page="pageChange"
:default-page-size="8"
/>
</n-card>
</div>
</AppPage>
<n-modal
style="width: 16%"
v-model:show="showQrCode"
preset="card"
:title="qrCodeData.title"
header-style="text-align: center"
content-style="text-align: center"
>
<n-image
:src="qrCodeData.data"
preview-disabled
alt="请使用手机Wireguard软件扫码"
/>
</n-modal>
<n-modal
style="width: 25%"
v-model:show="editModalShow"
preset="card"
:title="editModalTitle"
header-style="text-align: center"
>
<n-form
ref="editModalFormRef"
:rules="editModalFormRules"
:model="editModalForm"
label-placement="left"
label-width="auto"
label-align="left"
require-mark-placement="right"
>
<n-form-item label="名称" path="name">
<n-input v-model:value="editModalForm.name"/>
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input v-model:value="editModalForm.email"/>
</n-form-item>
<n-form-item label="IP" path="ipAllocation" :rule="{
required: true,
type: 'array',
message: 'IP不能为空',
trigger: ['blur','change']
}">
<n-select
v-model:value="editModalForm.ipAllocation"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="可访问IP" path="allowedIps">
<n-select
v-model:value="editModalForm.allowedIps"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="可访问IP扩展" path="extraAllowedIps">
<n-select
v-model:value="editModalForm.extraAllowedIps"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="服务端DNS" path="useServerDns">
<n-radio value="1" :checked="editModalForm.useServerDns === 1" @change="editModalForm.useServerDns = 1"></n-radio>
<n-radio value="0" :checked="editModalForm.useServerDns === 0" @change="editModalForm.useServerDns = 0"></n-radio>
</n-form-item>
<n-form-item label="公钥" path="keys.publicKey">
<n-input disabled v-model:value="editModalForm.keys.publicKey"></n-input>
</n-form-item>
<n-form-item label="私钥" path="keys.privateKey">
<n-input disabled v-model:value="editModalForm.keys.privateKey"></n-input>
</n-form-item>
<n-form-item label="共享密钥" path="keys.presharedKey">
<n-input disabled v-model:value="editModalForm.keys.presharedKey"></n-input>
</n-form-item>
<n-form-item label="状态" path="editModalForm.enabled">
<n-radio-group :value="editModalForm.enabled">
<n-radio :value="1" :checked="editModalForm.enabled === 1" @change="editModalForm.enabled = 1">启用</n-radio>
<n-radio :value="0" :checked="editModalForm.enabled === 0" @change="editModalForm.enabled = 0">禁用</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="离线监听" path="offlineMonitoring">
<n-radio-group :value="editModalForm.offlineMonitoring">
<n-radio :value="1" :checked="editModalForm.offlineMonitoring === 1" @change="editModalForm.offlineMonitoring = 1">启用</n-radio>
<n-radio :value="0" :checked="editModalForm.offlineMonitoring === 0" @change="editModalForm.offlineMonitoring = 0">禁用</n-radio>
</n-radio-group>
</n-form-item>
<n-button type="info" style="margin-left: 40%" @click="updateClient()">确认</n-button>
</n-form>
</n-modal>
<n-modal
style="width: 25%"
v-model:show="addModalShow"
preset="card"
title="添加"
header-style="text-align: center"
>
<n-form
ref="addModalFormRef"
:rules="addModalFormRules"
:model="addModalForm"
label-placement="left"
label-width="auto"
label-align="left"
require-mark-placement="right"
>
<n-form-item label="名称" path="name">
<n-input v-model:value="addModalForm.name"/>
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input v-model:value="addModalForm.email"/>
</n-form-item>
<n-form-item label="IP" path="ipAllocation" :rule="{
required: true,
type: 'array',
message: 'IP不能为空',
trigger: ['blur','change']
}">
<n-select
v-model:value="addModalForm.ipAllocation"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="可访问IP" path="allowedIps">
<n-select
v-model:value="addModalForm.allowedIps"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="可访问IP扩展" path="extraAllowedIps">
<n-select
v-model:value="addModalForm.extraAllowedIps"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="服务端DNS" path="useServerDns">
<n-radio value="1" :checked="addModalForm.useServerDns === 1" @change="addModalForm.useServerDns = 1"></n-radio>
<n-radio value="0" :checked="addModalForm.useServerDns === 0" @change="addModalForm.useServerDns = 0"></n-radio>
</n-form-item>
<n-form-item label="公钥" path="keys.publicKey">
<n-input v-model:value="addModalForm.keys.publicKey"></n-input>
</n-form-item>
<n-form-item label="私钥" path="keys.privateKey">
<n-input v-model:value="addModalForm.keys.privateKey"></n-input>
</n-form-item>
<n-form-item label="共享密钥" path="keys.presharedKey">
<n-input v-model:value="addModalForm.keys.presharedKey"></n-input>
</n-form-item>
<n-form-item>
<n-button style="margin-left: 28%" size="small" type="info" @click="generateKeys">生成密钥对</n-button>
</n-form-item>
<n-form-item label="状态" path="editModalForm.enabled">
<n-radio-group :value="addModalForm.enabled">
<n-radio :value="1" :checked="addModalForm.enabled === 1" @change="addModalForm.enabled = 1">启用</n-radio>
<n-radio :value="0" :checked="addModalForm.enabled === 0" @change="addModalForm.enabled = 0">禁用</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="离线监听" path="offlineMonitoring">
<n-radio-group :value="addModalForm.offlineMonitoring">
<n-radio :value="1" :checked="addModalForm.offlineMonitoring === 1" @change="addModalForm.offlineMonitoring = 1">启用</n-radio>
<n-radio :value="0" :checked="addModalForm.offlineMonitoring === 0" @change="addModalForm.offlineMonitoring = 0">禁用</n-radio>
</n-radio-group>
</n-form-item>
<n-button type="info" style="margin-left: 40%" @click="confirmAddClient()">确认</n-button>
</n-form>
</n-modal>
</template>
<script setup>
import api from '@/views/client/api'
import { NButton } from 'naive-ui'
import { debounce, ellipsis } from '@/utils'
import clientApi from '@/views/client/api'
import QueryBar from '@/components/query-bar/QueryBar.vue'
const selOptions = [
{
label: '启用',
value: 1,
},
{
label: '禁用',
value: 0
}
]
const listQueryRef = ref()
//
const listData = reactive({
data: [],
total: 0,
totalPage: 0,
})
//
const showQrCode = ref(false)
//
const editModalShow = ref(false)
const addModalShow = ref(false)
const editModalTitle = ref('')
//
const qrCodeData = {
title: '',
data: '',
}
//
const getMoreRowData = ref({
id: '',
name: ''
})
// form ref
const editModalFormRef = ref()
const addModalFormRef = ref()
// form rules
const editModalFormRules = {
name: [
{ required: true, message: '名称不能为空', trigger: 'blur' }
],
keys: {
privateKey: [
{ required: true, message: '密钥不能为空', trigger: 'blur' }
],
publicKey: [
{ required: true, message: '公钥不能为空', trigger: 'blur' }
],
presharedKey: [
{ required: true, message: '共享密钥不能为空', trigger: 'blur' }
],
},
enabled: [
{ required: true, message: '状态不能为空', trigger: 'change' }
]
}
// form rules
const addModalFormRules = {
name: [
{ required: true, message: '名称不能为空', trigger: 'blur' }
],
keys: {
privateKey: [
{ required: true, message: '密钥不能为空', trigger: 'blur' }
],
publicKey: [
{ required: true, message: '公钥不能为空', trigger: 'blur' }
],
presharedKey: [
{ required: true, message: '共享密钥不能为空', trigger: 'blur' }
],
},
enabled: [
{ required: true, message: '状态不能为空', trigger: 'change' }
]
}
//
const editModalForm = ref({
id: '',
name: '',
email: '',
ipAllocation: [],
allowedIps: [],
extraAllowedIps: [],
useServerDns: 0,
keys: {
privateKey: '',
publicKey: '',
presharedKey: ''
},
enabled: 1,
offlineMonitoring: 1
})
//
const addModalForm = ref({
id: '',
name: '',
email: '',
ipAllocation: [],
allowedIps: [],
extraAllowedIps: [],
useServerDns: 0,
keys: {
privateKey: '',
publicKey: '',
presharedKey: ''
},
enabled: 1,
offlineMonitoring: 1
})
//
const dropMenuOpts = [
{
key: 'download',
label: '下载',
},
{
key: 'mail',
label: '邮件',
},
{
key: 'delete',
label: '删除',
}
]
//
const searchParam = reactive({
name: '',
email: '',
ipAllocation: '',
enabled: undefined,
current: 1,
size: 8,
})
//
function getClientList() {
api.clientList(searchParam).then((res) => {
if (res.data.code === 200) {
listData.data = res.data.data.records
listData.total = res.data.data.total;
listData.totalPage = res.data.data.totalPage
}
})
}
//
function openQrCode(clientID,title) {
api.downloadClient("QRCODE",clientID).then((res) => {
if (res.data.code === 200) {
qrCodeData.data = res.data.data.qrCode
qrCodeData.title = title
showQrCode.value = true
}
})
}
//
function moreSelectHandle(key) {
switch (key) {
case "download": //
api.downloadClient("FILE",getMoreRowData.value.id).then((response) => {
if (response && response.status === 200) {
const blob = new Blob([response.data], {
type: "text/plain"
});
const link = document.createElement("a"); // a
link.download = getMoreRowData.value.name + ".conf"; // a
link.style.display = "none";
link.href = URL.createObjectURL(blob);
document.body.appendChild(link);
link.click(); //
URL.revokeObjectURL(link.href); // url
document.body.removeChild(link); //
}
})
break;
case "mail": //
api.downloadClient("EMAIL",getMoreRowData.value.id).then((response) => {
if (response.data.code === 200) {
$message.success("发送邮件成功,请注意查收!")
}
})
break;
case "delete": //
$dialog.confirm({
type: 'warning',
title: '删除',
content: `是否删除客户端:【${getMoreRowData.value.name}】?`,
async confirm() {
api.deleteClient(getMoreRowData.value.id).then(res => {
if (res.data.code === 200) {
$message.success("操作成功!")
getClientList()
}
})
},
})
break;
}
}
//
function saveRows(row) {
getMoreRowData.value = row
}
//
function openEditModal(row) {
editModalShow.value = true
editModalTitle.value = row.name
editModalForm.value.id = row.id
editModalForm.value.name = row.name
editModalForm.value.email = row.email
editModalForm.value.ipAllocation = row.ipAllocation
editModalForm.value.allowedIps = row.allowedIps
editModalForm.value.extraAllowedIps = row.extraAllowedIps
editModalForm.value.useServerDns = row.useServerDns
editModalForm.value.keys.privateKey = row.keys.privateKey
editModalForm.value.keys.publicKey = row.keys.publicKey
editModalForm.value.keys.presharedKey = row.keys.presharedKey
editModalForm.value.enabled = row.enabled
editModalForm.value.offlineMonitoring = row.offlineMonitoring
}
//
function updateClient() {
editModalFormRef.value.validate(async (valid) => {
if (valid) {
return valid
}
api.saveClient(editModalForm.value).then((res) => {
if (res.data.code === 200) {
editModalShow.value = false
getClientList()
}
})
})
}
//
async function confirmAddClient() {
addModalFormRef.value.validate(async (valid) => {
if (valid) {
return valid
}
clientApi.saveClient(addModalForm.value).then((response) => {
if (response.data.code === 200) {
refreshList()
addModalShow.value = false
}
})
})
}
//
const addClient = debounce(() => {
generateClientIPS()
addModalForm.value.name = ""
addModalShow.value = true
},300)
//
function generateKeys() {
clientApi.generateClientKeys().then(res => {
if (res.data.code === 200) {
addModalForm.value.keys.privateKey = res.data.data.privateKey
addModalForm.value.keys.publicKey = res.data.data.publicKey
addModalForm.value.keys.presharedKey = res.data.data.presharedKey
}
})
}
// IP
function generateClientIPS() {
clientApi.generateClientIP().then(res => {
if (res.data.code === 200) {
addModalForm.value.ipAllocation = res.data.data.clientIPS
addModalForm.value.allowedIps = res.data.data.serverIPS
generateKeys()
}
})
}
//
function refreshList() {
searchParam.name = ""
searchParam.email = ""
searchParam.enabled = undefined
searchParam.ipAllocation = ""
getClientList()
}
//
function pageChange(page) {
searchParam.current = page
getClientList()
}
//
function search() {
getClientList()
}
getClientList()
</script>
<style lang="scss">
.timeItem {
.n-form-item-blank {
display: block;
}
}
</style>

23
src/views/client/route.js Normal file
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

@ -1,100 +0,0 @@
<template>
<CommonPage show-footer>
<n-space size="large">
<n-card title="按钮 Button">
<n-space>
<n-button>Default</n-button>
<n-button type="tertiary">Tertiary</n-button>
<n-button type="primary">Primary</n-button>
<n-button type="info">Info</n-button>
<n-button type="success">Success</n-button>
<n-button type="warning">Warning</n-button>
<n-button type="error">Error</n-button>
</n-space>
</n-card>
<n-card title="带 Icon 的按钮">
<n-space>
<n-button type="info">
<TheIcon icon="material-symbols:add" :size="18" class="mr-5" />
新增
</n-button>
<n-button type="error">
<TheIcon icon="material-symbols:delete-outline" :size="18" class="mr-5" />
删除
</n-button>
<n-button type="warning">
<TheIcon icon="material-symbols:edit-outline" :size="18" class="mr-5" />
编辑
</n-button>
<n-button type="primary">
<TheIcon icon="majesticons:eye-line" :size="18" class="mr-5" />
查看
</n-button>
</n-space>
</n-card>
</n-space>
<n-space size="large" mt-30>
<n-card min-w-340 title="通知 Notification">
<n-space>
<n-button @click="notify('info')">信息</n-button>
<n-button @click="notify('success')">成功</n-button>
<n-button @click="notify('warning')">警告</n-button>
<n-button @click="notify('error')">错误</n-button>
</n-space>
</n-card>
<n-card min-w-340 title="确认弹窗 Dialog">
<n-button type="error" @click="handleDelete">
<icon-mi:delete mr-5 />
删除
</n-button>
</n-card>
<n-card min-w-340 title="消息提醒 Message">
<n-button :loading="loading" type="primary" @click="handleLogin">
<icon-mdi:login v-show="!loading" mr-5 />
登陆
</n-button>
</n-card>
</n-space>
</CommonPage>
</template>
<script setup>
const handleDelete = function () {
$dialog.confirm({
content: '确认删除?',
confirm() {
$message.success('删除成功')
},
cancel() {
$message.warning('已取消')
},
})
}
const loading = ref(false)
function handleLogin() {
loading.value = true
$message.loading('登陆中...')
setTimeout(() => {
$message.error('登陆失败')
$message.loading('正在尝试重新登陆...')
setTimeout(() => {
$message.success('登陆成功')
loading.value = false
}, 2000)
}, 2000)
}
function notify(type) {
$notification[type]({
content: '说点啥呢',
meta: '想不出来',
duration: 2500,
keepAliveOnHover: true,
})
}
</script>

View File

@ -1,31 +0,0 @@
<template>
<CommonPage show-footer>
<div w-350>
<n-input v-model:value="inputVal" />
<n-input-number v-model:value="number" mt-30 />
<p mt-20 text-center text-14 color-gray>右击标签重新加载可重置keep-alive</p>
</div>
</CommonPage>
</template>
<script setup>
defineOptions({ name: 'KeepAlive' })
const inputVal = ref('')
const number = ref(0)
onMounted(() => {
$message.success('onMounted')
})
onUnmounted(() => {
$message.error('onUnmounted')
})
onActivated(() => {
$message.info('onActivated')
})
onDeactivated(() => {
$message.warning('onDeactivated')
})
</script>

View File

@ -1,43 +0,0 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'Test',
path: '/base',
component: Layout,
redirect: '/base/index',
meta: {
title: '基础功能',
icon: 'majesticons:compass-line',
order: 1,
},
children: [
{
name: 'BaseComponents',
path: 'index',
component: () => import('./index.vue'),
meta: {
title: '基础组件',
icon: 'material-symbols:auto-awesome-outline-rounded',
},
},
{
name: 'Unocss',
path: 'unocss',
component: () => import('./unocss/index.vue'),
meta: {
title: 'Unocss',
icon: 'material-symbols:auto-awesome-outline-rounded',
},
},
{
name: 'KeepAlive',
path: 'keep-alive',
component: () => import('./keep-alive/index.vue'),
meta: {
title: 'KeepAlive',
icon: 'material-symbols:auto-awesome-outline-rounded',
keepAlive: true,
},
},
],
}

View File

@ -1,69 +0,0 @@
<template>
<CommonPage show-footer>
<p>
文档
<a c-blue hover-decoration-underline href="https://uno.antfu.me/" target="_blank">
https://uno.antfu.me/
</a>
</p>
<p>
playground
<a c-blue hover-decoration-underline href="https://unocss.antfu.me/play/" target="_blank">
https://unocss.antfu.me/play/
</a>
</p>
<div mt-20 w-350 f-c-c flex-col>
<div flex flex-wrap justify-around rounded-10 p-10 border="1 solid #ccc">
<div m-20 h-50 w-50 f-c-c rounded-5 p-10 border="1 solid">
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
<div m-20 h-50 w-50 flex justify-between rounded-5 p-10 border="1 solid">
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 self-end rounded-3 bg-black dark:bg-white />
</div>
<div m-20 h-50 w-50 flex justify-between rounded-5 p-10 border="1 solid">
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 self-center rounded-3 bg-black dark:bg-white />
<span h-6 w-6 self-end rounded-3 bg-black dark:bg-white />
</div>
<div m-20 h-50 w-50 flex justify-between rounded-5 p-10 border="1 solid">
<div flex-col justify-between>
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
<div flex-col justify-between>
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
</div>
<div m-20 h-50 w-50 flex-col items-center justify-between rounded-5 p-10 border="1 solid">
<div w-full flex justify-between>
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
<div h-6 w-6 rounded-3 bg-black dark:bg-white />
<div w-full flex justify-between>
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
</div>
<div m-20 h-50 w-50 flex-col justify-between rounded-5 p-10 border="1 solid">
<div w-full flex justify-between>
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
<div w-full flex justify-between>
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
<div w-full flex justify-between>
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
<span h-6 w-6 rounded-3 bg-black dark:bg-white />
</div>
</div>
</div>
<h2 mt-10 text-14 font-normal color-gray>Flex 骰子</h2>
</div>
</CommonPage>
</template>

View File

@ -1,54 +0,0 @@
<template>
<CommonPage>
<div h-60 flex items-center bg-white pl-20 pr-20 dark:bg-dark>
<input
v-model="post.title"
class="mr-20 flex-1 pb-15 pt-15 text-20 font-bold color-primary"
dark:bg-dark
type="text"
placeholder="输入文章标题..."
/>
<n-button type="primary" style="width: 80px" :loading="btnLoading" @click="handleSavePost">
<TheIcon v-if="!btnLoading" icon="line-md:confirm-circle" class="mr-5" :size="18" />
保存
</n-button>
</div>
<MdEditor
v-model="post.content"
:theme="appStore.isDark ? 'dark' : 'light'"
style="height: calc(100vh - 305px)"
/>
</CommonPage>
</template>
<script setup>
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useAppStore } from '@/store'
defineOptions({ name: 'MDEditor' })
const appStore = useAppStore()
// refs
let post = ref({})
let btnLoading = ref(false)
function handleSavePost() {
btnLoading.value = true
$message.loading('正在保存...')
setTimeout(() => {
$message.success('保存成功')
btnLoading.value = false
}, 2000)
}
</script>
<style lang="scss">
.md-preview {
ul,
ol {
list-style: revert;
}
}
</style>

View File

@ -1,46 +0,0 @@
<template>
<AppPage>
<div class="h-full flex-col" border="1 solid #ccc" dark:bg-dark>
<WangToolbar
border-b="1px solid #ccc"
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
/>
<WangEditor
v-model="valueHtml"
style="flex: 1; overflow-y: hidden"
:default-config="editorConfig"
mode="default"
@on-created="handleCreated"
/>
</div>
</AppPage>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor as WangEditor, Toolbar as WangToolbar } from '@wangeditor/editor-for-vue'
defineOptions({ name: 'RichTextEditor' })
const editorRef = shallowRef()
const toolbarConfig = { excludeKeys: 'fullScreen' }
const editorConfig = { placeholder: '请输入内容...', MENU_CONF: {} }
const valueHtml = ref('')
const handleCreated = (editor) => {
editorRef.value = editor
}
</script>
<style>
html.dark {
--w-e-textarea-bg-color: #333;
--w-e-textarea-color: #fff;
--w-e-toolbar-bg-color: #333;
--w-e-toolbar-color: #fff;
--w-e-toolbar-active-bg-color: #666;
--w-e-toolbar-active-color: #fff;
/* ...其他... */
}
</style>

View File

@ -1,65 +0,0 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'Demo',
path: '/demo',
component: Layout,
redirect: '/demo/crud',
meta: {
title: '示例页面',
icon: 'uil:pagelines',
role: ['admin'],
requireAuth: true,
order: 3,
},
children: [
{
name: 'Crud',
path: 'crud',
component: () => import('./table/index.vue'),
meta: {
title: 'CRUD表格',
icon: 'ic:baseline-table-view',
role: ['admin'],
requireAuth: true,
keepAlive: true,
},
},
{
name: 'MDEditor',
path: 'md-editor',
component: () => import('./editor/md-editor.vue'),
meta: {
title: 'MD编辑器',
icon: 'ri:markdown-line',
role: ['admin'],
requireAuth: true,
keepAlive: true,
},
},
{
name: 'RichTextEditor',
path: 'rich-text',
component: () => import('./editor/rich-text.vue'),
meta: {
title: '富文本编辑器',
icon: 'ic:sharp-text-rotation-none',
role: ['admin'],
requireAuth: true,
keepAlive: true,
},
},
{
name: 'Upload',
path: 'upload',
component: () => import('./upload/index.vue'),
meta: {
title: '图片上传',
icon: 'mdi:upload',
role: ['admin'],
requireAuth: true,
keepAlive: true,
},
},
],
}

View File

@ -1,9 +0,0 @@
import { request } from '@/utils'
export default {
getPosts: (params = {}) => request.get('posts', { params }),
getPostById: (id) => request.get(`/post/${id}`),
addPost: (data) => request.post('/post', data),
updatePost: (data) => request.put(`/post/${data.id}`, data),
deletePost: (id) => request.delete(`/post/${id}`),
}

View File

@ -1,233 +0,0 @@
<template>
<CommonPage show-footer title="文章">
<template #action>
<div>
<n-button type="primary" secondary @click="$table?.handleExport()">
<TheIcon icon="mdi:download" :size="18" class="mr-5" />
导出
</n-button>
<n-button type="primary" class="ml-16" @click="handleAdd">
<TheIcon icon="material-symbols:add" :size="18" class="mr-5" />
新建文章
</n-button>
</div>
</template>
<CrudTable
ref="$table"
v-model:query-items="queryItems"
:extra-params="extraParams"
:scroll-x="1200"
:columns="columns"
:get-data="api.getPosts"
@on-checked="onChecked"
@on-data-change="(data) => (tableData = data)"
>
<template #queryBar>
<QueryBarItem label="标题" :label-width="50">
<n-input
v-model:value="queryItems.title"
type="text"
placeholder="请输入标题"
@keypress.enter="$table?.handleSearch"
/>
</QueryBarItem>
</template>
</CrudTable>
<!-- 新增/编辑/查看 -->
<CrudModal
v-model:visible="modalVisible"
:title="modalTitle"
:loading="modalLoading"
:show-footer="modalAction !== 'view'"
@on-save="handleSave"
>
<n-form
ref="modalFormRef"
label-placement="left"
label-align="left"
:label-width="80"
:model="modalForm"
:disabled="modalAction === 'view'"
>
<n-form-item label="作者" path="author">
<n-input v-model:value="modalForm.author" disabled />
</n-form-item>
<n-form-item
label="文章标题"
path="title"
:rule="{
required: true,
message: '请输入文章标题',
trigger: ['input', 'blur'],
}"
>
<n-input v-model:value="modalForm.title" placeholder="请输入文章标题" />
</n-form-item>
<n-form-item
label="文章内容"
path="content"
:rule="{
required: true,
message: '请输入文章内容',
trigger: ['input', 'blur'],
}"
>
<n-input
v-model:value="modalForm.content"
placeholder="请输入文章内容"
type="textarea"
:autosize="{
minRows: 3,
maxRows: 5,
}"
/>
</n-form-item>
</n-form>
</CrudModal>
</CommonPage>
</template>
<script setup>
import { NButton, NSwitch } from 'naive-ui'
import { formatDateTime, renderIcon, isNullOrUndef } from '@/utils'
import { useCRUD } from '@/composables'
import api from './api'
defineOptions({ name: 'Crud' })
const $table = ref(null)
/** 表格数据,触发搜索的时候会更新这个值 */
const tableData = ref([])
/** QueryBar筛选参数可选 */
const queryItems = ref({})
/** 补充参数(可选) */
const extraParams = ref({})
onActivated(() => {
$table.value?.handleSearch()
})
const columns = [
{ type: 'selection', fixed: 'left' },
{
title: '发布',
key: 'isPublish',
width: 60,
align: 'center',
fixed: 'left',
render(row) {
return h(NSwitch, {
size: 'small',
rubberBand: false,
value: row['isPublish'],
loading: !!row.publishing,
onUpdateValue: () => handlePublish(row),
})
},
},
{ title: '标题', key: 'title', width: 150, ellipsis: { tooltip: true } },
{ title: '分类', key: 'category', width: 80, ellipsis: { tooltip: true } },
{ title: '创建人', key: 'author', width: 80 },
{
title: '创建时间',
key: 'createDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['createDate']))
},
},
{
title: '最后更新时间',
key: 'updateDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['updateDate']))
},
},
{
title: '操作',
key: 'actions',
width: 240,
align: 'center',
fixed: 'right',
hideInExcel: true,
render(row) {
return [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => handleView(row),
},
{ default: () => '查看', icon: renderIcon('majesticons:eye-line', { size: 14 }) }
),
h(
NButton,
{
size: 'small',
type: 'primary',
style: 'margin-left: 15px;',
onClick: () => handleEdit(row),
},
{ default: () => '编辑', icon: renderIcon('material-symbols:edit-outline', { size: 14 }) }
),
h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px;',
onClick: () => handleDelete(row.id),
},
{
default: () => '删除',
icon: renderIcon('material-symbols:delete-outline', { size: 14 }),
}
),
]
},
},
]
//
function onChecked(rowKeys) {
if (rowKeys.length) $message.info(`选中${rowKeys.join(' ')}`)
}
//
function handlePublish(row) {
if (isNullOrUndef(row.id)) return
row.publishing = true
setTimeout(() => {
row.isPublish = !row.isPublish
row.publishing = false
$message?.success(row.isPublish ? '已发布' : '已取消发布')
}, 1000)
}
const {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
} = useCRUD({
name: '文章',
initForm: { author: '大脸怪' },
doCreate: api.addPost,
doDelete: api.deletePost,
doUpdate: api.updatePost,
refresh: () => $table.value?.handleSearch(),
})
</script>

View File

@ -1,84 +0,0 @@
<template>
<CommonPage>
<n-upload
class="mx-auto w-[75%] p-20 text-center"
:custom-request="handleUpload"
:show-file-list="false"
accept=".png,.jpg,.jpeg"
@before-upload="onBeforeUpload"
>
<n-upload-dragger>
<div class="h-150 f-c-c flex-col">
<TheIcon icon="mdi:upload" :size="68" class="mb-12 c-primary" />
<n-text class="text-14 c-gray">点击或者拖动文件到该区域来上传</n-text>
</div>
</n-upload-dragger>
</n-upload>
<n-card v-if="imgList && imgList.length" class="mt-16 items-center">
<n-image-group>
<n-space justify="space-between" align="center">
<n-card v-for="(item, index) in imgList" :key="index" class="w-280 hover:card-shadow">
<div class="h-160 f-c-c">
<n-image width="200" :src="item.url" />
</div>
<n-space class="mt-16" justify="space-evenly">
<n-button dashed type="primary" @click="copy(item.url)">url</n-button>
<n-button dashed type="primary" @click="copy(`![${item.fileName}](${item.url})`)">
MD
</n-button>
<n-button
dashed
type="primary"
@click="copy(`&lt;img src=&quot;${item.url}&quot; /&gt;`)"
>
img
</n-button>
</n-space>
</n-card>
<div v-for="i in 4" :key="i" class="w-280" />
</n-space>
</n-image-group>
</n-card>
</CommonPage>
</template>
<script setup>
import { useClipboard } from '@vueuse/core'
defineOptions({ name: 'Upload' })
const { copy, copied } = useClipboard()
const imgList = reactive([
{ url: 'https://cdn.isme.top/images/5c23d52f880511ebb6edd017c2d2eca2.jpg' },
{ url: 'https://cdn.isme.top/images/5c23d52f880511ebb6edd017c2d2eca2.jpg' },
{ url: 'https://cdn.isme.top/images/5c23d52f880511ebb6edd017c2d2eca2.jpg' },
{ url: 'https://cdn.isme.top/images/5c23d52f880511ebb6edd017c2d2eca2.jpg' },
])
watch(copied, (val) => {
val && $message.success('已复制到剪切板')
})
function onBeforeUpload({ file }) {
if (!file.file?.type.startsWith('image/')) {
$message.error('只能上传图片')
return false
}
return true
}
async function handleUpload({ file, onFinish }) {
if (!file || !file.type) {
$message.error('请选择文件')
}
//
$message.loading('上传中...')
setTimeout(() => {
$message.success('上传成功')
imgList.push({ fileName: file.name, url: URL.createObjectURL(file.file) })
onFinish()
}, 1500)
}
</script>

View File

@ -1,16 +0,0 @@
<template>
<AppPage>
<n-result m-auto status="404" description="抱歉,您访问的页面不存在。">
<template #icon>
<img src="@/assets/images/404.webp" width="500" />
</template>
<template #footer>
<n-button @click="replace('/')">返回首页</n-button>
</template>
</n-result>
</AppPage>
</template>
<script setup>
const { replace } = useRouter()
</script>

View File

@ -1,24 +0,0 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'ErrorPage',
path: '/error-page',
component: Layout,
redirect: '/error-page/404',
meta: {
title: '错误页',
icon: 'mdi:alert-circle-outline',
order: 99,
},
children: [
{
name: 'ERROR-404',
path: '404',
component: () => import('./404.vue'),
meta: {
title: '404',
icon: 'tabler:error-404',
},
},
],
}

View File

@ -1,5 +1,7 @@
import { request } from '@/utils'
export default {
login: (data) => request.post('/auth/login', data, { noNeedToken: true }),
// 获取验证码
captcha: () => request.get('/login/captcha',{ noNeedToken: true }),
login: (data) => request.post('/login', data, { noNeedToken: true }),
}

View File

@ -5,7 +5,7 @@
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 px-20 py-35 md:block>
<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>
@ -14,12 +14,12 @@
<img src="@/assets/images/logo.png" height="50" class="mr-10" />
{{ title }}
</h5>
<div mt-32>
<div mt-20>
<n-input
v-model:value="loginInfo.name"
v-model:value="loginInfo.account"
autofocus
class="h-48 items-center text-16"
placeholder="name"
class="h-45 items-center text-16"
placeholder="账号"
:maxlength="20"
>
<template #prefix>
@ -27,13 +27,13 @@
</template>
</n-input>
</div>
<div mt-32>
<div mt-20>
<n-input
v-model:value="loginInfo.password"
class="h-48 items-center text-16"
class="h-45 items-center text-16"
type="password"
show-password-on="mousedown"
placeholder="password"
show-password-on="click"
placeholder="密码"
:maxlength="20"
@keydown.enter="handleLogin"
>
@ -43,12 +43,26 @@
</n-input>
</div>
<div mt-20>
<n-checkbox
:checked="isRemember"
label="记住我"
:on-update:checked="(val) => (isRemember = val)"
/>
<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>
@ -70,51 +84,54 @@
</template>
<script setup>
import { lStorage, setToken } from '@/utils'
import { useStorage } from '@vueuse/core'
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({
name: '',
account: '',
password: '',
captchaId: '',
captchaCode: ''
})
initLoginInfo()
//
getCaptcha()
function initLoginInfo() {
const localLoginInfo = lStorage.get('loginInfo')
if (localLoginInfo) {
loginInfo.value.name = localLoginInfo.name || ''
loginInfo.value.password = localLoginInfo.password || ''
}
//
async function getCaptcha() {
const res = await api.captcha()
captchaUrl.value = res.data.data.captcha
loginInfo.value.captchaId = res.data.data.id
}
const isRemember = useStorage('isRemember', false)
const loading = ref(false)
async function handleLogin() {
const { name, password } = loginInfo.value
if (!name || !password) {
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({ name, password: password.toString() })
const res = await api.login(loginInfo.value)
$message.success('登录成功')
setToken(res.data.token)
if (isRemember.value) {
lStorage.set('loginInfo', { name, password })
} else {
lStorage.remove('loginInfo')
}
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
@ -124,6 +141,7 @@ async function handleLogin() {
router.push('/')
}
} catch (error) {
await getCaptcha()
console.error(error)
$message.removeMessage()
}

View File

@ -1,3 +0,0 @@
<template>
<div>a-1-1</div>
</template>

View File

@ -1,3 +0,0 @@
<template>
<div>a-1-2</div>
</template>

View File

@ -1,8 +0,0 @@
<template>
<CommonPage>
<div>a-1</div>
<div pl-20>
<RouterView />
</div>
</CommonPage>
</template>

View File

@ -1,3 +0,0 @@
<template>
<div>a-2-1</div>
</template>

View File

@ -1,8 +0,0 @@
<template>
<CommonPage>
<div>a-2</div>
<div pl-20>
<RouterView />
</div>
</CommonPage>
</template>

View File

@ -1,8 +0,0 @@
<template>
<CommonPage>
<div>a</div>
<div pl-20>
<RouterView />
</div>
</CommonPage>
</template>

View File

@ -1,75 +0,0 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'MultipleMenu',
path: '/multi-menu',
component: Layout,
meta: {
title: '多级菜单',
icon: 'ic:baseline-menu',
role: ['admin'],
requireAuth: true,
order: 4,
},
children: [
{
name: 'a-1',
path: 'multiple-menu',
component: () => import('./a-1/index.vue'),
meta: {
title: 'a-1',
icon: 'ic:baseline-menu',
role: ['admin'],
requireAuth: true,
},
children: [
{
name: 'a-1-1',
path: 'a-1-1',
component: () => import('./a-1/a-1-1/index.vue'),
meta: {
title: 'a-1-1',
icon: 'ic:baseline-menu',
role: ['admin'],
requireAuth: true,
},
},
{
name: 'a-1-2',
path: 'a-1-2',
component: () => import('./a-1/a-1-2/index.vue'),
meta: {
title: 'a-1-2',
icon: 'ic:baseline-menu',
role: ['admin'],
requireAuth: true,
},
},
],
},
{
name: 'a-2',
path: 'a-2',
component: () => import('./a-2/index.vue'),
meta: {
title: 'a-2',
icon: 'ic:baseline-menu',
role: ['admin'],
requireAuth: true,
},
children: [
{
name: 'a-2-1',
path: 'a-2-1',
component: () => import('./a-2/a-2-1/index.vue'),
meta: {
title: 'a-2-1单个子菜单',
icon: 'ic:baseline-menu',
role: ['admin'],
requireAuth: true,
},
},
],
},
],
}

9
src/views/setting/api.js Normal file
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'), // 获取公网地址
}

489
src/views/setting/index.vue Normal file
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,
},
},
],
}

7
src/views/user/api.js Normal file
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}`), // 重置用户密码
}

405
src/views/user/index.vue Normal file
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>

23
src/views/user/route.js Normal file
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

@ -5,7 +5,7 @@
<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.name }}</span>
<span class="text-20 opacity-80">Hello, {{ userStore.nickname }}</span>
<span class="mt-4 opacity-50">今日事今日毕</span>
</div>
</div>
@ -14,188 +14,14 @@
<p class="mt-12 text-right text-12 opacity-40"> 查尔斯·史考伯</p>
</n-card>
<n-card class="ml-12 w-70%">
<h3 class="text-20 font-normal opacity-90">欢迎使用 Vue Naive Admin</h3>
<p class="mt-8 opacity-60">
这是一款基于 Vue3 + Vite + Pinia + Unocss + Naive UI 的轻量级后台管理模板
</p>
<footer class="mt-24 flex items-center justify-end">
<n-button
tag="a"
href="https://zclzone.github.io/vue-naive-admin-docs"
target="_blank"
type="primary"
ghost
>
开发文档
</n-button>
<n-button
tag="a"
href="https://github.com/zclzone/vue-naive-admin"
target="_blank"
type="primary"
class="ml-12"
>
代码仓库
</n-button>
</footer>
</n-card>
</div>
<div class="mt-12 flex">
<n-card title="项目" segmented>
<template #header-extra>
<n-button text type="primary">更多</n-button>
</template>
<div class="flex flex-wrap justify-between">
<n-card
v-for="i in 6"
:key="i"
size="small"
class="my-6 w-320 flex-shrink-0 cursor-pointer hover:card-shadow"
title="Vue Naive Admin"
>
<p class="op-60">一个基于 Vue3.0ViteNaive UI 的轻量级后台管理模板</p>
</n-card>
<div h-0 w-300></div>
<div h-0 w-300></div>
<div h-0 w-300></div>
<div h-0 w-300></div>
</div>
</n-card>
<n-card class="ml-12" title="技术栈" segmented>
<VChart :option="skillsOption" class="wh-full" autoresize />
</n-card>
</div>
<n-card class="mt-12" title="趋势" segmented>
<VChart :option="trendOption" class="h-480 w-full" autoresize />
</n-card>
</AppPage>
</template>
<script setup>
import { useUserStore } from '@/store'
import * as echarts from 'echarts/core'
import { TooltipComponent, GridComponent, LegendComponent } from 'echarts/components'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
const userStore = useUserStore()
echarts.use([
TooltipComponent,
GridComponent,
LegendComponent,
BarChart,
LineChart,
CanvasRenderer,
UniversalTransition,
PieChart,
])
const trendOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999',
},
},
},
legend: {
top: '5%',
data: ['star', 'fork'],
},
xAxis: [
{
type: 'category',
data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
axisPointer: {
type: 'shadow',
},
},
],
yAxis: [
{
type: 'value',
min: 0,
max: 3000,
interval: 500,
axisLabel: {
formatter: '{value}',
},
},
{
type: 'value',
min: 0,
max: 500,
interval: 100,
axisLabel: {
formatter: '{value}',
},
},
],
series: [
{
name: 'star',
type: 'line',
data: [200, 320, 520, 550, 600, 805, 888, 950, 1300, 2503, 2702, 2712],
},
{
name: 'fork',
yAxisIndex: 1,
type: 'bar',
data: [40, 72, 110, 115, 121, 175, 180, 201, 260, 398, 423, 455],
},
],
}
const skillsOption = {
tooltip: {
trigger: 'item',
formatter({ name, value }) {
return `${name} ${value}%`
},
},
legend: {
left: 'center',
},
series: [
{
top: '7%',
type: 'pie',
radius: ['40%', '85%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 36,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: [
{ value: 38.5, name: 'Vue' },
{ value: 37.0, name: 'JavaScript' },
{ value: 6.5, name: 'CSS' },
{ value: 6.2, name: 'HTML' },
{ value: 1.8, name: 'Other' },
],
},
],
}
</script>

View File

@ -5,6 +5,9 @@ export default {
path: '/',
component: Layout,
redirect: '/workbench',
meta: {
order: 0,
},
children: [
{
name: 'Workbench',