Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it } from 'vitest';

import { loadScript } from '../resources';

const testJsPath =
'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js';

describe('loadScript', () => {
beforeEach(() => {
// 每个测试前清空 head,保证环境干净
document.head.innerHTML = '';
});

it('should resolve when the script loads successfully', async () => {
const promise = loadScript(testJsPath);

// 此时脚本元素已被创建并插入
const script = document.querySelector(
`script[src="${testJsPath}"]`,
) as HTMLScriptElement;
expect(script).toBeTruthy();

// 模拟加载成功
script.dispatchEvent(new Event('load'));

// 等待 promise resolve
await expect(promise).resolves.toBeUndefined();
});

it('should not insert duplicate script and resolve immediately if already loaded', async () => {
// 先手动插入一个相同 src 的 script
const existing = document.createElement('script');
existing.src = 'bar.js';
document.head.append(existing);

// 再次调用
const promise = loadScript('bar.js');

// 立即 resolve
await expect(promise).resolves.toBeUndefined();

// head 中只保留一个
const scripts = document.head.querySelectorAll('script[src="bar.js"]');
expect(scripts).toHaveLength(1);
});

it('should reject when the script fails to load', async () => {
const promise = loadScript('error.js');

const script = document.querySelector(
'script[src="error.js"]',
) as HTMLScriptElement;
expect(script).toBeTruthy();

// 模拟加载失败
script.dispatchEvent(new Event('error'));

await expect(promise).rejects.toThrow('Failed to load script: error.js');
});

it('should handle multiple concurrent calls and only insert one script tag', async () => {
const p1 = loadScript(testJsPath);
const p2 = loadScript(testJsPath);

const script = document.querySelector(
`script[src="${testJsPath}"]`,
) as HTMLScriptElement;
expect(script).toBeTruthy();

// 触发一次 load,两个 promise 都应该 resolve
script.dispatchEvent(new Event('load'));

await expect(p1).resolves.toBeUndefined();
await expect(p2).resolves.toBeUndefined();

// 只插入一次
const scripts = document.head.querySelectorAll(
`script[src="${testJsPath}"]`,
);
expect(scripts).toHaveLength(1);
});
});
1 change: 1 addition & 0 deletions packages/@core/base/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './inference';
export * from './letter';
export * from './merge';
export * from './nprogress';
export * from './resources';
export * from './state-handler';
export * from './to';
export * from './tree';
Expand Down
21 changes: 21 additions & 0 deletions packages/@core/base/shared/src/utils/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* 加载js文件
* @param src js文件地址
*/
function loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
// 如果已经加载过,直接 resolve
return resolve();
}
const script = document.createElement('script');
script.src = src;
script.addEventListener('load', () => resolve());
script.addEventListener('error', () =>
reject(new Error(`Failed to load script: ${src}`)),
);
document.head.append(script);
});
}

export { loadScript };
113 changes: 113 additions & 0 deletions packages/effects/common-ui/src/ui/authentication/dingding-login.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';

import { RiDingding } from '@vben/icons';
import { $t } from '@vben/locales';

import { alert, useVbenModal } from '@vben-core/popup-ui';
import { VbenIconButton } from '@vben-core/shadcn-ui';
import { loadScript } from '@vben-core/shared/utils';

interface Props {
clientId: string;
corpId: string;
// 登录回调地址
redirectUri?: string;
// 是否内嵌二维码登录
isQrCode?: boolean;
}

const props = defineProps<Props>();

const route = useRoute();

const [Modal, modalApi] = useVbenModal({
header: false,
footer: false,
fullscreenButton: false,
class: 'w-[302px] h-[302px] dingding-qrcode-login-modal',
onOpened() {
handleQrCodeLogin();
},
});

const getRedirectUri = () => {
const { redirectUri } = props;
if (redirectUri) {
return redirectUri;
}
return window.location.origin + route.fullPath;
};

