feat: 完善鉴权和密码功能,添加飞书通知模块
- 完善Token过期/不存在时跳转登录重新认证 - 实现正常的账号密码登录验证(邮箱/手机号+密码) - 用户初始化密码设为工号(sn) - 添加修改密码功能(API+前端页面) - 修复扫码登录后跳转问题(zustand persist格式) - 创建飞书通知服务 FeishuNotificationService - 设置页面添加修改密码入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user