feat(@vben/web-antd): 重构个人中心用户资料与社交绑定页面

- 用户资料页改为卡片式布局,头像居中展示角色标签
- 社交绑定页替换表格为卡片列表,支持已绑定详情展开
- 新增微信小程序社交类型枚举,小程序端绑定入口置灰提示
- 头像上传兼容 server/client 两种模式的返回值
- 社交绑定列表增加类型安全(SocialBindItem interface)
- 隐藏暂不支持的钉钉和企业微信绑定入口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-18 15:19:56 +08:00
parent 666f25404d
commit bea5a82825
3 changed files with 292 additions and 217 deletions

View File

@@ -7,7 +7,7 @@ import { IconifyIcon } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils';
import { Descriptions, DescriptionsItem, Tooltip } from 'ant-design-vue';
import { Divider, Tag, Tooltip } from 'ant-design-vue';
import { updateUserProfile } from '#/api/system/user/profile';
import { CropperAvatar } from '#/components/cropper';
@@ -36,113 +36,155 @@ async function handelUpload({
const { httpRequest } = useUpload();
// 将 Blob 转换为 File
const fileObj = new File([file], filename, { type: file.type });
const avatar = await httpRequest(fileObj);
const result = await httpRequest(fileObj);
// 2. 更新用户头像
await updateUserProfile({ avatar });
const avatarUrl = typeof result === 'string' ? result : result?.url;
if (avatarUrl) {
await updateUserProfile({ avatar: avatarUrl });
}
}
</script>
<template>
<div v-if="profile">
<div class="flex flex-col items-center">
<div v-if="profile" class="profile-user">
<!-- 头像和基本信息 -->
<div class="flex flex-col items-center pb-4">
<Tooltip title="点击上传头像">
<CropperAvatar
:show-btn="false"
:upload-api="handelUpload"
:value="avatar"
:width="120"
:width="100"
@change="emit('success')"
/>
</Tooltip>
<h3 class="mt-3 text-lg font-semibold">
{{ profile.nickname || profile.username }}
</h3>
<div class="mt-1 flex flex-wrap justify-center gap-1">
<Tag
v-for="role in profile.roles"
:key="role.id"
color="blue"
>
{{ role.name }}
</Tag>
</div>
</div>
<div class="mt-8">
<Descriptions :column="2">
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
用户账号
</div>
</template>
{{ profile.username }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:user-switch-outlined"
class="mr-1"
/>
所属角色
</div>
</template>
{{ profile.roles.map((role) => role.name).join(',') }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:phone-outlined" class="mr-1" />
手机号码
</div>
</template>
{{ profile.mobile }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:mail-outlined" class="mr-1" />
用户邮箱
</div>
</template>
{{ profile.email }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:team-outlined" class="mr-1" />
所属部门
</div>
</template>
{{ profile.dept?.name }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:usergroup-add-outlined"
class="mr-1"
/>
所属岗位
</div>
</template>
<Divider class="!my-3" />
<!-- 详细信息列表 -->
<div class="space-y-3 px-2">
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:user-outlined" class="mr-2 text-base" />
<span>用户账号</span>
</div>
<span class="profile-item-value">{{ profile.username }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:phone-outlined" class="mr-2 text-base" />
<span>手机号码</span>
</div>
<span class="profile-item-value">{{ profile.mobile || '-' }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:mail-outlined" class="mr-2 text-base" />
<span>用户邮箱</span>
</div>
<span class="profile-item-value">{{ profile.email || '-' }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:team-outlined" class="mr-2 text-base" />
<span>所属部门</span>
</div>
<span class="profile-item-value">{{ profile.dept?.name || '-' }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon
icon="ant-design:usergroup-add-outlined"
class="mr-2 text-base"
/>
<span>所属岗位</span>
</div>
<span class="profile-item-value">
{{
profile.posts && profile.posts.length > 0
? profile.posts.map((post) => post.name).join(',')
: '-'
}}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:clock-circle-outlined"
class="mr-1"
/>
创建时间
</div>
</template>
</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon
icon="ant-design:clock-circle-outlined"
class="mr-2 text-base"
/>
<span>创建时间</span>
</div>
<span class="profile-item-value">
{{ formatDateTime(profile.createTime) }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:login-outlined" class="mr-1" />
登录时间
</div>
</template>
</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:login-outlined" class="mr-2 text-base" />
<span>登录时间</span>
</div>
<span class="profile-item-value">
{{ formatDateTime(profile.loginDate) }}
</DescriptionsItem>
</Descriptions>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.profile-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 4px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .profile-item {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.profile-item:last-child {
border-bottom: none;
}
.profile-item-label {
display: flex;
align-items: center;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
}
.dark .profile-item-label {
color: rgba(255, 255, 255, 0.65);
}
.profile-item-value {
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
}
.dark .profile-item-value {
color: rgba(255, 255, 255, 0.85);
}
</style>

View File

@@ -1,5 +1,4 @@
<script setup lang="tsx">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
<script setup lang="ts">
import type { SystemSocialUserApi } from '#/api/system/social/user';
import { computed, onMounted, ref } from 'vue';
@@ -8,125 +7,80 @@ import { useRoute } from 'vue-router';
import { confirm } from '@vben/common-ui';
import { DICT_TYPE, SystemUserSocialTypeEnum } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { getUrlValue } from '@vben/utils';
import { $t } from '@vben/locales';
import { formatDateTime, getUrlValue } from '@vben/utils';
import { Button, Card, Image, message } from 'ant-design-vue';
import { Avatar, Button, Image, message, Tag, Tooltip } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { socialAuthRedirect } from '#/api/core/auth';
import {
getBindSocialUserList,
socialBind,
socialUnbind,
} from '#/api/system/social/user';
import { $t } from '#/locales';
const emit = defineEmits<{
(e: 'update:activeName', v: string): void;
}>();
const route = useRoute();
/** 已经绑定的平台 */
const bindList = ref<SystemSocialUserApi.SocialUser[]>([]);
const allBindList = computed<any[]>(() => {
return Object.values(SystemUserSocialTypeEnum).map((social) => {
const socialUser = bindList.value.find((item) => item.type === social.type);
return {
...social,
socialUser,
};
});
});
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'type',
title: '绑定平台',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE },
},
},
{
field: 'openid',
title: '标识',
minWidth: 180,
},
{
field: 'nickname',
title: '昵称',
minWidth: 180,
},
{
field: 'operation',
title: '操作',
minWidth: 80,
align: 'center',
fixed: 'right',
slots: {
default: ({ row }: { row: SystemSocialUserApi.SocialUser }) => {
return (
<Button onClick={() => onUnbind(row)} type="link">
解绑
</Button>
);
},
},
},
];
/** 暂不支持钉钉和企业微信,后续开放时移除此过滤 */
const HIDDEN_SOCIAL_TYPES = [
SystemUserSocialTypeEnum.DINGTALK.type,
SystemUserSocialTypeEnum.WECHAT_ENTERPRISE.type,
];
interface SocialBindItem {
title: string;
type: number;
source: string;
img: string;
socialUser?: SystemSocialUserApi.SocialUser;
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
minHeight: 0,
keepSource: true,
proxyConfig: {
ajax: {
query: async () => {
bindList.value = await getBindSocialUserList();
return bindList.value;
},
},
},
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
const allBindList = computed<SocialBindItem[]>(() => {
return Object.values(SystemUserSocialTypeEnum)
.filter((social) => !HIDDEN_SOCIAL_TYPES.includes(social.type))
.map((social) => {
const socialUser = bindList.value.find(
(item) => item.type === social.type,
);
return {
...social,
socialUser,
};
});
});
/** 加载绑定列表 */
async function loadBindList() {
bindList.value = await getBindSocialUserList();
}
/** 解绑账号 */
function onUnbind(row: SystemSocialUserApi.SocialUser) {
function onUnbind(item: SocialBindItem) {
const socialUser = item.socialUser!;
confirm({
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`,
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, socialUser.type)}]平台的[${socialUser.nickname || socialUser.openid}]账号吗?`,
}).then(async () => {
await socialUnbind({ type: row.type, openid: row.openid });
// 提示成功
await socialUnbind({ type: socialUser.type, openid: socialUser.openid });
message.success($t('ui.actionMessage.operationSuccess'));
await gridApi.reload();
await loadBindList();
});
}
/** 绑定账号(跳转授权页面) */
async function onBind(bind: any) {
async function onBind(bind: SocialBindItem) {
const type = bind.type;
if (type <= 0) {
return;
}
try {
// 计算 redirectUri
// tricky: type 需要先 encode 一次,否则钉钉回调会丢失。配合 getUrlValue() 使用
const redirectUri = `${location.origin}/profile?${encodeURIComponent(`type=${type}`)}`;
// 进行跳转
window.location.href = await socialAuthRedirect(type, redirectUri);
} catch (error) {
console.error('社交绑定处理失败:', error);
@@ -135,7 +89,6 @@ async function onBind(bind: any) {
/** 监听路由变化,处理社交绑定回调 */
async function bindSocial() {
// 社交绑定
const type = Number(getUrlValue('type'));
const code = route.query.code as string;
const state = route.query.state as string;
@@ -143,64 +96,138 @@ async function bindSocial() {
return;
}
await socialBind({ type, code, state });
// 提示成功
message.success('绑定成功');
emit('update:activeName', 'userSocial');
await gridApi.reload();
// 清理 URL 参数,避免刷新重复触发
await loadBindList();
window.history.replaceState({}, '', location.pathname);
}
/** 初始化 */
onMounted(() => {
bindSocial();
onMounted(async () => {
await loadBindList();
await bindSocial();
});
</script>
<template>
<div class="flex flex-col">
<Grid />
<div class="space-y-4 py-2">
<div
v-for="item in allBindList"
:key="item.type"
class="rounded-lg border border-solid"
:class="
item.socialUser
? 'border-blue-200 dark:border-blue-800'
: 'border-gray-200 dark:border-gray-700'
"
>
<!-- 主行图标 + 平台名 + 状态 + 操作 -->
<div class="flex items-center gap-4 px-4 py-3">
<!-- 平台图标 -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center">
<Image
:src="item.img"
:width="36"
:height="36"
:alt="item.title"
:preview="false"
/>
</div>
<div class="pb-3">
<!-- 平台名称 + 状态 -->
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-black/85 dark:text-white/85"
>
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
</span>
<Tag
v-if="item.socialUser"
color="blue"
:bordered="false"
class="!mr-0 !text-xs"
>
已绑定
</Tag>
<Tag v-else :bordered="false" class="!mr-0 !text-xs">未绑定</Tag>
</div>
<span class="text-xs text-black/45 dark:text-white/45">
<template v-if="!item.socialUser">
绑定后可使用该平台快速登录
</template>
<template v-else>
{{ item.socialUser.nickname || item.socialUser.openid }}
</template>
</span>
</div>
<!-- 操作按钮 -->
<div class="shrink-0">
<template v-if="item.socialUser">
<Button size="small" danger @click="onUnbind(item)">解绑</Button>
</template>
<template
v-else-if="
item.type === SystemUserSocialTypeEnum.WECHAT_MINI_APP.type
"
>
<Tooltip title="请在微信小程序「我的」页面中完成绑定">
<Button size="small" disabled>小程序端绑定</Button>
</Tooltip>
</template>
<template v-else>
<Button size="small" type="primary" ghost @click="onBind(item)">
绑定
</Button>
</template>
</div>
</div>
<!-- 已绑定:展示详细信息 -->
<div
class="grid grid-cols-1 gap-2 px-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3"
v-if="item.socialUser"
class="border-t border-solid border-gray-100 bg-gray-50/50 px-4 py-3 dark:border-gray-700 dark:bg-white/[0.02]"
>
<Card v-for="item in allBindList" :key="item.type" class="!mb-2">
<div class="flex w-full items-center gap-4">
<Image
:src="item.img"
:width="40"
:height="40"
:alt="item.title"
:preview="false"
/>
<div class="flex flex-1 items-center justify-between">
<div class="flex flex-col">
<h4 class="mb-1 text-sm text-black/85 dark:text-white/85">
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
</h4>
<span class="text-black/45 dark:text-white/45">
<template v-if="item.socialUser">
{{ item.socialUser?.nickname || item.socialUser?.openid }}
</template>
<template v-else>
绑定
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
账号
</template>
</span>
</div>
<Button
:disabled="!!item.socialUser"
size="small"
type="link"
@click="onBind(item)"
>
{{ item.socialUser ? '已绑定' : '绑定' }}
</Button>
<div
class="grid grid-cols-1 gap-x-6 gap-y-2 text-xs sm:grid-cols-2 lg:grid-cols-3"
>
<!-- 头像 + 昵称 -->
<div class="flex items-center gap-2">
<span class="text-black/45 dark:text-white/45">昵称:</span>
<div class="flex items-center gap-1.5">
<Avatar
v-if="item.socialUser.avatar"
:src="item.socialUser.avatar"
:size="20"
/>
<span class="text-black/85 dark:text-white/85">
{{ item.socialUser.nickname || '-' }}
</span>
</div>
</div>
</Card>
<!-- OpenID -->
<div class="flex items-center gap-2">
<span class="shrink-0 text-black/45 dark:text-white/45">
标识:
</span>
<span
class="truncate text-black/85 dark:text-white/85"
:title="item.socialUser.openid"
>
{{ item.socialUser.openid }}
</span>
</div>
<!-- 绑定时间 -->
<div class="flex items-center gap-2">
<span class="shrink-0 text-black/45 dark:text-white/45">
绑定时间:
</span>
<span class="text-black/85 dark:text-white/85">
{{ formatDateTime(item.socialUser.createTime) || '-' }}
</span>
</div>
</div>
</div>
</div>
</div>