From 4667590feb10790c51e89f306d29fb7547ee564a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 28 Feb 2026 08:58:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E5=85=8D=E7=99=BB=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20H5=20JSSDK=20=E5=92=8C=20appId=20=E8=87=AA=E5=8A=A8=E8=8E=B7?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端改动: - 新增 /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 --- backend/app/controller/AuthController.php | 43 ++ backend/config/route.php | 1 + frontend/index.html | 5 +- frontend/src/pages/feishu-env-test.module.css | 270 ++++++++++ frontend/src/pages/feishu-env-test.tsx | 487 ++++++++++++++++++ frontend/src/pages/login/index.tsx | 262 ++++++++-- frontend/src/router/index.tsx | 2 + 7 files changed, 1035 insertions(+), 35 deletions(-) create mode 100644 frontend/src/pages/feishu-env-test.module.css create mode 100644 frontend/src/pages/feishu-env-test.tsx diff --git a/backend/app/controller/AuthController.php b/backend/app/controller/AuthController.php index 7e4b89d..79b6f7f 100644 --- a/backend/app/controller/AuthController.php +++ b/backend/app/controller/AuthController.php @@ -560,4 +560,47 @@ HTML; return $this->error($e->getMessage()); } } + + /** + * 获取飞书配置(用于前端免登) + * 根据 company 参数返回对应的 appId + */ + public function feishuConfig(Request $request): Response + { + $companyParam = $request->get('company'); + + if (!$companyParam) { + return $this->error('缺少 company 参数'); + } + + try { + // company 参数是 company_id + $company = \app\model\Company::find($companyParam); + if (!$company) { + return $this->error('公司不存在'); + } + + $companyId = $company->id; + + // 获取该公司的默认飞书应用 + $feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId) + ->where('system_id', 1) + ->where('status', 1) + ->first(); + + if (!$feishuApp) { + return $this->error('该公司未配置飞书应用'); + } + + error_log("✅ feishuConfig - company_id={$companyId}, app_id={$feishuApp->app_id}"); + + return $this->success([ + 'app_id' => $feishuApp->app_id, + 'company_id' => $companyId, + ]); + } catch (\Exception $e) { + error_log("❌ feishuConfig 失败: " . $e->getMessage()); + return $this->error($e->getMessage()); + } + } } diff --git a/backend/config/route.php b/backend/config/route.php index ac648d2..593397c 100644 --- a/backend/config/route.php +++ b/backend/config/route.php @@ -12,6 +12,7 @@ Route::group('/api/v1', function () { Route::post('/auth/feishu-login', [app\controller\AuthController::class, 'feishuLogin']); Route::post('/auth/qrcode-login', [app\controller\AuthController::class, 'qrcodeLogin']); Route::get('/auth/qrcode-login/status', [app\controller\AuthController::class, 'qrcodeLoginStatus']); + Route::get('/auth/feishu-config', [app\controller\AuthController::class, 'feishuConfig']); // 测试接口 Route::get('/test/notification', [app\controller\TestController::class, 'notification']); diff --git a/frontend/index.html b/frontend/index.html index 56e00a4..96e9f3e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,10 +4,11 @@ - frontend + 企业工具台 - + +
diff --git a/frontend/src/pages/feishu-env-test.module.css b/frontend/src/pages/feishu-env-test.module.css new file mode 100644 index 0000000..b8a1a96 --- /dev/null +++ b/frontend/src/pages/feishu-env-test.module.css @@ -0,0 +1,270 @@ +.page { + min-height: 100vh; + background: #f5f5f5; + padding: 20px; +} + +.container { + max-width: 800px; + margin: 0 auto; +} + +h1 { + text-align: center; + margin-bottom: 5px; + color: #333; +} + +.subtitle { + text-align: center; + color: #666; + margin-bottom: 20px; + font-size: 14px; +} + +.card { + margin-bottom: 16px; + border-radius: 8px; +} + +.buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.btn { + flex: 1; + min-width: 100px; + padding: 10px 16px; + border: 1px solid #d9d9d9; + border-radius: 6px; + background: white; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.btn:hover { + border-color: #1677ff; + color: #1677ff; +} + +.btnPrimary { + flex: 1; + min-width: 100px; + padding: 10px 16px; + border: none; + border-radius: 6px; + background: #1677ff; + color: white; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.btnPrimary:hover { + background: #4096ff; +} + +.checkInfo { + margin-top: 10px; + font-size: 12px; + color: #666; + text-align: center; +} + +.success { + color: #52c41a; +} + +.error { + color: #ff4d4f; +} + +.warning { + color: #faad14; +} + +.detail { + margin-top: 8px; + font-size: 13px; + color: #666; +} + +.tip { + margin-top: 8px; + padding: 8px 12px; + background: #fffbe6; + border-radius: 4px; + font-size: 12px; + color: #856404; +} + +.infoRow { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; +} + +.infoRow:last-child { + border-bottom: none; +} + +.label { + font-weight: 500; + color: #333; + flex-shrink: 0; + margin-right: 10px; +} + +.value { + color: #666; + word-break: break-all; + text-align: right; + flex: 1; +} + +.userAgent { + padding: 10px; + background: #f5f5f5; + border-radius: 4px; + font-size: 12px; + word-break: break-all; + font-family: monospace; + color: #666; +} + +.objectsList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.objectItem { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: #f5f5f5; + border-radius: 4px; + font-size: 13px; +} + +.objectName { + font-family: monospace; + color: #333; + font-weight: 500; +} + +.objectType { + color: #1677ff; +} + +.authCode { + padding: 12px; + background: #f5f5f5; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + color: #333; + border: 1px solid #d9d9d9; +} + +.suggestions { + font-size: 13px; + color: #666; +} + +.suggestionItem { + margin-bottom: 16px; +} + +.suggestionItem:last-child { + margin-bottom: 0; +} + +.suggestionItem strong { + display: block; + margin-bottom: 8px; + color: #333; +} + +.suggestionItem ul { + margin: 0; + padding-left: 20px; +} + +.suggestionItem li { + margin-bottom: 4px; +} + +/* 日志容器 */ +.logContainer { + max-height: 400px; + overflow-y: auto; + background: #1e1e1e; + border-radius: 6px; + padding: 12px; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; +} + +.noLogs { + color: #666; + text-align: center; + padding: 20px; +} + +.logEntry { + display: flex; + padding: 4px 0; + line-height: 1.5; + word-break: break-all; +} + +.logEntry.info { + color: #d4d4d4; +} + +.logEntry.success { + color: #4ade80; +} + +.logEntry.error { + color: #f87171; +} + +.logEntry.warning { + color: #fbbf24; +} + +.logTime { + color: #6b7280; + margin-right: 8px; + flex-shrink: 0; +} + +.logMessage { + flex: 1; +} + +/* 滚动条样式 */ +.logContainer::-webkit-scrollbar { + width: 6px; +} + +.logContainer::-webkit-scrollbar-track { + background: #2d2d2d; + border-radius: 3px; +} + +.logContainer::-webkit-scrollbar-thumb { + background: #555; + border-radius: 3px; +} + +.logContainer::-webkit-scrollbar-thumb:hover { + background: #777; +} diff --git a/frontend/src/pages/feishu-env-test.tsx b/frontend/src/pages/feishu-env-test.tsx new file mode 100644 index 0000000..f735e0b --- /dev/null +++ b/frontend/src/pages/feishu-env-test.tsx @@ -0,0 +1,487 @@ +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 + userAgent: string + isInFeishu: boolean + ttExists: boolean + ttType: string + ttMethods: string[] + globalObjects: Record +} + +interface LogEntry { + time: string + message: string + type: 'info' | 'success' | 'error' | 'warning' +} + +export default function FeishuEnvTest() { + const [envInfo, setEnvInfo] = useState(null) + const [ttReady, setTtReady] = useState(false) + const [authCode, setAuthCode] = useState('') + const [testResult, setTestResult] = useState('') + const [checkingCount, setCheckingCount] = useState(0) + const [logs, setLogs] = useState([]) + const [appId, setAppId] = useState('') + const pollingRef = useRef(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 = {} + 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 = {} + 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
加载中...
+ } + + return ( +
+
+

🔍 飞书环境检测

+

用于诊断飞书免登问题

+ + {/* 操作按钮 */} + +
+ + setAppId(e.target.value)} + placeholder="例如: cli_xxxxxxxxxxxxx" + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #d9d9d9', + borderRadius: '6px', + fontSize: '14px', + boxSizing: 'border-box' + }} + /> +
+
+ + + + + + +
+
+ 已检测次数: {checkingCount} | 轮询状态: {pollingRef.current ? '运行中' : '已停止'} +
+
+ + {/* 实时日志 */} + +
+ {logs.length === 0 ? ( +
暂无日志
+ ) : ( + logs.map((log, index) => ( +
+ [{log.time}] + {log.message} +
+ )) + )} +
+
+ + {/* tt 对象状态 */} + + {envInfo.ttExists ? ( +
+ ✅ tt 对象存在 +
类型: {envInfo.ttType}
+ {envInfo.ttMethods.length > 0 && ( +
+ 可用方法: {envInfo.ttMethods.slice(0, 10).join(', ')} + {envInfo.ttMethods.length > 10 && '...'} +
+ )} +
+ ) : ( +
+ ❌ tt 对象不存在 +
+ 如果在飞书客户端中打开,请尝试"开始轮询"按钮 +
+
+ )} +
+ + {/* 环境检测 */} + +
+ 当前 URL: + {envInfo.currentUrl} +
+
+ 域名: + {envInfo.origin} +
+
+ 路径: + {envInfo.pathname} +
+
+ 在飞书中: + + {envInfo.isInFeishu ? '✅ 是' : '❌ 否'} + +
+
+ URL 参数: + + {Object.keys(envInfo.searchParams).length > 0 + ? JSON.stringify(envInfo.searchParams) + : '(无)'} + +
+
+ + {/* User Agent */} + +
{envInfo.userAgent}
+
+ + {/* 全局对象 */} + + {Object.keys(envInfo.globalObjects).length > 0 ? ( +
+ {Object.entries(envInfo.globalObjects).map(([key, type]) => ( +
+ window.{key} + {type} +
+ ))} +
+ ) : ( +
⚠️ 未检测到飞书相关全局对象
+ )} +
+ + {/* 免登测试结果 */} + {testResult && ( + +
+ {testResult} +
+
+ )} + + {/* 授权码 */} + {authCode && ( + +
{authCode}
+
+ )} + + {/* 诊断建议 */} + +
+
+ 如果 tt 对象不存在: +
    +
  • 确认从飞书工作台应用图标进入,而非直接打开链接
  • +
  • 确认飞书开放平台 H5 应用地址配置正确
  • +
  • 尝试点击"开始轮询"按钮等待 tt 对象注入
  • +
+
+
+ 如果 UA 检测不是飞书: +
    +
  • 可能飞书客户端的 UA 已更新
  • +
  • 检查 User Agent 中是否包含 "Lark" 或 "feishu" 关键词
  • +
+
+
+ 如果免登测试失败: +
    +
  • 检查飞书应用是否获得必要权限
  • +
  • 检查后端飞书配置是否正确
  • +
  • 查看浏览器控制台详细错误日志
  • +
+
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index f24d405..8de4d40 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Toast } from 'antd-mobile' import { useUserStore } from '@/stores/user' @@ -12,10 +12,17 @@ declare global { tt?: any // 旧版 ttjsBridge(兼容性) ttjsBridge?: any + // H5 JSSDK(网页应用免登) + h5sdk?: { + ready: (callback: () => void) => void + config: (config: any) => void + error?: (handler: (error: any) => void) => void + } // 其他可能的飞书 API - h5sdk?: any bridge?: any _tt?: any + Lark?: any + Feishu?: any } } @@ -49,9 +56,9 @@ export default function Login() { return } - // 没有token,继续登录流程 - console.log('⚠️ 未检测到 token,显示登录页面') - setLoading(false) + // 没有 token,但不立即显示登录界面 + // 等待第二个 useEffect 检测飞书环境并尝试免登 + // 只有在非飞书环境或免登失败时才显示登录界面 // 监听飞书登录成功事件 const handleFeishuLoginSuccess = ((e: any) => { @@ -72,44 +79,113 @@ export default function Login() { 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 = {} + const feishuObjects: Record = {} for (const key in window) { if (key.toLowerCase().includes('tt') || key.toLowerCase().includes('lark') || key.toLowerCase().includes('feishu') || - key.toLowerCase().includes('bridge')) { - feishuObjects[key] = typeof window[key] + 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') - const fromFeishu = urlParams.get('from') === 'feishu' - // 关键修复:在飞书中但 tt 对象不存在时,刷新页面让飞书注入 tt 对象 - // 增加多次重试机制 + // 关键修复:在飞书中但 tt 对象不存在时,检查 h5sdk 并等待环境初始化 + // 网页应用会先注入 h5sdk,然后通过 h5sdk.ready() 等待 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}` + 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 - } else { - console.log('❌ 多次刷新后 tt 对象仍不存在,放弃自动登录') } + + // 如果没有 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) { @@ -141,11 +217,12 @@ export default function Login() { } else if (attempts >= maxAttempts) { clearInterval(checkInterval) console.error('❌ 等待 tt 对象超时,最后状态 window.tt:', window.tt, typeof window.tt) - console.error('❌ 请检查:1) 飞书开放平台是否正确配置 H5 应用地址 2) 页面是否从飞书应用入口进入') + console.error('❌ 请检查:1) 飞书开放平台是否正确配置 H5 应用地址 2) 页面是否从飞书应用入口进入 3) 应用是否已发布') - // 不自动弹错误,而是显示飞书内登录界面 + // 免登失败,显示登录界面(飞书内显示提示) setLoading(false) - setShowQRCode(true) + setEnvironment('feishu') + // 不显示二维码,而是显示手动登录选项 } }, 100) } else { @@ -176,21 +253,118 @@ export default function Login() { } 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('飞书环境异常,请刷新重试') + 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') + console.log('🔧 调用 tt.requestAuthCode (appId:', appId?.substring(0, 15) + '...)') - // 不传 appId,让飞书自动选择(后端会根据 company 参数处理) window.tt.requestAuthCode({ + appId: appId, success: (res: any) => { console.log('✅ 成功获取授权码:', res.code?.substring(0, 30)) handleFeishuLogin(res.code) @@ -384,6 +558,26 @@ export default function Login() {

企业工具台

+ + {/* 飞书内免登失败的提示 */} + {environment === 'feishu' && ( +
+
⚠️ 飞书免登失败
+
+ 可能原因:1) 应用配置未生效 2) 需要重新从工作台进入 +
+
+ )} +

请选择登录方式

@@ -395,13 +589,15 @@ export default function Login() {
- + {environment !== 'feishu' && ( + + )}
{environment === 'external' && ( diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 489450a..db03efe 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -16,6 +16,7 @@ import RejectPage from '@/pages/approval/reject' import Login from '@/pages/login' import SimpleLogin from '@/pages/login-simple' import DebugFeishu from '@/pages/debug-feishu' +import FeishuEnvTest from '@/pages/feishu-env-test' const router = createBrowserRouter([ { @@ -43,6 +44,7 @@ const router = createBrowserRouter([ { path: '/login', element: }, { path: '/login-simple', element: }, { path: '/debug-feishu', element: }, + { path: '/feishu-env-test', element: }, { path: '/approval/quick-action', element: }, { path: '/approval/reject', element: }, { path: '*', element: },