/**
* 内嵌二维码登录
*/
const handleQrCodeLogin = async () => {
const { clientId, corpId } = props;
if (!(window as any).DTFrameLogin) {
// 二维码登录 加载资源
await loadScript(
'https://g.alicdn.com/dingding/h5-dingtalk-login/0.21.0/ddlogin.js',
);
}
(window as any).DTFrameLogin(
{
id: 'dingding_qrcode_login_element',
width: 300,
height: 300,
},
{
// 注意:redirect_uri 需为完整URL,扫码后钉钉会带code跳转到这里
redirect_uri: encodeURIComponent(getRedirectUri()),
client_id: clientId,
scope: 'openid corpid',
response_type: 'code',
state: '1',
prompt: 'consent',
corpId,
},
(loginResult: any) => {
const { redirectUrl } = loginResult;
// 这里可以直接进行重定向
window.location.href = redirectUrl;
},
(errorMsg: string) => {
// 这里一般需要展示登录失败的具体原因
alert(`Login Error: ${errorMsg}`);
},
);
};
Comment on lines +45 to +79
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify parameter consistency and improve error handling.

The QR code login implementation is solid, but there are a few areas for improvement:

  1. Parameter naming inconsistency: Line 67 uses corpId while line 87 uses corpid - ensure consistency with DingTalk API requirements.
  2. Consider adding loading state: Users don't see feedback during script loading.
  3. Error handling: The alert for login errors could be more user-friendly.

🏁 Script executed:

#!/bin/bash
# Verify DingTalk API parameter naming consistency
rg -A 5 -B 5 "corpid|corpId" --type vue

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Search for `corpId` and `corpid` usage across all .vue files to verify naming consistency
rg -n 'corpId|corpid' -g '*.vue'

Length of output: 935


Update DingTalk QR Code Login Implementation

The corpId and corpid usages are correct (camel-case prop for the JS SDK vs. lowercase query param and scope name). Focus on the remaining improvements:

• Remove the parameter-name consistency suggestion—these keys serve different, correct purposes.
• Add a loading indicator/state while ddlogin.js is being fetched.
• Replace the alert in the error callback with a more user-friendly UI message (e.g., inline error banner or toast).

🤖 Prompt for AI Agents
In packages/effects/common-ui/src/ui/authentication/dingding-login.vue between
lines 45 and 79, add a loading state to show a loading indicator while the
ddlogin.js script is being fetched asynchronously. Remove any changes suggesting
renaming corpId or corpid as they are correctly used. Replace the alert in the
error callback with a user-friendly UI message such as an inline error banner or
toast notification to display login errors more gracefully.


const handleLogin = () => {
const { clientId, corpId, isQrCode } = props;
if (isQrCode) {
// 内嵌二维码登录
modalApi.open();
} else {
window.location.href = `https://login.dingtalk.com/oauth2/auth?redirect_uri=${encodeURIComponent(getRedirectUri())}&response_type=code&client_id=${clientId}&scope=openid&corpid=${corpId}&prompt=consent`;
}
};
</script>

<template>
<div>
<VbenIconButton
@click="handleLogin"
:tooltip="$t('authentication.dingdingLogin')"
tooltip-side="top"
>
<RiDingding />
</VbenIconButton>
<Modal>
<div id="dingding_qrcode_login_element"></div>
</Modal>
</div>
</template>

<style>
.dingding-qrcode-login-modal {
.relative {
padding: 0 !important;
}
}
</style>
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
<script setup lang="ts">
import { useAppConfig } from '@vben/hooks';
import { MdiGithub, MdiGoogle, MdiQqchat, MdiWechat } from '@vben/icons';
import { $t } from '@vben/locales';

import { VbenIconButton } from '@vben-core/shadcn-ui';

import DingdingLogin from './dingding-login.vue';

defineOptions({
name: 'ThirdPartyLogin',
});

const {
auth: { dingding: dingdingAuthConfig },
} = useAppConfig(import.meta.env, import.meta.env.PROD);
</script>

