feat: 新增个人中心页面
This commit is contained in:
parent
d751c14f7b
commit
423a8c031f
@ -70,6 +70,7 @@
|
||||
"pinia": "3.0.1",
|
||||
"tailwind-merge": "3.0.2",
|
||||
"vue": "3.5.13",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.1.2",
|
||||
"vue-router": "4.5.0"
|
||||
|
@ -71,6 +71,12 @@ importers:
|
||||
vue:
|
||||
specifier: 3.5.13
|
||||
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:
|
||||
specifier: 0.6.0
|
||||
version: 0.6.0(@types/sortablejs@1.15.8)
|
||||
@ -1568,6 +1574,9 @@ packages:
|
||||
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
clean-regexp@1.0.0:
|
||||
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
||||
engines: {node: '>=4'}
|
||||
@ -1738,6 +1747,9 @@ packages:
|
||||
de-indent@1.0.2:
|
||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||
|
||||
debounce@1.2.1:
|
||||
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
@ -1873,6 +1885,9 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
easy-bem@1.1.1:
|
||||
resolution: {integrity: sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==}
|
||||
|
||||
echarts@5.6.0:
|
||||
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||
|
||||
@ -4061,6 +4076,15 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
|
||||
engines: {node: '>=12'}
|
||||
@ -5541,6 +5565,8 @@ snapshots:
|
||||
isobject: 3.0.1
|
||||
static-extend: 0.1.2
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
clean-regexp@1.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
@ -5701,6 +5727,8 @@ snapshots:
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
|
||||
debounce@1.2.1: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
@ -5822,6 +5850,8 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
easy-bem@1.1.1: {}
|
||||
|
||||
echarts@5.6.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
@ -8341,6 +8371,15 @@ snapshots:
|
||||
|
||||
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)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
|
@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { VNode } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useSvgIcon } from '@/hooks/common/icon';
|
||||
import defaultAvatar from '@/assets/imgs/soybean.jpg';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
@ -14,11 +15,24 @@ const authStore = useAuthStore();
|
||||
const { routerPushByKey, toLogin } = useRouterPush();
|
||||
const { SvgIconVNode } = useSvgIcon();
|
||||
|
||||
const avatarLoading = ref(true);
|
||||
const avatarError = ref(false);
|
||||
|
||||
function loginOrRegister() {
|
||||
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 =
|
||||
| {
|
||||
@ -33,13 +47,21 @@ type DropdownOption =
|
||||
|
||||
const options = computed(() => {
|
||||
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'),
|
||||
key: 'logout',
|
||||
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 })
|
||||
}
|
||||
];
|
||||
|
||||
return opts;
|
||||
});
|
||||
|
||||
@ -59,7 +81,6 @@ function handleDropdown(key: DropdownKey) {
|
||||
if (key === 'logout') {
|
||||
logout();
|
||||
} else {
|
||||
// If your other options are jumps from other routes, they will be directly supported here
|
||||
routerPushByKey(key);
|
||||
}
|
||||
}
|
||||
@ -70,13 +91,65 @@ function handleDropdown(key: DropdownKey) {
|
||||
{{ $t('page.login.common.loginOrRegister') }}
|
||||
</NButton>
|
||||
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
|
||||
<div>
|
||||
<ButtonIcon>
|
||||
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
|
||||
<span class="text-16px font-medium">{{ authStore.userInfo.user?.userName }}</span>
|
||||
</ButtonIcon>
|
||||
<div class="avatar-wrapper">
|
||||
<NSpin :show="avatarLoading">
|
||||
<div class="avatar-container" :class="{ 'avatar-error': avatarError }">
|
||||
<NAvatar
|
||||
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>
|
||||
</NDropdown>
|
||||
</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',
|
||||
system_oss: 'File Management',
|
||||
'system_oss-config': 'OSS Config',
|
||||
monitor_cache: 'Cache Monitor'
|
||||
monitor_cache: 'Cache Monitor',
|
||||
monitor_online: 'Online User',
|
||||
'user-center': 'User Center'
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
|
@ -21,6 +21,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||
login: () => import("@/views/_builtin/login/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"),
|
||||
monitor_cache: () => import("@/views/monitor/cache/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_user": "/system/user",
|
||||
"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'
|
||||
}
|
||||
},
|
||||
{
|
||||
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',
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取在线设备列表 */
|
||||
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';
|
||||
|
||||
/** 绑定账户 */
|
||||
/** 获取跳转URL */
|
||||
export function fetchSocialAuthBinding(source: Api.System.SocialSource, tenantId: string = '000000') {
|
||||
return request<string>({
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改用户基本信息 */
|
||||
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 */
|
||||
interface UserInfo {
|
||||
/** 用户信息 */
|
||||
user?: Api.System.User;
|
||||
/** 角色权限 */
|
||||
user?: Api.System.User & {
|
||||
/** 所属角色 */
|
||||
roles: Api.System.Role[];
|
||||
};
|
||||
/** 角色列表 */
|
||||
roles: 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;
|
||||
/** 登录时间 */
|
||||
loginTime: string;
|
||||
loginTime: number;
|
||||
/** 令牌ID */
|
||||
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[] }
|
||||
>;
|
||||
|
||||
/** 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 */
|
||||
type UserInfo = {
|
||||
/** user post ids */
|
||||
@ -138,6 +144,54 @@ declare namespace Api {
|
||||
/** user list */
|
||||
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
|
||||
*
|
||||
@ -611,7 +665,8 @@ declare namespace Api {
|
||||
| 'wechat_open'
|
||||
| 'wechat_mp'
|
||||
| 'wechat_enterprise'
|
||||
| 'gitlab';
|
||||
| 'gitlab'
|
||||
| 'github';
|
||||
|
||||
/** oss */
|
||||
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']
|
||||
NA: typeof import('naive-ui')['NA']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
|
||||
NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
@ -120,6 +121,7 @@ declare module 'vue' {
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NSplit: typeof import('naive-ui')['NSplit']
|
||||
NStatistic: typeof import('naive-ui')['NStatistic']
|
||||
NSvgIcon: typeof import('naive-ui')['NSvgIcon']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTab: typeof import('naive-ui')['NTab']
|
||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||
@ -127,6 +129,7 @@ declare module 'vue' {
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NThing: typeof import('naive-ui')['NThing']
|
||||
NTime: typeof import('naive-ui')['NTime']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
NTree: typeof import('naive-ui')['NTree']
|
||||
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";
|
||||
"tool": "/tool";
|
||||
"tool_gen": "/tool/gen";
|
||||
"user-center": "/user-center";
|
||||
};
|
||||
|
||||
/**
|
||||
@ -86,6 +87,7 @@ declare module "@elegant-router/types" {
|
||||
| "social-callback"
|
||||
| "system"
|
||||
| "tool"
|
||||
| "user-center"
|
||||
>;
|
||||
|
||||
/**
|
||||
@ -108,6 +110,7 @@ declare module "@elegant-router/types" {
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "social-callback"
|
||||
| "user-center"
|
||||
| "home"
|
||||
| "monitor_cache"
|
||||
| "monitor_login-infor"
|
||||
|
@ -157,12 +157,11 @@ async function handleSocialLogin(type: Api.System.SocialSource) {
|
||||
<div class="mx-6px flex-y-center justify-between">
|
||||
<NCheckbox v-model:checked="remberMe">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
|
||||
<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="maxkey" @click="handleSocialLogin('maxkey')" />
|
||||
<ButtonIcon class="color-#c71d23" icon="simple-icons:gitee" @click="handleSocialLogin('gitee')" />
|
||||
<!-- <ButtonIcon class="color-#010409" icon="mdi:github" @click="handleSocialLogin('github')" /> -->
|
||||
<ButtonIcon icon="material-icon-theme:gitlab" @click="handleSocialLogin('gitlab')" />
|
||||
<ButtonIcon class="color-#010409" icon="mdi:github" @click="handleSocialLogin('github')" />
|
||||
</NSpace>
|
||||
</div>
|
||||
<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