feat: 完善鉴权和密码功能,添加飞书通知模块

- 完善Token过期/不存在时跳转登录重新认证
- 实现正常的账号密码登录验证(邮箱/手机号+密码)
- 用户初始化密码设为工号(sn)
- 添加修改密码功能(API+前端页面)
- 修复扫码登录后跳转问题(zustand persist格式)
- 创建飞书通知服务 FeishuNotificationService
- 设置页面添加修改密码入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-26 15:19:37 +08:00
parent ba555a59b7
commit 2e5f570b40
26 changed files with 2925 additions and 158 deletions
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useUserStore } from '@/stores/user'
import QRCodeLogin from '@/pages/login/QRCodeLogin'
// 声明飞书 JSAPI 类型
declare global {
interface Window {
tt?: any
ttjsBridge?: any
}
}
export default function FeishuAutoLogin() {
const navigate = useNavigate()
const { login, isLoggedIn } = useUserStore()
const [status, setStatus] = useState<'checking' | 'success' | 'failed' | 'show_options'>('checking')
const [showQRCode, setShowQRCode] = useState(false)
const [debugInfo, setDebugInfo] = useState<any>({})
useEffect(() => {
// 如果已登录,直接跳转
if (isLoggedIn) {
navigate('/dashboard')
return
}
const ua = navigator.userAgent
const currentUrl = window.location.href
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
console.log('=== 首页免登检测 ===')
console.log('URL:', currentUrl)
console.log('在飞书中:', isInFeishu)
console.log('window.tt:', !!window.tt)
setDebugInfo({
url: currentUrl,
isInFeishu,
hasTT: !!window.tt,
})
// 检查 URL 中是否有 auth_code
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('auth_code')
if (authCode) {
console.log('✅ 发现 auth_code,直接登录')
handleLogin(authCode)
return
}
if (!isInFeishu) {
console.log('不在飞书中,显示登录选项')
setStatus('show_options')
return
}
// 在飞书中,尝试免登
tryFeishuAutoLogin()
function tryFeishuAutoLogin() {
const appId = 'cli_a8033d607cba500d'
// 打印 tt 对象的所有方法
if (window.tt) {
console.log('tt 对象的方法:')
for (let key in window.tt) {
if (typeof window.tt[key] === 'function') {
console.log(' -', key)
}
}
}
// 方式1: tt.requestAuthCode
if (window.tt && typeof window.tt.requestAuthCode === 'function') {
console.log('使用 tt.requestAuthCode')
window.tt.requestAuthCode({
appId,
success: (res: any) => {
console.log('✅ 获取到授权码:', res.code?.substring(0, 20))
handleLogin(res.code)
},
fail: (err: any) => {
console.error('❌ 免登失败:', err)
setStatus('show_options')
},
})
return
}
// 方式2: ttjsBridge
if (window.ttjsBridge && typeof window.ttjsBridge.getAuthCode === 'function') {
console.log('使用 ttjsBridge.getAuthCode')
window.ttjsBridge.getAuthCode({
success: (code: string) => {
console.log('✅ 获取到授权码:', code?.substring(0, 20))
handleLogin(code)
},
fail: (err: any) => {
console.error('❌ 免登失败:', err)
setStatus('show_options')
},
})
return
}
// tt API 不可用,显示登录选项
console.log('⚠️ tt API 不可用')
setStatus('show_options')
}
async function handleLogin(authCode: string) {
try {
const response = await fetch('/api/v1/auth/feishu-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auth_code: authCode,
tenant_key: '14e7bc0fe5985740',
}),
})
const result = await response.json()
if (result.code === 0) {
console.log('✅ 登录成功')
login(result.data.user, result.data.token)
setStatus('success')
setTimeout(() => navigate('/dashboard'), 500)
} else {
console.error('❌ 登录失败:', result.message)
setStatus('show_options')
}
} catch (error) {
console.error('❌ 登录异常:', error)
setStatus('show_options')
}
}
}, [isLoggedIn, login, navigate])
// 检测中的状态
if (status === 'checking') {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white'
}}>
<h1>企业工具台</h1>
<p style={{ marginTop: 20 }}>正在登录...</p>
<div style={{
width: 40,
height: 40,
border: '4px solid rgba(255,255,255,0.3)',
borderTopColor: 'white',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginTop: 20
}}></div>
{/* 调试信息 */}
<div style={{
marginTop: 30,
padding: 10,
background: 'rgba(0,0,0,0.2)',
borderRadius: 8,
fontSize: 12,
textAlign: 'left'
}}>
<div>URL: {debugInfo.url?.substring(0, 50)}...</div>
<div>在飞书中: {debugInfo.isInFeishu ? '✅' : '❌'}</div>
<div>tt 对象: {debugInfo.hasTT ? '✅' : '❌'}</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)
}
// 成功状态
if (status === 'success') {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white'
}}>
<h1>✅ 登录成功</h1>
<p style={{ marginTop: 20 }}>正在进入应用...</p>
<div style={{
width: 40,
height: 40,
border: '4px solid rgba(255,255,255,0.3)',
borderTopColor: 'white',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginTop: 20
}}></div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)
}
// 显示登录选项
if (status === 'show_options') {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
padding: 20
}}>
<h1 style={{ marginBottom: 10 }}>企业工具台</h1>
<p style={{ marginBottom: 40, opacity: 0.9 }}>请选择登录方式</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: '100%', maxWidth: 320 }}>
{/* 飞书扫码登录 */}
<button
onClick={() => setShowQRCode(true)}
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
width: '100%',
padding: 16,
background: '#fff',
border: 'none',
borderRadius: 12,
cursor: 'pointer'
}}
>
<span style={{ fontSize: 28 }}>📱</span>
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontSize: 16, fontWeight: 500, color: '#333', marginBottom: 2 }}>飞书扫码登录</div>
<div style={{ fontSize: 12, color: '#999' }}>使用飞书 APP 扫码登录</div>
</div>
</button>
</div>
{debugInfo.isInFeishu && (
<p style={{ marginTop: 30, fontSize: 12, opacity: 0.7, textAlign: 'center' }}>
💡 检测到在飞书客户端中,但免登功能不可用
</p>
)}
<QRCodeLogin
visible={showQRCode}
onClose={() => setShowQRCode(false)}
/>
</div>
)
}
return null
}
@@ -1,5 +1,6 @@
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useEffect, useRef } from 'react'
import { useUserStore } from '@/stores/user'
import TabBar from './TabBar'
import Header from './Header'
import styles from './MobileLayout.module.css'
@@ -8,10 +9,26 @@ export default function MobileLayout() {
const navigate = useNavigate()
const location = useLocation()
const mainRef = useRef<HTMLDivElement>(null)
const { token } = useUserStore()
const showHeader = false // 所有页面都不显示 Header
const showTabBar = false // 暂时隐藏底部 Tab
// 认证检查:如果没有 token,跳转到登录页
useEffect(() => {
// 跳过登录页面本身的检查
if (location.pathname === '/login' || location.pathname === '/login-simple') {
return
}
// 检查 localStorage 中是否有 token
const storedToken = localStorage.getItem('token')
if (!storedToken) {
console.log('🔐 未登录,跳转到登录页')
navigate('/login', { replace: true })
}
}, [location.pathname, navigate])
// 路由变化时滚动到顶部
useEffect(() => {
if (mainRef.current) {
+19 -4
View File
@@ -138,7 +138,23 @@
"language": "Language",
"switchCompany": "Switch Company",
"notifications": "Notifications",
"security": "Security"
"security": "Security",
"changePassword": "Change Password"
},
"changePassword": {
"title": "Change Password",
"oldPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"oldPasswordPlaceholder": "Enter current password",
"newPasswordPlaceholder": "Enter new password (at least 6 characters)",
"confirmPasswordPlaceholder": "Enter new password again",
"submit": "Confirm",
"success": "Password changed successfully",
"oldPasswordError": "Current password is incorrect",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "New password must be at least 6 characters",
"fillAllFields": "Please fill in all fields"
},
"login": {
"title": "Login",
@@ -150,9 +166,8 @@
"loginFailed": "Login failed"
},
"settings": {
"language": "Language",
"theme": "Theme",
"notifications": "Notifications"
"title": "Settings",
"language": "Language"
},
"error": {
"networkError": "Network error, please try again later",
+19 -4
View File
@@ -113,7 +113,23 @@
"language": "Idioma",
"switchCompany": "Cambiar Empresa",
"notifications": "Notificaciones",
"security": "Seguridad"
"security": "Seguridad",
"changePassword": "Cambiar Contraseña"
},
"changePassword": {
"title": "Cambiar Contraseña",
"oldPassword": "Contraseña Actual",
"newPassword": "Nueva Contraseña",
"confirmPassword": "Confirmar Contraseña",
"oldPasswordPlaceholder": "Ingrese la contraseña actual",
"newPasswordPlaceholder": "Ingrese la nueva contraseña (mínimo 6 caracteres)",
"confirmPasswordPlaceholder": "Ingrese la nueva contraseña nuevamente",
"submit": "Confirmar",
"success": "Contraseña cambiada exitosamente",
"oldPasswordError": "La contraseña actual es incorrecta",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La nueva contraseña debe tener al menos 6 caracteres",
"fillAllFields": "Por favor complete todos los campos"
},
"login": {
"title": "Iniciar Sesión",
@@ -125,9 +141,8 @@
"loginFailed": "Error de inicio de sesión"
},
"settings": {
"language": "Idioma",
"theme": "Tema",
"notifications": "Notificaciones"
"title": "Configuración",
"language": "Idioma"
},
"error": {
"networkError": "Error de red, inténtelo más tarde",
+19 -4
View File
@@ -113,7 +113,23 @@
"language": "Langue",
"switchCompany": "Changer d'Entreprise",
"notifications": "Notifications",
"security": "Sécurité"
"security": "Sécurité",
"changePassword": "Changer le Mot de Passe"
},
"changePassword": {
"title": "Changer le Mot de Passe",
"oldPassword": "Mot de Passe Actuel",
"newPassword": "Nouveau Mot de Passe",
"confirmPassword": "Confirmer le Mot de Passe",
"oldPasswordPlaceholder": "Entrez le mot de passe actuel",
"newPasswordPlaceholder": "Entrez le nouveau mot de passe (au moins 6 caractères)",
"confirmPasswordPlaceholder": "Entrez le nouveau mot de passe à nouveau",
"submit": "Confirmer",
"success": "Mot de passe changé avec succès",
"oldPasswordError": "Le mot de passe actuel est incorrect",
"passwordMismatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le nouveau mot de passe doit contenir au moins 6 caractères",
"fillAllFields": "Veuillez remplir tous les champs"
},
"login": {
"title": "Connexion",
@@ -125,9 +141,8 @@
"loginFailed": "Échec de la connexion"
},
"settings": {
"language": "Langue",
"theme": "Thème",
"notifications": "Notifications"
"title": "Paramètres",
"language": "Langue"
},
"error": {
"networkError": "Erreur réseau, veuillez réessayer plus tard",
+19 -4
View File
@@ -113,7 +113,23 @@
"language": "Язык",
"switchCompany": "Сменить компанию",
"notifications": "Уведомления",
"security": "Безопасность"
"security": "Безопасность",
"changePassword": "Изменить пароль"
},
"changePassword": {
"title": "Изменить пароль",
"oldPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите пароль",
"oldPasswordPlaceholder": "Введите текущий пароль",
"newPasswordPlaceholder": "Введите новый пароль (минимум 6 символов)",
"confirmPasswordPlaceholder": "Введите новый пароль снова",
"submit": "Подтвердить",
"success": "Пароль успешно изменен",
"oldPasswordError": "Текущий пароль неверен",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Новый пароль должен быть не менее 6 символов",
"fillAllFields": "Пожалуйста, заполните все поля"
},
"login": {
"title": "Вход",
@@ -125,9 +141,8 @@
"loginFailed": "Ошибка входа"
},
"settings": {
"language": "Язык",
"theme": "Тема",
"notifications": "Уведомления"
"title": "Настройки",
"language": "Язык"
},
"error": {
"networkError": "Ошибка сети, попробуйте позже",
+19 -4
View File
@@ -138,7 +138,23 @@
"language": "语言设置",
"switchCompany": "切换公司",
"notifications": "消息通知",
"security": "账号安全"
"security": "账号安全",
"changePassword": "修改密码"
},
"changePassword": {
"title": "修改密码",
"oldPassword": "原密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"oldPasswordPlaceholder": "请输入原密码",
"newPasswordPlaceholder": "请输入新密码(至少6位)",
"confirmPasswordPlaceholder": "请再次输入新密码",
"submit": "确认修改",
"success": "密码修改成功",
"oldPasswordError": "原密码错误",
"passwordMismatch": "两次输入的密码不一致",
"passwordTooShort": "新密码长度不能少于6位",
"fillAllFields": "请填写完整信息"
},
"login": {
"title": "登录",
@@ -150,9 +166,8 @@
"loginFailed": "登录失败"
},
"settings": {
"language": "语言",
"theme": "主题",
"notifications": "通知设置"
"title": "设置",
"language": "语言"
},
"error": {
"networkError": "网络错误,请稍后重试",
@@ -0,0 +1,39 @@
.page {
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
background: #fff;
border-radius: 12px;
padding: 24px;
}
.title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0 0 24px 0;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: 14px;
color: #666;
}
.submitBtn {
margin-top: 12px;
}
@@ -0,0 +1,114 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Input, Button, Toast } from 'antd-mobile'
import { useTranslation } from 'react-i18next'
import { userApi } from '@/services/api'
import styles from './change-password.module.css'
export default function ChangePassword() {
const navigate = useNavigate()
const { t } = useTranslation()
const [formData, setFormData] = useState({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const [loading, setLoading] = useState(false)
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const validateForm = (): string | null => {
if (!formData.oldPassword || !formData.newPassword || !formData.confirmPassword) {
return t('changePassword.fillAllFields')
}
if (formData.newPassword.length < 6) {
return t('changePassword.passwordTooShort')
}
if (formData.newPassword !== formData.confirmPassword) {
return t('changePassword.passwordMismatch')
}
return null
}
const handleSubmit = async () => {
const error = validateForm()
if (error) {
Toast.show(error)
return
}
setLoading(true)
try {
await userApi.changePassword({
old_password: formData.oldPassword,
new_password: formData.newPassword,
confirm_password: formData.confirmPassword,
})
Toast.show(t('changePassword.success'))
setTimeout(() => {
navigate(-1)
}, 1500)
} catch (error: any) {
const message = error.message || t('error.unknownError')
Toast.show(message)
} finally {
setLoading(false)
}
}
return (
<div className={styles.page}>
<div className={styles.container}>
<h1 className={styles.title}>{t('changePassword.title')}</h1>
<div className={styles.form}>
<div className={styles.field}>
<label className={styles.label}>{t('changePassword.oldPassword')}</label>
<Input
type="password"
placeholder={t('changePassword.oldPasswordPlaceholder')}
value={formData.oldPassword}
onChange={(value) => handleChange('oldPassword', value)}
clearable
/>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('changePassword.newPassword')}</label>
<Input
type="password"
placeholder={t('changePassword.newPasswordPlaceholder')}
value={formData.newPassword}
onChange={(value) => handleChange('newPassword', value)}
clearable
/>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('changePassword.confirmPassword')}</label>
<Input
type="password"
placeholder={t('changePassword.confirmPasswordPlaceholder')}
value={formData.confirmPassword}
onChange={(value) => handleChange('confirmPassword', value)}
clearable
/>
</div>
<Button
block
color="primary"
size="large"
loading={loading}
onClick={handleSubmit}
className={styles.submitBtn}
>
{t('changePassword.submit')}
</Button>
</div>
</div>
</div>
)
}
+2 -2
View File
@@ -36,8 +36,8 @@ export default function Dashboard() {
const { t } = useTranslation()
const handleQuickAction = (action: QuickAction) => {
// 允许的应用:出差申请、审批
const allowedPaths = ['/business-trip/apply', '/business-trip']
// 允许的应用:出差申请、审批、系统设置
const allowedPaths = ['/business-trip/apply', '/business-trip', '/settings']
if (allowedPaths.includes(action.path)) {
navigate(action.path)
} else {
+107 -19
View File
@@ -12,10 +12,12 @@ declare global {
goto: string
width?: string
height?: string
style?: string
}) => {
matchOrigin: (origin: string) => boolean
matchData: (data: any) => boolean
}
LarkSSOSDKWebQRCode?: any
}
}
@@ -39,17 +41,36 @@ export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) {
initQRCode()
}
// 监听来自回调页面的成功消息
const handleCallbackMessage = (event: MessageEvent) => {
if (event.data?.type === 'feishu-login-success') {
console.log('✅ Received login success from callback popup')
// 轮询应该会检测到成功状态,但这里作为额外的触发器
// 如果轮询已经检测到并处理了,这个消息就忽略
}
}
window.addEventListener('message', handleCallbackMessage)
return () => {
// 清理
// 清理 message 事件监听器
if (qrObjRef.current?._cleanup) {
qrObjRef.current._cleanup()
}
// 清理回调消息监听器
window.removeEventListener('message', handleCallbackMessage)
// 清理容器
if (qrContainerRef.current) {
qrContainerRef.current.innerHTML = ''
}
// 清理轮询
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current)
}
// 重置状态
setUseFallback(false)
setQrcodeUrl('')
qrObjRef.current = null
}
}, [visible])
@@ -89,25 +110,90 @@ export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) {
// 检查飞书 QR SDK 是否已加载(在 index.html 中引入)
if (window.QRLogin) {
console.log('✅ Using Feishu QR SDK')
// 使用飞书官方 QR SDK
// 先清空容器内容(保留容器元素本身)
const container = document.getElementById('feishu-qrcode-container')
if (container) {
container.innerHTML = ''
}
// 确保 DOM 已渲染后再调用 SDK
setTimeout(() => {
qrObjRef.current = window.QRLogin({
id: 'feishu-qrcode-container',
goto: authUrl,
width: '250',
height: '250',
style: 'width:300px;height:270px;border:none'
})
}, 50)
setStatus('pending')
startPolling(result.data.session_id)
// 确保 DOM 已渲染后再调用 SDK
// Dialog 内容可能需要更长时间渲染,使用多次尝试
const initQRSDK = (retryCount = 0) => {
const container = document.getElementById('feishu-qrcode-container')
console.log('🔍 Looking for container, attempt', retryCount + 1, 'found:', !!container)
if (container) {
// 清空容器
container.innerHTML = ''
try {
console.log('🔧 Initializing QR SDK with goto:', authUrl.substring(0, 50) + '...')
qrObjRef.current = window.QRLogin({
id: 'feishu-qrcode-container',
goto: authUrl,
width: '250',
height: '250',
style: 'width:300px;height:270px;border:none'
})
console.log('✅ Feishu QR SDK initialized successfully')
console.log('📦 Container children count:', container.children.length)
setStatus('pending')
startPolling(result.data.session_id)
// 监听扫码后的 message 事件(关键步骤!)
const handleMessage = (event: MessageEvent) => {
console.log('📨 Received message event:', event.origin, event.data)
// 安全校验
if (!qrObjRef.current?.matchOrigin(event.origin)) {
console.warn('⚠️ Origin mismatch:', event.origin)
return
}
if (!qrObjRef.current?.matchData(event.data)) {
console.warn('⚠️ Data mismatch:', event.data)
return
}
// 获取临时授权码
const loginTmpCode = event.data?.tmp_code
if (loginTmpCode) {
console.log('✅ Got tmp_code:', loginTmpCode)
console.log('🔀 Redirecting to Feishu auth page...')
// 保存当前 URL,登录成功后返回
const currentUrl = window.location.href
sessionStorage.setItem('feishu_login_return_url', currentUrl)
// 重定向到飞书授权页面(官方文档推荐方式)
const authUrlWithCode = `${authUrl}&tmp_code=${loginTmpCode}`
window.location.href = authUrlWithCode
}
}
window.addEventListener('message', handleMessage)
// 保存清理函数
qrObjRef.current._cleanup = () => {
window.removeEventListener('message', handleMessage)
}
} catch (e) {
console.error('❌ QR SDK init error:', e)
// Fallback to normal QR code
setQrcodeUrl(authUrl)
setUseFallback(true)
startPolling(result.data.session_id)
}
} else if (retryCount < 10) {
// 容器还未渲染,继续等待
setTimeout(() => initQRSDK(retryCount + 1), 100)
} else {
console.error('❌ Container not found after 10 attempts, using fallback')
setQrcodeUrl(authUrl)
setUseFallback(true)
startPolling(result.data.session_id)
}
}
// 开始初始化
setTimeout(() => initQRSDK(), 100)
} else {
// SDK 未加载,使用普通二维码
console.warn('⚠️ Feishu QR SDK not loaded, using fallback')
@@ -126,6 +212,8 @@ export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) {
}
const startPolling = (id: string) => {
console.log('🔄 Starting polling for session:', id)
const poll = async () => {
try {
const response = await fetch(`/api/v1/auth/qrcode-login/status?session_id=${id}`)
+158 -71
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Toast } from 'antd-mobile'
import { useUserStore } from '@/stores/user'
@@ -9,17 +9,13 @@ import styles from './login.module.css'
declare global {
interface Window {
// 官方 tt API(H5 应用免登)
tt?: {
requestAuthCode: (params: {
appId: string
success?: (res: { code: string }) => void
fail?: (err: any) => void
}) => void
}
tt?: any
// 旧版 ttjsBridge(兼容性)
ttjsBridge?: {
getAuthCode: (params: any) => void
}
ttjsBridge?: any
// 其他可能的飞书 API
h5sdk?: any
bridge?: any
_tt?: any
}
}
@@ -46,76 +42,104 @@ export default function Login() {
}, [])
useEffect(() => {
// 1. 检测是否在飞书客户端内
const ua = navigator.userAgent.toLowerCase()
const isInFeishu = ua.includes('feishu') || ua.includes('lark') || window.tt !== undefined
// 打印详细的环境信息
const ua = navigator.userAgent
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
const currentUrl = window.location.href
console.log('🔍 Environment check:', { ua, isInFeishu, hasTt: window.tt !== undefined })
console.log('=== 环境检测 ===')
console.log('当前 URL:', currentUrl)
console.log('在飞书中:', isInFeishu)
console.log('window.tt:', window.tt)
if (isInFeishu) {
// 飞书内:静默授权
// 关键修复:在飞书中但 tt 对象不存在时,刷新页面让飞书注入 tt 对象
if (isInFeishu && !window.tt && currentUrl.indexOf('reloaded=1') === -1) {
console.log('⚠️ 在飞书中但 tt 对象不存在,刷新页面')
window.location.href = currentUrl + (currentUrl.indexOf('?') > 0 ? '&' : '?') + 'reloaded=1'
return
}
// 检查 URL 中是否有 auth_code 参数(飞书授权回调)
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('auth_code')
if (authCode) {
// 有授权码,直接处理登录
console.log('✅ Found auth_code in URL:', authCode.substring(0, 20) + '...')
handleFeishuLogin(authCode)
} else if (isInFeishu && window.tt) {
// 飞书内且有 tt 对象:静默授权
setEnvironment('feishu')
handleFeishuAutoLogin()
} else {
// 飞书外:检查 URL 中是否有 auth_code 参数(飞书授权回调)
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('auth_code')
const qrcodeId = urlParams.get('qrcode_id')
if (qrcodeId) {
// 扫码登录回调
setEnvironment('external')
handleQrcodeCallback(qrcodeId)
} else if (authCode) {
// 直接访问授权页面(不太可能)
setEnvironment('external')
handleFeishuCallback(authCode)
} else {
setEnvironment('external')
setLoading(false)
}
// 飞书外或 tt 对象不可用:显示登录选项
setEnvironment('external')
setLoading(false)
}
}, [])
// 飞书内静默授权(使用官方 tt.requestAuthCode API)
// 飞书内静默授权
const handleFeishuAutoLogin = () => {
console.log('🚀 Feishu auto login started')
console.log('🚀 开始飞书免登')
console.log('🔍 window.tt:', window.tt)
console.log('🔍 window.tt 类型:', typeof window.tt)
const appId = 'cli_a8033d607cba500d'
// 详细检查 tt 对象
if (window.tt) {
// 使用官方 tt.requestAuthCode API
window.tt.requestAuthCode({
appId: appId,
success: (res) => {
console.log('✅ Got auth code from tt.requestAuthCode:', res.code)
handleFeishuLogin(res.code)
},
fail: (error: any) => {
console.error('❌ requestAuthCode failed:', error)
setLoading(false)
Toast.show('获取授权失败,请重试')
},
})
} else if ((window as any).ttjsBridge) {
// 兼容旧版 ttjsBridge
console.warn('⚠️ Using legacy ttjsBridge API')
;(window as any).ttjsBridge.getAuthCode({
success: (authCode: string) => {
console.log('✅ Got auth code from ttjsBridge:', authCode)
handleFeishuLogin(authCode)
},
fail: (error: any) => {
console.error('❌ Get auth code failed:', error)
setLoading(false)
Toast.show('获取授权失败,请重试')
},
})
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.warn('⚠️ No Feishu bridge available')
console.log('❌ tt 对象不存在')
setLoading(false)
Toast.show('请在飞书中打开')
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)
}
// 跳转到飞书授权页面(飞书外扫码登录)
@@ -155,14 +179,18 @@ export default function Login() {
// 处理飞书登录
const handleFeishuLogin = async (authCode: string) => {
try {
const tenantKey = '14e7bc0fe5985740'
// 从 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,
tenant_key: tenantKey,
app_id: urlAppId || undefined, // 如果 URL 有 app_id 就传递
}),
})
@@ -183,10 +211,6 @@ export default function Login() {
}
}
// 邮箱密码登录
const handlePasswordLogin = () => {
navigate('/login-simple')
}
if (loading) {
return (
@@ -194,6 +218,69 @@ export default function 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>
)
+16
View File
@@ -86,3 +86,19 @@
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { showLanguageSwitcher } from '@/components/LanguageSwitcher'
import styles from './settings.module.css'
interface SettingItem {
id: string
icon: string
label: string
description?: string
path?: string
onClick?: () => void
}
export default function Settings() {
const navigate = useNavigate()
const { t } = useTranslation()
const settingGroups = [
{
title: t('profile.security'),
items: [
{
id: 'change-password',
icon: '🔒',
label: t('profile.changePassword'),
description: t('changePassword.title'),
path: '/change-password',
},
],
},
{
title: t('settings.title'),
items: [
{
id: 'language',
icon: '🌐',
label: t('settings.language'),
description: t('settings.language'),
onClick: () => showLanguageSwitcher(),
},
],
},
]
const handleItemClick = (item: SettingItem) => {
if (item.onClick) {
item.onClick()
} else if (item.path) {
navigate(item.path)
}
}
return (
<div className={styles.settings}>
<div className={styles.header}>
<h1 className={styles.title}>{t('profile.settings')}</h1>
</div>
{settingGroups.map((group) => (
<div key={group.title} className={styles.group}>
<div className={styles.groupTitle}>{group.title}</div>
<div className={styles.groupContent}>
{group.items.map((item) => (
<div
key={item.id}
className={styles.item}
onClick={() => handleItemClick(item)}
>
<div className={styles.itemLeft}>
<span className={styles.itemIcon}>{item.icon}</span>
<div className={styles.itemInfo}>
<div className={styles.itemLabel}>{item.label}</div>
{item.description && (
<div className={styles.itemDescription}>{item.description}</div>
)}
</div>
</div>
<span className={styles.itemArrow}>›</span>
</div>
))}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,83 @@
.settings {
min-height: 100vh;
background: #f5f5f5;
}
.header {
background: #fff;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.group {
margin-top: 12px;
background: #fff;
}
.groupTitle {
padding: 12px 20px 8px;
font-size: 13px;
color: #999;
}
.groupContent {
background: #fff;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background-color 0.2s;
}
.item:last-child {
border-bottom: none;
}
.item:active {
background: #f5f5f5;
}
.itemLeft {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.itemIcon {
font-size: 20px;
}
.itemInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.itemLabel {
font-size: 15px;
color: #333;
}
.itemDescription {
font-size: 12px;
color: #999;
}
.itemArrow {
font-size: 18px;
color: #ccc;
}
+7 -1
View File
@@ -1,6 +1,9 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import MobileLayout from '@/components/layout/MobileLayout'
import Dashboard from '@/pages/dashboard'
import Profile from '@/pages/profile'
import Settings from '@/pages/settings'
import ChangePassword from '@/pages/change-password'
import BusinessTripList from '@/pages/business-trip/list'
import BusinessTripApply from '@/pages/business-trip/apply'
import BusinessTripDetail from '@/pages/business-trip/detail'
@@ -18,6 +21,9 @@ const router = createBrowserRouter([
children: [
{ index: true, element: <Navigate to='/dashboard' replace /> },
{ path: 'dashboard', element: <Dashboard /> },
{ path: 'profile', element: <Profile /> },
{ path: 'settings', element: <Settings /> },
{ path: 'change-password', element: <ChangePassword /> },
{ path: 'business-trip', element: <BusinessTripList /> },
{ path: 'business-trip/apply', element: <BusinessTripApply /> },
{ path: 'business-trip/apply/select-approver', element: <SelectApprover /> },
@@ -29,7 +35,7 @@ const router = createBrowserRouter([
},
{ path: '/login', element: <Login /> },
{ path: '/login-simple', element: <SimpleLogin /> },
{ path: '*', element: <Navigate to='/dashboard' replace /> },
{ path: '*', element: <Navigate to='/login' replace /> },
])
export default router
+56 -2
View File
@@ -1,7 +1,34 @@
import axios from 'axios'
import { Toast } from 'antd-mobile'
const API_BASE = '/api/v1'
// 标记是否正在处理登出(防止重复跳转)
let isLoggingOut = false
// 处理 401 未授权错误
const handleUnauthorized = () => {
if (isLoggingOut) return
isLoggingOut = true
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('user-storage')
// 显示提示
Toast.show({
content: '登录已过期,请重新登录',
duration: 2000,
})
// 延迟跳转,确保提示显示
setTimeout(() => {
isLoggingOut = false
// 跳转到登录页,并清除历史记录
window.location.href = '/login'
}, 2000)
}
// 创建 axios 实例
const api = axios.create({
baseURL: API_BASE,
@@ -38,6 +65,13 @@ api.interceptors.response.use(
return Promise.reject(new Error(data.message || '请求失败'))
},
(error) => {
// 处理 401 未授权错误
if (error.response?.status === 401 || error.response?.data?.code === 401) {
handleUnauthorized()
return Promise.reject(new Error('登录已过期'))
}
// 处理其他错误
const message = error.response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(message))
}
@@ -48,8 +82,24 @@ export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
feishuLogin: (authCode: string, tenantKey: string) =>
api.post('/auth/feishu-login', { auth_code: authCode, tenant_key: tenantKey }),
// 飞书免登(支持 app_id 或 tenant_key)
feishuLogin: (authCode: string, options?: { appId?: string; tenantKey?: string }) =>
api.post('/auth/feishu-login', {
auth_code: authCode,
app_id: options?.appId,
tenant_key: options?.tenantKey,
}),
// 生成二维码登录会话
qrcodeLogin: (appId?: string, tenantKey?: string) =>
api.post('/auth/qrcode-login', {
app_id: appId,
tenant_key: tenantKey,
}),
// 轮询二维码登录状态
qrcodeLoginStatus: (sessionId: string) =>
api.get('/auth/qrcode-login-status', { params: { session_id: sessionId } }),
}
// ========== 用户 API ==========
@@ -63,6 +113,10 @@ export const userApi = {
// 获取用户列表
getUsers: (params: { keyword?: string; page?: number; page_size?: number }) =>
api.get('/user/list', { params }),
// 修改密码
changePassword: (data: { old_password: string; new_password: string; confirm_password: string }) =>
api.post('/user/change-password', data),
}
// ========== 出差申请 API ==========