feat: login and profile

pull/98/head^2
xingyu4j 2025-05-09 16:19:01 +08:00
parent 477e387ce9
commit 04d2cc2952
7 changed files with 1059 additions and 0 deletions

View File

@ -0,0 +1,215 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { AuthApi } from '#/api/core/auth';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import {
checkCaptcha,
getCaptcha,
getTenantByWebsite,
getTenantSimpleList,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'SocialLogin' });
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { query } = useRoute();
const router = useRouter();
const tenantEnable = isTenantEnable();
const captchaEnable = isCaptchaEnable();
const loginRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // 'blockPuzzle' | 'clickWord'
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); //
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
//
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// > store >
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// store
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 使
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
//
accessStore.setTenantId(tenantId);
loginRef.value.getFormApi().setFieldValue('tenantId', tenantId);
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 尝试登录当账号已经绑定socialLogin 会直接获得 token */
const socialType = Number(getUrlValue('type'));
const redirect = getUrlValue('redirect');
const socialCode = query?.code as string;
const socialState = query?.state as string;
async function tryLogin() {
// redirect
if (redirect) {
await router.replace({
query: {
...query,
redirect: encodeURIComponent(redirect),
},
});
}
//
await authStore.authLogin('social', {
type: socialType,
code: socialCode,
state: socialState,
});
}
/** 处理登录 */
async function handleLogin(values: any) {
//
if (captchaEnable) {
verifyRef.value.show();
return;
}
//
await authStore.authLogin('username', {
...values,
socialType,
socialCode,
socialState,
});
}
/** 验证码通过,执行登录 */
async function handleVerifySuccess({ captchaVerification }: any) {
try {
await authStore.authLogin('username', {
...(await loginRef.value.getFormApi().getValues()),
captchaVerification,
socialType,
socialCode,
socialState,
});
} catch (error) {
console.error('Error in handleLogin:', error);
}
}
/** tricky: 配合 login.vue 中redirectUri 需要对参数进行 encode需要在回调后进行decode */
function getUrlValue(key: string): string {
const url = new URL(decodeURIComponent(location.href));
return url.searchParams.get(key) ?? '';
}
/** 组件挂载时获取租户信息 */
onMounted(async () => {
await fetchTenantList();
await tryLogin();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z
.string()
.min(1, { message: $t('authentication.usernameTip') })
.default(import.meta.env.VITE_APP_DEFAULT_USERNAME),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.passwordTip'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z
.string()
.min(1, { message: $t('authentication.passwordTip') })
.default(import.meta.env.VITE_APP_DEFAULT_PASSWORD),
},
];
});
</script>
<template>
<div>
<AuthenticationLogin
ref="loginRef"
:form-schema="formSchema"
:loading="authStore.loginLoading"
:show-code-login="false"
:show-qrcode-login="false"
:show-third-party-login="false"
:show-register="false"
@submit="handleLogin"
/>
<Verification
ref="verifyRef"
v-if="captchaEnable"
:captcha-type="captchaType"
:check-captcha-api="checkCaptcha"
:get-captcha-api="getCaptcha"
:img-size="{ width: '400px', height: '200px' }"
mode="pop"
@on-success="handleVerifySuccess"
/>
</div>
</template>

View File

