feat: 新增个人中心页面
This commit is contained in:
parent
d751c14f7b
commit
423a8c031f
@ -70,6 +70,7 @@
|
|||||||
"pinia": "3.0.1",
|
"pinia": "3.0.1",
|
||||||
"tailwind-merge": "3.0.2",
|
"tailwind-merge": "3.0.2",
|
||||||
"vue": "3.5.13",
|
"vue": "3.5.13",
|
||||||
|
"vue-advanced-cropper": "^2.8.9",
|
||||||
"vue-draggable-plus": "0.6.0",
|
"vue-draggable-plus": "0.6.0",
|
||||||
"vue-i18n": "11.1.2",
|
"vue-i18n": "11.1.2",
|
||||||
"vue-router": "4.5.0"
|
"vue-router": "4.5.0"
|
||||||
|
@ -71,6 +71,12 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: 3.5.13
|
specifier: 3.5.13
|
||||||
version: 3.5.13(typescript@5.8.2)
|
version: 3.5.13(typescript@5.8.2)
|
||||||
|
vue-advanced-cropper:
|
||||||
|
specifier: ^2.8.9
|
||||||
|
version: 2.8.9(vue@3.5.13(typescript@5.8.2))
|
||||||
|
vue-cropper:
|
||||||
|
specifier: ^0.6.5
|
||||||
|
version: 0.6.5
|
||||||
vue-draggable-plus:
|
vue-draggable-plus:
|
||||||
specifier: 0.6.0
|
specifier: 0.6.0
|
||||||
version: 0.6.0(@types/sortablejs@1.15.8)
|
version: 0.6.0(@types/sortablejs@1.15.8)
|
||||||
@ -1568,6 +1574,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
|
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
classnames@2.5.1:
|
||||||
|
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||||
|
|
||||||
clean-regexp@1.0.0:
|
clean-regexp@1.0.0:
|
||||||
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -1738,6 +1747,9 @@ packages:
|
|||||||
de-indent@1.0.2:
|
de-indent@1.0.2:
|
||||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||||
|
|
||||||
|
debounce@1.2.1:
|
||||||
|
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1873,6 +1885,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
easy-bem@1.1.1:
|
||||||
|
resolution: {integrity: sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==}
|
||||||
|
|
||||||
echarts@5.6.0:
|
echarts@5.6.0:
|
||||||
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||||
|
|
||||||
@ -4061,6 +4076,15 @@ packages:
|
|||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
vue-advanced-cropper@2.8.9:
|
||||||
|
resolution: {integrity: sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==}
|
||||||
|
engines: {node: '>=8', npm: '>=5'}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
|
vue-cropper@0.6.5:
|
||||||
|
resolution: {integrity: sha512-lSvY6IpeA/Tv/iPZ/FOkMHVRBPSlm7t57nuHEZFBMRNOH8ElvfqVlnHGDOAMlvPhh9gHiddiQoASS+fY0MFX0g==}
|
||||||
|
|
||||||
vue-demi@0.13.11:
|
vue-demi@0.13.11:
|
||||||
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
|
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -5541,6 +5565,8 @@ snapshots:
|
|||||||
isobject: 3.0.1
|
isobject: 3.0.1
|
||||||
static-extend: 0.1.2
|
static-extend: 0.1.2
|
||||||
|
|
||||||
|
classnames@2.5.1: {}
|
||||||
|
|
||||||
clean-regexp@1.0.0:
|
clean-regexp@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 1.0.5
|
escape-string-regexp: 1.0.5
|
||||||
@ -5701,6 +5727,8 @@ snapshots:
|
|||||||
|
|
||||||
de-indent@1.0.2: {}
|
de-indent@1.0.2: {}
|
||||||
|
|
||||||
|
debounce@1.2.1: {}
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
@ -5822,6 +5850,8 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
easy-bem@1.1.1: {}
|
||||||
|
|
||||||
echarts@5.6.0:
|
echarts@5.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.3.0
|
tslib: 2.3.0
|
||||||
@ -8341,6 +8371,15 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
|
vue-advanced-cropper@2.8.9(vue@3.5.13(typescript@5.8.2)):
|
||||||
|
dependencies:
|
||||||
|
classnames: 2.5.1
|
||||||
|
debounce: 1.2.1
|
||||||
|
easy-bem: 1.1.1
|
||||||
|
vue: 3.5.13(typescript@5.8.2)
|
||||||
|
|
||||||
|
vue-cropper@0.6.5: {}
|
||||||
|
|
||||||
vue-demi@0.13.11(vue@3.5.13(typescript@5.8.2)):
|
vue-demi@0.13.11(vue@3.5.13(typescript@5.8.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.13(typescript@5.8.2)
|
vue: 3.5.13(typescript@5.8.2)
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { VNode } from 'vue';
|
import type { VNode } from 'vue';
|
||||||
import { useAuthStore } from '@/store/modules/auth';
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import { useSvgIcon } from '@/hooks/common/icon';
|
import { useSvgIcon } from '@/hooks/common/icon';
|
||||||
|
import defaultAvatar from '@/assets/imgs/soybean.jpg';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@ -14,11 +15,24 @@ const authStore = useAuthStore();
|
|||||||
const { routerPushByKey, toLogin } = useRouterPush();
|
const { routerPushByKey, toLogin } = useRouterPush();
|
||||||
const { SvgIconVNode } = useSvgIcon();
|
const { SvgIconVNode } = useSvgIcon();
|
||||||
|
|
||||||
|
const avatarLoading = ref(true);
|
||||||
|
const avatarError = ref(false);
|
||||||
|
|
||||||
function loginOrRegister() {
|
function loginOrRegister() {
|
||||||
toLogin();
|
toLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
type DropdownKey = 'logout';
|
function handleAvatarLoad() {
|
||||||
|
avatarLoading.value = false;
|
||||||
|
avatarError.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarError() {
|
||||||
|
avatarLoading.value = false;
|
||||||
|
avatarError.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropdownKey = 'user-center' | 'logout';
|
||||||
|
|
||||||
type DropdownOption =
|
type DropdownOption =
|
||||||
| {
|
| {
|
||||||
@ -33,13 +47,21 @@ type DropdownOption =
|
|||||||
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
const opts: DropdownOption[] = [
|
const opts: DropdownOption[] = [
|
||||||
|
{
|
||||||
|
label: $t('common.userCenter'),
|
||||||
|
key: 'user-center',
|
||||||
|
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
key: 'divider'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: $t('common.logout'),
|
label: $t('common.logout'),
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 })
|
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 })
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -59,7 +81,6 @@ function handleDropdown(key: DropdownKey) {
|
|||||||
if (key === 'logout') {
|
if (key === 'logout') {
|
||||||
logout();
|
logout();
|
||||||
} else {
|
} else {
|
||||||
// If your other options are jumps from other routes, they will be directly supported here
|
|
||||||
routerPushByKey(key);
|
routerPushByKey(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,13 +91,65 @@ function handleDropdown(key: DropdownKey) {
|
|||||||
{{ $t('page.login.common.loginOrRegister') }}
|
{{ $t('page.login.common.loginOrRegister') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
|
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
|
||||||
<div>
|
<div class="avatar-wrapper">
|
||||||
<ButtonIcon>
|
<NSpin :show="avatarLoading">
|
||||||
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
|
<div class="avatar-container" :class="{ 'avatar-error': avatarError }">
|
||||||
<span class="text-16px font-medium">{{ authStore.userInfo.user?.userName }}</span>
|
<NAvatar
|
||||||
</ButtonIcon>
|
v-if="authStore.userInfo.user?.avatar"
|
||||||
|
:size="24"
|
||||||
|
round
|
||||||
|
:src="authStore.userInfo.user?.avatar"
|
||||||
|
@load="handleAvatarLoad"
|
||||||
|
@error="handleAvatarError"
|
||||||
|
/>
|
||||||
|
<NAvatar v-else :size="32" round :src="defaultAvatar" @load="handleAvatarLoad" @error="handleAvatarError" />
|
||||||
|
<span class="user-name">{{ authStore.userInfo.user?.nickName }}</span>
|
||||||
|
</div>
|
||||||
|
</NSpin>
|
||||||
</div>
|
</div>
|
||||||
</NDropdown>
|
</NDropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style lang="scss" scoped>
|
||||||
|
.avatar-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&.avatar-error {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
max-width: 120px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
background-color: var(--tag-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -186,7 +186,9 @@ const local: App.I18n.Schema = {
|
|||||||
'social-callback': 'Social Callback',
|
'social-callback': 'Social Callback',
|
||||||
system_oss: 'File Management',
|
system_oss: 'File Management',
|
||||||
'system_oss-config': 'OSS Config',
|
'system_oss-config': 'OSS Config',
|
||||||
monitor_cache: 'Cache Monitor'
|
monitor_cache: 'Cache Monitor',
|
||||||
|
monitor_online: 'Online User',
|
||||||
|
'user-center': 'User Center'
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
|
@ -21,6 +21,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||||
login: () => import("@/views/_builtin/login/index.vue"),
|
login: () => import("@/views/_builtin/login/index.vue"),
|
||||||
"social-callback": () => import("@/views/_builtin/social-callback/index.vue"),
|
"social-callback": () => import("@/views/_builtin/social-callback/index.vue"),
|
||||||
|
"user-center": () => import("@/views/_builtin/user-center/index.vue"),
|
||||||
home: () => import("@/views/home/index.vue"),
|
home: () => import("@/views/home/index.vue"),
|
||||||
monitor_cache: () => import("@/views/monitor/cache/index.vue"),
|
monitor_cache: () => import("@/views/monitor/cache/index.vue"),
|
||||||
"monitor_login-infor": () => import("@/views/monitor/login-infor/index.vue"),
|
"monitor_login-infor": () => import("@/views/monitor/login-infor/index.vue"),
|
||||||
|
@ -293,5 +293,16 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user-center',
|
||||||
|
path: '/user-center',
|
||||||
|
component: 'layout.base$view.user-center',
|
||||||
|
meta: {
|
||||||
|
title: 'user-center',
|
||||||
|
i18nKey: 'route.user-center',
|
||||||
|
icon: 'material-symbols:account-circle-full',
|
||||||
|
hideInMenu: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
@ -190,7 +190,8 @@ const routeMap: RouteMap = {
|
|||||||
"system_tenant": "/system/tenant",
|
"system_tenant": "/system/tenant",
|
||||||
"system_user": "/system/user",
|
"system_user": "/system/user",
|
||||||
"tool": "/tool",
|
"tool": "/tool",
|
||||||
"tool_gen": "/tool/gen"
|
"tool_gen": "/tool/gen",
|
||||||
|
"user-center": "/user-center"
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -113,6 +113,17 @@ const dynamicConstantRoutes: ElegantRoute[] = [
|
|||||||
icon: 'simple-icons:authy'
|
icon: 'simple-icons:authy'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'user-center',
|
||||||
|
path: '/user-center',
|
||||||
|
component: 'layout.base$view.user-center',
|
||||||
|
meta: {
|
||||||
|
title: 'user-center',
|
||||||
|
i18nKey: 'route.user-center',
|
||||||
|
icon: 'material-symbols:account-circle-full',
|
||||||
|
hideInMenu: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'system',
|
name: 'system',
|
||||||
path: '/system',
|
path: '/system',
|
||||||
|
4
src/service/api/monitor/index.ts
Normal file
4
src/service/api/monitor/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './online';
|
||||||
|
export * from './oper-log';
|
||||||
|
export * from './cache';
|
||||||
|
export * from './login-infor';
|
@ -20,3 +20,12 @@ export function fetchForceLogout(tokenId: string) {
|
|||||||
method: 'delete'
|
method: 'delete'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取在线设备列表 */
|
||||||
|
export function fetchGetOnlineDeviceList(params?: Api.Monitor.OnlineUserSearchParams) {
|
||||||
|
return request<Api.Monitor.OnlineUserList>({
|
||||||
|
url: '/monitor/online',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { request } from '../../request';
|
import { request } from '../../request';
|
||||||
|
|
||||||
/** 绑定账户 */
|
/** 获取跳转URL */
|
||||||
export function fetchSocialAuthBinding(source: Api.System.SocialSource, tenantId: string = '000000') {
|
export function fetchSocialAuthBinding(source: Api.System.SocialSource, tenantId: string = '000000') {
|
||||||
return request<string>({
|
return request<string>({
|
||||||
url: `/auth/binding/${source}`,
|
url: `/auth/binding/${source}`,
|
||||||
@ -11,3 +11,19 @@ export function fetchSocialAuthBinding(source: Api.System.SocialSource, tenantId
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解绑账户 */
|
||||||
|
export function fetchSocialAuthUnbinding(socialId: string) {
|
||||||
|
return request<string>({
|
||||||
|
url: `/auth/unlock/${socialId}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询社会化关系列表 */
|
||||||
|
export function fetchSocialList() {
|
||||||
|
return request<Api.System.Social[]>({
|
||||||
|
url: '/system/social/list',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -57,3 +57,30 @@ export function fetchGetDeptTree() {
|
|||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 修改用户基本信息 */
|
||||||
|
export function fetchUpdateUserProfile(data: Api.System.UserProfileOperateParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: '/system/user/profile',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改用户密码 */
|
||||||
|
export function fetchUpdateUserPassword(data: Api.System.UserPasswordOperateParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: '/system/user/profile/updatePwd',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改用户头像 */
|
||||||
|
export function fetchUpdateUserAvatar(formData: FormData) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: '/system/user/profile/avatar',
|
||||||
|
method: 'post',
|
||||||
|
data: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
7
src/typings/api/api.d.ts
vendored
7
src/typings/api/api.d.ts
vendored
@ -155,8 +155,11 @@ declare namespace Api {
|
|||||||
/** userinfo */
|
/** userinfo */
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
/** 用户信息 */
|
/** 用户信息 */
|
||||||
user?: Api.System.User;
|
user?: Api.System.User & {
|
||||||
/** 角色权限 */
|
/** 所属角色 */
|
||||||
|
roles: Api.System.Role[];
|
||||||
|
};
|
||||||
|
/** 角色列表 */
|
||||||
roles: string[];
|
roles: string[];
|
||||||
/** 菜单权限 */
|
/** 菜单权限 */
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
2
src/typings/api/monitor.api.d.ts
vendored
2
src/typings/api/monitor.api.d.ts
vendored
@ -151,7 +151,7 @@ declare namespace Api {
|
|||||||
/** 设备类型 */
|
/** 设备类型 */
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
/** 登录时间 */
|
/** 登录时间 */
|
||||||
loginTime: string;
|
loginTime: number;
|
||||||
/** 令牌ID */
|
/** 令牌ID */
|
||||||
tokenId: string;
|
tokenId: string;
|
||||||
}>;
|
}>;
|
||||||
|
57
src/typings/api/system.api.d.ts
vendored
57
src/typings/api/system.api.d.ts
vendored
@ -127,6 +127,12 @@ declare namespace Api {
|
|||||||
> & { roleIds: CommonType.IdType[]; postIds: CommonType.IdType[] }
|
> & { roleIds: CommonType.IdType[]; postIds: CommonType.IdType[] }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/** user profile operate params */
|
||||||
|
type UserProfileOperateParams = CommonType.RecordNullable<Pick<User, 'nickName' | 'email' | 'phonenumber' | 'sex'>>;
|
||||||
|
|
||||||
|
/** user password operate params */
|
||||||
|
type UserPasswordOperateParams = CommonType.RecordNullable<Pick<User, 'password'> & { newPassword: string }>;
|
||||||
|
|
||||||
/** user info */
|
/** user info */
|
||||||
type UserInfo = {
|
type UserInfo = {
|
||||||
/** user post ids */
|
/** user post ids */
|
||||||
@ -138,6 +144,54 @@ declare namespace Api {
|
|||||||
/** user list */
|
/** user list */
|
||||||
type UserList = Common.PaginatingQueryRecord<User>;
|
type UserList = Common.PaginatingQueryRecord<User>;
|
||||||
|
|
||||||
|
/** social */
|
||||||
|
type Social = Common.CommonRecord<{
|
||||||
|
/** 用户ID */
|
||||||
|
userId: CommonType.IdType;
|
||||||
|
/** 租户ID */
|
||||||
|
tenantId: CommonType.IdType;
|
||||||
|
/** 认证的唯一ID */
|
||||||
|
authId: string;
|
||||||
|
/** 用户来源 */
|
||||||
|
source: string;
|
||||||
|
/** 用户的授权令牌 */
|
||||||
|
accessToken: string;
|
||||||
|
/** 用户的授权令牌的有效期,部分平台可能没有 */
|
||||||
|
expireIn: number;
|
||||||
|
/** 刷新令牌,部分平台可能没有 */
|
||||||
|
refreshToken: string;
|
||||||
|
/** 用户的 open id */
|
||||||
|
openId: string;
|
||||||
|
/** 授权的第三方账号 */
|
||||||
|
userName: string;
|
||||||
|
/** 授权的第三方昵称 */
|
||||||
|
nickName: string;
|
||||||
|
/** 授权的第三方邮箱 */
|
||||||
|
email: string;
|
||||||
|
/** 授权的第三方头像地址 */
|
||||||
|
avatar: string;
|
||||||
|
/** 平台的授权信息,部分平台可能没有 */
|
||||||
|
accessCode: string;
|
||||||
|
/** 用户的 unionid */
|
||||||
|
unionId: string;
|
||||||
|
/** 授予的权限,部分平台可能没有 */
|
||||||
|
scope: string;
|
||||||
|
/** 个别平台的授权信息,部分平台可能没有 */
|
||||||
|
tokenType: string;
|
||||||
|
/** id token,部分平台可能没有 */
|
||||||
|
idToken: string;
|
||||||
|
/** 小米平台用户的附带属性,部分平台可能没有 */
|
||||||
|
macAlgorithm: string;
|
||||||
|
/** 小米平台用户的附带属性,部分平台可能没有 */
|
||||||
|
macKey: string;
|
||||||
|
/** 用户的授权code,部分平台可能没有 */
|
||||||
|
code: string;
|
||||||
|
/** Twitter平台用户的附带属性,部分平台可能没有 */
|
||||||
|
oauthToken: string;
|
||||||
|
/** Twitter平台用户的附带属性,部分平台可能没有 */
|
||||||
|
oauthTokenSecret: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* icon type
|
* icon type
|
||||||
*
|
*
|
||||||
@ -611,7 +665,8 @@ declare namespace Api {
|
|||||||
| 'wechat_open'
|
| 'wechat_open'
|
||||||
| 'wechat_mp'
|
| 'wechat_mp'
|
||||||
| 'wechat_enterprise'
|
| 'wechat_enterprise'
|
||||||
| 'gitlab';
|
| 'gitlab'
|
||||||
|
| 'github';
|
||||||
|
|
||||||
/** oss */
|
/** oss */
|
||||||
type Oss = Common.CommonRecord<{
|
type Oss = Common.CommonRecord<{
|
||||||
|
3
src/typings/components.d.ts
vendored
3
src/typings/components.d.ts
vendored
@ -65,6 +65,7 @@ declare module 'vue' {
|
|||||||
MonacoEditor: typeof import('./../components/common/monaco-editor.vue')['default']
|
MonacoEditor: typeof import('./../components/common/monaco-editor.vue')['default']
|
||||||
NA: typeof import('naive-ui')['NA']
|
NA: typeof import('naive-ui')['NA']
|
||||||
NAlert: typeof import('naive-ui')['NAlert']
|
NAlert: typeof import('naive-ui')['NAlert']
|
||||||
|
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||||
NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
|
NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
|
||||||
NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem']
|
NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem']
|
||||||
NButton: typeof import('naive-ui')['NButton']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
@ -120,6 +121,7 @@ declare module 'vue' {
|
|||||||
NSpin: typeof import('naive-ui')['NSpin']
|
NSpin: typeof import('naive-ui')['NSpin']
|
||||||
NSplit: typeof import('naive-ui')['NSplit']
|
NSplit: typeof import('naive-ui')['NSplit']
|
||||||
NStatistic: typeof import('naive-ui')['NStatistic']
|
NStatistic: typeof import('naive-ui')['NStatistic']
|
||||||
|
NSvgIcon: typeof import('naive-ui')['NSvgIcon']
|
||||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
NTab: typeof import('naive-ui')['NTab']
|
NTab: typeof import('naive-ui')['NTab']
|
||||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||||
@ -127,6 +129,7 @@ declare module 'vue' {
|
|||||||
NTag: typeof import('naive-ui')['NTag']
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
NText: typeof import('naive-ui')['NText']
|
NText: typeof import('naive-ui')['NText']
|
||||||
NThing: typeof import('naive-ui')['NThing']
|
NThing: typeof import('naive-ui')['NThing']
|
||||||
|
NTime: typeof import('naive-ui')['NTime']
|
||||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
NTree: typeof import('naive-ui')['NTree']
|
NTree: typeof import('naive-ui')['NTree']
|
||||||
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
|
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
|
||||||
|
3
src/typings/elegant-router.d.ts
vendored
3
src/typings/elegant-router.d.ts
vendored
@ -45,6 +45,7 @@ declare module "@elegant-router/types" {
|
|||||||
"system_user": "/system/user";
|
"system_user": "/system/user";
|
||||||
"tool": "/tool";
|
"tool": "/tool";
|
||||||
"tool_gen": "/tool/gen";
|
"tool_gen": "/tool/gen";
|
||||||
|
"user-center": "/user-center";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -86,6 +87,7 @@ declare module "@elegant-router/types" {
|
|||||||
| "social-callback"
|
| "social-callback"
|
||||||
| "system"
|
| "system"
|
||||||
| "tool"
|
| "tool"
|
||||||
|
| "user-center"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -108,6 +110,7 @@ declare module "@elegant-router/types" {
|
|||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "login"
|
| "login"
|
||||||
| "social-callback"
|
| "social-callback"
|
||||||
|
| "user-center"
|
||||||
| "home"
|
| "home"
|
||||||
| "monitor_cache"
|
| "monitor_cache"
|
||||||
| "monitor_login-infor"
|
| "monitor_login-infor"
|
||||||
|
@ -157,12 +157,11 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
|
|||||||
<div class="mx-6px flex-y-center justify-between">
|
<div class="mx-6px flex-y-center justify-between">
|
||||||
<NCheckbox v-model:checked="remberMe">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
|
<NCheckbox v-model:checked="remberMe">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
|
||||||
<NSpace :size="1">
|
<NSpace :size="1">
|
||||||
<ButtonIcon class="color-#44b549" icon="ic:outline-wechat" @click="handleSocialLogin('wechat_enterprise')" />
|
<ButtonIcon class="color-#44b549" icon="ic:outline-wechat" @click="handleSocialLogin('wechat_open')" />
|
||||||
<ButtonIcon local-icon="topiam" @click="handleSocialLogin('topiam')" />
|
<ButtonIcon local-icon="topiam" @click="handleSocialLogin('topiam')" />
|
||||||
<ButtonIcon local-icon="maxkey" @click="handleSocialLogin('maxkey')" />
|
<ButtonIcon local-icon="maxkey" @click="handleSocialLogin('maxkey')" />
|
||||||
<ButtonIcon class="color-#c71d23" icon="simple-icons:gitee" @click="handleSocialLogin('gitee')" />
|
<ButtonIcon class="color-#c71d23" icon="simple-icons:gitee" @click="handleSocialLogin('gitee')" />
|
||||||
<!-- <ButtonIcon class="color-#010409" icon="mdi:github" @click="handleSocialLogin('github')" /> -->
|
<ButtonIcon class="color-#010409" icon="mdi:github" @click="handleSocialLogin('github')" />
|
||||||
<ButtonIcon icon="material-icon-theme:gitlab" @click="handleSocialLogin('gitlab')" />
|
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</div>
|
</div>
|
||||||
<NButton type="primary" size="large" block :loading="authStore.loginLoading" @click="handleSubmit">
|
<NButton type="primary" size="large" block :loading="authStore.loginLoading" @click="handleSubmit">
|
||||||
|
242
src/views/_builtin/user-center/index.vue
Normal file
242
src/views/_builtin/user-center/index.vue
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
import { NButton } from 'naive-ui';
|
||||||
|
import { useLoading } from '@sa/hooks';
|
||||||
|
import { fetchUpdateUserPassword, fetchUpdateUserProfile } from '@/service/api/system';
|
||||||
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
|
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
||||||
|
import OnlineTable from './modules/online-table.vue';
|
||||||
|
import SocialCard from './modules/social-card.vue';
|
||||||
|
import UserAvatar from './modules/user-avatar.vue';
|
||||||
|
defineOptions({
|
||||||
|
name: 'UserCenter'
|
||||||
|
});
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { userInfo } = authStore;
|
||||||
|
|
||||||
|
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
|
||||||
|
|
||||||
|
const {
|
||||||
|
formRef: profileFormRef,
|
||||||
|
validate: profileValidate,
|
||||||
|
restoreValidation: profileRestoreValidation
|
||||||
|
} = useNaiveForm();
|
||||||
|
const {
|
||||||
|
formRef: passwordFormRef,
|
||||||
|
validate: passwordValidate,
|
||||||
|
restoreValidation: passwordRestoreValidation
|
||||||
|
} = useNaiveForm();
|
||||||
|
const { createRequiredRule, patternRules } = useFormRules();
|
||||||
|
|
||||||
|
type ProfileModel = Api.System.UserProfileOperateParams;
|
||||||
|
type PasswordModel = Api.System.UserPasswordOperateParams & { confirmPassword: string };
|
||||||
|
|
||||||
|
const profileModel: ProfileModel = reactive(createDefaultProfileModel());
|
||||||
|
const passwordModel: PasswordModel = reactive(createDefaultPasswordModel());
|
||||||
|
|
||||||
|
function createDefaultProfileModel(): ProfileModel {
|
||||||
|
return {
|
||||||
|
nickName: userInfo.user?.nickName || '',
|
||||||
|
email: userInfo.user?.email || '',
|
||||||
|
phonenumber: userInfo.user?.phonenumber || '',
|
||||||
|
sex: userInfo.user?.sex || '0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultPasswordModel(): PasswordModel {
|
||||||
|
return {
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
newPassword: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileRuleKey = Extract<keyof ProfileModel, 'nickName' | 'email' | 'phonenumber' | 'sex'>;
|
||||||
|
type PasswordRuleKey = Extract<keyof PasswordModel, 'password' | 'confirmPassword' | 'newPassword'>;
|
||||||
|
|
||||||
|
const profileRules: Record<ProfileRuleKey, App.Global.FormRule> = {
|
||||||
|
nickName: createRequiredRule('昵称不能为空'),
|
||||||
|
email: { ...patternRules.email, required: true },
|
||||||
|
phonenumber: { ...patternRules.phone, required: true },
|
||||||
|
sex: createRequiredRule('性别不能为空')
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordRules: Record<PasswordRuleKey, App.Global.FormRule> = {
|
||||||
|
password: createRequiredRule('密码不能为空'),
|
||||||
|
confirmPassword: createRequiredRule('确认密码不能为空'),
|
||||||
|
newPassword: createRequiredRule('新密码不能为空')
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateProfile() {
|
||||||
|
await profileValidate();
|
||||||
|
startBtnLoading();
|
||||||
|
const { error } = await fetchUpdateUserProfile(profileModel);
|
||||||
|
if (!error) {
|
||||||
|
window.$message?.success('更新成功');
|
||||||
|
// 更新本地用户信息
|
||||||
|
if (userInfo.user) {
|
||||||
|
Object.assign(userInfo.user, profileModel);
|
||||||
|
profileRestoreValidation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endBtnLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword() {
|
||||||
|
await passwordValidate();
|
||||||
|
if (passwordModel.newPassword !== passwordModel.confirmPassword) {
|
||||||
|
window.$message?.error('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startBtnLoading();
|
||||||
|
const { error } = await fetchUpdateUserPassword(passwordModel);
|
||||||
|
if (!error) {
|
||||||
|
window.$message?.success('密码修改成功');
|
||||||
|
// 清空表单
|
||||||
|
Object.assign(passwordModel, createDefaultPasswordModel());
|
||||||
|
passwordRestoreValidation();
|
||||||
|
}
|
||||||
|
endBtnLoading();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-16px">
|
||||||
|
<!-- 个人信息卡片 -->
|
||||||
|
<NCard title="个人信息" class="w-360px shadow-sm">
|
||||||
|
<div class="flex-x-center flex-wrap gap-24px">
|
||||||
|
<div class="flex-center flex-col gap-16px">
|
||||||
|
<div class="relative">
|
||||||
|
<UserAvatar />
|
||||||
|
</div>
|
||||||
|
<div class="text-18px font-medium">{{ userInfo.user?.nickName }}</div>
|
||||||
|
<div class="text-14px text-gray-500">{{ userInfo.user?.userName }}</div>
|
||||||
|
</div>
|
||||||
|
<NDescriptions :column="1" label-placement="left" label-width="120px">
|
||||||
|
<NDescriptionsItem label="手机号码">
|
||||||
|
<div class="text-14px">{{ userInfo.user?.phonenumber }}</div>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem label="用户邮箱">
|
||||||
|
<div class="text-14px">{{ userInfo.user?.email }}</div>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem label="所属部门">
|
||||||
|
<div class="text-14px">{{ userInfo.user?.deptName }}</div>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem label="所属角色">
|
||||||
|
<NSpace>
|
||||||
|
<NTag v-for="role in userInfo.user?.roles" :key="role.roleId" type="primary" size="small">
|
||||||
|
{{ role.roleName }}
|
||||||
|
</NTag>
|
||||||
|
</NSpace>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
<NDescriptionsItem label="创建日期">
|
||||||
|
<div class="text-14px">{{ userInfo.user?.createTime }}</div>
|
||||||
|
</NDescriptionsItem>
|
||||||
|
</NDescriptions>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<!-- 基本资料卡片 -->
|
||||||
|
<NCard title="基本资料" class="shadow-sm">
|
||||||
|
<NTabs type="line" animated class="h-full" s>
|
||||||
|
<NTabPane name="userInfo" tab="基本资料">
|
||||||
|
<NForm
|
||||||
|
ref="profileFormRef"
|
||||||
|
:model="profileModel"
|
||||||
|
:rules="profileRules"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="100px"
|
||||||
|
class="mt-16px max-w-520px"
|
||||||
|
>
|
||||||
|
<NFormItem label="昵称" path="nickName">
|
||||||
|
<NInput v-model:value="profileModel.nickName" placeholder="请输入昵称" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="邮箱" path="email">
|
||||||
|
<NInput v-model:value="profileModel.email" placeholder="请输入邮箱" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="手机号" path="phonenumber">
|
||||||
|
<NInput v-model:value="profileModel.phonenumber" placeholder="请输入手机号" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="性别" path="sex">
|
||||||
|
<NRadioGroup v-model:value="profileModel.sex">
|
||||||
|
<NRadio value="0">男</NRadio>
|
||||||
|
<NRadio value="1">女</NRadio>
|
||||||
|
</NRadioGroup>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem class="flex items-center justify-end">
|
||||||
|
<NButton class="ml-20px w-80px" type="primary" :loading="btnLoading" @click="updateProfile">
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon icon="ic:outline-save" class="size-24px" />
|
||||||
|
</template>
|
||||||
|
保存
|
||||||
|
</NButton>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
</NTabPane>
|
||||||
|
<NTabPane name="updatePwd" tab="修改密码">
|
||||||
|
<NForm
|
||||||
|
ref="passwordFormRef"
|
||||||
|
:model="passwordModel"
|
||||||
|
:rules="passwordRules"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="100px"
|
||||||
|
class="mt-16px max-w-520px"
|
||||||
|
>
|
||||||
|
<NFormItem label="旧密码" path="password">
|
||||||
|
<NInput
|
||||||
|
v-model:value="passwordModel.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入旧密码"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="新密码" path="newPassword">
|
||||||
|
<NInput
|
||||||
|
v-model:value="passwordModel.newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="确认密码" path="confirmPassword">
|
||||||
|
<NInput
|
||||||
|
v-model:value="passwordModel.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem class="flex items-center justify-end">
|
||||||
|
<NButton class="ml-20px w-120px" type="primary" :loading="btnLoading" @click="updatePassword">
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon icon="ic:outline-key" class="size-24px" />
|
||||||
|
</template>
|
||||||
|
修改密码
|
||||||
|
</NButton>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
</NTabPane>
|
||||||
|
<NTabPane name="social" tab="第三方应用">
|
||||||
|
<SocialCard />
|
||||||
|
</NTabPane>
|
||||||
|
<NTabPane name="online" tab="在线设备">
|
||||||
|
<div class="h-full">
|
||||||
|
<OnlineTable />
|
||||||
|
</div>
|
||||||
|
</NTabPane>
|
||||||
|
</NTabs>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shadow-sm {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tabs-pane-wrapper),
|
||||||
|
:deep(.n-tab-pane) {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
88
src/views/_builtin/user-center/modules/online-table.vue
Normal file
88
src/views/_builtin/user-center/modules/online-table.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { NTime } from 'naive-ui';
|
||||||
|
import { useLoading } from '@sa/hooks';
|
||||||
|
import { fetchForceLogout, fetchGetOnlineDeviceList } from '@/service/api/monitor';
|
||||||
|
import { useAppStore } from '@/store/modules/app';
|
||||||
|
import { useTable } from '@/hooks/common/table';
|
||||||
|
import { $t } from '@/locales';
|
||||||
|
import ButtonIcon from '@/components/custom/button-icon.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'OnlineTable'
|
||||||
|
});
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading(false);
|
||||||
|
|
||||||
|
const { columns, data, loading, mobilePagination, getData } = useTable({
|
||||||
|
apiFn: fetchGetOnlineDeviceList,
|
||||||
|
apiParams: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10
|
||||||
|
},
|
||||||
|
columns: () => [
|
||||||
|
{ title: '用户名', key: 'userName', align: 'center', minWidth: 120 },
|
||||||
|
{ title: 'IP地址', key: 'ipaddr', align: 'center', minWidth: 120 },
|
||||||
|
{ title: '登录地点', key: 'loginLocation', align: 'center', minWidth: 120 },
|
||||||
|
{ title: '浏览器', key: 'browser', align: 'center', minWidth: 120 },
|
||||||
|
{ title: '操作系统', key: 'os', align: 'center', minWidth: 120 },
|
||||||
|
{
|
||||||
|
title: '登录时间',
|
||||||
|
key: 'loginTime',
|
||||||
|
align: 'center',
|
||||||
|
minWidth: 180,
|
||||||
|
render: row => <NTime time={row.loginTime} format="yyyy-MM-dd HH:mm:ss" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'operate',
|
||||||
|
title: $t('common.operate'),
|
||||||
|
align: 'center',
|
||||||
|
minWidth: 80,
|
||||||
|
render: row => {
|
||||||
|
return (
|
||||||
|
<div class="flex-center gap-8px">
|
||||||
|
<ButtonIcon
|
||||||
|
text
|
||||||
|
type="error"
|
||||||
|
icon="material-symbols:delete-outline"
|
||||||
|
loading={btnLoading.value}
|
||||||
|
class="text-18px"
|
||||||
|
tooltipContent="强制下线"
|
||||||
|
popconfirmContent="确定强制下线吗?"
|
||||||
|
onPositiveClick={() => forceLogout(row.tokenId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 强制下线 */
|
||||||
|
async function forceLogout(tokenId: string) {
|
||||||
|
startBtnLoading();
|
||||||
|
const { error } = await fetchForceLogout(tokenId);
|
||||||
|
if (!error) {
|
||||||
|
window.$message?.success('强制下线成功');
|
||||||
|
await getData();
|
||||||
|
}
|
||||||
|
endBtnLoading();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="data"
|
||||||
|
size="small"
|
||||||
|
:flex-height="!appStore.isMobile"
|
||||||
|
:scroll-x="962"
|
||||||
|
:loading="loading"
|
||||||
|
remote
|
||||||
|
:row-key="row => row.noticeId"
|
||||||
|
:pagination="mobilePagination"
|
||||||
|
class="h-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
117
src/views/_builtin/user-center/modules/social-card.vue
Normal file
117
src/views/_builtin/user-center/modules/social-card.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useLoading } from '@sa/hooks';
|
||||||
|
import { fetchSocialAuthBinding, fetchSocialAuthUnbinding, fetchSocialList } from '@/service/api/system';
|
||||||
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SocialCard'
|
||||||
|
});
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { userInfo } = authStore;
|
||||||
|
|
||||||
|
const socialList = ref<Api.System.Social[]>([]);
|
||||||
|
const { loading, startLoading, endLoading } = useLoading();
|
||||||
|
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
|
||||||
|
|
||||||
|
/** 获取SSO账户列表 */
|
||||||
|
async function getSsoUserList() {
|
||||||
|
startLoading();
|
||||||
|
const { data, error } = await fetchSocialList();
|
||||||
|
if (!error) {
|
||||||
|
socialList.value = data || [];
|
||||||
|
}
|
||||||
|
endLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定SSO账户 */
|
||||||
|
async function bindSsoAccount(type: Api.System.SocialSource) {
|
||||||
|
const { data, error } = await fetchSocialAuthBinding(type, userInfo.user?.tenantId);
|
||||||
|
if (!error) {
|
||||||
|
window.location.href = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解绑SSO账户 */
|
||||||
|
async function unbindSsoAccount(socialId: string) {
|
||||||
|
startBtnLoading();
|
||||||
|
const { error } = await fetchSocialAuthUnbinding(socialId);
|
||||||
|
if (!error) {
|
||||||
|
window.$message?.success('账户解绑成功');
|
||||||
|
await getSsoUserList();
|
||||||
|
}
|
||||||
|
endBtnLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialSources = [
|
||||||
|
{ key: 'wechat_open', icon: 'ic:outline-wechat', color: '#44b549', name: '微信' },
|
||||||
|
{ key: 'topiam', localIcon: 'topiam', color: '', name: 'TopIAM' },
|
||||||
|
{ key: 'maxkey', localIcon: 'maxkey', color: '', name: 'MaxKey' },
|
||||||
|
{ key: 'gitee', icon: 'simple-icons:gitee', color: '#c71d23', name: 'Gitee' },
|
||||||
|
{ key: 'github', icon: 'mdi:github', color: '#010409', name: 'GitHub' }
|
||||||
|
];
|
||||||
|
|
||||||
|
getSsoUserList();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NSpin :show="loading" class="mt-16px">
|
||||||
|
<div class="grid grid-cols-1 gap-16px 2xl:grid-cols-3 xl:grid-cols-2">
|
||||||
|
<div v-for="source in socialSources" :key="source.key" class="relative">
|
||||||
|
<NCard
|
||||||
|
class="h-full transition-all duration-300 hover:shadow-md"
|
||||||
|
:class="[socialList.some(s => s.source === source.key) ? 'border-primary' : 'border-transparent']"
|
||||||
|
:bordered="true"
|
||||||
|
>
|
||||||
|
<template v-if="socialList.some(s => s.source === source.key)">
|
||||||
|
<div class="flex flex-col items-center gap-16px">
|
||||||
|
<NAvatar
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
:src="socialList.find(s => s.source === source.key)?.avatar"
|
||||||
|
class="size-80px"
|
||||||
|
/>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-16px font-medium">
|
||||||
|
{{ socialList.find(s => s.source === source.key)?.nickName }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4px text-12px text-gray-500">
|
||||||
|
绑定时间:{{ socialList.find(s => s.source === source.key)?.createTime }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NButton
|
||||||
|
type="error"
|
||||||
|
size="small"
|
||||||
|
:loading="btnLoading"
|
||||||
|
@click="unbindSsoAccount(socialList.find(s => s.source === source.key)?.authId || '')"
|
||||||
|
>
|
||||||
|
解绑
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="h-full flex flex-col items-center justify-center gap-16px">
|
||||||
|
<SvgIcon
|
||||||
|
:local-icon="source.localIcon"
|
||||||
|
:icon="source.icon"
|
||||||
|
class="size-48px"
|
||||||
|
:style="{ color: source.color }"
|
||||||
|
/>
|
||||||
|
<div class="text-16px font-medium">{{ source.name }}</div>
|
||||||
|
<NButton type="primary" size="small" @click="bindSsoAccount(source.key as Api.System.SocialSource)">
|
||||||
|
绑定
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NSpin>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.border-primary {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
195
src/views/_builtin/user-center/modules/user-avatar.vue
Normal file
195
src/views/_builtin/user-center/modules/user-avatar.vue
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import type { UploadFileInfo } from 'naive-ui';
|
||||||
|
import { NButton, NModal, NUpload } from 'naive-ui';
|
||||||
|
import { Cropper } from 'vue-advanced-cropper';
|
||||||
|
import { fetchUpdateUserAvatar } from '@/service/api/system';
|
||||||
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
|
import defaultAvatar from '@/assets/imgs/soybean.jpg';
|
||||||
|
import 'vue-advanced-cropper/dist/style.css';
|
||||||
|
|
||||||
|
interface CropperOptions {
|
||||||
|
img: string;
|
||||||
|
fileName: string;
|
||||||
|
stencilProps: {
|
||||||
|
aspectRatio: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CropperRef {
|
||||||
|
getResult: () => {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const showModal = ref(false);
|
||||||
|
const imageUrl = ref(authStore.userInfo.user?.avatar || defaultAvatar);
|
||||||
|
const cropperRef = ref<CropperRef | null>(null);
|
||||||
|
|
||||||
|
// 图片裁剪数据
|
||||||
|
const options = reactive<CropperOptions>({
|
||||||
|
img: imageUrl.value,
|
||||||
|
fileName: '',
|
||||||
|
stencilProps: {
|
||||||
|
aspectRatio: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 编辑头像 */
|
||||||
|
function handleEdit() {
|
||||||
|
showModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理文件选择 */
|
||||||
|
async function handleFileSelect(data: { file: UploadFileInfo }) {
|
||||||
|
const file = data.file.file;
|
||||||
|
if (!file) return false;
|
||||||
|
|
||||||
|
if (!file.type.includes('image/')) {
|
||||||
|
window.$message?.error('请上传图片类型文件(JPG、PNG等)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
options.img = reader.result as string;
|
||||||
|
options.fileName = file.name;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理裁剪 */
|
||||||
|
async function handleCrop() {
|
||||||
|
if (!cropperRef.value) return;
|
||||||
|
|
||||||
|
const { canvas } = cropperRef.value.getResult();
|
||||||
|
|
||||||
|
// 将 canvas 转换为 blob
|
||||||
|
canvas.toBlob(async (blob: Blob | null) => {
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatarfile', blob, options.fileName || 'avatar.png');
|
||||||
|
|
||||||
|
const { error } = await fetchUpdateUserAvatar(formData);
|
||||||
|
if (!error) {
|
||||||
|
window.$message?.success('头像更新成功!');
|
||||||
|
imageUrl.value = URL.createObjectURL(blob);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭对话框 */
|
||||||
|
function handleClose() {
|
||||||
|
showModal.value = false;
|
||||||
|
options.img = imageUrl.value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="avatar-wrapper" @click="handleEdit">
|
||||||
|
<div class="avatar-container">
|
||||||
|
<img :src="imageUrl" alt="user-avatar" class="avatar-image" />
|
||||||
|
<div class="avatar-overlay">
|
||||||
|
<SvgIcon icon="ep:plus" class="text-24px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NModal v-model:show="showModal" preset="card" title="修改头像" class="w-400px" @close="handleClose">
|
||||||
|
<div class="upload-container">
|
||||||
|
<div v-if="options.img !== imageUrl" class="cropper-container">
|
||||||
|
<Cropper ref="cropperRef" class="cropper" :src="options.img" :stencil-props="options.stencilProps" />
|
||||||
|
</div>
|
||||||
|
<img v-else :src="imageUrl" alt="user-avatar" class="preview-image" />
|
||||||
|
<div class="button-group">
|
||||||
|
<NUpload accept=".jpg,.jpeg,.png,.gif" :max="1" :show-file-list="false" @before-upload="handleFileSelect">
|
||||||
|
<NButton class="upload-button">选择图片</NButton>
|
||||||
|
</NUpload>
|
||||||
|
<NButton v-if="options.img !== imageUrl" type="primary" class="upload-button" @click="handleCrop">
|
||||||
|
确认裁剪
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.avatar-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover .avatar-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper {
|
||||||
|
height: 100%;
|
||||||
|
background: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
Reference in New Issue
Block a user