220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
'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>
|
||
)
|
||
}
|