gongxiangp2/app/dingtalk-entry/DingtalkEntryClient.tsx
2026-07-08 09:50:57 +08:00

215 lines
6.9 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
declare global {
interface Window {
dd?: any
}
}
type DingtalkEntryClientProps = {
corpId: string
redirectPath: string
}
function loadDingtalkJsApi() {
return new Promise<void>((resolve, reject) => {
if (window.dd?.runtime?.permission) {
resolve()
return
}
const existing = document.querySelector<HTMLScriptElement>('script[data-dingtalk-jsapi="true"]')
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => reject(new Error('钉钉JSAPI加载失败')), { once: true })
return
}
const script = document.createElement('script')
script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.39/dingtalk.open.js'
script.async = true
script.dataset.dingtalkJsapi = 'true'
script.onload = () => resolve()
script.onerror = () => reject(new Error('钉钉JSAPI加载失败'))
document.head.appendChild(script)
})
}
function requestDingtalkAuthCode(corpId: string) {
return new Promise<string>((resolve, reject) => {
const dd = window.dd
if (!dd?.ready || !dd?.runtime?.permission?.requestAuthCode) {
reject(new Error('当前页面未检测到钉钉免登环境'))
return
}
const timer = window.setTimeout(() => {
reject(new Error('钉钉免登初始化超时'))
}, 15000)
if (dd.error) {
dd.error((error: unknown) => {
window.clearTimeout(timer)
reject(error instanceof Error ? error : new Error(formatDingtalkError(error)))
})
}
dd.ready(() => {
window.clearTimeout(timer)
dd.runtime.permission.requestAuthCode({
corpId,
onSuccess: (result: { code?: string }) => {
const code = String(result?.code || '').trim()
if (!code) {
reject(new Error('钉钉未返回免登授权码'))
return
}
resolve(code)
},
onFail: (error: unknown) => {
reject(error instanceof Error ? error : new Error(formatDingtalkError(error)))
},
})
})
})
}
function formatDingtalkError(error: unknown) {
if (!error) return '钉钉免登失败'
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
const record = error as Record<string, unknown>
const message =
record.message ||
record.errorMessage ||
record.errMsg ||
record.errmsg ||
record.submsg
if (message) return String(message)
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function navigateDingtalkH5Login(authCode: string, redirectPath: string) {
const params = new URLSearchParams({
code: authCode,
redirect: redirectPath,
source: 'dingtalk-entry-v2',
})
window.location.replace(`/api/dingtalk/h5-login?${params.toString()}`)
}
async function hasActiveSession() {
try {
const response = await fetch('/api/auth/session', {
cache: 'no-store',
credentials: 'include',
})
if (!response.ok) return false
const data = await response.json().catch(() => null)
return Boolean(data?.user)
} catch {
return false
}
}
export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEntryClientProps) {
const startedRef = useRef(false)
const [message, setMessage] = useState('正在打开问卷...')
useEffect(() => {
if (startedRef.current) return
startedRef.current = true
const run = async () => {
if (!corpId) {
setMessage('请返回钉钉,从最新通知重新打开问卷。')
return
}
if (!/DingTalk/i.test(window.navigator.userAgent || '')) {
setMessage('请在钉钉客户端内打开通知。')
return
}
try {
setMessage('正在打开问卷...')
if (await hasActiveSession()) {
window.location.replace(redirectPath)
return
}
await loadDingtalkJsApi()
const authCode = await requestDingtalkAuthCode(corpId)
navigateDingtalkH5Login(authCode, redirectPath)
} catch (error) {
console.error('[Dingtalk Entry] 打开问卷失败:', error)
setMessage('请返回钉钉,从最新通知重新打开问卷。')
}
}
void run()
}, [corpId, redirectPath])
return (
<main className="min-h-[100dvh] overflow-hidden bg-[#f6f8fb] text-[#111827]">
<div className="bg-black text-white">
<div className="mx-auto flex h-16 max-w-[430px] items-center px-5">
<div className="min-w-0">
<div className="truncate text-[18px] font-semibold tracking-tight">由壹点「素材库」</div>
</div>
</div>
</div>
<section className="mx-auto max-w-[430px] px-5 py-8">
<div className="overflow-hidden rounded-[26px] border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]">
<div className="text-[14px] font-semibold text-[#1677ff]">考核调研</div>
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开问卷</h1>
<p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">{message}</p>
<div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4">
<div className="flex items-center justify-between gap-4">
<span className="text-[15px] font-semibold text-[#101828]">准备问卷内容</span>
<span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span>
</div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]">
<div className="h-full w-2/3 animate-pulse rounded-full bg-[#1677ff]" />
</div>
</div>
<div className="mt-7 rounded-[18px] border border-[#eef2f7] bg-white p-5">
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div>
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
<div className="mt-8 space-y-5">
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 flex-1 rounded-full bg-[#f1f5f9]" />
</div>
<div className="flex items-center gap-3">
<span className="h-4 w-4 rounded-full border border-[#e3eaf3]" />
<span className="h-3 w-2/3 rounded-full bg-[#f1f5f9]" />
</div>
</div>
</div>
</div>
</section>
</main>
)
}