feat: login and profile
parent
477e387ce9
commit
04d2cc2952
|
@ -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>
|
|
@ -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_id、scope 等参数
|
||||
|
||||
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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue