Vue3 + Vite 实战:接入钉钉 OAuth 扫码登录(内嵌二维码 + 跳转授权)
Vue3 Vite 实战接入钉钉 OAuth 扫码登录内嵌二维码 跳转授权本文基于 Vue 3 Vite TypeScript Pinia 的登录页工程完整演示钉钉开放平台OAuth2 授权码模式内嵌扫码DTFrameLogin与整页跳转授权两条链路并说明前后端如何用code换取业务 Token。照着步骤做本地即可跑通。一、先搞清楚我们要接的是哪一种「钉钉登录」钉钉开放能力里常见两类登录容易混类型典型场景前端关键字段本文是否覆盖OAuth2 网站应用登录PC 网页扫码 / 跳转授权拿code换用户身份client_id、redirect_uri、scopeopenid是企业内部 H5 / JSAPI钉钉客户端内打开 H5用corpId、dd.readycorpId、AgentId 等否本文方案是用户打开登录页 → 扫码或跳转钉钉授权 → 前端拿到授权码code→ 交给自家后端 → 后端用 AppSecret 向钉钉换用户信息并签发业务 Token → 前端进入系统首页。要点一句话前端只持有 Client IDAppKey可以写进环境变量。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?codexxxstateyyy登录页从 URL 读取code。三、开放平台侧准备可实操清单3.1 创建应用打开 钉钉开放平台登录开发者账号。创建企业内部应用或按文档创建具备「登录」能力的应用以控制台当前产品名为准。在应用详情中找到Client ID也常叫 AppKey—— 给前端用。Client Secret也常叫 AppSecret——只给后端用。3.2 配置回调地址最容易踩坑在「登录与分享」或「应用首页 / 回调域名」一类配置里把授权回调地址加入白名单。地址必须与代码里拼出来的redirect_uri完全一致含协议、域名、路径、查询串。示例请换成你自己的域名https://www.example.com/login?typeding本地调试时若走内嵌扫码且redirect_uri取当前页面源还需要额外加http://localhost:8007/login?typeding经验跳转授权路径若写死了生产域名本地点「钉钉登录」按钮会跳到生产环境而不是本机。内嵌二维码一般用window.location.origin两边要分开想清楚。3.3 权限与 scope网站扫码登录常用response_typecodescopeopenidpromptconsent首次或需要用户确认授权时后端换 Token、查用户信息所需的接口权限在开放平台按官方文档开通具体接口名以钉钉最新文档为准。四、前端工程准备4.1 技术栈约定本文示例栈Vue 3 Vue Router 4 PiniaVite 5 TypeScriptAxios钉钉登录 SDKCDN 引入不装 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_IDdingxxxxxxxxxxxxxxxxVITE_前缀才会被 Vite 注入到前端代码。types/global.d.ts里可为ImportMetaEnv补上类型interfaceImportMetaEnv{readonlyVITE_DINGTALK_CLIENT_ID?:string;// ...}4.3 TypeScript 声明 SDK新建types/dingtalk.d.tsdeclareglobal{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_URIhttps://www.example.com/login?typeding;exportfunctiongetClientId():string{constidimport.meta.env.VITE_DINGTALK_CLIENT_IDasstring|undefined;return(idString(id).trim())||;}/** CSRF 防护用的 state */exportconstgenerateState(){if(window?.crypto?.randomUUID){returnwindow.crypto.randomUUID();}returnstate-Date.now();};/** 内嵌扫码按当前访问源动态生成 redirect_uri需 URL encode */exportconstgetEncodedRedirectUri(){if(window?.location){returnencodeURIComponent(window.location.origin/login?typeding);}returnencodeURIComponent(REDIRECT_URI);};/** 动态注入钉钉登录 SDK只加载一次 */exportconstloadLoginSdk(version0.37.0){returnnewPromisevoid((resolve,reject){if(window.DTFrameLogin){resolve();return;}constscriptdocument.createElement(script);script.srchttps://g.alicdn.com/dingding/h5-dingtalk-login/${version}/ddlogin.js;script.onload()resolve();script.onerror()reject(newError(钉钉SDK加载失败));document.head.appendChild(script);});};/** 整页跳转到钉钉授权页 */exportconstredirectToAuthPage(){constclientIdgetClientId();constredirectUriREDIRECT_URI;conststategenerateState();sessionStorage.setItem(dingtalk_login_state,state);consturlnewURL(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.hrefurl.toString();};说明generateStatesessionStorage用于防 CSRF回调落地后建议校验state是否与本地一致见后文「踩坑」。内嵌扫码与按钮跳转的redirect_uri可以不同策略一个跟当前域名一个跟生产域名。两边都必须在开放平台登记。可在App.vue的onMounted里提前loadLoginSdk()缩短用户打开登录页后的等待。六、UI 组件内嵌二维码 「钉钉登录」按钮组件职责挂载后加载 SDK调用DTFrameLogin渲染二维码。扫码成功 →emit(login, authCode)。点击按钮 →redirectToAuthPage()整页授权。失败展示错误文案与重试。核心逻辑示意src/components/QrLoginPanel/index.vuetemplate div classflex flex-col justify-center items-center w-full h-full div classdd-qr-wrap div iddingtalk-container classdd-qr-inner/div div v-ifisLoading classdd-login-overlay n-spin sizesmall description加载钉钉登录... / /div /div n-text v-iferrorMessage typeerror{{ errorMessage }}/n-text n-button v-iferrorMessage quaternary clickhandleRetry重试/n-button n-button typeprimary clickhandleAuthRedirect钉钉登录/n-button /div /template script langts 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 nameding tab钉钉扫码登录 QrLoginPanel loginhandleLoginByCode errorhandleScanError / /n-tab-pane七、拿到 code 之后调后端换业务 Token7.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}){constresponseawaitloginByCode(params);const{data,success}response;if(data?.accessToken){constex7*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 回跳consthandleLoginByCodeasync(authCode:string|any){if(!authCode||typeofauthCode!string){message.warning(未获取到授权码请重试);return;}// 建议同时校验 state见第八节constpayload{code:authCode};try{constresawaituserStore.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((){consturlParamsnewURLSearchParams(window.location.search);constcodeurlParams.get(code);if(code){loginType.valueding;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.developmentVITE_PORT8007VITE_DINGTALK_CLIENT_IDdingxxxxxxxxxxxxxxxx VITE_GLOB_API_URL_PREFIX/api# 开发代理指向你的后端服务示例VITE_PROXY[[/api,https://api.example.com]]Step 2开放平台白名单至少登记生产https://www.example.com/login?typeding本地若用动态 origin 扫码http://localhost:8007/login?typedingStep 3启动前端npmrun dev浏览器打开http://localhost:8007/login默认切到「钉钉扫码登录」页签应看到二维码区域。Step 4验证扫码链路手机钉钉扫码并确认授权。浏览器 Network 出现POST /api/login/dingtalkRequest 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查询参数?typeding是否也写进了白名单若代码里带了查询串白名单一般也要带3. 本地扫码能用按钮跳转却去了生产站这是「动态 origin」与「写死生产回调」两套策略并存时的正常现象。开发阶段可把redirectToAuthPage的redirectUri也改成当前 origin或单独做环境分支。4. 前端发了code后端却说字段不对对齐字段名codevsauthCode。以实际 JSON 为准不要只信类型定义。5.state写了却没校验写入sessionStorage[dingtalk_login_state]后回调时应conststateFromUrlurlParams.get(state);conststateLocalsessionStorage.getItem(dingtalk_login_state);if(stateFromUrlstateLocalstateFromUrl!stateLocal){message.error(登录状态校验失败请重试);return;}内嵌扫码成功回调里也会带回state同样建议比对。6. 登录成功但不跳转换票成功后记得显式跳转如router.replace(/)。若只存了 Token 却没有路由跳转用户会感觉「卡住」。7. Client ID 写进前端是否安全Client ID 本身是公开标识会出现在授权 URL 和前端包中这是 OAuth 公开客户端的常态。真正敏感的是Secret以及后端签发的业务 Token。十、文件清单对照实现路径作用.env*VITE_DINGTALK_CLIENT_IDtypes/dingtalk.d.tsDTFrameLogin全局类型src/utils/dingtalkAuth.tsSDK 加载、Client ID、跳转授权、statesrc/components/QrLoginPanel/index.vue内嵌二维码 跳转按钮src/views/login/index.vue处理授权码换票并进入首页src/api/user.tsPOST /api/login/dingtalksrc/store/modules/user.tsloginWithCode持久化 Tokensrc/App.vue可选预加载 SDK十一、小结接入钉钉网页扫码登录可以按这条最短路径落地开放平台创建应用拿到 Client ID / Secret配齐回调白名单。前端 CDN 加载h5-dingtalk-login用DTFrameLogin做内嵌扫码必要时再做oauth2/auth整页跳转。两条路都只负责拿到授权码用 Secret 换用户身份、发业务 Token 必须在服务端完成。登录成功后保存 Token并跳转到系统首页。把回调地址、字段名、state校验这三处对齐联调成功率会高很多。其余 UI、Tab、加载态按你们设计系统微调即可。参考链接钉钉开放平台钉钉登录 JS SDKCDNhttps://g.alicdn.com/dingding/h5-dingtalk-login/OAuth 授权入口https://login.dingtalk.com/oauth2/auth具体换票、用户信息接口以开放平台当前文档版本为准接口路径偶有迭代联调时请对照最新文档。