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