feat(admin): 用户管理新增三方账号绑定抽屉

- 新增 UserSocialBind.vue 抽屉组件(SocialLogo + 解绑, 无绑定按钮)
- 新增 user-social.api.ts, 独立于 SocialApi
- UserList.vue 更多菜单增加三方绑定入口(VIEW 权限)
- 清理 social.api.ts 中残留的旧 admin 方法
- i18n: iam/user.json 补充 action.socialBind 和 social 段
This commit is contained in:
DaxPay Dev
2026-06-22 17:09:17 +08:00
parent 3ff6cebdf0
commit f461fdd507
5 changed files with 232 additions and 3 deletions

View File

@@ -0,0 +1,49 @@
import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 社交账号绑定结果
*/
export interface SocialBindResult {
/** 主键 */
id?: string;
/** 本地用户ID */
userId?: string;
/** 终端编码 */
clientCode?: string;
/** 平台编码 */
source?: string;
/** 平台用户唯一标识 */
openId?: string;
/** 平台昵称 */
username?: string;
/** 平台头像 */
avatar?: string;
/** 绑定时间 */
createTime?: string;
}
/**
* 用户三方账号绑定管理 API (管理员)
*
* 与登录/绑定流程的 SocialApi 完全分离, 归属用户管理域.
*/
export const UserSocialApi = {
/**
* 查询指定用户的第三方账号绑定列表
* @param userId 目标用户ID
*/
bindList(userId: string): Promise<Result<SocialBindResult[]>> {
return defHttp.get({ url: '/user/admin/social/bind-list', params: { userId } });
},
/**
* 解除指定用户的第三方账号绑定
* @param userId 目标用户ID
* @param source 平台编码
*/
unbind(userId: string, source: string): Promise<Result<void>> {
return defHttp.post({ url: '/user/admin/social/unbind', params: { userId, source } });
},
};

View File