<template>
Expand All @@ -20,18 +27,40 @@ defineOptions({
</div>

<div class="mt-4 flex flex-wrap justify-center">
<VbenIconButton class="mb-3">
<VbenIconButton
:tooltip="$t('authentication.wechatLogin')"
tooltip-side="top"
class="mb-3"
>
<MdiWechat />
</VbenIconButton>
<VbenIconButton class="mb-3">
<VbenIconButton
:tooltip="$t('authentication.qqLogin')"
tooltip-side="top"
class="mb-3"
>
<MdiQqchat />
</VbenIconButton>
<VbenIconButton class="mb-3">
<VbenIconButton
:tooltip="$t('authentication.githubLogin')"
tooltip-side="top"
class="mb-3"
>
<MdiGithub />
</VbenIconButton>
<VbenIconButton class="mb-3">
<VbenIconButton
:tooltip="$t('authentication.googleLogin')"
tooltip-side="top"
class="mb-3"
>
<MdiGoogle />
</VbenIconButton>
<DingdingLogin
v-if="dingdingAuthConfig"
:corp-id="dingdingAuthConfig.corpId"
:client-id="dingdingAuthConfig.clientId"
class="mb-3"
/>
</div>
</div>
</template>
17 changes: 15 additions & 2 deletions packages/effects/hooks/src/use-app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,22 @@ export function useAppConfig(
? window._VBEN_ADMIN_PRO_APP_CONF_
: (env as VbenAdminProAppConfigRaw);

const { VITE_GLOB_API_URL } = config;
const {
VITE_GLOB_API_URL,
VITE_GLOB_AUTH_DINGDING_CORP_ID,
VITE_GLOB_AUTH_DINGDING_CLIENT_ID,
} = config;

return {
const applicationConfig: ApplicationConfig = {
apiURL: VITE_GLOB_API_URL,
auth: {},
};
if (VITE_GLOB_AUTH_DINGDING_CORP_ID && VITE_GLOB_AUTH_DINGDING_CLIENT_ID) {
applicationConfig.auth.dingding = {
clientId: VITE_GLOB_AUTH_DINGDING_CLIENT_ID,
corpId: VITE_GLOB_AUTH_DINGDING_CORP_ID,
};
}

return applicationConfig;
}
2 changes: 2 additions & 0 deletions packages/icons/src/iconify/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export const MdiGithub = createIconifyIcon('mdi:github');
export const MdiGoogle = createIconifyIcon('mdi:google');

export const MdiQqchat = createIconifyIcon('mdi:qqchat');

export const RiDingding = createIconifyIcon('ri:dingding-fill');
5 changes: 5 additions & 0 deletions packages/locales/src/langs/en-US/authentication.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"qrcodeSubtitle": "Scan the QR code with your phone to login",
"qrcodePrompt": "Click 'Confirm' after scanning to complete login",
"qrcodeLogin": "QR Code Login",
"wechatLogin": "Wechat Login",
"qqLogin": "QQ Login",
"githubLogin": "Github Login",
"googleLogin": "Google Login",
"dingdingLogin": "Dingding Login",
"codeSubtitle": "Enter your phone number to start managing your project",
"code": "Security code",
"codeTip": "Security code required {0} characters",
Expand Down
5 changes: 5 additions & 0 deletions packages/locales/src/langs/zh-CN/authentication.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"qrcodeSubtitle": "请用手机扫描二维码登录",
"qrcodePrompt": "扫码后点击 '确认',即可完成登录",
"qrcodeLogin": "扫码登录",
"wechatLogin": "微信登录",
"qqLogin": "QQ登录",
"githubLogin": "Github登录",
"googleLogin": "Google登录",
"dingdingLogin": "钉钉登录",
"codeSubtitle": "请输入您的手机号码以开始管理您的项目",
"code": "验证码",
"codeTip": "请输入{0}位验证码",
Expand Down
10 changes: 10 additions & 0 deletions packages/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,20 @@ declare module 'vue-router' {

export interface VbenAdminProAppConfigRaw {
VITE_GLOB_API_URL: string;
VITE_GLOB_AUTH_DINGDING_CLIENT_ID: string;
VITE_GLOB_AUTH_DINGDING_CORP_ID: string;
}

interface AuthConfig {
dingding?: {
clientId: string;
corpId: string;
};
}

export interface ApplicationConfig {
apiURL: string;
auth: AuthConfig;
}

declare global {
Expand Down
4 changes: 4 additions & 0 deletions playground/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ VITE_DEVTOOLS=false

# 是否注入全局loading
VITE_INJECT_APP_LOADING=true

# 钉钉登录配置
VITE_GLOB_AUTH_DINGDING_CLIENT_ID=应用的clientId
VITE_GLOB_AUTH_DINGDING_CORP_ID=应用的corpId