4667590feb
后端改动: - 新增 /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>
488 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|