@@ -30,7 +30,17 @@
"confirmAssignRole": "Are you sure you want to save the role assignment?",
"confirmBan": "Are you sure to ban selected users?",
"confirmUnlock": "Are you sure to unlock selected users?",
"confirmResetPassword": "Are you sure to reset password for selected users?"
"confirmResetPassword": "Are you sure to reset password for selected users?",
"socialBind": "Social Bindings"
},
"social": {
"title": "Social Account Bindings",
"bound": "Bound",
"unbound": "Not Bound",
"unbindAction": "Unbind",
"unbindConfirm": "Are you sure to unbind this user's {name} account?",
"unbindSuccess": "Unbind successful",
"noEnabled": "No enabled social platforms"
},
"password": {
"password": "Password",

View File

@@ -30,7 +30,17 @@
"confirmAssignRole": "确定要保存角色分配吗?",
"confirmBan": "确定要封禁选中的用户吗?",
"confirmUnlock": "确定要解锁选中的用户吗?",
"confirmResetPassword": "确定要重置选中用户的密码吗?"
"confirmResetPassword": "确定要重置选中用户的密码吗?",
"socialBind": "三方绑定"
},
"social": {
"title": "三方账号绑定",
"bound": "已绑定",
"unbound": "未绑定",
"unbindAction": "解绑",
"unbindConfirm": "确定要解绑该用户的 {name} 账号吗?",
"unbindSuccess": "解绑成功",
"noEnabled": "暂无启用的第三方平台"
},
"password": {
"password": "密码",

View File

@@ -10,9 +10,9 @@
import { UserApi } from '#/api/iam/user.api';
import { BQuery, type QueryField } from '#/components/query';
import { PermCodes } from '#/constants/perm-codes';
import { clientCodeColorMap, clientCodeI18nMap } from '#/enums/clientCode';
import { useMessage } from '#/hooks/useMessage';
import { PermCodes } from '#/constants/perm-codes';
import { usePermission } from '#/hooks/usePermission';
import UserAdd from './components/UserAdd.vue';
@@ -20,6 +20,7 @@
import UserInfo from './components/UserInfo.vue';
import UserResetPassword from './components/UserResetPassword.vue';
import UserRoleAssign from './components/UserRoleAssign.vue';
import UserSocialBind from './components/UserSocialBind.vue';
/**
* Tab 配置项接口
@@ -54,6 +55,7 @@
const userInfoRef = ref();
const userResetPasswordRef = ref();
const userRoleAssignRef = ref();
const userSocialBindRef = ref();
// 加载状态
const loading = ref(false);
@@ -207,6 +209,12 @@
label: $t('iam.user.action.resetPassword'),
disabled: !hasPermission(PermCodes.Iam.UserManager.RESET_PASSWORD),
},
// 三方绑定
{
key: 'socialBind',
label: $t('iam.user.action.socialBind'),
disabled: !hasPermission(PermCodes.Iam.UserManager.VIEW),
},
{ type: 'divider' },
// 封禁
{
@@ -238,6 +246,11 @@
break;
}
case 'socialBind': {
handleSocialBind(row);
break;
}
case 'unlock': {
handleUnlock([row.id]);
@@ -305,6 +318,13 @@
userRoleAssignRef.value?.show(row.id);
}
/**
* 三方账号绑定
*/
function handleSocialBind(row: any) {
userSocialBindRef.value?.show(row.id, row.name);
}
/**
* 封禁
*/
@@ -529,6 +549,7 @@
<UserInfo ref="userInfoRef" />
<UserResetPassword ref="userResetPasswordRef" @ok="queryPage" />
<UserRoleAssign ref="userRoleAssignRef" @ok="queryPage" />
<UserSocialBind ref="userSocialBindRef" />
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script lang="ts" setup>
import type { SocialBindResult } from '#/api/iam/user-social.api';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { SocialApi } from '#/api/iam/social.api';
import { UserSocialApi } from '#/api/iam/user-social.api';
import { SocialLogo } from '#/components/social';
import { useMessage } from '#/hooks/useMessage';
defineOptions({ name: 'UserSocialBind' });
const { message, confirm } = useMessage();
/** 抽屉可见性 */
const visible = ref(false);
/** 加载状态 */
const loading = ref(false);
/** 当前用户ID */
const userId = ref('');
/** 当前用户名(用于标题) */
const userName = ref('');
/** 已启用的平台列表 */
const enabledPlatforms = ref<{ source: string }[]>([]);
/** 目标用户的绑定列表 */
const bindList = ref<SocialBindResult[]>([]);
/** 平台列表(合并启用 + 绑定状态) */
const platformList = computed(() =>
enabledPlatforms.value.map((p) => ({
...p,
name: $t(`iam.social.platform.${p.source}`),
})),
);
/** 抽屉标题 */
const drawerTitle = computed(() =>
userName.value ? `${$t('iam.user.social.title')} - ${userName.value}` : $t('iam.user.social.title'),
);
/**
* 查找平台是否已绑定
*/
function findBind(source: string) {
return bindList.value.find((item) => item.source === source);
}
/**
* 打开抽屉
* @param id 用户ID
* @param name 用户名(用于标题显示)
*/
async function show(id: number | string, name?: string) {
userId.value = String(id);
userName.value = name || '';
visible.value = true;
await fetchData();
}
/**
* 拉取数据: 已启用平台 + 目标用户绑定列表
*/
async function fetchData() {
loading.value = true;
try {
const [{ data: platforms }, { data: binds }] = await Promise.all([
SocialApi.enabledList(),
UserSocialApi.bindList(userId.value),
]);
enabledPlatforms.value = platforms ?? [];
bindList.value = binds ?? [];
} finally {
loading.value = false;
}
}
/**
* 解绑第三方账号
*/
function handleUnbind(source: string, nickname?: string) {
confirm({
title: $t('common.confirm'),
content: $t('iam.user.social.unbindConfirm', { name: nickname || source }),
onOk: async () => {
await UserSocialApi.unbind(userId.value, source);
message.success($t('iam.user.social.unbindSuccess'));
await fetchData();
},
});
}
defineExpose({
show,
});
</script>
<template>
<a-drawer v-model:open="visible" :title="drawerTitle" :size="640" :destroy-on-close="true">
<a-spin :spinning="loading">
<a-empty v-if="platformList.length === 0" :description="$t('iam.user.social.noEnabled')" />
<div v-else class="space-y-3">
<div
v-for="platform in platformList"
:key="platform.source"
class="flex items-center justify-between border-b border-gray-100 py-3 last:border-0"
>
<div class="flex items-center gap-3">
<SocialLogo :source="platform.source" :size="32" />
<span class="text-base font-medium">{{ platform.name }}</span>
<template v-if="findBind(platform.source)">
<a-tag color="green">{{ $t('iam.user.social.bound') }}</a-tag>
<span class="text-sm text-gray-500">
{{ findBind(platform.source)?.username }}
</span>
</template>
<a-tag v-else color="default">{{ $t('iam.user.social.unbound') }}</a-tag>
</div>
<div>
<a-button
v-if="findBind(platform.source)"
danger
size="small"
@click="handleUnbind(platform.source, findBind(platform.source)?.username)"
>
{{ $t('iam.user.social.unbindAction') }}
</a-button>
</div>
</div>
</div>
</a-spin>
</a-drawer>
</template>