@ -0,0 +1,221 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { AuthenticationAuthTitle, VbenButton } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { authorize, getAuthorize } from '#/api/system/oauth2/open';
defineOptions({ name: 'SSOLogin' });
const { query } = useRoute(); //
const client = ref({
name: '',
logo: '',
}); //
const queryParams = reactive({
responseType: '',
clientId: '',
redirectUri: '',
state: '',
scopes: [] as string[], // query
}); // URL client_idscope
const loading = ref(false); //
/** 初始化授权信息 */
async function init() {
//
if (query.client_id === undefined) {
return;
}
//
// client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write
// client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read
queryParams.responseType = query.response_type as string;
queryParams.clientId = query.client_id as string;
queryParams.redirectUri = query.redirect_uri as string;
queryParams.state = query.state as string;
if (query.scope) {
queryParams.scopes = (query.scope as string).split(' ');
}
// scope
if (queryParams.scopes.length > 0) {
const data = await doAuthorize(true, queryParams.scopes, []);
if (data) {
location.href = data;
return;
}
}
// 1.1
const data = await getAuthorize(queryParams.clientId);
client.value = data.client;
// 1.2 scope
let scopes;
// params.scope scopes
if (queryParams.scopes.length > 0) {
scopes = data.scopes.filter((scope) =>
queryParams.scopes.includes(scope.key),
);
// params.scope 使 scopes
} else {
scopes = data.scopes;
queryParams.scopes = scopes.map((scope) => scope.key);
}
// 2.
formApi.setFieldValue(
'scopes',
scopes.filter((scope) => scope.value).map((scope) => scope.key),
);
}
/** 处理授权的提交 */
async function handleSubmit(approved: boolean) {
// checkedScopes + uncheckedScopes
let checkedScopes: string[];
let uncheckedScopes: string[];
if (approved) {
//
const res = await formApi.getValues();
checkedScopes = res.scopes;
uncheckedScopes = queryParams.scopes.filter(
(item) => !checkedScopes.includes(item),
);
} else {
//
checkedScopes = [];
uncheckedScopes = queryParams.scopes;
}
//
loading.value = true;
try {
const data = await doAuthorize(false, checkedScopes, uncheckedScopes);
if (!data) {
return;
}
//
location.href = data;
} finally {
loading.value = false;
}
}
/** 调用授权 API 接口 */
const doAuthorize = (
autoApprove: boolean,
checkedScopes: string[],
uncheckedScopes: string[],
) => {
return authorize(
queryParams.responseType,
queryParams.clientId,
queryParams.redirectUri,
queryParams.state,
autoApprove,
checkedScopes,
uncheckedScopes,
);
};
/** 格式化 scope 文本 */
function formatScope(scope: string) {
// scope 便
// demo "system_oauth2_scope" scope
switch (scope) {
case 'user.read': {
return '访问你的个人信息';
}
case 'user.write': {
return '修改你的个人信息';
}
default: {
return scope;
}
}
}
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'scopes',
label: '授权范围',
component: 'CheckboxGroup',
componentProps: {
options: queryParams.scopes.map((scope) => ({
label: formatScope(scope),
value: scope,
})),
class: 'flex flex-col gap-2',
},
},
];
});
const [Form, formApi] = useVbenForm(
reactive({
commonConfig: {
hideLabel: true,
hideRequiredMark: true,
},
schema: formSchema,
showDefaultActions: false,
}),
);
/** 初始化 */
onMounted(() => {
init();
});
</script>
<template>
<div @keydown.enter.prevent="handleSubmit(true)">
<AuthenticationAuthTitle>
<slot name="title">
{{ `${client.name} 👋🏻` }}
</slot>
<template #desc>
<span class="text-muted-foreground">
此第三方应用请求获得以下权限
</span>
</template>
</AuthenticationAuthTitle>
<Form />
<div class="flex gap-2">
<VbenButton
:class="{
'cursor-wait': loading,
}"
:loading="loading"
aria-label="login"
class="w-2/3"
@click="handleSubmit(true)"
>
同意授权
</VbenButton>
<VbenButton
:class="{
'cursor-wait': loading,
}"
:loading="loading"
aria-label="login"
class="w-1/3"
variant="outline"
@click="handleSubmit(false)"
>
拒绝
</VbenButton>
</div>
</div>
</template>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { NCard, NTabs } from 'naive-ui';
import { getUserProfile } from '#/api/system/user/profile';
import { useAuthStore } from '#/store';
import BaseInfo from './modules/base-info.vue';
import ProfileUser from './modules/profile-user.vue';
import ResetPwd from './modules/reset-pwd.vue';
import UserSocial from './modules/user-social.vue';
const authStore = useAuthStore();
const activeName = ref('basicInfo');
/** 加载个人信息 */
const profile = ref<SystemUserProfileApi.UserProfileRespVO>();
async function loadProfile() {
profile.value = await getUserProfile();
}
/** 刷新个人信息 */
async function refreshProfile() {
//
await loadProfile();
// store
await authStore.fetchUserInfo();
}
/** 初始化 */
onMounted(loadProfile);
</script>
<template>
<Page auto-content-height>
<div class="flex">
<!-- 左侧 个人信息 -->
<NCard class="w-2/5" title="个人信息">
<ProfileUser :profile="profile" @success="refreshProfile" />
</NCard>
<!-- 右侧 标签页 -->
<NCard class="ml-3 w-3/5">
<NTabs v-model:active-key="activeName" class="-mt-4">
<NTabs.TabPane key="basicInfo" tab="基本设置">
<BaseInfo :profile="profile" @success="refreshProfile" />
</NTabs.TabPane>
<NTabs.TabPane key="resetPwd" tab="密码设置">
<ResetPwd />
</NTabs.TabPane>
<NTabs.TabPane key="userSocial" tab="社交绑定" force-render>
<UserSocial @update:active-name="activeName = $event" />
</NTabs.TabPane>
<!-- TODO @芋艿在线设备 -->
</NTabs>
</NCard>
</div>
</Page>
</template>

