mirror of
https://github.com/m-xlsea/ruoyi-plus-soybean.git
synced 2025-09-24 07:49:47 +08:00
feat: 新增个人中心页面
This commit is contained in:
@ -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>
|
Reference in New Issue
Block a user