Vue3 + Vite 实战:接入钉钉 OAuth 扫码登录(内嵌二维码 + 跳转授权)
本文基于 Vue 3 + Vite + TypeScript + Pinia 的登录页工程,完整演示钉钉开放平台OAuth2 授权码模式:内嵌扫码(
DTFrameLogin)与整页跳转授权两条链路,并说明前后端如何用code换取业务 Token。照着步骤做,本地即可跑通。
一、先搞清楚:我们要接的是哪一种「钉钉登录」
钉钉开放能力里常见两类登录,容易混:
| 类型 | 典型场景 | 前端关键字段 | 本文是否覆盖 |
|---|---|---|---|
| OAuth2 网站应用登录 | PC 网页扫码 / 跳转授权,拿code换用户身份 | client_id、redirect_uri、scope=openid | 是 |
| 企业内部 H5 / JSAPI | 钉钉客户端内打开 H5,用corpId、dd.ready | corpId、AgentId 等 | 否 |
本文方案是:用户打开登录页 → 扫码或跳转钉钉授权 → 前端拿到授权码code→ 交给自家后端 → 后端用 AppSecret 向钉钉换用户信息并签发业务 Token → 前端进入系统首页。
要点一句话:
- 前端只持有 Client ID(AppKey),可以写进环境变量。
- AppSecret / Client Secret 只能放在服务端,绝不能出现在前端仓库或浏览器包里。
二、整体架构与登录时序
┌─────────────┐ 加载 CDN SDK ┌──────────────────────┐ │ 登录页 │ ───────────────────▶ │ g.alicdn.com │ │ (Vue SPA) │ │ h5-dingtalk-login │ └──────┬──────┘ └──────────────────────┘ │ │ ① DTFrameLogin 内嵌二维码 │ 或 ② 跳转 login.dingtalk.com/oauth2/auth ▼ ┌──────────────────────┐ │ 钉钉授权页 / 扫码端 │ └──────────┬───────────┘ │ 返回 authCode / ?code= ▼ ┌──────────────────────┐ POST { code } ┌─────────────────┐ │ handleLoginByCode │ ──────────────────▶ │ 业务后端 │ └──────────────────────┘ │ /api/login/ │ │ dingtalk │ └────────┬────────┘ │ 用 Secret 调钉钉 API │ 签发 accessToken ▼ 前端存 Token,跳转系统首页两条前端入口最终汇合到同一接口:
- 内嵌扫码:SDK 成功回调里直接拿到
authCode。 - 按钮跳转:钉钉把用户重定向回
redirect_uri?code=xxx&state=yyy,登录页从 URL 读取code。
三、开放平台侧准备(可实操清单)
3.1 创建应用
- 打开 钉钉开放平台,登录开发者账号。
- 创建企业内部应用或按文档创建具备「登录」能力的应用(以控制台当前产品名为准)。
- 在应用详情中找到:
- Client ID(也常叫 AppKey)—— 给前端用。
- Client Secret(也常叫 AppSecret)——只给后端用。
3.2 配置回调地址(最容易踩坑)
在「登录与分享」或「应用首页 / 回调域名」一类配置里,把授权回调地址加入白名单。地址必须与代码里拼出来的redirect_uri完全一致(含协议、域名、路径、查询串)。
示例(请换成你自己的域名):
https://www.example.com/login?type=ding本地调试时,若走内嵌扫码且redirect_uri取当前页面源,还需要额外加:
http://localhost:8007/login?type=ding经验:跳转授权路径若写死了生产域名,本地点「钉钉登录」按钮会跳到生产环境,而不是本机。内嵌二维码一般用
window.location.origin,两边要分开想清楚。
3.3 权限与 scope
网站扫码登录常用:
response_type=codescope=openidprompt=consent(首次或需要用户确认授权时)
后端换 Token、查用户信息所需的接口权限,在开放平台按官方文档开通(具体接口名以钉钉最新文档为准)。
四、前端工程准备
4.1 技术栈约定
本文示例栈:
- Vue 3 + Vue Router 4 + Pinia
- Vite 5 + TypeScript
- Axios
- 钉钉登录 SDK:CDN 引入,不装 npm 包
CDN 地址:
https://g.alicdn.com/dingding/h5-dingtalk-login/0.37.0/ddlogin.js加载成功后,全局会挂上window.DTFrameLogin(部分旧文档还会提到DDLogin,本方案以DTFrameLogin为准)。
4.2 环境变量
在项目根目录.env/.env.development/.env.production中配置:
# 钉钉 OAuth Client ID(与开放平台应用一致)VITE_DINGTALK_CLIENT_ID=dingxxxxxxxxxxxxxxxxVITE_前缀才会被 Vite 注入到前端代码。types/global.d.ts里可为ImportMetaEnv补上类型:
interfaceImportMetaEnv{readonlyVITE_DINGTALK_CLIENT_ID?:string;// ...}4.3 TypeScript 声明 SDK
新建types/dingtalk.d.ts:
declareglobal{interfaceWindow{DTFrameLogin?:(config:{id:string;width:number;height:number},authConfig:{redirect_uri:string;client_id:string;scope?:string;response_type?:string;state?:string;prompt?:string;},onSuccess:(result:{redirectUrl?:string;authCode?:string;state?:string;})=>void,onFail?:(error:string)=>void)=>void;}}export{};五、工具层:加载 SDK、拼跳转 URL、生成 state
建议单独建src/utils/dingtalkAuth.ts,把「可配置项」集中管理。
/** 整页跳转授权使用的回调地址(须与开放平台白名单一致) */constREDIRECT_URI='https://www.example.com/login?type=ding';exportfunctiongetClientId():string{constid=import.meta.env.VITE_DINGTALK_CLIENT_IDasstring|undefined;return(id&&String(id).trim())||'';}/** CSRF 防护用的 state */exportconstgenerateState=()=>{if(window?.crypto?.randomUUID){returnwindow.crypto.randomUUID();}return'state-'+Date.now();};/** 内嵌扫码:按当前访问源动态生成 redirect_uri(需 URL encode) */exportconstgetEncodedRedirectUri=()=>{if(window?.location){returnencodeURIComponent(window.location.origin+'/login?type=ding');}returnencodeURIComponent(REDIRECT_URI);};/** 动态注入钉钉登录 SDK,只加载一次 */exportconstloadLoginSdk=(version='0.37.0')=>{returnnewPromise<void>((resolve,reject)=>{if(window.DTFrameLogin){resolve();return;}constscript=document.createElement('script');script.src=`https://g.alicdn.com/dingding/h5-dingtalk-login/${version}/ddlogin.js`;script.onload=()=>resolve();script.onerror=()=>reject(newError('钉钉SDK加载失败'));document.head.appendChild(script);});};/** 整页跳转到钉钉授权页 */exportconstredirectToAuthPage=()=>{constclientId=getClientId();constredirectUri=REDIRECT_URI;conststate=generateState();sessionStorage.setItem('dingtalk_login_state',state);consturl=newURL('https://login.dingtalk.com/oauth2/auth');url.searchParams.set('redirect_uri',redirectUri);url.searchParams.set('response_type','code');url.searchParams.set('client_id',clientId);url.searchParams.set('scope','openid');url.searchParams.set('prompt','consent');url.searchParams.set('state',state);window.location.href=url.toString();};说明:
generateState+sessionStorage用于防 CSRF;回调落地后建议校验state是否与本地一致(见后文「踩坑」)。- 内嵌扫码与按钮跳转的
redirect_uri可以不同策略:一个跟当前域名,一个跟生产域名。两边都必须在开放平台登记。
可在App.vue的onMounted里提前loadLoginSdk(),缩短用户打开登录页后的等待。
六、UI 组件:内嵌二维码 +「钉钉登录」按钮
组件职责:
- 挂载后加载 SDK,调用
DTFrameLogin渲染二维码。 - 扫码成功 →
emit('login', authCode)。 - 点击按钮 →
redirectToAuthPage()整页授权。 - 失败展示错误文案与重试。
核心逻辑示意(src/components/QrLoginPanel/index.vue):
<template> <div class="flex flex-col justify-center items-center w-full h-full"> <div class="dd-qr-wrap"> <div id="dingtalk-container" class="dd-qr-inner"></div> <div v-if="isLoading" class="dd-login-overlay"> <n-spin size="small" description="加载钉钉登录..." /> </div> </div> <n-text v-if="errorMessage" type="error">{{ errorMessage }}</n-text> <n-button v-if="errorMessage" quaternary @click="handleRetry">重试</n-button> <n-button type="primary" @click="handleAuthRedirect">钉钉登录</n-button> </div> </template> <script lang="ts"> import { ref, defineComponent, onMounted } from 'vue'; import { loadLoginSdk, getClientId, generateState, redirectToAuthPage, getEncodedRedirectUri, } from '@/utils/dingtalkAuth'; export default defineComponent({ name: 'QrLoginPanel', emits: ['login', 'error'], setup(_, { emit }) { const isLoading = ref(false); const errorMessage = ref(''); const onAuthSuccess = (result: { authCode?: string }) => { emit('login', result.authCode); }; const onAuthFail = (error: unknown) => { const msg = typeof error === 'string' ? error : String(error); errorMessage.value = msg; emit('error', msg); }; const renderQrCode = () => { const clientId = getClientId(); const state = generateState(); const redirectUri = getEncodedRedirectUri(); sessionStorage.setItem('dingtalk_login_state', state); window.DTFrameLogin?.( { id: 'dingtalk-container', width: 300, height: 300 }, { redirect_uri: redirectUri, client_id: clientId, scope: 'openid', state, response_type: 'code', prompt: 'consent', }, onAuthSuccess, onAuthFail ); }; const initLogin = async () => { errorMessage.value = ''; if (!window.DTFrameLogin) { await loadLoginSdk(); } renderQrCode(); }; const handleRetry = async () => { isLoading.value = true; try { await initLogin(); } catch (e) { onAuthFail(e); } finally { isLoading.value = false; } }; const handleAuthRedirect = () => { try { redirectToAuthPage(); } catch (e) { onAuthFail(e); } }; onMounted(async () => { isLoading.value = true; try { await initLogin(); } catch (e) { onAuthFail(e); } finally { isLoading.value = false; } }); return { isLoading, errorMessage, handleAuthRedirect, handleRetry }; }, }); </script>容器样式要点:给#dingtalk-container固定宽高(如 300×300),与DTFrameLogin的width/height一致,避免二维码被裁切。
登录页挂上组件:
<n-tab-pane name="ding" tab="钉钉扫码登录"> <QrLoginPanel @login="handleLoginByCode" @error="handleScanError" /> </n-tab-pane>七、拿到 code 之后:调后端换业务 Token
7.1 API 封装
// src/api/user.tsimporthttpfrom'@/utils/http/axios';/** 钉钉扫码 / 授权回调登录 */exportfunctionloginByCode(params:{code:string;state?:string}){returnhttp.request({url:'/api/login/dingtalk',method:'post',data:params,},{// 保留后端原始结构,自行判断 success / accessTokenisTransformResponse:false,});}请求体字段名以你们后端约定为准。本文示例发送{ code }(注意:若类型里曾写成authCode,要以实际请求体为准,避免类型与报文不一致)。
7.2 Pinia Store
// store 片段asyncloginWithCode(params:{code:string;state?:string}){constresponse=awaitloginByCode(params);const{data,success}=response;if(data?.accessToken){constex=7*24*60*60*1000;storage.set(ACCESS_TOKEN,data.accessToken,ex);storage.set(CURRENT_USER,data,ex);this.setToken(data.accessToken);this.setUserInfo(data);}returnresponse;}7.3 登录页统一处理(扫码回调 + URL 回跳)
consthandleLoginByCode=async(authCode:string|any)=>{if(!authCode||typeofauthCode!=='string'){message.warning('未获取到授权码,请重试');return;}// 建议同时校验 state(见第八节)constpayload={code:authCode};try{constres=awaituserStore.loginWithCode(payload);const{success,message:msg,data}=resas{success?:boolean;message?:string;data?:{accessToken?:string;account?:{id?:string;personName?:string;username?:string};};};if(!success||!data?.accessToken){message.error(msg||'登录失败');return;}message.success('登录成功,即将进入系统');router.replace('/');}catch(e:unknown){message.error(einstanceofError?e.message:'登录失败');}};consthandleScanError=(msg:string)=>{message.error(msg||'钉钉登录异常');};onMounted(()=>{consturlParams=newURLSearchParams(window.location.search);constcode=urlParams.get('code');if(code){loginType.value='ding';handleLoginByCode(code);}});后端期望响应形态示例:
{"success":true,"message":"ok","data":{"accessToken":"eyJhbGciOi...","account":{"id":"10001","personName":"张三","username":"zhangsan"}}}7.4 后端要做什么(前端对接视角)
前端仓库通常不包含 Secret 换票逻辑,但联调时你需要后端同事实现大致流程:
- 接收
POST /api/login/dingtalk,读取code。 - 使用Client ID + Client Secret调用钉钉「用 code 换 userAccessToken / 用户信息」接口(以钉钉最新 OpenAPI 为准)。
- 用钉钉用户唯一标识(如
unionId/openId)匹配或绑定本地账号。 - 签发你们自己的
accessToken,返回给前端。
切记:Secret 只出现在服务端配置中心或密钥库。
八、本地联调步骤(按顺序打勾)
Step 1:配置环境
npminstall编辑.env.development:
VITE_PORT=8007VITE_DINGTALK_CLIENT_ID=dingxxxxxxxxxxxxxxxx VITE_GLOB_API_URL_PREFIX=/api# 开发代理指向你的后端服务,示例:VITE_PROXY=[["/api","https://api.example.com"]]Step 2:开放平台白名单
至少登记:
- 生产:
https://www.example.com/login?type=ding - 本地(若用动态 origin 扫码):
http://localhost:8007/login?type=ding
Step 3:启动前端
npmrun dev浏览器打开:http://localhost:8007/login
默认切到「钉钉扫码登录」页签,应看到二维码区域。
Step 4:验证扫码链路
- 手机钉钉扫码并确认授权。
- 浏览器 Network 出现
POST /api/login/dingtalk,Request Payload 含code。 - 响应
success: true且带accessToken。 - 前端保存 Token 后跳转到系统首页(如
/)。
Step 5:验证跳转链路
- 点击「钉钉登录」。
- 跳转到
https://login.dingtalk.com/oauth2/auth?... - 授权后回到配置的
redirect_uri,地址栏出现code=。 - 登录页
onMounted读到code后自动走同一套换票逻辑。
九、常见问题与踩坑
1. 二维码空白 / SDK 加载失败
- 检查 CDN 是否被公司网络拦截;可在 Network 看
ddlogin.js是否 200。 - 确认
#dingtalk-container在调用DTFrameLogin时已挂载到 DOM。 - 提供「重试」按钮重新执行
initLogin。
2.redirect_uri不匹配
钉钉会直接拒绝授权。核对:
- 协议
http/https - 端口(本地
8007) - 路径
/login - 查询参数
?type=ding是否也写进了白名单(若代码里带了查询串,白名单一般也要带)
3. 本地扫码能用,按钮跳转却去了生产站
这是「动态 origin」与「写死生产回调」两套策略并存时的正常现象。开发阶段可把redirectToAuthPage的redirectUri也改成当前 origin,或单独做环境分支。
4. 前端发了code,后端却说字段不对
对齐字段名:codevsauthCode。以实际 JSON 为准,不要只信类型定义。
5.state写了却没校验
写入sessionStorage['dingtalk_login_state']后,回调时应:
conststateFromUrl=urlParams.get('state');conststateLocal=sessionStorage.getItem('dingtalk_login_state');if(stateFromUrl&&stateLocal&&stateFromUrl!==stateLocal){message.error('登录状态校验失败,请重试');return;}内嵌扫码成功回调里也会带回state,同样建议比对。
6. 登录成功但不跳转
换票成功后记得显式跳转(如router.replace('/'))。若只存了 Token 却没有路由跳转,用户会感觉「卡住」。
7. Client ID 写进前端是否安全?
Client ID 本身是公开标识,会出现在授权 URL 和前端包中,这是 OAuth 公开客户端的常态。真正敏感的是Secret以及后端签发的业务 Token。
十、文件清单(对照实现)
| 路径 | 作用 |
|---|---|
.env* | VITE_DINGTALK_CLIENT_ID |
types/dingtalk.d.ts | DTFrameLogin全局类型 |
src/utils/dingtalkAuth.ts | SDK 加载、Client ID、跳转授权、state |
src/components/QrLoginPanel/index.vue | 内嵌二维码 + 跳转按钮 |
src/views/login/index.vue | 处理授权码,换票并进入首页 |
src/api/user.ts | POST /api/login/dingtalk |
src/store/modules/user.ts | loginWithCode持久化 Token |
src/App.vue | 可选:预加载 SDK |
十一、小结
接入钉钉网页扫码登录,可以按这条最短路径落地:
- 开放平台创建应用,拿到 Client ID / Secret,配齐回调白名单。
- 前端 CDN 加载
h5-dingtalk-login,用DTFrameLogin做内嵌扫码,必要时再做oauth2/auth整页跳转。 - 两条路都只负责拿到授权码;用 Secret 换用户身份、发业务 Token 必须在服务端完成。
- 登录成功后保存 Token,并跳转到系统首页。
把回调地址、字段名、state校验这三处对齐,联调成功率会高很多。其余 UI、Tab、加载态按你们设计系统微调即可。
参考链接
- 钉钉开放平台
- 钉钉登录 JS SDK(CDN):
https://g.alicdn.com/dingding/h5-dingtalk-login/ - OAuth 授权入口:
https://login.dingtalk.com/oauth2/auth
(具体换票、用户信息接口以开放平台当前文档版本为准,接口路径偶有迭代,联调时请对照最新文档。)