View File

@ -0,0 +1,106 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { watch } from 'vue';
import { $t } from '@vben/locales';
import { useVbenForm, z } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { updateUserProfile } from '#/api/system/user/profile';
import { DICT_TYPE, getDictOptions } from '#/utils';
const props = defineProps<{
profile?: SystemUserProfileApi.UserProfileRespVO;
}>();
const emit = defineEmits<{
(e: 'success'): void;
}>();
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 70,
},
schema: [
{
label: '用户昵称',
fieldName: 'nickname',
component: 'Input',
componentProps: {
placeholder: '请输入用户昵称',
},
rules: 'required',
},
{
label: '用户手机',
fieldName: 'mobile',
component: 'Input',
componentProps: {
placeholder: '请输入用户手机',
},
rules: z.string(),
},
{
label: '用户邮箱',
fieldName: 'email',
component: 'Input',
componentProps: {
placeholder: '请输入用户邮箱',
},
rules: z.string().email('请输入正确的邮箱'),
},
{
label: '用户性别',
fieldName: 'sex',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number(),
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
content: '更新信息',
},
handleSubmit,
});
async function handleSubmit(values: Recordable<any>) {
try {
formApi.setLoading(true);
//
await updateUserProfile(values as SystemUserProfileApi.UpdateProfileReqVO);
//
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
formApi.setLoading(false);
}
}
/** 监听 profile 变化 */
watch(
() => props.profile,
(newProfile) => {
if (newProfile) {
formApi.setValues(newProfile);
}
},
{ immediate: true },
);
</script>
<template>
<div class="mt-16px md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@ -0,0 +1,144 @@
<script setup lang="ts">
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils';
import { NDescriptions, NDescriptionsItem, NTooltip } from 'naive-ui';
import { updateUserProfile } from '#/api/system/user/profile';
import { CropperAvatar } from '#/components/cropper';
import { useUpload } from '#/components/upload/use-upload';
const props = defineProps<{
profile?: SystemUserProfileApi.UserProfileRespVO;
}>();
const emit = defineEmits<{
(e: 'success'): void;
}>();
const avatar = computed(
() => props.profile?.avatar || preferences.app.defaultAvatar,
);
async function handelUpload({
file,
filename,
}: {
file: Blob;
filename: string;
}) {
// 1. URL
const { httpRequest } = useUpload();
// Blob File
const fileObj = new File([file], filename, { type: file.type });
const avatar = await httpRequest(fileObj);
// 2.
await updateUserProfile({ avatar });
}
</script>
<template>
<div v-if="profile">
<div class="flex flex-col items-center">
<NTooltip title="点击上传头像">
<CropperAvatar
:show-btn="false"
:upload-api="handelUpload"
:value="avatar"
:width="120"
@change="emit('success')"
/>
</NTooltip>
</div>
<div class="mt-8">
<NDescriptions :column="2">
<NDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
用户账号
</div>
</template>
{{ profile.username }}
</NDescriptionsItem>
<NDescriptionsItem>
<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(',') }}
</NDescriptionsItem>
<NDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:phone-outlined" class="mr-1" />
手机号码
</div>
</template>
{{ profile.mobile }}
</NDescriptionsItem>
<NDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:mail-outlined" class="mr-1" />
用户邮箱
</div>
</template>
{{ profile.email }}
</NDescriptionsItem>
<NDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:team-outlined" class="mr-1" />
所属部门
</div>
</template>
{{ profile.dept?.name }}
</NDescriptionsItem>
<NDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:usergroup-add-outlined"
class="mr-1"
/>
所属岗位
</div>
</template>
{{ profile.posts.map((post) => post.name).join(',') }}
</NDescriptionsItem>
<NDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:clock-circle-outlined"
class="mr-1"
/>
创建时间
</div>
</template>
{{ formatDateTime(profile.createTime) }}
</NDescriptionsItem>
<NDescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:login-outlined" class="mr-1" />
登录时间
</div>
</template>
{{ formatDateTime(profile.loginDate) }}
</NDescriptionsItem>
</NDescriptions>
</div>
</div>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { $t } from '@vben/locales';
import { useVbenForm, z } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { updateUserPassword } from '#/api/system/user/profile';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 70,
},
schema: [
{
component: 'InputPassword',
fieldName: 'oldPassword',
label: '旧密码',
rules: z
.string({ message: '请输入密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符'),
},
{
component: 'InputPassword',
dependencies: {
rules(values) {
return z
.string({ message: '请输入新密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value !== values.oldPassword,
'新旧密码不能相同',
);
},
triggerFields: ['newPassword', 'oldPassword'],
},
fieldName: 'newPassword',
label: '新密码',
rules: 'required',
},
{
component: 'InputPassword',
dependencies: {
rules(values) {
return z
.string({ message: '请输入确认密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value === values.newPassword,
'新密码和确认密码不一致',
);
},
triggerFields: ['newPassword', 'confirmPassword'],
},
fieldName: 'confirmPassword',
label: '确认密码',
rules: 'required',
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
content: '修改密码',
},
handleSubmit,
});
async function handleSubmit(values: Recordable<any>) {
try {
formApi.setLoading(true);
//
await updateUserPassword({
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
formApi.setLoading(false);
}
}
</script>
<template>
<div class="mt-[16px] md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@ -0,0 +1,215 @@
<script setup lang="tsx">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSocialUserApi } from '#/api/system/social/user';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { confirm } from '@vben/common-ui';
import { NButton, NCard, NImage } from 'naive-ui';
import { message } from '#/adapter/naive';
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';
import { DICT_TYPE, getDictLabel, SystemUserSocialTypeEnum } from '#/utils';
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 (
<NButton onClick={() => onUnbind(row)} text type="primary">
解绑
</NButton>
);
},
},
},
];
}
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>,
});
/** 解绑账号 */
function onUnbind(row: SystemSocialUserApi.SocialUser) {
confirm({
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`,
}).then(async () => {
await socialUnbind({ type: row.type, openid: row.openid });
//
message.success($t('ui.actionMessage.operationSuccess'));
await gridApi.reload();
});
}
/** 绑定账号(跳转授权页面) */
async function onBind(bind: any) {
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);
}
}
/** 监听路由变化,处理社交绑定回调 */
async function bindSocial() {
//
const type = Number(getUrlValue('type'));
const code = route.query.code as string;
const state = route.query.state as string;
if (!code) {
return;
}
await socialBind({ type, code, state });
//
message.success('绑定成功');
emit('update:activeName', 'userSocial');
await gridApi.reload();
// URL
window.history.replaceState({}, '', location.pathname);
}
// TODO @ util
// encode decode
function getUrlValue(key: string): string {
const url = new URL(decodeURIComponent(location.href));
return url.searchParams.get(key) ?? '';
}
/** 初始化 */
onMounted(() => {
bindSocial();
});
</script>
<template>
<div class="flex flex-col">
<Grid />
<div class="pb-3">
<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"
>
<NCard v-for="item in allBindList" :key="item.type" class="!mb-2">
<div class="flex w-full items-center gap-4">
<NImage
: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-[4px] text-[14px] 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>
<NButton
:disabled="!!item.socialUser"
size="small"
type="link"
@click="onBind(item)"
>
{{ item.socialUser ? '已绑定' : '绑定' }}
</NButton>
</div>
</div>
</NCard>
</div>
</div>
</div>
</template>