Merge feature/mzl-dev: 修复飞书免登功能

This commit is contained in:
root
2026-02-28 09:03:58 +08:00
7 changed files with 1083 additions and 124 deletions
+43
View File
@@ -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());
}
}
}
+1
View File
@@ -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']);
+4 -43
View File
@@ -4,50 +4,11 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<!-- 飞书扫码登录 SDK -->
<title>企业工具台</title>
<!-- 飞书扫码登录 SDK(飞书外扫码登录用) -->
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>
<!-- 飞书 H5 JSBridge SDK(多 CDN 备用) -->
<script>
// 尝试加载飞书 JSBridge,如果失败则尝试其他 CDN
var scripts = [
'https://lf1-cdn-tos.bytegoofy.com/goofy/lark/passport/h5-jsbridge-1.0.3.js',
'https://lf9-cn.feishucdn.com/obj/lark/passport/h5-jsbridge-1.0.3.js'
];
function loadScript(url) {
return new Promise(function(resolve, reject) {
var script = document.createElement('script');
script.src = url;
script.onload = function() { console.log('JSBridge loaded from:', url); resolve(); };
script.onerror = function() { reject(url); };
document.head.appendChild(script);
});
}
// 按顺序尝试加载 JSBridge
function loadJSBridge() {
var index = 0;
function tryNext() {
if (index < scripts.length) {
loadScript(scripts[index])
.catch(function() {
console.warn('JSBridge load failed:', scripts[index]);
index++;
tryNext();
});
} else {
console.error('All JSBridge CDNs failed');
}
}
tryNext();
}
// 页面加载后尝试加载 JSBridge
if (navigator.userAgent.includes('Lark') || navigator.userAgent.includes('feishu')) {
loadJSBridge();
}
</script>
<!-- 飞书 H5 JSSDK(网页应用免登用) -->
<script src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.19.js"></script>
</head>
<body>
<div id="root"></div>
@@ -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;
}
+487
View File
@@ -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<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>
)
}
+276 -81
View File
@@ -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,32 +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: 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')
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) {
@@ -110,20 +198,33 @@ export default function Login() {
setEnvironment('feishu')
handleFeishuAutoLogin()
} else if (isInFeishu) {
// 飞书内环境,等待 tt 对象加载后尝试免登
console.log('⏳ 在飞书内环境,等待 tt 对象加载...')
// 飞书内环境,tt 对象由飞书客户端自动注入
// 使用轮询等待 tt 对象可用(最多 10 秒)
console.log('⏳ 在飞书内环境,等待飞书客户端注入 tt 对象...')
setEnvironment('feishu')
setLoading(false)
// 给 tt 对象一些时间来加载(最多 2 秒)
setTimeout(() => {
if (window.tt) {
console.log('✅ tt 对象已加载,开始免登')
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 {
console.log('⚠️ tt 对象仍未加载,显示二维码登录')
setShowQRCode(true)
} 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')
// 不显示二维码,而是显示手动登录选项
}
}, 2000)
}, 100)
} else {
// 飞书外:显示登录选项
console.log('📱 飞书外环境,显示登录选项')
@@ -135,8 +236,6 @@ export default function Login() {
// 飞书内静默授权
const handleFeishuAutoLogin = () => {
console.log('🚀 开始飞书免登')
console.log('🔍 window.tt:', window.tt)
console.log('🔍 window.tt 类型:', typeof window.tt)
// 从 URL 获取 company 参数(可能来自当前 URL 或 redirect URL)
const urlParams = new URLSearchParams(window.location.search)
@@ -153,60 +252,134 @@ export default function Login() {
}
}
// 详细检查 tt 对象
if (window.tt) {
console.log('✅ tt 对象存在')
console.log('🔍 tt 的所有属性:', Object.keys(window.tt))
console.log('🔍 tt.requestAuthCode 类型:', typeof window.tt.requestAuthCode)
console.log('🔍 window.tt:', window.tt)
console.log('🔍 window.h5sdk:', window.h5sdk)
console.log('🔍 tt 的属性:', Object.keys(window.tt || {}))
// 检查 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 未加载,请刷新重试')
// 如果 tt 对象已存在,直接使用(无需等待 h5sdk.ready)
if (window.tt) {
console.log('✅ tt 对象已存在,直接调用免登')
performAuthCodeRequest()
return
}
// 等待一小段时间,确保 tt 完全初始化
setTimeout(() => {
console.log('⏰ 延迟后再次检查 tt:', !!window.tt)
// 如果 tt 不存在但 h5sdk 存在,使用 h5sdk.ready() 等待
if (window.h5sdk && typeof window.h5sdk.ready === 'function') {
console.log('✅ 检测到 h5sdk,使用 h5sdk.ready() 等待 tt 对象')
setEnvironment('feishu')
if (!window.tt) {
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('飞书 API 加载失败')
Toast.show('获取飞书配置失败,请重试')
return
}
}
// 尝试调用 requestAuthCode
try {
console.log('🔧 调用 tt.requestAuthCode,company:', companyParam)
if (!appId) {
console.error('❌ 无法获取 appId')
setLoading(false)
Toast.show('缺少飞书应用配置')
return
}
// 不传 appId,让飞书自动选择(后端会根据 company 参数处理)
window.tt.requestAuthCode({
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)
// 调用 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('免登失败,请重试')
}
}
// 跳转到飞书授权页面(飞书外扫码登录)
@@ -385,6 +558,26 @@ export default function Login() {
<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}>
@@ -396,13 +589,15 @@ export default function Login() {
</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>
{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' && (
+2
View File
@@ -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: <Login /> },
{ path: '/login-simple', element: <SimpleLogin /> },
{ path: '/debug-feishu', element: <DebugFeishu /> },
{ path: '/feishu-env-test', element: <FeishuEnvTest /> },
{ path: '/approval/quick-action', element: <QuickAction /> },
{ path: '/approval/reject', element: <RejectPage /> },
{ path: '*', element: <Navigate to='/login' replace /> },