| | |
| | | <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'; |
| | | import { useRoute } from 'vue-router'; |
| | | |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | import { confirm } from '@vben/common-ui'; |
| | | import { DICT_TYPE, SystemUserSocialTypeEnum } from '@vben/constants'; |
| | | import { getDictLabel } from '@vben/hooks'; |
| | | import { SystemUserSocialTypeEnum } from '@vben/constants'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { getUrlValue } from '@vben/utils'; |
| | | |
| | | import { Button, Card, Image, message } from 'ant-design-vue'; |
| | | |
| | | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { message } from 'ant-design-vue'; |
| | | import { socialAuthRedirect } from '#/api/core/auth'; |
| | | import { |
| | | getBindSocialUserList, |
| | | socialBind, |
| | | socialUnbind, |
| | | } from '#/api/system/social/user'; |
| | | import { $t } from '#/locales'; |
| | | import { getBindSocialUserList, socialBind, socialUnbind } from '#/api/system/social/user'; |
| | | |
| | | const emit = defineEmits<{ |
| | | (e: 'update:activeName', v: string): void; |
| | | }>(); |
| | | |
| | | const emit = defineEmits<{ 'update:activeName': [string] }>(); |
| | | const route = useRoute(); |
| | | /** 已经绑定的平台 */ |
| | | const router = useRouter(); |
| | | 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 loading = ref(true); |
| | | const failed = ref(false); |
| | | const busyType = ref<number>(); |
| | | const callbackFailed = ref(false); |
| | | const allBindList = computed(() => Object.values(SystemUserSocialTypeEnum).map(platform => ({ |
| | | ...platform, user: bindList.value.find(item => item.type === platform.type), |
| | | }))); |
| | | async function load() { |
| | | loading.value = true; failed.value = false; |
| | | try { bindList.value = await getBindSocialUserList(); } |
| | | catch { failed.value = true; } |
| | | finally { loading.value = false; } |
| | | } |
| | | |
| | | 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, |
| | | }, |
| | | }, |
| | | }); |
| | | |
| | | /** 解绑账号 */ |
| | | function onUnbind(row: SystemSocialUserApi.SocialUser) { |
| | | confirm(`确定解绑[${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; |
| | | } |
| | | async function unbind(user: SystemSocialUserApi.SocialUser, title: string) { |
| | | try { await confirm('解除与' + title + '的绑定后,将无法通过该账号登录。确定解除绑定吗?'); } |
| | | catch { return; } |
| | | busyType.value = user.type; |
| | | try { |
| | | // 计算 redirectUri |
| | | // tricky: type 需要先 encode 一次,否则钉钉回调会丢失。配合 getUrlValue() 使用 |
| | | // hash 路由(生产构建)下必须带 # 前缀,否则回跳后 vue-router 解析到 /,进不了绑定流程 |
| | | await socialUnbind({ type: user.type, openid: user.openid }); |
| | | message.success('已解除绑定'); |
| | | await load(); |
| | | } finally { busyType.value = undefined; } |
| | | } |
| | | async function bind(type: number) { |
| | | busyType.value = type; |
| | | try { |
| | | const hashPrefix = import.meta.env.VITE_ROUTER_HISTORY === 'hash' ? '/#' : ''; |
| | | const redirectUri = `${location.origin}${hashPrefix}/profile?${encodeURIComponent(`type=${type}`)}`; |
| | | |
| | | // 进行跳转 |
| | | const redirectUri = location.origin + hashPrefix + '/profile?' + encodeURIComponent('type=' + type); |
| | | window.location.href = await socialAuthRedirect(type, redirectUri); |
| | | } catch (error) { |
| | | console.error('社交绑定处理失败:', error); |
| | | } finally { busyType.value = undefined; } |
| | | } |
| | | } |
| | | |
| | | /** 监听路由变化,处理社交绑定回调 */ |
| | | 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('绑定成功'); |
| | | onMounted(async () => { |
| | | const code = route.query.code; |
| | | if (typeof code === 'string' && code) { |
| | | emit('update:activeName', 'userSocial'); |
| | | await gridApi.reload(); |
| | | // 清理 URL 参数,避免刷新重复触发 |
| | | window.history.replaceState({}, '', location.pathname); |
| | | const type = Number(getUrlValue('type')); |
| | | try { |
| | | await socialBind({ type, code, state: String(route.query.state ?? '') }); |
| | | message.success('绑定成功'); |
| | | const query: Record<string, any> = { ...route.query, section: 'userSocial' }; |
| | | delete query.code; delete query.state; delete query.type; |
| | | await router.replace({ query }); |
| | | } catch { callbackFailed.value = true; } |
| | | } |
| | | |
| | | /** 初始化 */ |
| | | onMounted(() => { |
| | | bindSocial(); |
| | | await load(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="flex flex-col"> |
| | | <Grid /> |
| | | <div class="social-settings"> |
| | | <p v-if="callbackFailed" class="social-error" role="alert">账号绑定未完成,请重新发起绑定。</p> |
| | | <div v-if="loading" class="social-state" role="status"><IconifyIcon icon="lucide:loader-circle" />正在加载绑定信息…</div> |
| | | <div v-else-if="failed" class="social-state" role="status"><span>暂时无法获取绑定信息</span><button type="button" @click="load">重新加载</button></div> |
| | | <div v-else class="social-platforms"> |
| | | <article v-for="item in allBindList" :key="item.type" class="social-platform"> |
| | | <span class="social-platform-icon" :class="item.source"><IconifyIcon :icon="item.source === 'dingtalk' ? 'ant-design:dingtalk-outlined' : 'ant-design:wechat-outlined'" /></span> |
| | | <div class="social-platform-copy"><h3>{{ item.title }}<span :class="{ bound: item.user }">{{ item.user ? '已绑定' : '未绑定' }}</span></h3><p>{{ item.user ? (item.user.nickname || '已关联此平台账号') : '绑定后可使用' + item.title + '账号登录' }}</p></div> |
| | | <button type="button" :disabled="busyType !== undefined" :class="{ unbind: item.user }" @click="item.user ? unbind(item.user, item.title) : bind(item.type)">{{ busyType === item.type ? '处理中…' : item.user ? '解除绑定' : '立即绑定' }}</button> |
| | | </article> |
| | | </div> |
| | | <div class="social-note"><IconifyIcon icon="lucide:info" /><p>绑定时将前往对应平台授权。解除绑定不影响当前账号使用用户名和密码登录。</p></div> |
| | | </div> |
| | | </template> |
| | | <style scoped> |
| | | .social-platforms { display: grid; gap: 14px; }.social-platform { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 20px; border: 1px solid #dfeaf4; border-radius: 6px; }.social-platform-icon { display: grid; width: 48px; height: 48px; flex: none; place-items: center; border-radius: 10px; color: #2076bf; background: #edf5fd; font-size: 28px; }.social-platform-icon.wechat_enterprise { color: #19895a; background: #edf8f2; }.social-platform-copy { min-width: 0; flex: 1; }.social-platform-copy h3 { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; margin: 0 0 7px; font-size: 15px; font-weight: 650; }.social-platform-copy h3 span { padding: 2px 7px; border-radius: 3px; color: #657a8c; background: #f0f4f7; font-size: 10px; font-weight: 400; }.social-platform-copy h3 span.bound { color: #207348; background: #edf8f1; }.social-platform-copy p { margin: 0; color: var(--portal-muted); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }.social-platform button,.social-state button { min-height: 34px; flex: none; padding: 6px 13px; border: 1px solid #b7242d; border-radius: 4px; color: #b7242d; background: transparent; font-size: 12px; cursor: pointer; }.social-platform button.unbind { border-color: #d5e0eb; color: #4e6b85; }.social-platform button:disabled { opacity: .5; cursor: wait; }.social-note { display: flex; align-items: flex-start; gap: 9px; margin-top: 22px; color: var(--portal-muted); font-size: 12px; line-height: 1.8; }.social-note svg { width: 16px; height: 16px; flex: none; margin-top: 3px; }.social-note p { margin: 0; }.social-state { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 14px; color: var(--portal-muted); font-size: 13px; }.social-state svg { width: 20px; }.social-error { padding: 12px; color: #a3222b; background: #fff2f1; font-size: 12px; }button:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; } |
| | | @media(max-width:520px) { .social-platform { flex-wrap: wrap; gap: 12px; padding: 16px; }.social-platform-copy { flex-basis: calc(100% - 60px); }.social-platform button { margin-left: 60px; }.social-state { flex-direction: column; } } |
| | | </style> |
| | | |
| | | <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" |
| | | > |
| | | <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> |
| | | </div> |
| | | </Card> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |