Files
x-server-mobile/frontend/src/pages/login/index.tsx
T
root 3ad8f34914 feat: 添加飞书免登自动登录功能
修改内容:
1. approval/detail.tsx - 在加载详情前检查登录状态,如果在飞书内则自动免登
2. login/index.tsx - 改进飞书环境检测和tt对象注入逻辑

业务流程:
1. 用户在飞书中点击消息按钮
2. 打开审批详情页 → 检测未登录
3. 如果在飞书客户端内 → 自动调用 tt.requestAuthCode
4. 免登成功 → 自动加载详情
5. 免登失败 → 跳转到登录页面

相关文件:
- frontend/src/pages/approval/detail.tsx
- frontend/src/pages/login/index.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:14:19 +08:00

346 lines
12 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, useRef } 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
// 其他可能的飞书 API
h5sdk?: any
bridge?: any
_tt?: any
}
}
export default function Login() {
const navigate = useNavigate()
const { login } = useUserStore()
const [loading, setLoading] = useState(true)
const [environment, setEnvironment] = useState<'feishu' | 'external' | null>(null)
const [showQRCode, setShowQRCode] = useState(false)
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)
}
}, [])
useEffect(() => {
// 打印详细的环境信息
const ua = navigator.userAgent
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
const currentUrl = window.location.href
console.log('=== 环境检测 ===')
console.log('当前 URL:', currentUrl)
console.log('User Agent:', ua)
console.log('在飞书中:', isInFeishu)
console.log('window.tt:', window.tt)
// 检查 URL 中是否来自飞书消息通知
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('auth_code')
const fromFeishu = urlParams.get('from') === 'feishu'
// 关键修复:在飞书中但 tt 对象不存在时,刷新页面让飞书注入 tt 对象
// 增加多次重试机制
if (isInFeishu && !window.tt) {
const reloadCount = parseInt(urlParams.get('reload') || '0')
if (reloadCount < 3) {
console.log(`⚠️ 在飞书中但 tt 对象不存在,第 ${reloadCount + 1} 次刷新页面`)
const separator = currentUrl.indexOf('?') > 0 ? '&' : '?'
// 清除旧的 reload 参数并添加新的
const newUrl = currentUrl.split('?')[0] + '?' + urlParams.toString().replace(/reload=\d+/g, '').replace(/^&+/, '')
window.location.href = newUrl + (newUrl.includes('?') ? '&' : '?') + `reload=${reloadCount + 1}`
return
} else {
console.log('❌ 多次刷新后 tt 对象仍不存在,放弃自动登录')
}
}
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 对象不可用:提示用户
console.log('⚠️ 在飞书内但 tt 对象不可用')
setEnvironment('feishu')
setLoading(false)
Toast.show('请在飞书客户端中打开,或使用扫码登录')
} else {
// 飞书外:显示登录选项
console.log('📱 飞书外环境,显示登录选项')
setEnvironment('external')
setLoading(false)
}
}, [])
// 飞书内静默授权
const handleFeishuAutoLogin = () => {
console.log('🚀 开始飞书免登')
console.log('🔍 window.tt:', window.tt)
console.log('🔍 window.tt 类型:', typeof window.tt)
const appId = 'cli_a8033d607cba500d'
// 详细检查 tt 对象
if (window.tt) {
console.log('✅ tt 对象存在')
console.log('🔍 tt 的所有属性:', Object.keys(window.tt))
console.log('🔍 tt.requestAuthCode 类型:', typeof window.tt.requestAuthCode)
// 检查 requestAuthCode 是否存在
if ('requestAuthCode' in window.tt) {
console.log('✅ requestAuthCode 属性存在')
console.log('🔍 类型:', typeof window.tt.requestAuthCode)
} else {
console.log('❌ requestAuthCode 属性不存在')
}
} else {
console.log('❌ tt 对象不存在')
setLoading(false)
Toast.show('飞书 API 未加载,请刷新重试')
return
}
// 等待一小段时间,确保 tt 完全初始化
setTimeout(() => {
console.log('⏰ 延迟后再次检查 tt:', !!window.tt)
if (!window.tt) {
setLoading(false)
Toast.show('飞书 API 加载失败')
return
}
// 尝试调用 requestAuthCode
try {
console.log('🔧 调用 tt.requestAuthCode,appId:', appId)
window.tt.requestAuthCode({
appId: appId,
success: (res: any) => {
console.log('✅ 成功获取授权码:', res)
console.log('✅ code:', res.code?.substring(0, 30))
handleFeishuLogin(res.code)
},
fail: (error: any) => {
console.error('❌ requestAuthCode 失败:', error)
console.error('❌ 错误详情:', JSON.stringify(error))
setLoading(false)
Toast.show('免登失败:' + (error.errorMessage || error.errMsg || JSON.stringify(error)))
},
})
} catch (e) {
console.error('❌ 调用异常:', e)
setLoading(false)
Toast.show('免登失败,请使用其他方式登录')
}
}, 500)
}
// 跳转到飞书授权页面(飞书外扫码登录)
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 获取 app_id 参数
const urlParams = new URLSearchParams(window.location.search)
const urlAppId = urlParams.get('app_id')
console.log('🔍 URL 中的 app_id:', urlAppId)
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 就传递
}),
})
const result = await response.json()
if (result.code === 0) {
login(result.data.user, result.data.token)
Toast.show('登录成功')
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>
<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>
<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>
)
}