feat: 新增个人中心页面

This commit is contained in:
xlsea 2025-04-28 23:52:26 +08:00
parent d751c14f7b
commit 423a8c031f
22 changed files with 920 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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"),

View File

@ -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
}
}
];

View File

@ -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"
};
/**

View File

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

View File

@ -0,0 +1,4 @@
export * from './online';
export * from './oper-log';
export * from './cache';
export * from './login-infor';

View File

@ -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
});
}

View File

@ -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'
});
}

View File

@ -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
});
}

View File

@ -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[];

View File

@ -151,7 +151,7 @@ declare namespace Api {
/** 设备类型 */
deviceType: string;
/** 登录时间 */
loginTime: string;
loginTime: number;
/** 令牌ID */
tokenId: string;
}>;

View File

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

View File

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

View File

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

View File

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

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

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

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

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