Files
x-server-mobile/frontend/src/pages/login/index.tsx
T
root 4667590feb fix: 修复飞书免登功能,添加 H5 JSSDK 和 appId 自动获取
后端改动:
- 新增 /api/v1/auth/feishu-config 接口,根据 company 参数返回对应 appId

前端改动:
- 添加飞书 H5 JSSDK (h5-js-sdk-1.5.19.js)
- 修改登录页免登逻辑:先从后端获取 appId,再调用 tt.requestAuthCode
- 新增飞书环境检测测试页面 (/feishu-env-test)
- 移除测试页面的自动轮询逻辑

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 08:58:51 +08:00

617 lines
21 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.
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Toast } from 'antd-mobile'
import { useUserStore } from '@/stores/user'
import QRCodeLogin from './QRCodeLogin'
import styles from './login.module.css'
// 声明飞书 JSAPI 类型
declare global {
interface Window {
// 官方 tt API(H5 应用免登)
tt?: any
// 旧版 ttjsBridge(兼容性)
ttjsBridge?: any
// H5 JSSDK(网页应用免登)
h5sdk?: {
ready: (callback: () => void) => void
config: (config: any) => void
error?: (handler: (error: any) => void) => void
}
// 其他可能的飞书 API
bridge?: any
_tt?: any
Lark?: any
Feishu?: any
}
}
export default function Login() {
const navigate = useNavigate()
const { login, user } = useUserStore()
const [loading, setLoading] = useState(true)
const [environment, setEnvironment] = useState<'feishu' | 'external' | null>(null)
const [showQRCode, setShowQRCode] = useState(false)
useEffect(() => {
// 首先检查是否已登录
const token = localStorage.getItem('token')
const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect')
console.log('🔐 登录页检查:', { hasToken: !!token, hasUser: !!user, redirect })
// 如果有 token,说明已登录
if (token) {
console.log('✅ 检测到 token,用户已登录')
// 如果有 redirect 参数,跳转到目标页面
if (redirect) {
console.log('✅ 跳转到 redirect 页面:', redirect)
window.location.href = redirect
return
}
// 否则跳转到 dashboard
console.log('✅ 跳转到 dashboard')
navigate('/dashboard')
return
}
// 没有 token,但不立即显示登录界面
// 等待第二个 useEffect 检测飞书环境并尝试免登
// 只有在非飞书环境或免登失败时才显示登录界面
// 监听飞书登录成功事件
const handleFeishuLoginSuccess = ((e: any) => {
console.log('🎉 收到飞书登录成功事件:', e.detail)
const { user, token } = e.detail
login(user, token)
}) as EventListener
window.addEventListener('feishu-login-success', handleFeishuLoginSuccess)
return () => {
window.removeEventListener('feishu-login-success', handleFeishuLoginSuccess)
}
}, [user, login, navigate])
useEffect(() => {
// 打印详细的环境信息
const ua = navigator.userAgent
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
const currentUrl = window.location.href
const currentOrigin = window.location.origin
console.log('=== 环境检测 ===')
console.log('当前 URL:', currentUrl)
console.log('当前域名:', currentOrigin)
console.log('User Agent:', ua)
console.log('在飞书中:', isInFeishu)
console.log('window.tt:', window.tt)
// 检查域名是否匹配线上环境
const expectedDomain = 'uniservice-mobile.hnxinyouhe.com'
const isExpectedDomain = currentOrigin.includes(expectedDomain) || currentOrigin.includes('xsm.dev')
console.log('域名检查:', {
expected: expectedDomain,
current: currentOrigin,
match: isExpectedDomain
})
// 检查所有可能的飞书相关全局对象
const feishuObjects: Record<string, string> = {}
for (const key in window) {
if (key.toLowerCase().includes('tt') ||
key.toLowerCase().includes('lark') ||
key.toLowerCase().includes('feishu') ||
key.toLowerCase().includes('bridge') ||
key.toLowerCase().includes('h5sdk')) {
feishuObjects[key] = typeof (window as any)[key]
}
}
console.log('🔍 飞书相关全局对象:', feishuObjects)
console.log('🔍 h5sdk 对象:', window.h5sdk)
if (window.h5sdk) {
console.log(' h5sdk 属性:', Object.keys(window.h5sdk))
}
// 检查 URL 中是否来自飞书消息通知
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('auth_code')
// 关键修复:在飞书中但 tt 对象不存在时,检查 h5sdk 并等待环境初始化
// 网页应用会先注入 h5sdk,然后通过 h5sdk.ready() 等待 tt 对象
if (isInFeishu && !window.tt) {
console.log('⚠️ 在飞书中但 tt 对象不存在,检查 h5sdk...')
// 如果有 h5sdk,使用 h5sdk.ready() 等待
if (window.h5sdk && typeof window.h5sdk.ready === 'function') {
console.log('✅ 检测到 h5sdk,使用 h5sdk.ready() 等待 tt 对象注入')
setEnvironment('feishu')
let readyCalled = false
const timeoutId = setTimeout(() => {
if (!readyCalled) {
console.error('❌ h5sdk.ready() 超时(5秒)')
setLoading(false)
}
}, 5000)
window.h5sdk.ready(() => {
readyCalled = true
clearTimeout(timeoutId)
console.log('✅ h5sdk.ready() 回调触发,检查 tt 对象:', !!window.tt)
if (window.tt) {
console.log('✅ tt 对象已注入,开始免登')
handleFeishuAutoLogin()
} else {
console.error('❌ h5sdk.ready() 后 tt 对象仍不存在')
setLoading(false)
}
})
// 设置错误处理
if (window.h5sdk.error) {
window.h5sdk.error((error: any) => {
console.error('❌ h5sdk error:', error)
setLoading(false)
})
}
return
}
// 如果没有 h5sdk,轮询等待 tt 对象
console.log('⚠️ 未检测到 h5sdk,开始轮询等待 tt 对象...')
setEnvironment('feishu')
let attempts = 0
const maxAttempts = 100 // 100 * 100ms = 10秒
const checkInterval = setInterval(() => {
attempts++
console.log(`🔍 检查 tt 对象 (${attempts}/${maxAttempts}):`, !!window.tt, typeof window.tt)
if (window.tt && typeof window.tt === 'object') {
clearInterval(checkInterval)
console.log('✅ tt 对象已注入,开始免登')
handleFeishuAutoLogin()
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval)
console.error('❌ 等待 tt 对象超时')
console.error('❌ 请检查飞书开放平台配置:')
console.error(' 1. H5 可信域名配置是否正确')
console.error(' 2. 确认应用已发布')
console.error(' 3. 尝试从飞书工作台重新进入应用')
console.error(' 4. 检查是否正确加载了 H5 JSSDK')
setLoading(false)
}
}, 100)
return
}
if (authCode) {
// 有授权码,直接处理登录
console.log('✅ Found auth_code in URL:', authCode.substring(0, 20) + '...')
handleFeishuLogin(authCode)
} else if (isInFeishu && window.tt) {
// 飞书内且有 tt 对象:静默授权
console.log('✅ 飞书内环境,tt 对象可用,开始免登')
setEnvironment('feishu')
handleFeishuAutoLogin()
} else if (isInFeishu) {
// 飞书内环境,tt 对象由飞书客户端自动注入
// 使用轮询等待 tt 对象可用(最多 10 秒)
console.log('⏳ 在飞书内环境,等待飞书客户端注入 tt 对象...')
setEnvironment('feishu')
let attempts = 0
const maxAttempts = 100 // 100 * 100ms = 10秒
const checkInterval = setInterval(() => {
attempts++
console.log(`🔍 检查 tt 对象 (${attempts}/${maxAttempts}):`, !!window.tt, typeof window.tt)
if (window.tt && typeof window.tt === 'object') {
clearInterval(checkInterval)
console.log('✅ tt 对象已由飞书客户端注入,开始免登')
handleFeishuAutoLogin()
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval)
console.error('❌ 等待 tt 对象超时,最后状态 window.tt:', window.tt, typeof window.tt)
console.error('❌ 请检查:1) 飞书开放平台是否正确配置 H5 应用地址 2) 页面是否从飞书应用入口进入 3) 应用是否已发布')
// 免登失败,显示登录界面(飞书内显示提示)
setLoading(false)
setEnvironment('feishu')
// 不显示二维码,而是显示手动登录选项
}
}, 100)
} else {
// 飞书外:显示登录选项
console.log('📱 飞书外环境,显示登录选项')
setEnvironment('external')
setLoading(false)
}
}, [])
// 飞书内静默授权
const handleFeishuAutoLogin = () => {
console.log('🚀 开始飞书免登')
// 从 URL 获取 company 参数(可能来自当前 URL 或 redirect URL)
const urlParams = new URLSearchParams(window.location.search)
let companyParam = urlParams.get('company')
console.log('🔍 URL 中的 company 参数:', companyParam)
// 如果当前 URL 没有 company,尝试从 redirect 参数中获取
if (!companyParam) {
const redirect = urlParams.get('redirect')
if (redirect) {
const redirectUrl = new URL(redirect, window.location.origin)
companyParam = redirectUrl.searchParams.get('company')
console.log('🔍 从 redirect URL 中提取的 company 参数:', companyParam)
}
}
console.log('🔍 window.tt:', window.tt)
console.log('🔍 window.h5sdk:', window.h5sdk)
console.log('🔍 tt 的属性:', Object.keys(window.tt || {}))
// 如果 tt 对象已存在,直接使用(无需等待 h5sdk.ready)
if (window.tt) {
console.log('✅ tt 对象已存在,直接调用免登')
performAuthCodeRequest()
return
}
// 如果 tt 不存在但 h5sdk 存在,使用 h5sdk.ready() 等待
if (window.h5sdk && typeof window.h5sdk.ready === 'function') {
console.log('✅ 检测到 h5sdk,使用 h5sdk.ready() 等待 tt 对象')
setEnvironment('feishu')
let readyCalled = false
const timeoutId = setTimeout(() => {
if (!readyCalled) {
console.error('❌ h5sdk.ready() 超时(5秒)')
setLoading(false)
Toast.show('飞书环境初始化超时,请刷新重试')
}
}, 5000)
window.h5sdk.ready(() => {
readyCalled = true
clearTimeout(timeoutId)
console.log('✅ h5sdk.ready() 回调触发,检查 tt 对象:', !!window.tt)
if (window.tt) {
console.log('✅ tt 对象已注入,开始免登')
performAuthCodeRequest()
} else {
console.error('❌ h5sdk.ready() 后 tt 对象仍不存在')
setLoading(false)
Toast.show('飞书环境异常,请刷新重试')
}
})
// 设置错误处理
if (window.h5sdk.error) {
window.h5sdk.error((error: any) => {
console.error('❌ h5sdk error:', error)
setLoading(false)
Toast.show('飞书环境异常:' + JSON.stringify(error))
})
}
return
}
// 都不存在,报错
console.error('❌ tt 对象和 h5sdk 都不存在')
setLoading(false)
Toast.show('飞书环境异常,请刷新重试')
}
// 执行实际的 requestAuthCode 调用
const performAuthCodeRequest = async () => {
console.log('🔧 准备调用 tt.requestAuthCode')
console.log('🔍 当前 window.tt:', window.tt)
console.log('🔍 tt 的属性:', Object.keys(window.tt || {}))
if (!window.tt) {
console.error('❌ tt 对象不存在')
setLoading(false)
Toast.show('飞书环境异常(tt 对象不存在),请刷新重试')
return
}
// 获取 appId:优先从 URL 参数,否则从后端获取
const urlParams = new URLSearchParams(window.location.search)
let appId = urlParams.get('app_id')
let companyParam = urlParams.get('company')
console.log('🔍 URL 参数 - app_id:', appId, 'company:', companyParam)
// 如果没有 appId 但有 company,从后端获取
if (!appId && companyParam) {
try {
console.log('📡 从后端获取 appId...')
const response = await fetch(`/api/v1/auth/feishu-config?company=${companyParam}`)
const result = await response.json()
if (result.code === 0 && result.data.app_id) {
appId = result.data.app_id
console.log('✅ 从后端获取到 appId:', appId?.substring(0, 15) + '...')
} else {
console.error('❌ 获取 appId 失败:', result.message)
setLoading(false)
Toast.show('获取飞书配置失败:' + (result.message || '未知错误'))
return
}
} catch (e) {
console.error('❌ 获取 appId 异常:', e)
setLoading(false)
Toast.show('获取飞书配置失败,请重试')
return
}
}
if (!appId) {
console.error('❌ 无法获取 appId')
setLoading(false)
Toast.show('缺少飞书应用配置')
return
}
// 调用 tt.requestAuthCode 获取授权码
try {
console.log('🔧 调用 tt.requestAuthCode (appId:', appId?.substring(0, 15) + '...)')
window.tt.requestAuthCode({
appId: appId,
success: (res: any) => {
console.log('✅ 成功获取授权码:', res.code?.substring(0, 30))
handleFeishuLogin(res.code)
},
fail: (error: any) => {
console.error('❌ requestAuthCode 失败:', error)
setLoading(false)
Toast.show('免登失败:' + (error.errorMessage || error.errMsg || JSON.stringify(error)))
},
})
} catch (e) {
console.error('❌ 调用异常:', e)
setLoading(false)
Toast.show('免登失败,请重试')
}
}
// 跳转到飞书授权页面(飞书外扫码登录)
const redirectToFeishu = () => {
// 直接打开扫码登录弹窗
setShowQRCode(true)
}
// 处理飞书回调(飞书外扫码后回调)
const handleFeishuCallback = async (authCode: string) => {
try {
await handleFeishuLogin(authCode)
} catch (error) {
console.error('登录失败:', error)
setLoading(false)
}
}
// 处理扫码登录回调
const handleQrcodeCallback = async (qrcodeId: string) => {
try {
// 飞书扫码后会带着 qrcode_id 参数跳转回来
// 这个时候前端需要显示 loading 状态
// 后端会处理 qrcodeLoginCallback,更新 session 状态
// 前端只需要轮询状态即可
setShowQRCode(true)
setLoading(false)
// 设置轮询 session_id
// 实际上这个逻辑在 QRCodeLogin 组件中处理
} catch (error) {
console.error('处理扫码回调失败:', error)
setLoading(false)
}
}
// 处理飞书登录
const handleFeishuLogin = async (authCode: string) => {
try {
// 从 URL 获取参数
const urlParams = new URLSearchParams(window.location.search)
let companyParam = urlParams.get('company')
const urlAppId = urlParams.get('app_id')
// 如果当前 URL 没有 company,尝试从 redirect 参数中获取
if (!companyParam) {
const redirect = urlParams.get('redirect')
if (redirect) {
try {
const redirectUrl = new URL(redirect, window.location.origin)
companyParam = redirectUrl.searchParams.get('company')
console.log('🔍 从 redirect URL 中提取的 company 参数:', companyParam)
} catch (e) {
console.log('⚠️ 无法解析 redirect URL:', redirect)
}
}
}
console.log('🔍 URL 中的参数:')
console.log(' - app_id:', urlAppId)
console.log(' - company:', companyParam)
const response = await fetch('/api/v1/auth/feishu-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auth_code: authCode,
app_id: urlAppId || undefined, // 如果 URL 有 app_id 就传递
company: companyParam || undefined, // 传递 company 参数
}),
})
const result = await response.json()
if (result.code === 0) {
login(result.data.user, result.data.token)
Toast.show('登录成功')
// 检查是否有 redirect 参数
const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect')
if (redirect) {
console.log('🔗 登录成功,跳转到:', redirect)
// redirect 参数可能包含 ?company=xxx,需要直接跳转
window.location.href = redirect
} else {
navigate('/dashboard')
}
} else {
Toast.show(result.message || '登录失败')
setLoading(false)
}
} catch (error: any) {
console.error('登录失败:', error)
Toast.show('登录失败,请重试')
setLoading(false)
}
}
if (loading) {
return (
<div className={styles.login}>
<div className={styles.container}>
<h1>企业工具台</h1>
<p>正在登录...</p>
<div className={styles.spinner}></div>
{/* 调试信息 */}
<div style={{
marginTop: '20px',
padding: '10px',
background: 'rgba(0,0,0,0.2)',
borderRadius: '8px',
fontSize: '12px',
textAlign: 'left',
fontFamily: 'monospace',
maxWidth: '100%',
overflow: 'auto'
}}>
<div style={{ marginBottom: '5px', color: '#aaa' }}>调试信息:</div>
<div style={{ marginBottom: '3px' }}>URL: {window.location.href.substring(0, 60)}...</div>
<div style={{ marginBottom: '3px' }}>UA: {navigator.userAgent.includes('Lark') ? '✅ 飞书' : '❌ 非飞书'}</div>
<div style={{ marginBottom: '3px' }}>tt: {window.tt ? '✅ 存在' : '❌ 不存在'}</div>
{window.tt && (
<div style={{ marginBottom: '3px', wordBreak: 'break-all' }}>
tt属性: {Object.keys(window.tt).join(', ')}
</div>
)}
</div>
</div>
</div>
)
}
// 邮箱密码登录
const handlePasswordLogin = () => {
navigate('/login-simple')
}
if (loading) {
return (
<div className={styles.login}>
<div className={styles.container}>
<h1>企业工具台</h1>
<p>正在登录...</p>
<div className={styles.spinner}></div>
{/* 调试信息 */}
<div style={{
marginTop: '20px',
padding: '10px',
background: 'rgba(0,0,0,0.2)',
borderRadius: '8px',
fontSize: '12px',
textAlign: 'left',
fontFamily: 'monospace',
maxWidth: '100%',
overflow: 'auto'
}}>
<div style={{ marginBottom: '5px', color: '#aaa' }}>调试信息:</div>
<div style={{ marginBottom: '3px' }}>URL: {window.location.href.substring(0, 60)}...</div>
<div style={{ marginBottom: '3px' }}>UA: {navigator.userAgent.includes('Lark') ? '✅ 飞书' : '❌ 非飞书'}</div>
<div style={{ marginBottom: '3px' }}>tt: {window.tt ? '✅ 存在' : '❌ 不存在'}</div>
{window.tt && (
<div style={{ marginBottom: '3px', wordBreak: 'break-all' }}>
tt属性: {Object.keys(window.tt).join(', ')}
</div>
)}
</div>
</div>
</div>
)
}
return (
<div className={styles.login}>
<div className={styles.container}>
<h1>企业工具台</h1>
{/* 飞书内免登失败的提示 */}
{environment === 'feishu' && (
<div style={{
background: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '8px',
padding: '12px',
marginBottom: '20px',
fontSize: '14px',
color: '#856404',
textAlign: 'center'
}}>
<div style={{ marginBottom: '5px', fontWeight: 'bold' }}>⚠️ 飞书免登失败</div>
<div style={{ fontSize: '12px' }}>
可能原因:1) 应用配置未生效 2) 需要重新从工作台进入
</div>
</div>
)}
<p className={styles.subtitle}>请选择登录方式</p>
<div className={styles.loginMethods}>
<button className={styles.methodBtn} onClick={handlePasswordLogin}>
<div className={styles.methodIcon}>📧</div>
<div className={styles.methodText}>
<div className={styles.methodTitle}>邮箱密码登录</div>
<div className={styles.methodDesc}>使用企业邮箱登录</div>
</div>
</button>
{environment !== 'feishu' && (
<button className={styles.methodBtn} onClick={redirectToFeishu}>
<div className={styles.methodIcon}>📱</div>
<div className={styles.methodText}>
<div className={styles.methodTitle}>飞书扫码登录</div>
<div className={styles.methodDesc}>使用飞书 APP 扫码登录</div>
</div>
</button>
)}
</div>
{environment === 'external' && (
<p className={styles.tip}>
💡 提示:在飞书客户端内打开可自动登录
</p>
)}
<QRCodeLogin
visible={showQRCode}
onClose={() => setShowQRCode(false)}
/>
</div>
</div>
)
}