mirror of
https://github.com/FatttSnake/Pinnacle-OA.git
synced 2026-04-04 22:41:24 +08:00
Added page frame
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
104
ui/src/assets/css/common.css
Normal file
104
ui/src/assets/css/common.css
Normal file
@@ -0,0 +1,104 @@
|
||||
:root {
|
||||
--main-color: #00D4FF;
|
||||
--background-color: #F5F5F5;
|
||||
--font-main-color: #4D4D4D;
|
||||
--font-secondary-color: #9E9E9E;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--font-main-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fill-with {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fill-height {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.background-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.center-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vertical-center-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.horizontal-center-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-size-xs {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.icon-size-xs > use {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.icon-size-sm {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon-size-sm > use {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon-size-md {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.icon-size-md > use {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.icon-size-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.icon-size-lg > use {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.icon-size-xl {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.icon-size-xl > use {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.icon-size-menu {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
.icon-size-menu > use {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
}
|
||||
1
ui/src/assets/svg/chat.svg
Normal file
1
ui/src/assets/svg/chat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="24" height="24" viewBox="0 0 24 24"><defs><clipPath id="master_svg0_24_0283/24_741"><rect x="0" y="0" width="24" height="24" rx="0"/></clipPath></defs><g style="mix-blend-mode:passthrough" clip-path="url(#master_svg0_24_0283/24_741)"><g><path d="M20.9931,13.2849C22.961,11.8133,24,9.92076,24,7.7996C24,3.49885,19.5873,0,14.1641,0C8.74009,0,4.32732,3.49885,4.32732,7.7996C4.32732,8.99721,4.66991,10.1325,5.28122,11.1474C2.2171,11.9155,0,14.1537,0,16.7931C0,18.5202,0.89816,19.9253,2.40879,21.0084C2.73721,21.7533,2.25195,23.3488,1.62118,24Q3.47438,23.6936,4.55685,22.3125C5.54881,22.6766,6.44948,22.721,7.61657,22.721C11.8258,22.721,15.2348,20.0676,15.2348,16.7931C15.2348,16.3789,15.18,15.9748,15.0763,15.5846C15.941,15.558,16.7762,15.4723,17.651,15.2072C19.1046,16.7528,21.2008,17.1153,21.2954,17.1308L23.2183,17.4484L21.8627,16.0497C21.2837,15.452,20.8364,13.9754,20.9931,13.2849ZM5.17427,17.8573C4.64645,17.8573,4.21831,17.4296,4.21831,16.9022C4.21831,16.3745,4.64645,15.9468,5.17427,15.9468C5.70207,15.9468,6.1302,16.3745,6.1302,16.9022C6.13023,17.4296,5.70207,17.8573,5.17427,17.8573ZM7.70619,17.8573C7.17839,17.8573,6.75023,17.4296,6.75023,16.9022C6.75023,16.3745,7.17837,15.9468,7.70619,15.9468C8.23401,15.9468,8.66215,16.3745,8.66215,16.9022C8.66215,17.4296,8.23401,17.8573,7.70619,17.8573ZM10.2381,17.8573C9.71019,17.8573,9.28218,17.4296,9.28218,16.9022C9.28218,16.3745,9.71031,15.9468,10.2381,15.9468C10.7667,15.9468,11.1949,16.3745,11.1949,16.9022C11.1949,17.4296,10.7667,17.8573,10.2381,17.8573ZM20.0069,15.3457C19.4447,15.0699,18.8307,14.6597,18.3631,14.0642L18.0749,13.6959L17.6352,13.857C16.6796,14.2073,15.8092,14.2958,14.5352,14.3084C13.328,12.275,10.6851,10.8628,7.61659,10.8628C7.29068,10.8628,6.96954,10.8788,6.65437,10.9098C5.99134,9.98511,5.61404,8.92533,5.61404,7.79955C5.61404,4.20781,9.44963,1.28565,14.1639,1.28565C18.8775,1.28565,22.713,4.20781,22.713,7.79955C22.713,9.57371,21.8251,11.1089,20.0739,12.3637L19.9306,12.4662L19.8603,12.6269C19.5478,13.3368,19.6652,14.4011,20.0069,15.3457ZM10.6612,6.69636C9.94573,6.69636,9.3659,7.27603,9.3659,7.99135C9.3659,8.70609,9.94559,9.28582,10.6612,9.28582C11.3775,9.28582,11.9573,8.70612,11.9573,7.99135C11.9573,7.27606,11.3775,6.69636,10.6612,6.69636ZM14.0928,6.69636C13.3774,6.69636,12.7975,7.27603,12.7975,7.99135C12.7975,8.70609,13.3774,9.28582,14.0928,9.28582C14.8091,9.28582,15.3889,8.70612,15.3889,7.99135C15.3889,7.27606,14.8091,6.69636,14.0928,6.69636ZM17.5246,6.69636C16.809,6.69636,16.2292,7.27603,16.2292,7.99135C16.2292,8.70609,16.809,9.28582,17.5246,9.28582C18.2409,9.28582,18.8206,8.70612,18.8206,7.99135C18.8206,7.27606,18.2409,6.69636,17.5246,6.69636Z" fill-opacity="1"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
1
ui/src/assets/svg/home.svg
Normal file
1
ui/src/assets/svg/home.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="24" height="24" viewBox="0 0 24 24"><g style="mix-blend-mode:passthrough"><g style="mix-blend-mode:passthrough"><path d="M11.2633,0.229798C11.6966,-0.0765992,12.3034,-0.0765992,12.7367,0.229798C12.7367,0.229798,23.5367,7.86616,23.5367,7.86616C23.829,8.07284,24,8.39063,24,8.72727C24,8.72727,24,20.7273,24,20.7273C24,21.5953,23.6207,22.4277,22.9456,23.0414C22.2704,23.6552,21.3548,24,20.4,24C20.4,24,3.6,24,3.6,24C2.64522,24,1.72955,23.6552,1.05442,23.0414C0.379284,22.4277,0,21.5953,0,20.7273C0,20.7273,0,8.72727,0,8.72727C0,8.39063,0.170968,8.07284,0.463271,7.86616C0.463271,7.86616,11.2633,0.229798,11.2633,0.229798C11.2633,0.229798,11.2633,0.229798,11.2633,0.229798ZM9.6,21.8182C9.6,21.8182,14.4,21.8182,14.4,21.8182C14.4,21.8182,14.4,13.0909,14.4,13.0909C14.4,13.0909,9.6,13.0909,9.6,13.0909C9.6,13.0909,9.6,21.8182,9.6,21.8182C9.6,21.8182,9.6,21.8182,9.6,21.8182ZM16.8,21.8182C16.8,21.8182,16.8,12,16.8,12C16.8,11.3975,16.2628,10.9091,15.6,10.9091C15.6,10.9091,8.4,10.9091,8.4,10.9091C7.73726,10.9091,7.2,11.3975,7.2,12C7.2,12,7.2,21.8182,7.2,21.8182C7.2,21.8182,3.6,21.8182,3.6,21.8182C3.28174,21.8182,2.97652,21.7032,2.75147,21.4987C2.52643,21.2941,2.4,21.0166,2.4,20.7273C2.4,20.7273,2.4,9.26081,2.4,9.26081C2.4,9.26081,12,2.47294,12,2.47294C12,2.47294,21.6,9.26081,21.6,9.26081C21.6,9.26081,21.6,20.7273,21.6,20.7273C21.6,21.0166,21.4735,21.2941,21.2485,21.4987C21.0235,21.7032,20.7182,21.8182,20.4,21.8182C20.4,21.8182,16.8,21.8182,16.8,21.8182C16.8,21.8182,16.8,21.8182,16.8,21.8182Z" fill-rule="evenodd" fill-opacity="1"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
2
ui/src/assets/svg/user.svg
Normal file
2
ui/src/assets/svg/user.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="30.231319427490234" height="32"
|
||||
viewBox="0 0 30.231319427490234 32"><g><path d="M29.0453,26.1146C28.2855,24.3136,27.192,22.6975,25.8051,21.3106C24.4182,19.9236,22.8021,18.8342,21.0011,18.0704C20.985,18.0623,20.9689,18.0583,20.9528,18.0503C23.4574,16.2412,25.0855,13.2945,25.0855,9.96985C25.0855,4.46231,20.6232,0,15.1157,0C9.60812,0,5.14581,4.46231,5.14581,9.96985C5.14581,13.2945,6.77395,16.2412,9.27847,18.0543C9.26239,18.0623,9.24631,18.0663,9.23023,18.0744C7.42923,18.8342,5.81315,19.9236,4.42621,21.3146C3.03928,22.7015,1.94983,24.3176,1.18601,26.1186C0.438272,27.8794,0.0402826,29.7487,0.0000815847,31.6704C-0.00393876,31.8513,0.140785,32,0.32169,32L2.73375,32C2.91063,32,3.05134,31.8593,3.05536,31.6864C3.13576,28.5829,4.38199,25.6764,6.58501,23.4734C8.8644,21.194,11.8915,19.9397,15.1157,19.9397C18.3398,19.9397,21.3669,21.194,23.6463,23.4734C25.8493,25.6764,27.0956,28.5829,27.176,31.6864C27.18,31.8633,27.3207,32,27.4976,32L29.9096,32C30.0905,32,30.2353,31.8513,30.2312,31.6704C30.191,29.7487,29.793,27.8794,29.0453,26.1146ZM15.1157,16.8844C13.2704,16.8844,11.5338,16.1648,10.2272,14.8583C8.92068,13.5518,8.20109,11.8151,8.20109,9.96985C8.20109,8.12462,8.92068,6.38794,10.2272,5.08141C11.5338,3.77487,13.2704,3.05528,15.1157,3.05528C16.9609,3.05528,18.6976,3.77487,20.0041,5.08141C21.3106,6.38794,22.0302,8.12462,22.0302,9.96985C22.0302,11.8151,21.3106,13.5518,20.0041,14.8583C18.6976,16.1648,16.9609,16.8844,15.1157,16.8844Z" fill-opacity="1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -2,10 +2,10 @@ import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import router from '@/router'
|
||||
|
||||
import { PRODUCTION_NAME } from './constants/Common.constants.js'
|
||||
|
||||
import '@/assets/css/base.css'
|
||||
import '@/assets/css/common.css'
|
||||
|
||||
/*
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.matched.length === 0) {
|
||||
from.path ? next({ path: from.path }) : next('/')
|
||||
@@ -15,6 +15,7 @@ router.beforeEach((to, from, next) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
*/
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -1,9 +1,256 @@
|
||||
<template><div></div></template>
|
||||
<template>
|
||||
<el-backtop target=".main-box" :right="80" :bottom="80" />
|
||||
<div class="background">
|
||||
<el-container class="fill">
|
||||
<el-aside width="collapse" class="background-white aside">
|
||||
<el-scrollbar>
|
||||
<el-menu
|
||||
:collapse="isCollapsed"
|
||||
:unique-opened="true"
|
||||
:default-active="$route.path"
|
||||
:router="true"
|
||||
class="menu"
|
||||
:text-color="COLOR_FONT_MAIN()"
|
||||
:active-text-color="COLOR_PRODUCTION()"
|
||||
>
|
||||
<el-menu-item
|
||||
@mousedown.left="isCollapsed = !isCollapsed"
|
||||
:disabled="true"
|
||||
style="cursor: pointer; opacity: 1; border-bottom: 1px #ddd solid"
|
||||
>
|
||||
<el-icon :size="SIZE_ICON_LG()">
|
||||
<icon-pinnacle-pinnacle :color="COLOR_PRODUCTION()" />
|
||||
</el-icon>
|
||||
<template #title>
|
||||
<span class="menu-production-name">
|
||||
{{ PRODUCTION_NAME() }}
|
||||
</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<template v-for="(route, index) in routes">
|
||||
<el-menu-item
|
||||
v-if="!route.children"
|
||||
:key="index"
|
||||
:index="route.path ?? ''"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="route.meta.icon" />
|
||||
</el-icon>
|
||||
<template #title>{{ route.meta.title }}</template>
|
||||
</el-menu-item>
|
||||
<el-sub-menu
|
||||
v-if="route.children"
|
||||
:key="index"
|
||||
:index="route.path ?? ''"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon>
|
||||
<component :is="route.meta.icon" />
|
||||
</el-icon>
|
||||
<span>{{ route.meta.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="(sub, index) in route.children"
|
||||
:key="index"
|
||||
:index="sub.path ?? ''"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="sub.meta.icon" />
|
||||
</el-icon>
|
||||
<template #title>{{ sub.meta.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header height="56px" class="background-white main-header">
|
||||
<el-badge is-dot>
|
||||
<el-icon
|
||||
:size="SIZE_ICON_MD()"
|
||||
:color="COLOR_PRODUCTION()"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<icon-pinnacle-chat />
|
||||
</el-icon>
|
||||
</el-badge>
|
||||
<el-badge is-dot>
|
||||
<el-icon
|
||||
:size="SIZE_ICON_MD()"
|
||||
:color="COLOR_PRODUCTION()"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<icon-pinnacle-notice />
|
||||
</el-icon>
|
||||
</el-badge>
|
||||
<el-divider direction="vertical" />
|
||||
<el-popover
|
||||
transition="el-zoom-in-top"
|
||||
popper-style="box-shadow: rgb(14 18 22 / 20%) 0px 10px 38px -10px, rgb(14 18 22 / 20%) 0px 10px 20px -15px;"
|
||||
>
|
||||
<template #reference>
|
||||
<div style="display: flex">
|
||||
<div class="user-head">
|
||||
<el-avatar>
|
||||
<el-icon :size="SIZE_ICON_SM()" :color="COLOR_FONT_MAIN()">
|
||||
<icon-pinnacle-user />
|
||||
</el-icon>
|
||||
</el-avatar>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">
|
||||
<span>用户名</span>
|
||||
</div>
|
||||
<div class="user-desc">
|
||||
<span>用户介绍</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div style="display: flex; gap: 10px; flex-direction: column">
|
||||
<div>
|
||||
<el-button style="width: 100%">个人档案</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<el-button style="width: 100%">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</el-header>
|
||||
<ElScrollbar v-if="$route.meta.requiresScrollbar">
|
||||
<ElMain class="main-box" :class="{ noPadding: !$route.meta.requiresPadding }">
|
||||
<ElBacktop :right="100" :bottom="100" />
|
||||
<RouterView></RouterView>
|
||||
</ElMain>
|
||||
</ElScrollbar>
|
||||
<ElMain
|
||||
v-else
|
||||
class="main-box"
|
||||
:class="{ noPadding: !$route.meta.requiresPadding }"
|
||||
>
|
||||
<ElBacktop :right="100" :bottom="100" />
|
||||
<RouterView></RouterView>
|
||||
</ElMain>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
COLOR_FONT_MAIN,
|
||||
COLOR_PRODUCTION,
|
||||
PRODUCTION_NAME,
|
||||
SIZE_ICON_LG,
|
||||
SIZE_ICON_MD,
|
||||
SIZE_ICON_SM
|
||||
} from '@/constants/Common.constants.js'
|
||||
import _ from 'lodash'
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MainPage'
|
||||
name: 'MainFrame',
|
||||
methods: {
|
||||
SIZE_ICON_LG() {
|
||||
return SIZE_ICON_LG
|
||||
},
|
||||
PRODUCTION_NAME() {
|
||||
return PRODUCTION_NAME
|
||||
},
|
||||
SIZE_ICON_SM() {
|
||||
return SIZE_ICON_SM
|
||||
},
|
||||
SIZE_ICON_MD() {
|
||||
return SIZE_ICON_MD
|
||||
},
|
||||
COLOR_PRODUCTION() {
|
||||
return COLOR_PRODUCTION
|
||||
},
|
||||
COLOR_FONT_MAIN() {
|
||||
return COLOR_FONT_MAIN
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
routes: _.filter(_.get(this.$router, 'options.routes[0].children'), 'meta.title'),
|
||||
isCollapsed: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log(this.routes)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.background {
|
||||
width: 100vw;
|
||||
min-width: 900px;
|
||||
height: 100vh;
|
||||
min-height: 500px;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.aside {
|
||||
border-right: 1px #ddd solid;
|
||||
box-shadow: 0 0 1em #ddd;
|
||||
}
|
||||
|
||||
.menu:not(.el-menu--collapse) {
|
||||
width: 245px;
|
||||
}
|
||||
|
||||
.menu-top > * {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu-production-name {
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
font-size: 1.2em;
|
||||
color: var(--main-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: right;
|
||||
border-bottom: 1px #ddd solid;
|
||||
box-shadow: 0 0 1em #ddd;
|
||||
}
|
||||
|
||||
.main-header > *:not(:last-child) {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.user-head {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.user-head > span {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--main-color);
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-desc {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.noPadding {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
9
ui/src/pages/home/Home.vue
Normal file
9
ui/src/pages/home/Home.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template><div>Home</div></template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HomePage'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -2,7 +2,29 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: []
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: async () => await import('@/pages/Main.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: 'home'
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
component: async () => await import('@/pages/home/Home.vue'),
|
||||
name: 'home',
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: IconPinnacleHome,
|
||||
requiresScrollbar: false,
|
||||
requiresPadding: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
8
ui/src/vite-env.d.ts
vendored
8
ui/src/vite-env.d.ts
vendored
@@ -1,7 +1,9 @@
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
import type { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user