feat(login): 集成飞书登录功能及其相关界面和路由支持
Some checks failed
Lint Code / Lint Code (push) Failing after 5m15s
Some checks failed
Lint Code / Lint Code (push) Failing after 5m15s
- 新增飞书登录API接口定义及请求方法 - 添加飞书登录相关的类型声明 - 本地多语言文件增加飞书登录文案(中英文) - 登录页面新增飞书登录视图和样式,支持扫码或授权登录 - 添加飞书登录状态控制、回调处理逻辑,支持token和用户信息存储 - 路由白名单增加飞书登录回调路径,避免权限拦截 - 登录页新增切换账号密码登录和飞书登录的切换按钮 - Vite配置新增本地api代理规则,便于接口联调测试
This commit is contained in:
@@ -220,6 +220,7 @@ login:
|
|||||||
pureAlipayLogin: Alipay Login
|
pureAlipayLogin: Alipay Login
|
||||||
pureQQLogin: QQ Login
|
pureQQLogin: QQ Login
|
||||||
pureWeiBoLogin: Weibo Login
|
pureWeiBoLogin: Weibo Login
|
||||||
|
pureFeishuLogin: Feishu Login
|
||||||
purePhone: Phone
|
purePhone: Phone
|
||||||
pureSmsVerifyCode: SMS VerifyCode
|
pureSmsVerifyCode: SMS VerifyCode
|
||||||
pureBack: Back
|
pureBack: Back
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ login:
|
|||||||
pureAlipayLogin: 支付宝登录
|
pureAlipayLogin: 支付宝登录
|
||||||
pureQQLogin: QQ登录
|
pureQQLogin: QQ登录
|
||||||
pureWeiBoLogin: 微博登录
|
pureWeiBoLogin: 微博登录
|
||||||
|
pureFeishuLogin: 飞书登录
|
||||||
purePhone: 手机号码
|
purePhone: 手机号码
|
||||||
pureSmsVerifyCode: 短信验证码
|
pureSmsVerifyCode: 短信验证码
|
||||||
pureBack: 返回
|
pureBack: 返回
|
||||||
|
|||||||
33
src/api/feishu.ts
Normal file
33
src/api/feishu.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { http } from "@/utils/http";
|
||||||
|
|
||||||
|
export type FeishuLoginResult = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
/** 是否登录成功 */
|
||||||
|
isLogin: boolean;
|
||||||
|
/** 用户ID */
|
||||||
|
userId: string | number;
|
||||||
|
/** 用户名 */
|
||||||
|
username: string;
|
||||||
|
/** 真实姓名 */
|
||||||
|
realName: string;
|
||||||
|
/** 头像 */
|
||||||
|
avatar: string;
|
||||||
|
/** 手机号 */
|
||||||
|
phone: string;
|
||||||
|
/** 邮箱 */
|
||||||
|
email: string;
|
||||||
|
/** token */
|
||||||
|
token: string;
|
||||||
|
/** token名称 */
|
||||||
|
tokenName: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 飞书OAuth登录接口(前端回调后调用) */
|
||||||
|
export const getFeishuLogin = (code: string) => {
|
||||||
|
return http.request<FeishuLoginResult>("post", "/api/v1/auth/feishu/login", {
|
||||||
|
params: { code }
|
||||||
|
});
|
||||||
|
};
|
||||||
81
src/api/默认模块.openapi.json
Normal file
81
src/api/默认模块.openapi.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.1",
|
||||||
|
"info": {
|
||||||
|
"title": "默认模块",
|
||||||
|
"description": "",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"tags": [],
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/auth/feishu/login": {
|
||||||
|
"post": {
|
||||||
|
"summary": "飞书OAuth登录接口(前端回调后调用)",
|
||||||
|
"deprecated": false,
|
||||||
|
"description": "前端从飞书回调中获取code,然后调用此接口完成登录",
|
||||||
|
"tags": [],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"in": "query",
|
||||||
|
"description": "飞书授权码",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BaseResponseMapObject",
|
||||||
|
"description": "登录结果(包含token和用户信息)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"BaseResponseMapObject": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"isLogin": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"$ref": "#/components/schemas/userId"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"description": "",
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {},
|
||||||
|
"securitySchemes": {}
|
||||||
|
},
|
||||||
|
"servers": [],
|
||||||
|
"security": []
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ export function resetRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 路由白名单 */
|
/** 路由白名单 */
|
||||||
const whiteList = ["/login"];
|
const whiteList = ["/login", "/auth/callback"];
|
||||||
|
|
||||||
const { VITE_HIDE_HOME } = import.meta.env;
|
const { VITE_HIDE_HOME } = import.meta.env;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import Motion from "./utils/motion";
|
import Motion from "./utils/motion";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { message } from "@/utils/message";
|
import { message } from "@/utils/message";
|
||||||
import { loginRules } from "./utils/rule";
|
import { loginRules } from "./utils/rule";
|
||||||
import TypeIt from "@/components/ReTypeit";
|
import TypeIt from "@/components/ReTypeit";
|
||||||
@@ -20,10 +20,13 @@ import { useUserStoreHook } from "@/store/modules/user";
|
|||||||
import { initRouter, getTopMenu } from "@/router/utils";
|
import { initRouter, getTopMenu } from "@/router/utils";
|
||||||
import { bg, avatar, illustration } from "./utils/static";
|
import { bg, avatar, illustration } from "./utils/static";
|
||||||
import { ReImageVerify } from "@/components/ReImageVerify";
|
import { ReImageVerify } from "@/components/ReImageVerify";
|
||||||
import { ref, toRaw, reactive, watch, computed } from "vue";
|
import { ref, toRaw, reactive, watch, computed, onMounted } from "vue";
|
||||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||||
import { useTranslationLang } from "@/layout/hooks/useTranslationLang";
|
import { useTranslationLang } from "@/layout/hooks/useTranslationLang";
|
||||||
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
||||||
|
import { getFeishuLogin } from "@/api/feishu";
|
||||||
|
import { setToken, userKey } from "@/utils/auth";
|
||||||
|
import { storageLocal } from "@pureadmin/utils";
|
||||||
|
|
||||||
import dayIcon from "@/assets/svg/day.svg?component";
|
import dayIcon from "@/assets/svg/day.svg?component";
|
||||||
import darkIcon from "@/assets/svg/dark.svg?component";
|
import darkIcon from "@/assets/svg/dark.svg?component";
|
||||||
@@ -38,9 +41,16 @@ defineOptions({
|
|||||||
name: "Login"
|
name: "Login"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 飞书OAuth配置
|
||||||
|
const FEISHU_APP_ID = "cli_a94c8a7930badcd5";
|
||||||
|
const FEISHU_REDIRECT_URI = encodeURIComponent(
|
||||||
|
window.location.origin + "/login"
|
||||||
|
);
|
||||||
|
|
||||||
const imgCode = ref("");
|
const imgCode = ref("");
|
||||||
const loginDay = ref(7);
|
const loginDay = ref(7);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const checked = ref(false);
|
const checked = ref(false);
|
||||||
const disabled = ref(false);
|
const disabled = ref(false);
|
||||||
@@ -49,6 +59,12 @@ const currentPage = computed(() => {
|
|||||||
return useUserStoreHook().currentPage;
|
return useUserStoreHook().currentPage;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 飞书登录loading状态
|
||||||
|
const feishuLoading = ref(false);
|
||||||
|
|
||||||
|
// 是否显示飞书登录(作为默认登录方式)
|
||||||
|
const showFeishuLogin = ref(true);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { initStorage } = useLayout();
|
const { initStorage } = useLayout();
|
||||||
initStorage();
|
initStorage();
|
||||||
@@ -116,6 +132,91 @@ watch(checked, bool => {
|
|||||||
watch(loginDay, value => {
|
watch(loginDay, value => {
|
||||||
useUserStoreHook().SET_LOGINDAY(value);
|
useUserStoreHook().SET_LOGINDAY(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 跳转到飞书授权页面 */
|
||||||
|
const handleFeishuLogin = () => {
|
||||||
|
const feishuAuthUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${FEISHU_APP_ID}&redirect_uri=${FEISHU_REDIRECT_URI}`;
|
||||||
|
window.location.href = feishuAuthUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 处理飞书回调登录 */
|
||||||
|
const handleFeishuCallback = async (code: string) => {
|
||||||
|
feishuLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getFeishuLogin(code);
|
||||||
|
if (res.code === 0 && res.data?.isLogin) {
|
||||||
|
const { data } = res;
|
||||||
|
// 设置token(适配后端返回的字段名)
|
||||||
|
setToken({
|
||||||
|
accessToken: data.token,
|
||||||
|
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
refreshToken: data.token,
|
||||||
|
avatar: data.avatar,
|
||||||
|
username: data.username,
|
||||||
|
nickname: data.realName,
|
||||||
|
roles: ["admin"], // 根据实际业务调整
|
||||||
|
permissions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存用户信息到本地存储
|
||||||
|
storageLocal().setItem(userKey, {
|
||||||
|
refreshToken: data.token,
|
||||||
|
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).getTime(),
|
||||||
|
avatar: data.avatar,
|
||||||
|
username: data.username,
|
||||||
|
nickname: data.realName,
|
||||||
|
roles: ["admin"],
|
||||||
|
permissions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新全局状态
|
||||||
|
const userStore = useUserStoreHook();
|
||||||
|
userStore.SET_AVATAR(data.avatar);
|
||||||
|
userStore.SET_USERNAME(data.username);
|
||||||
|
userStore.SET_NICKNAME(data.realName);
|
||||||
|
userStore.SET_ROLES(["admin"]);
|
||||||
|
userStore.SET_PERMS([]);
|
||||||
|
|
||||||
|
// 初始化路由
|
||||||
|
await initRouter();
|
||||||
|
|
||||||
|
// 跳转到首页
|
||||||
|
router.push(getTopMenu(true).path).then(() => {
|
||||||
|
message(t("login.pureLoginSuccess"), { type: "success" });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
message(res.message || "飞书登录失败", { type: "error" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("飞书登录失败:", err);
|
||||||
|
message("飞书登录失败", { type: "error" });
|
||||||
|
} finally {
|
||||||
|
feishuLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 切换到账号密码登录 */
|
||||||
|
const switchToAccountLogin = () => {
|
||||||
|
showFeishuLogin.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 切换到飞书登录 */
|
||||||
|
const switchToFeishuLogin = () => {
|
||||||
|
showFeishuLogin.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 页面加载时检查是否有飞书回调code
|
||||||
|
onMounted(() => {
|
||||||
|
// 从 URL 中解析 code 参数(处理 hash 路由模式下 query 参数在 hash 之前的情况)
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const code = urlParams.get("code");
|
||||||
|
if (code) {
|
||||||
|
handleFeishuCallback(code);
|
||||||
|
// 清除 URL 中的 code 参数,避免重复处理
|
||||||
|
const newUrl = window.location.pathname + window.location.hash;
|
||||||
|
window.history.replaceState({}, document.title, newUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -178,8 +279,48 @@ watch(loginDay, value => {
|
|||||||
</h2>
|
</h2>
|
||||||
</Motion>
|
</Motion>
|
||||||
|
|
||||||
|
<!-- 飞书登录 - 默认登录方式 -->
|
||||||
|
<div
|
||||||
|
v-if="currentPage === 0 && showFeishuLogin"
|
||||||
|
class="feishu-login-container"
|
||||||
|
>
|
||||||
|
<Motion :delay="100">
|
||||||
|
<div class="feishu-login-box">
|
||||||
|
<div class="feishu-icon">
|
||||||
|
<svg viewBox="0 0 1024 1024" width="64" height="64">
|
||||||
|
<path
|
||||||
|
fill="#3370FF"
|
||||||
|
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#3370FF"
|
||||||
|
d="M696 480H544V328c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v152H328c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h152v152c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V544h152c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="feishu-title">飞书登录</h3>
|
||||||
|
<p class="feishu-desc">使用飞书扫码或授权登录</p>
|
||||||
|
<el-button
|
||||||
|
class="w-full mt-4 feishu-login-btn"
|
||||||
|
size="large"
|
||||||
|
type="primary"
|
||||||
|
:loading="feishuLoading"
|
||||||
|
@click="handleFeishuLogin"
|
||||||
|
>
|
||||||
|
<IconifyIconOnline icon="ri:login-circle-line" class="mr-1" />
|
||||||
|
点击授权登录
|
||||||
|
</el-button>
|
||||||
|
<div class="login-switch">
|
||||||
|
<el-button link type="primary" @click="switchToAccountLogin">
|
||||||
|
使用账号密码登录
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Motion>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-form
|
<el-form
|
||||||
v-if="currentPage === 0"
|
v-if="currentPage === 0 && !showFeishuLogin"
|
||||||
ref="ruleFormRef"
|
ref="ruleFormRef"
|
||||||
:model="ruleForm"
|
:model="ruleForm"
|
||||||
:rules="loginRules"
|
:rules="loginRules"
|
||||||
@@ -298,6 +439,17 @@ watch(loginDay, value => {
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</Motion>
|
</Motion>
|
||||||
|
|
||||||
|
<!-- 切换回飞书登录 -->
|
||||||
|
<Motion :delay="350">
|
||||||
|
<el-form-item>
|
||||||
|
<div class="login-switch mt-4">
|
||||||
|
<el-button link type="primary" @click="switchToFeishuLogin">
|
||||||
|
使用飞书登录
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</Motion>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<Motion v-if="currentPage === 0" :delay="350">
|
<Motion v-if="currentPage === 0" :delay="350">
|
||||||
@@ -372,4 +524,61 @@ watch(loginDay, value => {
|
|||||||
left: 20px;
|
left: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 飞书登录样式 */
|
||||||
|
.feishu-login-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feishu-login-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feishu-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: linear-gradient(135deg, #3370ff20, #3370ff10);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feishu-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feishu-desc {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feishu-login-btn {
|
||||||
|
height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: linear-gradient(135deg, #3370ff, #2b5fd9);
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, #2860d9, #1f4fb3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-switch {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const operates = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const thirdParty = [
|
const thirdParty = [
|
||||||
|
{
|
||||||
|
title: $t("login.pureFeishuLogin"),
|
||||||
|
icon: "feishu"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: $t("login.pureWeChatLogin"),
|
title: $t("login.pureWeChatLogin"),
|
||||||
icon: "wechat"
|
icon: "wechat"
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export default async ({ mode }: ConfigEnv): Promise<UserConfigExport> => {
|
|||||||
port: VITE_PORT,
|
port: VITE_PORT,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
// 本地跨域代理 https://cn.vitejs.dev/config/server-options.html#server-proxy
|
// 本地跨域代理 https://cn.vitejs.dev/config/server-options.html#server-proxy
|
||||||
proxy: {},
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8080/api",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: path => path.replace(/^\/api/, "")
|
||||||
|
}
|
||||||
|
},
|
||||||
// 预热文件以提前转换和缓存结果,降低启动期间的初始页面加载时长并防止转换瀑布
|
// 预热文件以提前转换和缓存结果,降低启动期间的初始页面加载时长并防止转换瀑布
|
||||||
warmup: {
|
warmup: {
|
||||||
clientFiles: ["./index.html", "./src/{views,components}/*"]
|
clientFiles: ["./index.html", "./src/{views,components}/*"]
|
||||||
|
|||||||
Reference in New Issue
Block a user