feat: 重构个人消息中心,支持展示个人公告,并优化相关地址

This commit is contained in:
2025-04-05 22:43:35 +08:00
parent ec43ba4c8f
commit 89d0d9ebb1
25 changed files with 586 additions and 257 deletions

View File

@@ -0,0 +1,245 @@
<template>
<a-card title="基本信息" bordered class="gradient-card">
<div class="body">
<section>
<a-upload
:file-list="avatarList"
accept="image/*"
:show-file-list="false"
list-type="picture-card"
:show-upload-button="true"
:on-before-upload="onBeforeUpload"
>
<template #upload-button>
<Avatar :src="avatarList[0].url" :name="userStore.nickname" :size="100" trigger>
<template #trigger-icon><icon-camera /></template>
</Avatar>
</template>
</a-upload>
<div class="name">
<span style="margin-right: 10px">{{ userInfo.nickname }}</span>
<icon-edit :size="16" class="btn" @click="onUpdate" />
</div>
<div class="id">
<GiSvgIcon name="id" :size="16" />
<span>{{ userInfo.id }}</span>
</div>
</section>
<footer>
<a-descriptions :column="4" size="large">
<a-descriptions-item :span="4">
<template #label> <icon-user /><span style="margin-left: 5px">用户名</span></template>
{{ userInfo.username }}
<icon-man v-if="userInfo.gender === 1" style="color: #19bbf1" />
<icon-woman v-else-if="userInfo.gender === 2" style="color: #fa7fa9" />
</a-descriptions-item>
<a-descriptions-item :span="4">
<template #label> <icon-phone /><span style="margin-left: 5px">手机</span></template>
{{ userInfo.phone || '暂无' }}
</a-descriptions-item>
<a-descriptions-item :span="4">
<template #label> <icon-email /><span style="margin-left: 5px">邮箱</span></template>
{{ userInfo.email || '暂无' }}
</a-descriptions-item>
<a-descriptions-item :span="4">
<template #label> <icon-mind-mapping /><span style="margin-left: 5px">部门</span></template>
{{ userInfo.deptName }}
</a-descriptions-item>
<a-descriptions-item :span="4">
<template #label> <icon-user-group /><span style="margin-left: 5px">角色</span></template>
{{ userInfo.roles.join('') }}
</a-descriptions-item>
</a-descriptions>
</footer>
</div>
<div class="footer">注册于 {{ userInfo.registrationDate }}</div>
</a-card>
<a-modal v-model:visible="visible" title="上传头像" :width="width >= 400 ? 400 : '100%'" :footer="false" draggable @close="reset">
<a-row>
<a-col :span="14" style="width: 200px; height: 200px">
<VueCropper
ref="cropperRef"
:img="options.img"
:info="true"
:auto-crop="options.autoCrop"
:auto-crop-width="options.autoCropWidth"
:auto-crop-height="options.autoCropHeight"
:fixed-box="options.fixedBox"
:fixed="options.fixed"
:full="options.full"
:center-box="options.centerBox"
:can-move="options.canMove"
:output-type="options.outputType"
:output-size="options.outputSize"
@real-time="handleRealTime"
/>
</a-col>
<a-col :span="10" style="display: flex; justify-content: center">
<div :style="previewStyle">
<div :style="previews.div">
<img :src="previews.url" :style="previews.img" alt="" />
</div>
</div>
</a-col>
</a-row>
<div style="text-align: center; padding-top: 30px">
<a-space>
<a-button type="primary" @click="handleUpload">确定</a-button>
<a-button @click="reset">取消</a-button>
</a-space>
</div>
</a-modal>
<BasicInfoUpdateModal ref="BasicInfoUpdateModalRef" />
</template>
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { type FileItem, Message } from '@arco-design/web-vue'
import { VueCropper } from 'vue-cropper'
import BasicInfoUpdateModal from './BasicInfoUpdateModal.vue'
import { uploadAvatar } from '@/apis/system'
import 'vue-cropper/dist/index.css'
import { useUserStore } from '@/stores'
import getAvatar from '@/utils/avatar'
const { width } = useWindowSize()
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const avatar = {
uid: '-2',
name: 'avatar.png',
url: userInfo.value.avatar,
}
const avatarList = ref<FileItem[]>([avatar])
const fileRef = ref(reactive({ name: 'avatar.png' }))
const options: cropperOptions = reactive({
img: '',
autoCrop: true,
autoCropWidth: 160,
autoCropHeight: 160,
fixedBox: true,
fixed: true,
full: false,
centerBox: true,
canMove: true,
outputSize: 1,
outputType: 'png',
})
const visible = ref(false)
// 打开裁剪框
const onBeforeUpload = (file: File): boolean => {
fileRef.value = file
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
options.img = reader.result
}
visible.value = true
return false
}
// 重置
const reset = () => {
fileRef.value = { name: '' }
options.img = ''
visible.value = false
}
const previews: any = ref({})
const previewStyle: any = ref({})
// 实时预览
const handleRealTime = (data: any) => {
previewStyle.value = {
width: `${data.w}px`,
height: `${data.h}px`,
overflow: 'hidden',
margin: '0',
zoom: 100 / data.h,
borderRadius: '50%',
}
previews.value = data
}
const cropperRef = ref()
// 上传头像
const handleUpload = async () => {
cropperRef.value.getCropBlob((data: any) => {
const formData = new FormData()
formData.append('avatarFile', data, fileRef.value?.name)
uploadAvatar(formData).then((res) => {
userInfo.value.avatar = res.data.avatar
avatarList.value[0].url = getAvatar(res.data.avatar, undefined)
reset()
Message.success('更新成功')
})
})
}
const BasicInfoUpdateModalRef = ref<InstanceType<typeof BasicInfoUpdateModal>>()
// 修改基本信息
const onUpdate = async () => {
BasicInfoUpdateModalRef.value?.onUpdate()
}
</script>
<style scoped lang="scss">
:deep(.arco-avatar-trigger-icon-button) {
width: 32px;
height: 32px;
line-height: 32px;
background-color: #e8f3ff;
.arco-icon-camera {
margin-top: 8px;
color: rgb(var(--arcoblue-6));
font-size: 14px;
}
}
.body {
display: flex;
flex-direction: column;
padding: 28px 10px 20px 10px;
.btn {
cursor: pointer;
}
& > section {
flex: 1 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 32px 0 50px;
.name {
font-size: 20px;
margin: 20px 0;
}
.id {
span {
font-size: 12px;
font-weight: 400;
line-height: 20px;
padding: 0 6px;
color: var(--color-text-3);
}
}
}
& > footer .footer_item {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 12px;
}
}
.footer {
margin: 0 -16px;
padding-top: 16px;
font-size: 12px;
text-align: center;
border-top: 1px solid var(--color-border-2);
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<a-modal
v-model:visible="visible"
title="修改基本信息"
:mask-closable="false"
:esc-to-close="false"
:width="width >= 500 ? 500 : '100%'"
draggable
@before-ok="save"
@close="reset"
>
<GiForm ref="formRef" v-model="form" :columns="columns" />
</a-modal>
</template>
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { Message } from '@arco-design/web-vue'
import { updateUserBaseInfo } from '@/apis/system'
import { type ColumnItem, GiForm } from '@/components/GiForm'
import { useUserStore } from '@/stores'
import { useResetReactive } from '@/hooks'
const { width } = useWindowSize()
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const visible = ref(false)
const formRef = ref<InstanceType<typeof GiForm>>()
const [form, resetForm] = useResetReactive({
nickname: userInfo.value.nickname,
gender: userInfo.value.gender,
})
const columns: ColumnItem[] = reactive([
{
label: '昵称',
field: 'nickname',
type: 'input',
span: 24,
rules: [{ required: true, message: '请输入昵称' }],
},
{
label: '性别',
field: 'gender',
type: 'radio-group',
span: 24,
props: {
options: [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
{ label: '未知', value: 0, disabled: true },
],
},
rules: [{ required: true, message: '请选择性别' }],
},
])
// 重置
const reset = () => {
formRef.value?.formRef?.resetFields()
resetForm()
}
// 保存
const save = async () => {
const isInvalid = await formRef.value?.formRef?.validate()
if (isInvalid) return false
try {
await updateUserBaseInfo(form)
Message.success('修改成功')
// 修改成功后,重新获取用户信息
await userStore.getInfo()
return true
} catch (error) {
return false
}
}
// 修改
const onUpdate = async () => {
reset()
visible.value = true
}
defineExpose({ onUpdate })
</script>

View File

@@ -0,0 +1,85 @@
<template>
<a-card title="安全设置" bordered class="gradient-card">
<div v-for="item in modeList" :key="item.title">
<div class="item">
<div class="icon-wrapper"><GiSvgIcon :name="item.icon" :size="26" /></div>
<div class="info">
<div class="info-top">
<span class="label">{{ item.title }}</span>
<span class="bind">
<icon-check-circle-fill v-if="item.status" :size="14" class="success" />
<icon-exclamation-circle-fill v-else :size="14" class="warning" />
<span style="font-size: 12px" :class="item.status ? 'success' : 'warning'">{{ item.statusString }}</span>
</span>
</div>
<div class="info-desc">
<span class="value">{{ item.value }}</span>
{{ item.subtitle }}
</div>
</div>
<div class="btn-wrapper">
<a-button
v-if="item.jumpMode === 'modal'"
class="btn"
:type="item.status ? 'secondary' : 'primary'"
@click="onUpdate(item.type, item.status)"
>
{{ ['password'].includes(item.type) || item.status ? '修改' : '绑定' }}
</a-button>
</div>
</div>
</div>
</a-card>
<VerifyModel ref="verifyModelRef" />
</template>
<script setup lang="ts">
import type { ModeItem } from '../type'
import VerifyModel from '../components/VerifyModel.vue'
import { useUserStore } from '@/stores'
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const modeList = ref<ModeItem[]>([])
modeList.value = [
{
title: '安全手机',
icon: 'phone-color',
value: userInfo.value.phone,
subtitle: `${userInfo.value.phone ? '' : '手机号'}可用于登录、身份验证、密码找回、通知接收`,
type: 'phone',
jumpMode: 'modal',
status: !!userInfo.value.phone,
statusString: userInfo.value.phone ? '已绑定' : '未绑定',
},
{
title: '安全邮箱',
icon: 'email-color',
value: userInfo.value.email,
subtitle: `${userInfo.value.email ? '' : '邮箱'}可用于登录、身份验证、密码找回、通知接收`,
type: 'email',
jumpMode: 'modal',
status: !!userInfo.value.email,
statusString: userInfo.value.email ? '已绑定' : '未绑定',
},
{
title: '登录密码',
icon: 'password-color',
subtitle: userInfo.value.pwdResetTime ? `为了您的账号安全,建议定期修改密码` : '请设置密码,可通过账号+密码登录',
type: 'password',
jumpMode: 'modal',
status: !!userInfo.value.pwdResetTime,
statusString: userInfo.value.pwdResetTime ? '已设置' : '未设置',
},
]
const verifyModelRef = ref<InstanceType<typeof VerifyModel>>()
// 修改
const onUpdate = (type: string) => {
verifyModelRef.value?.open(type)
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,105 @@
<template>
<a-card title="第三方账号" bordered class="gradient-card">
<div v-for="item in modeList" :key="item.title">
<div class="item">
<div class="icon-wrapper"><GiSvgIcon :name="item.icon" :size="26" /></div>
<div class="info">
<div class="info-top">
<span class="label">{{ item.title }}</span>
<span class="bind">
<icon-check-circle-fill v-if="item.status" :size="14" class="success" />
<icon-exclamation-circle-fill v-else :size="14" class="warning" />
<span style="font-size: 12px" :class="item.status ? 'success' : 'warning'">{{
item.status ? '已绑定' : '未绑定'
}}</span>
</span>
</div>
<div class="info-desc">
<span class="value">{{ item.value }}</span>
{{ item.subtitle }}
</div>
</div>
<div class="btn-wrapper">
<a-button
v-if="item.jumpMode === 'modal'"
class="btn"
:type="item.status ? 'secondary' : 'primary'"
@click="onUpdate(item.type, item.status)"
>
{{ item.status ? '修改' : '绑定' }}
</a-button>
<a-button
v-else-if="item.jumpMode === 'link'"
class="btn"
:type="item.status ? 'secondary' : 'primary'"
@click="onBinding(item.type, item.status)"
>
{{ item.status ? '解绑' : '绑定' }}
</a-button>
</div>
</div>
</div>
</a-card>
<VerifyModel ref="verifyModelRef" />
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue'
import type { ModeItem } from '../type'
import VerifyModel from '../components/VerifyModel.vue'
import { listUserSocial, socialAuth, unbindSocialAccount } from '@/apis'
const socialList = ref<any>([])
const modeList = ref<ModeItem[]>([])
// 初始化数据
const initData = () => {
listUserSocial().then((res) => {
socialList.value = res.data.map((el) => el.source)
modeList.value = [
{
title: '绑定 Gitee',
icon: 'gitee',
subtitle: `${socialList.value.includes('GITEE') ? '' : '绑定后,'}可通过 Gitee 进行登录`,
jumpMode: 'link',
type: 'gitee',
status: socialList.value.includes('GITEE'),
},
{
title: '绑定 GitHub',
icon: 'github',
subtitle: `${socialList.value.includes('GITHUB') ? '' : '绑定后,'}可通过 GitHub 进行登录`,
type: 'github',
jumpMode: 'link',
status: socialList.value.includes('GITHUB'),
},
]
})
}
// 绑定
const onBinding = (type: string, status: boolean) => {
if (!status) {
socialAuth(type).then((res) => {
window.open(res.data.authorizeUrl, '_self')
})
} else {
unbindSocialAccount(type).then(() => {
initData()
Message.success('解绑成功')
})
}
}
const verifyModelRef = ref<InstanceType<typeof VerifyModel>>()
// 修改
const onUpdate = (type: string) => {
verifyModelRef.value?.open(type)
}
onMounted(() => {
initData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="gi_page">
<a-row wrap :gutter="16" align="stretch">
<a-col :xs="24" :sm="24" :md="10" :lg="10" :xl="7" :xxl="7">
<LeftBox />
</a-col>
<a-col :xs="24" :sm="24" :md="14" :lg="14" :xl="17" :xxl="17">
<div>
<PasswordPolicy />
</div>
<div style="margin-top: 16px">
<RightBox />
</div>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import LeftBox from './BasicInfo.vue'
import RightBox from './Social.vue'
import PasswordPolicy from './Security.vue'
defineOptions({ name: 'UserProfile' })
</script>
<style scoped lang="scss">
.gi_page {
background-color: var(--color-bg-1);
}
</style>