gongxiangp2/app/dingtalk-entry/DingtalkEntryClient.tsx
2026-07-07 19:35:25 +08:00

220 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import Image from 'next/image'
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="fixed inset-0 min-h-[100dvh] overflow-hidden bg-[#f7fbff] text-[#111827]">
<div className="flex h-14 items-center justify-between bg-gradient-to-r from-[#1677ff] to-[#4f9cff] px-5 text-white shadow-[0_10px_24px_rgba(22,119,255,0.18)]">
<span className="text-2xl leading-none">‹</span>
<span className="text-[15px] font-semibold">素材库</span>
<span className="text-xl leading-none">•••</span>
</div>
<section className="mx-auto max-w-[390px] px-7 pt-12">
<div className="text-center">
<Image
src="/api/favicon"
alt="素材库"
width={42}
height={42}
unoptimized
className="mx-auto h-[42px] w-[42px] rounded-[10px] object-cover"
/>
<h1 className="mt-3 text-[23px] font-semibold leading-none">素材库</h1>
<p className="mt-2 text-[14px] font-medium text-[#8a94a6]">内部素材协作平台</p>
</div>
<div className="mt-10 flex min-h-[58px] items-center justify-between rounded-full bg-white px-5 shadow-[0_12px_30px_rgba(15,23,42,0.12)]">
<div className="flex items-center gap-3">
<span className="h-7 w-7 animate-spin rounded-full border-[4px] border-[#d7e8ff] border-t-[#1677ff]" />
<span className="text-[16px] font-semibold">正在打开问卷</span>
</div>
<span className="text-[15px] font-semibold text-[#1677ff]">马上进入</span>
</div>
<p className="mt-7 text-center text-[13px] font-medium leading-6 text-[#8a94a6]">{message}</p>
<div className="mt-11 rounded-[18px] bg-white/90 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.08)]">
<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>
</section>
</main>
)
}