云
9 小时以前 91bfcf61f44f353dfebc54a57b5b774721de3e9a
src/views/_core/profile/modules/user-social.vue
@@ -1,207 +1,84 @@
<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;
onMounted(async () => {
  const code = route.query.code;
  if (typeof code === 'string' && code) {
    emit('update:activeName', 'userSocial');
    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; }
  }
  await socialBind({ type, code, state });
  // 提示成功
  message.success('绑定成功');
  emit('update:activeName', 'userSocial');
  await gridApi.reload();
  // 清理 URL 参数,避免刷新重复触发
  window.history.replaceState({}, '', location.pathname);
}
/** 初始化 */
onMounted(() => {
  bindSocial();
  await load();
});
</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"
      >
        <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 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>