Files
x-server-mobile/frontend/src/pages/feishu-env-test.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

488 lines
17 KiB
TypeScript

import { useEffect, useState, useRef } from 'react'
import { Card } from 'antd-mobile'
import styles from './feishu-env-test.module.css'
// 声明飞书 JSAPI 类型
declare global {
interface Window {
tt?: any
ttjsBridge?: any
h5sdk?: {
ready: (callback: () => void) => void
config: (config: any) => void
error?: (handler: (error: any) => void) => void
}
bridge?: any
_tt?: any
Lark?: any
Feishu?: any
}
}
interface EnvInfo {
currentUrl: string
origin: string
pathname: string
searchParams: Record<string, string>
userAgent: string
isInFeishu: boolean
ttExists: boolean
ttType: string
ttMethods: string[]
globalObjects: Record<string, string>
}
interface LogEntry {
time: string
message: string
type: 'info' | 'success' | 'error' | 'warning'
}
export default function FeishuEnvTest() {
const [envInfo, setEnvInfo] = useState<EnvInfo | null>(null)
const [ttReady, setTtReady] = useState(false)
const [authCode, setAuthCode] = useState<string>('')
const [testResult, setTestResult] = useState<string>('')
const [checkingCount, setCheckingCount] = useState(0)
const [logs, setLogs] = useState<LogEntry[]>([])
const [appId, setAppId] = useState<string>('')
const pollingRef = useRef<NodeJS.Timeout | null>(null)
// 添加日志
const addLog = (message: string, type: 'info' | 'success' | 'error' | 'warning' = 'info') => {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}.${now.getMilliseconds().toString().padStart(3, '0')}`
setLogs(prev => [...prev, { time, message, type }])
}
useEffect(() => {
addLog('=== 页面加载完成 ===', 'info')
checkEnvironment()
// 移除自动轮询,避免不停地检查
}, [])
const checkEnvironment = () => {
addLog('🔍 开始检查环境...', 'info')
const ua = navigator.userAgent
const url = new URL(window.location.href)
const searchParams: Record<string, string> = {}
url.searchParams.forEach((value, key) => {
searchParams[key] = value
})
// 检查 UA
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
addLog(`📱 User Agent 检测: ${isInFeishu ? '✅ 飞书环境' : '❌ 非飞书环境'}`, isInFeishu ? 'success' : 'warning')
// 检查 URL
addLog(`🔗 当前 URL: ${window.location.href}`, 'info')
addLog(`🔗 域名: ${window.location.origin}`, 'info')
addLog(`🔗 路径: ${window.location.pathname}`, 'info')
// 检查参数
if (Object.keys(searchParams).length > 0) {
addLog(`📋 URL 参数: ${JSON.stringify(searchParams)}`, 'info')
}
// 检查所有可能的飞书相关全局对象
addLog('🔍 检查全局对象...', 'info')
const globalObjects: Record<string, string> = {}
const keysToCheck = ['tt', 'ttjsBridge', 'h5sdk', 'bridge', '_tt', 'Lark', 'Feishu']
keysToCheck.forEach(key => {
if ((window as any)[key] !== undefined) {
globalObjects[key] = typeof (window as any)[key]
addLog(` ✓ window.${key} = ${typeof (window as any)[key]}`, 'success')
} else {
addLog(` ✗ window.${key} = undefined`, 'info')
}
})
// 检查 tt 对象
let ttType = 'undefined'
let ttMethods: string[] = []
if (window.tt) {
ttType = typeof window.tt
addLog(`✅ tt 对象存在!类型: ${ttType}`, 'success')
try {
ttMethods = Object.keys(window.tt).filter(k => typeof window.tt[k] === 'function')
addLog(` 可用方法 (${ttMethods.length} 个): ${ttMethods.slice(0, 5).join(', ')}${ttMethods.length > 5 ? '...' : ''}`, 'info')
} catch (e) {
addLog(` 无法获取方法列表: ${(e as Error).message}`, 'warning')
}
} else {
addLog('❌ tt 对象不存在', 'error')
}
// 检查 h5sdk 对象
if (window.h5sdk) {
addLog(`✅ h5sdk 对象存在!类型: ${typeof window.h5sdk}`, 'success')
try {
const h5sdkMethods = Object.keys(window.h5sdk).filter(k => typeof window.h5sdk[k] === 'function')
addLog(` h5sdk 可用方法 (${h5sdkMethods.length} 个): ${h5sdkMethods.join(', ')}`, 'info')
addLog(` has ready(): ${typeof window.h5sdk.ready === 'function' ? '✅ 是' : '❌ 否'}`, 'info')
addLog(` has config(): ${typeof window.h5sdk.config === 'function' ? '✅ 是' : '❌ 否'}`, 'info')
addLog(` has error(): ${typeof window.h5sdk.error === 'function' ? '✅ 是' : '❌ 否'}`, 'info')
} catch (e) {
addLog(` 无法获取 h5sdk 方法列表: ${(e as Error).message}`, 'warning')
}
} else {
addLog('❌ h5sdk 对象不存在', 'warning')
}
setEnvInfo({
currentUrl: window.location.href,
origin: window.location.origin,
pathname: window.location.pathname,
searchParams,
userAgent: ua,
isInFeishu,
ttExists: !!window.tt,
ttType,
ttMethods,
globalObjects,
})
setCheckingCount(prev => prev + 1)
addLog(`✅ 环境检查完成 (第 ${checkingCount + 1} 次)`, 'info')
}
const testAuthCode = () => {
addLog('🚀 开始测试免登...', 'info')
if (!window.tt) {
addLog('❌ tt 对象不存在,无法测试免登', 'error')
setTestResult('❌ tt 对象不存在,无法测试')
return
}
if (!appId) {
addLog('❌ 请先输入 appId', 'error')
setTestResult('❌ 请先输入 appId')
return
}
addLog(`📞 调用 tt.requestAuthCode (appId: ${appId.substring(0, 10)}...)`, 'info')
setTestResult('正在调用 tt.requestAuthCode...')
try {
window.tt.requestAuthCode({
appId: appId,
success: (res: any) => {
addLog(`✅ requestAuthCode 成功!`, 'success')
addLog(` 授权码: ${res.code?.substring(0, 30)}...`, 'info')
addLog(` 完整响应: ${JSON.stringify(res)}`, 'info')
setAuthCode(res.code || '无授权码')
setTestResult('✅ 成功获取授权码: ' + (res.code?.substring(0, 20) || '无') + '...')
},
fail: (err: any) => {
addLog(`❌ requestAuthCode 失败: ${JSON.stringify(err)}`, 'error')
setTestResult('❌ 获取授权码失败: ' + JSON.stringify(err))
},
})
} catch (e: any) {
addLog(`❌ 调用异常: ${e.message}`, 'error')
setTestResult('❌ 调用异常: ' + e.message)
}
}
// 测试 h5sdk.ready()
const testH5sdkReady = () => {
addLog('🚀 开始测试 h5sdk.ready()...', 'info')
if (!window.h5sdk) {
addLog('❌ h5sdk 对象不存在,无法测试', 'error')
setTestResult('❌ h5sdk 对象不存在')
return
}
if (typeof window.h5sdk.ready !== 'function') {
addLog('❌ h5sdk.ready 不是函数', 'error')
setTestResult('❌ h5sdk.ready 不可用')
return
}
addLog('📞 调用 h5sdk.ready()...', 'info')
setTestResult('正在调用 h5sdk.ready()...')
const timeoutId = setTimeout(() => {
addLog('⚠️ h5sdk.ready() 5秒内未触发回调', 'warning')
setTestResult('⚠️ h5sdk.ready() 超时')
}, 5000)
try {
window.h5sdk.ready(() => {
clearTimeout(timeoutId)
addLog('✅ h5sdk.ready() 回调已触发!', 'success')
addLog(` tt 对象状态: ${window.tt ? '✅ 存在' : '❌ 不存在'}`, window.tt ? 'success' : 'error')
if (window.tt) {
const ttMethods = Object.keys(window.tt).filter(k => typeof window.tt[k] === 'function')
addLog(` tt 可用方法 (${ttMethods.length} 个): ${ttMethods.slice(0, 5).join(', ')}${ttMethods.length > 5 ? '...' : ''}`, 'info')
}
setTestResult('✅ h5sdk.ready() 成功,tt 对象' + (window.tt ? '已注入' : '仍未注入'))
setTtReady(!!window.tt)
checkEnvironment()
})
// 设置错误处理
if (window.h5sdk.error) {
window.h5sdk.error((error: any) => {
clearTimeout(timeoutId)
addLog(`❌ h5sdk error: ${JSON.stringify(error)}`, 'error')
setTestResult('❌ h5sdk 报错: ' + JSON.stringify(error))
})
}
} catch (e: any) {
clearTimeout(timeoutId)
addLog(`❌ 调用异常: ${e.message}`, 'error')
setTestResult('❌ 调用异常: ' + e.message)
}
}
// 开始轮询检查 tt 对象
const startPolling = () => {
if (pollingRef.current) {
addLog('⚠️ 轮询已在运行中', 'warning')
return
}
addLog('⏱️ 开始轮询检查 tt 对象(最多 10 秒)...', 'info')
setTestResult('开始轮询检查 tt 对象(最多 10 秒)...')
let attempts = 0
const maxAttempts = 100
pollingRef.current = setInterval(() => {
attempts++
addLog(` 🔍 第 ${attempts} 次检查 tt 对象...`, 'info')
if (window.tt) {
addLog(`✅ tt 对象已注入!(第 ${attempts} 次检查)`, 'success')
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
checkEnvironment()
setTestResult(`✅ tt 对象已注入(第 ${attempts} 次检查)`)
setTtReady(true)
} else if (attempts >= maxAttempts) {
addLog(`❌ 超时:tt 对象仍未注入(已检查 ${attempts} 次)`, 'error')
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
setTestResult('❌ 超时:tt 对象仍未注入')
}
}, 100)
}
// 停止轮询
const stopPolling = () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
addLog('⏹️ 已停止轮询', 'info')
}
}
// 清空日志
const clearLogs = () => {
setLogs([])
addLog('📝 日志已清空', 'info')
}
if (!envInfo) {
return <div className={styles.page}>加载中...</div>
}
return (
<div className={styles.page}>
<div className={styles.container}>
<h1>🔍 飞书环境检测</h1>
<p className={styles.subtitle}>用于诊断飞书免登问题</p>
{/* 操作按钮 */}
<Card title="操作" className={styles.card}>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
飞书 AppId(必填):
</label>
<input
type="text"
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="例如: cli_xxxxxxxxxxxxx"
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d9d9d9',
borderRadius: '6px',
fontSize: '14px',
boxSizing: 'border-box'
}}
/>
</div>
<div className={styles.buttons}>
<button onClick={checkEnvironment} className={styles.btn}>
🔄 刷新检测
</button>
<button onClick={startPolling} className={styles.btn}>
⏱️ 开始轮询
</button>
<button onClick={stopPolling} className={styles.btn}>
⏹️ 停止轮询
</button>
<button onClick={testAuthCode} className={styles.btnPrimary}>
🚀 测试免登
</button>
<button onClick={testH5sdkReady} className={styles.btnPrimary}>
🔧 测试 h5sdk.ready
</button>
<button onClick={clearLogs} className={styles.btn}>
🗑️ 清空日志
</button>
</div>
<div className={styles.checkInfo}>
已检测次数: {checkingCount} | 轮询状态: {pollingRef.current ? '运行中' : '已停止'}
</div>
</Card>
{/* 实时日志 */}
<Card title="📋 实时日志" className={styles.card}>
<div className={styles.logContainer}>
{logs.length === 0 ? (
<div className={styles.noLogs}>暂无日志</div>
) : (
logs.map((log, index) => (
<div key={index} className={`${styles.logEntry} ${styles[log.type]}`}>
<span className={styles.logTime}>[{log.time}]</span>
<span className={styles.logMessage}>{log.message}</span>
</div>
))
)}
</div>
</Card>
{/* tt 对象状态 */}
<Card title="tt 对象状态" className={styles.card}>
{envInfo.ttExists ? (
<div className={styles.success}>
✅ tt 对象存在
<div className={styles.detail}>类型: {envInfo.ttType}</div>
{envInfo.ttMethods.length > 0 && (
<div className={styles.detail}>
可用方法: {envInfo.ttMethods.slice(0, 10).join(', ')}
{envInfo.ttMethods.length > 10 && '...'}
</div>
)}
</div>
) : (
<div className={styles.error}>
❌ tt 对象不存在
<div className={styles.tip}>
如果在飞书客户端中打开,请尝试"开始轮询"按钮
</div>
</div>
)}
</Card>
{/* 环境检测 */}
<Card title="环境信息" className={styles.card}>
<div className={styles.infoRow}>
<span className={styles.label}>当前 URL:</span>
<span className={styles.value}>{envInfo.currentUrl}</span>
</div>
<div className={styles.infoRow}>
<span className={styles.label}>域名:</span>
<span className={styles.value}>{envInfo.origin}</span>
</div>
<div className={styles.infoRow}>
<span className={styles.label}>路径:</span>
<span className={styles.value}>{envInfo.pathname}</span>
</div>
<div className={styles.infoRow}>
<span className={styles.label}>在飞书中:</span>
<span className={envInfo.isInFeishu ? styles.success : styles.warning}>
{envInfo.isInFeishu ? '✅ 是' : '❌ 否'}
</span>
</div>
<div className={styles.infoRow}>
<span className={styles.label}>URL 参数:</span>
<span className={styles.value}>
{Object.keys(envInfo.searchParams).length > 0
? JSON.stringify(envInfo.searchParams)
: '(无)'}
</span>
</div>
</Card>
{/* User Agent */}
<Card title="User Agent" className={styles.card}>
<div className={styles.userAgent}>{envInfo.userAgent}</div>
</Card>
{/* 全局对象 */}
<Card title="飞书相关全局对象" className={styles.card}>
{Object.keys(envInfo.globalObjects).length > 0 ? (
<div className={styles.objectsList}>
{Object.entries(envInfo.globalObjects).map(([key, type]) => (
<div key={key} className={styles.objectItem}>
<span className={styles.objectName}>window.{key}</span>
<span className={styles.objectType}>{type}</span>
</div>
))}
</div>
) : (
<div className={styles.warning}>⚠️ 未检测到飞书相关全局对象</div>
)}
</Card>
{/* 免登测试结果 */}
{testResult && (
<Card title="测试结果" className={styles.card}>
<div className={testResult.includes('✅') ? styles.success : styles.error}>
{testResult}
</div>
</Card>
)}
{/* 授权码 */}
{authCode && (
<Card title="获取的授权码" className={styles.card}>
<div className={styles.authCode}>{authCode}</div>
</Card>
)}
{/* 诊断建议 */}
<Card title="💡 诊断建议" className={styles.card}>
<div className={styles.suggestions}>
<div className={styles.suggestionItem}>
<strong>如果 tt 对象不存在:</strong>
<ul>
<li>确认从飞书工作台应用图标进入,而非直接打开链接</li>
<li>确认飞书开放平台 H5 应用地址配置正确</li>
<li>尝试点击"开始轮询"按钮等待 tt 对象注入</li>
</ul>
</div>
<div className={styles.suggestionItem}>
<strong>如果 UA 检测不是飞书:</strong>
<ul>
<li>可能飞书客户端的 UA 已更新</li>
<li>检查 User Agent 中是否包含 "Lark" 或 "feishu" 关键词</li>
</ul>
</div>
<div className={styles.suggestionItem}>
<strong>如果免登测试失败:</strong>
<ul>
<li>检查飞书应用是否获得必要权限</li>
<li>检查后端飞书配置是否正确</li>
<li>查看浏览器控制台详细错误日志</li>
</ul>
</div>
</div>
</Card>
</div>
</div>
)
}