diff --git a/backend/app/controller/AuthController.php b/backend/app/controller/AuthController.php index fc7654d..a9e5c05 100644 --- a/backend/app/controller/AuthController.php +++ b/backend/app/controller/AuthController.php @@ -12,25 +12,38 @@ use Webman\Http\Response; class AuthController extends BaseController { /** - * 用户登录 + * 用户登录(邮箱/手机号 + 密码) */ public function login(Request $request): Response { $username = $request->post('username'); $password = $request->post('password'); - // TODO: 实现真实的登录逻辑 if (!$username || !$password) { return $this->error('用户名或密码不能为空'); } - // 模拟用户数据(用于测试) - $user = User::first(); + // 通过邮箱或手机号查找用户 + $user = User::where(function ($query) use ($username) { + $query->where('email', $username) + ->orWhere('phone', $username); + })->where('status', 1) + ->first(); if (!$user) { - return $this->error('用户不存在'); + return $this->error('用户不存在或已被禁用'); } + // 验证密码(如果没有设置密码,提示使用飞书登录) + if (!$user->password) { + return $this->error('该账号未设置密码,请使用飞书登录'); + } + + if (!password_verify($password, $user->password)) { + return $this->error('密码错误'); + } + + // 生成 token $token = JwtUtil::encode([ 'user_id' => $user->id, 'company_id' => $user->company_id, @@ -39,7 +52,7 @@ class AuthController extends BaseController return $this->success([ 'token' => $token, - 'user' => $user, + 'user' => $user->load('company'), ]); } @@ -49,6 +62,7 @@ class AuthController extends BaseController public function feishuLogin(Request $request): Response { $authCode = $request->post('auth_code'); + $appId = $request->post('app_id'); $tenantKey = $request->post('tenant_key'); if (!$authCode) { @@ -58,33 +72,74 @@ class AuthController extends BaseController try { $feishuService = new FeishuService(); - // 根据 tenant_key 获取公司 - $company = $feishuService->getCompanyByTenantKey($tenantKey); + // 方式1: 根据 app_id 查找飞书应用配置(推荐) + if ($appId) { + error_log("🔍 使用 app_id 查找配置: {$appId}"); - if (!$company) { - return $this->error('公司不存在'); + // 根据 app_id 查找飞书应用 + $feishuApp = \app\model\CompanyFeishuApp::where('app_id', $appId) + ->where('status', 1) + ->first(); + + if (!$feishuApp) { + return $this->error('飞书应用配置不存在'); + } + + $companyId = $feishuApp->company_id; + error_log("✅ 找到飞书应用配置: company_id={$companyId}"); + + $feishuService->setCompany($companyId); + } + // 方式2: 根据 tenant_key 查找(兼容旧版) + elseif ($tenantKey) { + error_log("🔍 使用 tenant_key 查找配置: {$tenantKey}"); + + $company = $feishuService->getCompanyByTenantKey($tenantKey); + 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("✅ 找到公司配置: company_id={$companyId}"); + $feishuService->setCompany($companyId); + } else { + return $this->error('缺少 app_id 或 tenant_key 参数'); } - // 设置公司并获取用户信息 - $feishuService->setCompany($company->id); + // 获取用户信息 $feishuData = $feishuService->getUserInfo($authCode); + error_log("👤 获取到用户信息: " . json_encode($feishuData)); // 创建或更新用户 - $user = $feishuService->createOrUpdateUser($feishuData, $company->id); + $user = $feishuService->createOrUpdateUser($feishuData, $companyId); // 生成 JWT Token $token = JwtUtil::encode([ 'user_id' => $user->id, 'company_id' => $user->company_id, 'feishu_user_id' => $user->feishu_user_id, - 'union_id' => $user->union_id, + 'union_id' => $user->feishu_union_id, ]); + error_log("✅ 登录成功: user_id={$user->id}, company_id={$user->company_id}"); + return $this->success([ 'token' => $token, 'user' => $user->load('company'), ]); } catch (\Exception $e) { + error_log("❌ 登录失败: " . $e->getMessage()); return $this->error($e->getMessage()); } } @@ -115,44 +170,75 @@ class AuthController extends BaseController public function qrcodeLogin(Request $request): Response { $tenantKey = $request->post('tenant_key'); + $appId = $request->post('app_id'); - if (!$tenantKey) { - return $this->error('缺少租户密钥'); - } + error_log("📱 qrcodeLogin - tenant_key: {$tenantKey}, app_id: {$appId}"); try { $feishuService = new \app\service\FeishuService(); + $companyId = null; + $feishuApp = null; - // 根据 tenant_key 获取公司 - $company = $feishuService->getCompanyByTenantKey($tenantKey); + // 方式1: 根据 app_id 查找飞书应用配置(推荐) + if ($appId) { + $feishuApp = \app\model\CompanyFeishuApp::where('app_id', $appId) + ->where('status', 1) + ->first(); - if (!$company) { - return $this->error('公司不存在'); + if (!$feishuApp) { + return $this->error('飞书应用配置不存在'); + } + + $companyId = $feishuApp->company_id; + error_log("✅ 通过 app_id 找到配置: company_id={$companyId}"); + } + // 方式2: 根据 tenant_key 查找(兼容旧版) + elseif ($tenantKey) { + $company = $feishuService->getCompanyByTenantKey($tenantKey); + 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("✅ 通过 tenant_key 找到配置: company_id={$companyId}"); + } else { + return $this->error('缺少 app_id 或 tenant_key 参数'); } - $feishuService->setCompany($company->id); + $feishuService->setCompany($companyId); // 生成登录会话 $loginSession = \app\model\FeishuLoginSession::create([ - 'company_id' => $company->id, + 'company_id' => $companyId, 'tenant_key' => $tenantKey, 'status' => 'pending', 'expires_at' => date('Y-m-d H:i:s', time() + 300), // 5分钟过期 ]); - // 构建扫码登录 URL(使用官方 passport 端点) - $appId = 'cli_a8033d607cba500d'; - // 使用固定的 redirect_uri,session_id 通过 state 传递 + // 构建扫码登录 URL $redirectUri = urlencode("https://xsm.dev.hnxinyouhe.com/login/callback"); - $authUrl = "https://passport.feishu.cn/suite/passport/oauth/authorize?client_id={$appId}&redirect_uri={$redirectUri}&response_type=code&state={$loginSession->id}"; + $authUrl = "https://passport.feishu.cn/suite/passport/oauth/authorize?client_id={$feishuApp->app_id}&redirect_uri={$redirectUri}&response_type=code&state={$loginSession->id}"; + + error_log("✅ 生成二维码会话: session_id={$loginSession->id}"); - // 生成二维码内容(返回 URL,前端生成二维码) return $this->success([ 'session_id' => $loginSession->id, 'auth_url' => $authUrl, 'expires_at' => $loginSession->expires_at, ]); } catch (\Exception $e) { + error_log("❌ qrcodeLogin 失败: " . $e->getMessage()); return $this->error($e->getMessage()); } } @@ -224,46 +310,191 @@ class AuthController extends BaseController // OAuth 2.0 标准参数是 code $authCode = $request->get('code'); + error_log("🔔 QRCode callback received - session_id: {$sessionId}, code: " . substr($authCode ?? 'null', 0, 20) . "..."); + + // 如果没有参数,直接返回空页面(可能是 QR SDK 的预检请求) + if (!$sessionId && !$authCode) { + error_log("ℹ️ Callback called without parameters, returning empty page"); + return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], ''); + } + if (!$sessionId) { + error_log("❌ Callback missing session_id"); return $this->error('缺少会话ID'); } if (!$authCode) { + error_log("❌ Callback missing auth_code"); return $this->error('缺少授权码'); } try { $session = \app\model\FeishuLoginSession::find($sessionId); + error_log("🔍 Session found: " . ($session ? "id={$session->id}, status={$session->status}" : "null")); + if (!$session) { + error_log("❌ Session not found for id: {$sessionId}"); return $this->error('登录会话不存在'); } // 检查是否过期 if (strtotime($session->expires_at) < time()) { + error_log("⏰ Session expired at: {$session->expires_at}"); $session->status = 'expired'; $session->save(); return $this->error('二维码已过期'); } + error_log("🔄 Processing login for company_id: {$session->company_id}"); + // 使用 session 的公司信息 $feishuService = new \app\service\FeishuService(); $feishuService->setCompany($session->company_id); // 获取用户信息 $feishuData = $feishuService->getUserInfo($authCode); + error_log("👤 Got user info: " . json_encode($feishuData)); // 创建或更新用户 $user = $feishuService->createOrUpdateUser($feishuData, $session->company_id); + error_log("✅ User created/updated: user_id={$user->id}"); // 更新会话状态 $session->status = 'success'; $session->user_id = $user->id; - $session->save(); + $saved = $session->save(); + error_log("💾 Session save result: " . ($saved ? 'success' : 'failed') . ", new status: {$session->status}"); - // 返回极简空白页面(飞书APP内扫码通常不会真正访问此URL) - // 飞书会在APP内直接处理回调,返回到之前的界面 - return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], ''); + // 生成 JWT Token + $token = \app\util\JwtUtil::encode([ + 'user_id' => $user->id, + 'company_id' => $user->company_id, + 'feishu_user_id' => $user->feishu_user_id, + 'union_id' => $user->feishu_union_id, + ]); + error_log("🎫 Generated token for user: {$user->id}"); + + // 返回成功页面(包含 token,前端会保存到 localStorage) + $userName = htmlspecialchars($user->name ?? '用户'); + // 准备用户数据JSON + $userJson = json_encode([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'avatar' => $user->avatar, + 'phone' => $user->phone, + 'company_id' => $user->company_id, + 'status' => $user->status, + ], JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE); + + $html = << + + + + + 登录成功 + + + +
+
✅
+

登录成功!

+

欢迎回来,{$userName}

+

正在跳转到应用...

+
+
+ + + +HTML; + return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], $html); } catch (\Exception $e) { return $this->error($e->getMessage()); } diff --git a/backend/app/controller/UserController.php b/backend/app/controller/UserController.php index 1893380..f73cf64 100644 --- a/backend/app/controller/UserController.php +++ b/backend/app/controller/UserController.php @@ -60,6 +60,47 @@ class UserController extends BaseController return $this->success(); } + /** + * 修改密码 + */ + public function changePassword(Request $request): Response + { + $userId = $this->getUserId($request); + $user = User::find($userId); + + if (!$user) { + return $this->error('用户不存在'); + } + + $oldPassword = $request->post('old_password'); + $newPassword = $request->post('new_password'); + $confirmPassword = $request->post('confirm_password'); + + // 验证参数 + if (!$oldPassword || !$newPassword || !$confirmPassword) { + return $this->error('请填写完整信息'); + } + + if (strlen($newPassword) < 6) { + return $this->error('新密码长度不能少于6位'); + } + + if ($newPassword !== $confirmPassword) { + return $this->error('两次输入的密码不一致'); + } + + // 验证旧密码 + if ($user->password && !password_verify($oldPassword, $user->password)) { + return $this->error('原密码错误'); + } + + // 更新密码 + $user->password = password_hash($newPassword, PASSWORD_DEFAULT); + $user->save(); + + return $this->success(null, '密码修改成功'); + } + /** * 获取用户列表(用于选择审批人等) */ diff --git a/backend/app/service/FeishuNotificationService.php b/backend/app/service/FeishuNotificationService.php new file mode 100644 index 0000000..8491adf --- /dev/null +++ b/backend/app/service/FeishuNotificationService.php @@ -0,0 +1,478 @@ +companyId = $companyId; + return $this; + } + + /** + * 设置飞书应用ID(支持多应用) + */ + public function setApp(int $appId): self + { + $this->appId = $appId; + return $this; + } + + /** + * 获取飞书应用配置 + */ + private function getAppConfig(): array + { + if (!$this->companyId) { + throw new Exception('未设置公司ID'); + } + + if ($this->appId) { + $app = \app\model\CompanyFeishuApp::find($this->appId); + if (!$app || $app->status !== 1) { + throw new Exception('飞书应用配置不存在'); + } + return $app->toArray(); + } + + $app = \app\model\CompanyFeishuApp::where('company_id', $this->companyId) + ->where('system_id', 1) + ->where('status', 1) + ->orderBy('sort') + ->first(); + + if (!$app) { + throw new Exception('未配置飞书应用'); + } + + return $app->toArray(); + } + + /** + * 获取 access_token + */ + private function getAccessToken(): string + { + $app = $this->getAppConfig(); + return FeishuUtil::getAppAccessToken($app['id']); + } + + /** + * 发送审批通知 + * + * @param array $params + * - user_id: 接收人ID + * - title: 通知标题 + * - applicant: 申请人姓名 + * - type: 申请类型 + * - content: 申请内容摘要 + * - url: 详情链接 + * - approve_url: 通过按钮链接(可选) + * - reject_url: 拒绝按钮链接(可选) + * @return bool + */ + public function sendApprovalNotification(array $params): bool + { + $userId = $params['user_id'] ?? null; + if (!$userId) { + error_log('❌ sendApprovalNotification: 缺少 user_id'); + return false; + } + + try { + $user = User::find($userId); + if (!$user || !$user->feishu_user_id) { + error_log("❌ sendApprovalNotification: 用户不存在或未绑定飞书, user_id={$userId}"); + return false; + } + + $card = $this->buildApprovalCard($params); + return $this->sendMessage($user->feishu_user_id, $card); + } catch (Exception $e) { + error_log('❌ sendApprovalNotification 失败: ' . $e->getMessage()); + return false; + } + } + + /** + * 发送系统通知 + * + * @param array $params + * - user_ids: 接收人ID数组 + * - title: 通知标题 + * - content: 通知内容 + * - url: 跳转链接(可选) + * @return array 返回每个用户的发送结果 + */ + public function sendSystemNotification(array $params): array + { + $userIds = $params['user_ids'] ?? []; + if (empty($userIds)) { + error_log('❌ sendSystemNotification: 缺少 user_ids'); + return []; + } + + $results = []; + foreach ($userIds as $userId) { + try { + $user = User::find($userId); + if (!$user || !$user->feishu_user_id) { + $results[$userId] = false; + continue; + } + + $card = $this->buildSystemCard($params); + $results[$userId] = $this->sendMessage($user->feishu_user_id, $card); + } catch (Exception $e) { + error_log("❌ sendSystemNotification 失败: user_id={$userId}, error=" . $e->getMessage()); + $results[$userId] = false; + } + } + + return $results; + } + + /** + * 发送提醒通知 + * + * @param array $params + * - user_id: 接收人ID + * - title: 提醒标题 + * - content: 提醒内容 + * - level: 提醒级别 (info/warning/urgent) + * - url: 跳转链接(可选) + * @return bool + */ + public function sendReminderNotification(array $params): bool + { + $userId = $params['user_id'] ?? null; + if (!$userId) { + error_log('❌ sendReminderNotification: 缺少 user_id'); + return false; + } + + try { + $user = User::find($userId); + if (!$user || !$user->feishu_user_id) { + error_log("❌ sendReminderNotification: 用户不存在或未绑定飞书, user_id={$userId}"); + return false; + } + + $card = $this->buildReminderCard($params); + return $this->sendMessage($user->feishu_user_id, $card); + } catch (Exception $e) { + error_log('❌ sendReminderNotification 失败: ' . $e->getMessage()); + return false; + } + } + + /** + * 批量发送通知 + * + * @param array $params + * - user_ids: 接收人ID数组 + * - type: 通知类型 (approval/system/reminder) + * - 其他参数根据类型不同而不同 + * @return array + */ + public function sendBatchNotification(array $params): array + { + $userIds = $params['user_ids'] ?? []; + $type = $params['type'] ?? 'system'; + + $results = []; + foreach ($userIds as $userId) { + $notifyParams = array_merge($params, ['user_id' => $userId]); + unset($notifyParams['user_ids']); + + switch ($type) { + case 'approval': + $results[$userId] = $this->sendApprovalNotification($notifyParams); + break; + case 'reminder': + $results[$userId] = $this->sendReminderNotification($notifyParams); + break; + case 'system': + default: + $results[$userId] = $this->sendSystemNotification(['user_ids' => [$userId]] + $params)[$userId] ?? false; + break; + } + } + + return $results; + } + + /** + * 构建审批通知卡片 + */ + private function buildApprovalCard(array $params): array + { + $title = $params['title'] ?? '新的审批申请'; + $applicant = $params['applicant'] ?? '未知'; + $type = $params['type'] ?? '申请'; + $content = $params['content'] ?? ''; + $url = $params['url'] ?? ''; + $approveUrl = $params['approve_url'] ?? ''; + $rejectUrl = $params['reject_url'] ?? ''; + + $elements = [ + [ + 'tag' => 'div', + 'fields' => [ + [ + 'is_short' => true, + 'text' => [ + 'content' => "**申请人**: {$applicant}", + 'tag' => 'lark_md', + ], + ], + [ + 'is_short' => true, + 'text' => [ + 'content' => "**申请类型**: {$type}", + 'tag' => 'lark_md', + ], + ], + ], + ], + ]; + + if ($content) { + $elements[] = [ + 'tag' => 'div', + 'text' => [ + 'content' => "**申请内容**:\n{$content}", + 'tag' => 'lark_md', + ], + ]; + } + + // 构建操作按钮 + $actions = []; + + if ($approveUrl) { + $actions[] = [ + 'tag' => 'button', + 'text' => [ + 'content' => '通过', + 'tag' => 'plain_text', + ], + 'type' => 'primary', + 'url' => $approveUrl, + ]; + } + + if ($rejectUrl) { + $actions[] = [ + 'tag' => 'button', + 'text' => [ + 'content' => '拒绝', + 'tag' => 'plain_text', + ], + 'type' => 'default', + 'url' => $rejectUrl, + ]; + } + + if ($url) { + $actions[] = [ + 'tag' => 'button', + 'text' => [ + 'content' => '查看详情', + 'tag' => 'plain_text', + ], + 'type' => 'default', + 'url' => $url, + ]; + } + + if (!empty($actions)) { + $elements[] = [ + 'tag' => 'action', + 'actions' => $actions, + ]; + } + + return [ + 'header' => [ + 'title' => [ + 'content' => "📋 {$title}", + 'tag' => 'plain_text', + ], + 'template' => 'blue', + ], + 'elements' => $elements, + ]; + } + + /** + * 构建系统通知卡片 + */ + private function buildSystemCard(array $params): array + { + $title = $params['title'] ?? '系统通知'; + $content = $params['content'] ?? ''; + $url = $params['url'] ?? ''; + + $elements = []; + + if ($content) { + $elements[] = [ + 'tag' => 'div', + 'text' => [ + 'content' => $content, + 'tag' => 'plain_text', + ], + ]; + } + + if ($url) { + $elements[] = [ + 'tag' => 'action', + 'actions' => [ + [ + 'tag' => 'button', + 'text' => [ + 'content' => '查看详情', + 'tag' => 'plain_text', + ], + 'type' => 'default', + 'url' => $url, + ], + ], + ]; + } + + return [ + 'header' => [ + 'title' => [ + 'content' => "🔔 {$title}", + 'tag' => 'plain_text', + ], + 'template' => 'gray', + ], + 'elements' => $elements, + ]; + } + + /** + * 构建提醒通知卡片 + */ + private function buildReminderCard(array $params): array + { + $title = $params['title'] ?? '提醒'; + $content = $params['content'] ?? ''; + $level = $params['level'] ?? 'info'; // info, warning, urgent + $url = $params['url'] ?? ''; + + // 根据级别选择模板颜色 + $templateMap = [ + 'info' => 'blue', + 'warning' => 'orange', + 'urgent' => 'red', + ]; + $template = $templateMap[$level] ?? 'blue'; + + // 根据级别选择图标 + $iconMap = [ + 'info' => '💡', + 'warning' => '⚠️', + 'urgent' => '🚨', + ]; + $icon = $iconMap[$level] ?? '💡'; + + $elements = []; + + if ($content) { + $elements[] = [ + 'tag' => 'div', + 'text' => [ + 'content' => $content, + 'tag' => 'plain_text', + ], + ]; + } + + if ($url) { + $elements[] = [ + 'tag' => 'action', + 'actions' => [ + [ + 'tag' => 'button', + 'text' => [ + 'content' => '查看', + 'tag' => 'plain_text', + ], + 'type' => 'primary', + 'url' => $url, + ], + ], + ]; + } + + return [ + 'header' => [ + 'title' => [ + 'content' => "{$icon} {$title}", + 'tag' => 'plain_text', + ], + 'template' => $template, + ], + 'elements' => $elements, + ]; + } + + /** + * 发送消息到飞书 + */ + private function sendMessage(string $feishuUserId, array $card): bool + { + try { + $token = $this->getAccessToken(); + + $response = HttpClient::httpPost( + "https://open.feishu.cn/open-apis/message/v4/send_messages", + [ + 'receive_id_type' => 'user_id', + 'receive_id' => $feishuUserId, + 'msg_type' => 'interactive', + 'content' => json_encode(['card' => $card]), + ], + [ + 'Authorization: Bearer ' . $token, + ] + ); + + $code = $response['code'] ?? -1; + $success = $code === 0; + + if ($success) { + error_log("✅ 飞书消息发送成功: feishu_user_id={$feishuUserId}"); + } else { + error_log("❌ 飞书消息发送失败: feishu_user_id={$feishuUserId}, code={$code}, msg=" . ($response['msg'] ?? '')); + } + + return $success; + } catch (Exception $e) { + error_log('❌ sendMessage 异常: ' . $e->getMessage()); + return false; + } + } +} diff --git a/backend/app/service/FeishuService.php b/backend/app/service/FeishuService.php index 23224f2..27c6074 100644 --- a/backend/app/service/FeishuService.php +++ b/backend/app/service/FeishuService.php @@ -122,6 +122,7 @@ class FeishuService /** * 创建或更新用户 + * 注意:同一用户在不同企业的账号是独立的,通过 company_id + union_id 组合查找 */ public function createOrUpdateUser(array $feishuData, int $companyId): User { @@ -135,27 +136,47 @@ class FeishuService throw new Exception('缺少 union_id'); } - // 查找或创建用户 - $user = User::byFeishuUnionId($unionId)->first(); + // ⭐ 关键:通过 company_id + union_id 组合查找用户 + // 这样同一个 union_id 在不同公司的账号是独立的 + $user = User::where('company_id', $companyId) + ->where('union_id', $unionId) + ->first(); if (!$user) { + error_log("✨ 创建新用户: company={$companyId}, union_id={$unionId}, name={$name}"); + + // 初始密码使用 sn(工号),如果没有 sn 则使用 union_id 的前6位 + $initialPassword = $feishuData['sn'] ?? substr($unionId, 0, 6); + $user = User::create([ 'company_id' => $companyId, 'feishu_user_id' => $userId, - 'feishu_union_id' => $unionId, + 'union_id' => $unionId, 'name' => $name, 'email' => $email, 'avatar' => $avatar, - 'status' => 1, + 'password' => password_hash($initialPassword, PASSWORD_DEFAULT), // 初始密码加密 + 'status' => 1, // 新用户默认在职 ]); + error_log("🔑 初始密码设置为 sn: {$initialPassword}"); } else { - // 更新用户信息 - $user->update([ + error_log("🔄 更新用户: id={$user->id}, company={$companyId}, union_id={$unionId}, 当前状态={$user->status}"); + + // 离职员工禁止登录 + if ($user->status == 0) { + error_log("❌ 离职员工尝试登录: id={$user->id}, status=0"); + throw new Exception('该员工已离职,无法登录系统'); + } + + // 更新用户信息(在职员工) + $updateData = [ 'feishu_user_id' => $userId, 'name' => $name, 'avatar' => $avatar, 'email' => $email, - ]); + ]; + + $user->update($updateData); } return $user; diff --git a/backend/app/util/FeishuUtil.php b/backend/app/util/FeishuUtil.php index fbf9eef..7dacb8c 100644 --- a/backend/app/util/FeishuUtil.php +++ b/backend/app/util/FeishuUtil.php @@ -50,7 +50,8 @@ class FeishuUtil ]); if ($response['code'] !== 0) { - throw new Exception('获取 app_access_token 失败: ' . ($response['msg'] ?? '未知错误')); + $errorMsg = $response['msg'] ?? $response['message'] ?? '未知错误'; + throw new Exception('获取 app_access_token 失败: ' . $errorMsg); } $token = $response['tenant_access_token']; @@ -109,7 +110,8 @@ class FeishuUtil error_log("📝 获取 user_access_token 响应: " . json_encode($response)); if ($response['code'] !== 0) { - throw new Exception('获取 user_access_token 失败: ' . ($response['msg'] ?? '未知错误')); + $errorMsg = $response['msg'] ?? $response['message'] ?? '未知错误'; + throw new Exception('获取 user_access_token 失败: ' . $errorMsg); } if (!isset($response['data']['access_token'])) { @@ -149,7 +151,8 @@ class FeishuUtil error_log("📝 获取用户信息响应: " . json_encode($response)); if ($response['code'] !== 0) { - throw new Exception('获取用户信息失败: ' . ($response['msg'] ?? '未知错误')); + $errorMsg = $response['msg'] ?? $response['message'] ?? '未知错误'; + throw new Exception('获取用户信息失败: ' . $errorMsg); } $userInfo = $response['data'] ?? []; diff --git a/backend/config/route.php b/backend/config/route.php index d30ef7f..f995bb3 100644 --- a/backend/config/route.php +++ b/backend/config/route.php @@ -24,6 +24,7 @@ Route::group('/api/v1/user', function () { Route::get('/companies', [app\controller\UserController::class, 'companies']); Route::get('/list', [app\controller\UserController::class, 'list']); Route::post('/switch-company', [app\controller\UserController::class, 'switchCompany']); + Route::post('/change-password', [app\controller\UserController::class, 'changePassword']); })->middleware($authMiddleware); // 审批路由(需要认证) diff --git a/docs/feishu-qrcode-login.md b/docs/feishu-qrcode-login.md new file mode 100644 index 0000000..e63f3ab --- /dev/null +++ b/docs/feishu-qrcode-login.md @@ -0,0 +1,1041 @@ +# 飞书登录实现指南 + +本文档包含两种飞书登录方式: +1. **扫码登录** - 用户在飞书外打开应用,扫描二维码登录 +2. **静默授权(免登)** - 用户在飞书客户端内打开应用,直接获取身份登录 + +--- + +## 一、前置条件 + +### 1.1 飞书开放平台配置 + +**必须配置**(关键!否则回调不会被调用): + +1. 登录 [飞书开放平台](https://open.feishu.cn/) +2. 选择应用(如 `cli_a8033d607cba500d`) +3. 进入 **权限管理** → **安全设置** → **重定向 URL** +4. 添加回调地址:`https://your-domain.com/login/callback` +5. **保存并发布应用** + +### 1.2 数据库表 + +确保存在以下表: + +```sql +-- 飞书登录会话表 +CREATE TABLE feishu_login_session ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + tenant_key VARCHAR(255) NOT NULL, + status ENUM('pending', 'scanning', 'success', 'expired') DEFAULT 'pending', + user_id INT DEFAULT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 公司飞书应用配置表 +CREATE TABLE company_feishu_apps ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + app_id VARCHAR(255) NOT NULL, + app_secret VARCHAR(255) NOT NULL, + callback_url VARCHAR(500), + mobile_callback_url VARCHAR(500), + status TINYINT DEFAULT 1 +); +``` + +--- + +## 二、后端实现 + +### 2.1 路由配置 + +```php +// backend/config/route.php + +// 飞书扫码登录相关路由 +Route::post('/api/v1/auth/qrcode-login', [app\controller\AuthController::class, 'qrcodeLogin']); +Route::get('/api/v1/auth/qrcode-login/status', [app\controller\AuthController::class, 'qrcodeLoginStatus']); +Route::get('/login/callback', [app\controller\AuthController::class, 'qrcodeLoginCallback']); +``` + +### 2.2 控制器实现 + +```php +post('tenant_key'); + + if (!$tenantKey) { + return $this->error('缺少租户密钥'); + } + + try { + $feishuService = new \app\service\FeishuService(); + + // 根据 tenant_key 获取公司 + $company = $feishuService->getCompanyByTenantKey($tenantKey); + if (!$company) { + return $this->error('公司不存在'); + } + + $feishuService->setCompany($company->id); + + // 获取飞书应用配置 + $feishuApp = \app\model\CompanyFeishuApp::where('company_id', $company->id) + ->where('system_id', 1) + ->where('status', 1) + ->first(); + + if (!$feishuApp) { + return $this->error('未配置飞书应用'); + } + + // 生成登录会话(5分钟过期) + $loginSession = \app\model\FeishuLoginSession::create([ + 'company_id' => $company->id, + 'tenant_key' => $tenantKey, + 'status' => 'pending', + 'expires_at' => date('Y-m-d H:i:s', time() + 300), + ]); + + // 构建扫码登录 URL + $redirectUri = urlencode("https://your-domain.com/login/callback"); + $authUrl = "https://passport.feishu.cn/suite/passport/oauth/authorize?" + . "client_id={$feishuApp->app_id}&" + . "redirect_uri={$redirectUri}&" + . "response_type=code&" + . "state={$loginSession->id}"; + + return $this->success([ + 'session_id' => $loginSession->id, + 'auth_url' => $authUrl, + 'expires_at' => $loginSession->expires_at, + ]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 轮询扫码登录状态 + * GET /api/v1/auth/qrcode-login/status?session_id=xxx + */ + public function qrcodeLoginStatus(Request $request): Response + { + $sessionId = $request->get('session_id'); + + if (!$sessionId) { + return $this->error('缺少会话ID'); + } + + $session = \app\model\FeishuLoginSession::find($sessionId); + + if (!$session) { + return $this->error('登录会话不存在'); + } + + // 检查是否过期 + if (strtotime($session->expires_at) < time()) { + $session->status = 'expired'; + $session->save(); + return $this->error('二维码已过期,请刷新重试'); + } + + // 返回当前状态 + if ($session->status === 'success') { + $user = \app\model\User::find($session->user_id); + if (!$user) { + return $this->error('用户不存在'); + } + + $token = \app\util\JwtUtil::encode([ + 'user_id' => $user->id, + 'company_id' => $user->company_id, + 'feishu_user_id' => $user->feishu_user_id, + 'union_id' => $user->feishu_union_id, + ]); + + // 清除会话 + $session->delete(); + + return $this->success([ + 'status' => 'success', + 'token' => $token, + 'user' => $user->load('company'), + ]); + } else if ($session->status === 'expired') { + return $this->error('二维码已过期,请刷新重试'); + } else { + // pending 或 scanning + return $this->success([ + 'status' => $session->status, + ]); + } + } + + /** + * 飞书扫码登录回调 + * GET /login/callback?code=xxx&state=xxx + */ + public function qrcodeLoginCallback(Request $request): Response + { + $sessionId = $request->get('state'); + $authCode = $request->get('code'); + + error_log("🔔 QRCode callback - session_id: {$sessionId}"); + + // 无参数时返回空页面(SDK 初始化时的预检请求) + if (!$sessionId && !$authCode) { + return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], ''); + } + + if (!$sessionId || !$authCode) { + return $this->error('参数错误'); + } + + try { + $session = \app\model\FeishuLoginSession::find($sessionId); + if (!$session) { + return $this->error('登录会话不存在'); + } + + // 检查是否过期 + if (strtotime($session->expires_at) < time()) { + $session->status = 'expired'; + $session->save(); + return $this->error('二维码已过期'); + } + + // 获取用户信息 + $feishuService = new \app\service\FeishuService(); + $feishuService->setCompany($session->company_id); + $feishuData = $feishuService->getUserInfo($authCode); + + // 创建或更新用户 + $user = $feishuService->createOrUpdateUser($feishuData, $session->company_id); + + // 更新会话状态 + $session->status = 'success'; + $session->user_id = $user->id; + $session->save(); + + // 生成 JWT Token + $token = \app\util\JwtUtil::encode([ + 'user_id' => $user->id, + 'company_id' => $user->company_id, + 'feishu_user_id' => $user->feishu_user_id, + 'union_id' => $user->feishu_union_id, + ]); + + // 返回包含 token 的 HTML 页面 + $userName = htmlspecialchars($user->name ?? '用户'); + $html = $this->getSuccessHtml($token, $user->id, $userName); + + return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], $html); + + } catch (\Exception $e) { + error_log("❌ Callback error: " . $e->getMessage()); + return $this->error($e->getMessage()); + } + } + + /** + * 生成登录成功 HTML 页面 + */ + private function getSuccessHtml(string $token, int $userId, string $userName): string + { + return << + + + + + 登录成功 + + + +
+
✅
+

登录成功!

+

欢迎回来,{$userName}

+

正在跳转到应用...

+
+
+ + + +HTML; + } +} +``` + +--- + +## 三、飞书静默授权(免登)实现 + +飞书静默授权是指用户在飞书客户端内打开应用时,可以直接获取用户身份登录,无需扫码或输入密码。 + +### 3.1 后端接口 + +后端已有 `/api/v1/auth/feishu-login` 接口,用于处理飞书免登: + +```php +// backend/app/controller/AuthController.php + +/** + * 飞书免登 + */ +public function feishuLogin(Request $request): Response +{ + $authCode = $request->post('auth_code'); + $tenantKey = $request->post('tenant_key'); + + if (!$authCode) { + return $this->error('缺少授权码'); + } + + try { + $feishuService = new FeishuService(); + + // 根据 tenant_key 获取公司 + $company = $feishuService->getCompanyByTenantKey($tenantKey); + if (!$company) { + return $this->error('公司不存在'); + } + + // 设置公司并获取用户信息 + $feishuService->setCompany($company->id); + $feishuData = $feishuService->getUserInfo($authCode); + + // 创建或更新用户 + $user = $feishuService->createOrUpdateUser($feishuData, $company->id); + + // 生成 JWT Token + $token = JwtUtil::encode([ + 'user_id' => $user->id, + 'company_id' => $user->company_id, + 'feishu_user_id' => $user->feishu_user_id, + 'union_id' => $user->union_id, + ]); + + return $this->success([ + 'token' => $token, + 'user' => $user->load('company'), + ]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } +} +``` + +### 3.2 前端实现 + +#### 3.2.1 引入飞书 JSAPI SDK + +在 `index.html` 中添加(**注意**:需要同时引入 JSAPI SDK 和 QR SDK): + +```html + + + + +``` + +#### 3.2.2 类型声明 + +```typescript +// src/types/feishu.d.ts + +declare global { + interface Window { + // 飞书 JSAPI(免登) + tt?: { + ready: (callback: () => void) => void + requestAuthCode: (params: { + appId: string + success?: (res: { code: string }) => void + fail?: (err: any) => void + }) => void + checkJsApi?: (params: { + jsApiList: string[] + success: (res: any) => void + fail?: (err: any) => void + }) => void + } + // 飞书 QR SDK(扫码登录) + QRLogin?: (config: { + id: string + goto: string + width?: string + height?: string + style?: string + }) => { + matchOrigin: (origin: string) => boolean + matchData: (data: any) => boolean + } + } +} + +export {} +``` + +#### 3.2.3 登录组件实现 + +```typescript +// src/pages/login/index.tsx + +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Toast } from 'antd-mobile' +import { useUserStore } from '@/stores/user' + +export default function Login() { + const navigate = useNavigate() + const { login } = useUserStore() + const [loading, setLoading] = useState(true) + const [environment, setEnvironment] = useState<'feishu' | 'external' | null>(null) + + useEffect(() => { + // 检测是否在飞书客户端内 + const ua = navigator.userAgent.toLowerCase() + const isInFeishu = ua.includes('feishu') || ua.includes('lark') || window.tt !== undefined + + if (isInFeishu) { + // 飞书内:静默授权 + setEnvironment('feishu') + handleFeishuAutoLogin() + } else { + // 飞书外:显示登录选项 + setEnvironment('external') + setLoading(false) + } + }, []) + + // 飞书内静默授权 + const handleFeishuAutoLogin = () => { + const appId = 'cli_a8033d607cba500d' + const maxWaitTime = 5000 // 最多等待 5 秒 + const checkInterval = 100 // 每 100ms 检查一次 + let elapsedTime = 0 + + // 等待 tt API 加载完成 + const waitForTT = () => { + return new Promise((resolve, reject) => { + const checkTT = () => { + elapsedTime += checkInterval + + if (window.tt && typeof window.tt.requestAuthCode === 'function') { + resolve() + } else if (elapsedTime >= maxWaitTime) { + reject(new Error('tt API 加载超时')) + } else { + setTimeout(checkTT, checkInterval) + } + } + checkTT() + }) + } + + // 执行飞书免登 + const doFeishuLogin = () => { + if (!window.tt) { + throw new Error('tt API 未加载') + } + + // 调用 tt.ready 确保 SDK 准备就绪 + window.tt.ready(() => { + // 调用 requestAuthCode 获取授权码 + window.tt.requestAuthCode({ + appId: appId, + success: (res) => { + // 用授权码登录后端 + handleFeishuLogin(res.code) + }, + fail: (error: any) => { + console.error('requestAuthCode failed:', error) + setLoading(false) + Toast.show('获取授权失败') + }, + }) + }) + } + + // 开始执行 + waitForTT() + .then(() => doFeishuLogin()) + .catch((error) => { + console.error('Feishu auto login failed:', error) + setLoading(false) + Toast.show('飞书免登失败') + }) + } + + // 处理飞书登录 + const handleFeishuLogin = async (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: 'your_tenant_key', + }), + }) + + const result = await response.json() + + if (result.code === 0) { + login(result.data.user, result.data.token) + Toast.show('登录成功') + navigate('/dashboard') + } else { + Toast.show(result.message || '登录失败') + setLoading(false) + } + } catch (error) { + console.error('登录失败:', error) + Toast.show('登录失败,请重试') + setLoading(false) + } + } + + if (loading) { + return
正在登录...
+ } + + return ( +
+

企业工具台

+ {environment === 'external' && ( +

💡 提示:在飞书客户端内打开可自动登录

+ )} + {/* 显示其他登录方式... */} +
+ ) +} +``` + +### 3.3 飞书免登流程 + +``` +┌────────────────────────────────────────────────────────────┐ +│ 用户在飞书客户端内打开应用 │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 前端检测环境 (navigator.userAgent 或 window.tt) │ +│ - 检测到飞书环境 → 执行免登流程 │ +│ - 未检测到 → 显示登录选项(扫码登录等) │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 等待飞书 JSAPI SDK 加载完成 │ +│ - 检测 window.tt.requestAuthCode 是否可用 │ +│ - 超时时间:5 秒 │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 调用 tt.ready() 确保 SDK 准备就绪 │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 调用 tt.requestAuthCode({ appId, success, fail }) │ +│ - appId: 飞书应用 ID │ +│ - success: 回调函数,返回 { code: "xxx" } │ +│ - fail: 错误回调 │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 获取 auth_code(临时授权码) │ +│ - 有效期:5 分钟 │ +│ - 只能使用一次 │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 前端调用后端接口 POST /api/v1/auth/feishu-login │ +│ { auth_code: "xxx", tenant_key: "xxx" } │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 后端处理流程 │ +│ 1. 用 auth_code 换取 user_access_token │ +│ 2. 获取用户信息 │ +│ 3. 创建/更新用户记录 │ +│ 4. 生成 JWT token │ +│ 5. 返回 { token, user } │ +└───────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ 前端保存 token 并跳转到首页 │ +│ - localStorage.setItem('token', token) │ +│ - navigate('/dashboard') │ +└────────────────────────────────────────────────────────────┘ +``` + +### 3.4 关键要点 + +| 要点 | 说明 | +|------|------| +| **SDK 引入** | 必须引入飞书 JSAPI SDK | +| **环境检测** | 通过 `navigator.userAgent` 或 `window.tt` 检测 | +| **tt.ready** | 调用前必须确保 SDK 准备就绪 | +| **auth_code** | 临时授权码,5 分钟有效期,只能用一次 | +| **错误处理** | 需要处理 SDK 加载超时、授权失败等情况 | + +--- + +## 四、前端实现(扫码登录) + +### 4.1 引入飞书 QR SDK + +在 `index.html` 中添加(**注意**:需与 JSAPI SDK 一起引入): + +```html + + + + +``` + +### 4.2 类型声明 + +```typescript +// src/types/feishu.d.ts + +declare global { + interface Window { + QRLogin?: (config: { + id: string + goto: string + width?: string + height?: string + style?: string + }) => { + matchOrigin: (origin: string) => boolean + matchData: (data: any) => boolean + } + } +} + +export {} +``` + +### 3.3 React 组件实现 + +```typescript +// src/pages/login/QRCodeLogin.tsx + +import { useState, useEffect, useRef } from 'react' +import { Dialog, Toast } from 'antd-mobile' +import { useNavigate } from 'react-router-dom' +import styles from './QRCodeLogin.module.css' + +interface QRCodeLoginProps { + visible: boolean + onClose: () => void +} + +export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) { + const navigate = useNavigate() + const [status, setStatus] = useState<'loading' | 'pending' | 'scanning' | 'success'>('loading') + const [qrcodeUrl, setQrcodeUrl] = useState('') + const qrContainerRef = useRef(null) + const qrObjRef = useRef(null) + + useEffect(() => { + if (visible) { + initQRCode() + } + + return () => { + // 清理 + if (qrObjRef.current?._cleanup) { + qrObjRef.current._cleanup() + } + if (qrContainerRef.current) { + qrContainerRef.current.innerHTML = '' + } + qrObjRef.current = null + } + }, [visible]) + + const initQRCode = async () => { + setStatus('loading') + setQrcodeUrl('') + + try { + // 等待 SDK 加载 + await new Promise(resolve => setTimeout(resolve, 100)) + + // 生成登录会话 + const response = await fetch('/api/v1/auth/qrcode-login', { + method: 'POST', + headers: { 'Content-Type' => 'application/json' }, + body: JSON.stringify({ + tenant_key: 'your_tenant_key', // 替换为实际的 tenant_key + }), + }) + + const result = await response.json() + + if (result.code === 0) { + const authUrl = result.data.auth_url + + if (window.QRLogin) { + setStatus('pending') + + // 等待 DOM 渲染 + const initQRSDK = (retryCount = 0) => { + const container = document.getElementById('feishu-qrcode-container') + + if (container) { + container.innerHTML = '' + + try { + // 初始化 QR SDK + qrObjRef.current = window.QRLogin({ + id: 'feishu-qrcode-container', + goto: authUrl, + width: '250', + height: '250', + style: 'width:300px;height:270px;border:none' + }) + + setStatus('pending') + + // ⭐ 关键:监听扫码后的 message 事件 + const handleMessage = (event: MessageEvent) => { + // 安全校验 + if (!qrObjRef.current?.matchOrigin(event.origin)) return + if (!qrObjRef.current?.matchData(event.data)) return + + // 获取临时授权码 + const loginTmpCode = event.data?.tmp_code + if (loginTmpCode) { + console.log('✅ Got tmp_code:', loginTmpCode) + + // ⭐ 重定向到飞书授权页面(官方推荐方式) + 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) + Toast.show('二维码生成失败') + } + } else if (retryCount < 10) { + setTimeout(() => initQRSDK(retryCount + 1), 100) + } + } + + setTimeout(() => initQRSDK(), 100) + } + } else { + Toast.show(result.message || '生成二维码失败') + } + } catch (error) { + console.error('生成二维码失败:', error) + Toast.show('生成二维码失败') + } + } + + const getStatusText = () => { + switch (status) { + case 'loading': return '正在生成二维码...' + case 'pending': return '请使用飞书扫码登录' + case 'scanning': return '已扫码,请在手机上确认' + case 'success': return '登录成功!' + default: return '' + } + } + + return ( + + {status === 'loading' && ( +
正在生成二维码...
+ )} + + {status !== 'loading' && ( + <> +
+

💡 打开飞书 APP,扫一扫登录

+ + )} + + } + onClose={onClose} + closeOnMaskClick={true} + /> + ) +} +``` + +--- + +## 四、Nginx 配置 + +```nginx +server { + listen 80; + server_name your-domain.com; + + # ⭐ 飞书登录回调代理(必须在前端代理之前) + location /login/callback { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 后端 API 代理 + location /api/ { + proxy_pass http://127.0.0.1:8787/api/; + proxy_http_version 1.1; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 前端代理(放在最后) + location / { + proxy_pass http://127.0.0.1:5173; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +--- + +## 五、完整流程图 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ 用户打开登录页面 │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 前端调用 POST /api/v1/auth/qrcode-login │ +│ - 创建会话 (status: pending) │ +│ - 返回 auth_url │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ QR SDK 初始化 │ +│ - window.QRLogin({ goto: authUrl }) │ +│ - 渲染二维码 │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 用户用飞书 APP 扫码 │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ QR SDK 触发 message 事件 │ +│ - event.data.tmp_code = "xxx-xxx-xxx" │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 前端接收 tmp_code │ +│ - window.location.href = authUrl + "&tmp_code=" + tmp_code │ +│ - ⭐ 页面重定向到飞书授权页面 │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 飞书授权页面加载 │ +│ - URL 包含 tmp_code 参数 │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 用户在手机上点击"允许" │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 飞书重定向到 redirect_uri │ +│ - GET /login/callback?code=xxx&state=session_id │ +│ - ⭐ code 只能使用一次 │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 后端处理回调 │ +│ 1. 验证 state (session_id) │ +│ 2. 用 code 换取 user_access_token │ +│ 3. 获取用户信息 │ +│ 4. 创建/更新用户记录 │ +│ 5. 更新会话状态 (status: success) │ +│ 6. 生成 JWT token │ +│ 7. 返回包含 token 的 HTML 页面 │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ HTML 页面执行 JavaScript │ +│ 1. localStorage.setItem('token', token) │ +│ 2. 跳转到 /dashboard │ +└───────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ 登录成功,用户进入应用 │ +└────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 六、关键要点总结 + +| 要点 | 说明 | +|------|------| +| **redirect_uri 白名单** | ⭐ 最重要!必须在飞书开放平台配置,否则回调不会被调用 | +| **SDK 引入** | 使用官方 SDK: `LarkSSOSDKWebQRCode-1.0.3.js` | +| **message 事件** | 扫码后 SDK 触发 `message` 事件,携带 `tmp_code` | +| **页面重定向** | ⭐ 使用 `window.location.href` 重定向(不是弹窗) | +| **tmp_code 参数** | 追加到 auth_url 后: `&tmp_code=xxx` | +| **code 参数** | 飞书授权后通过 `redirect_uri?code=xxx` 返回,只能使用一次 | +| **token 传递** | 后端通过 HTML 页面将 token 传递给前端 localStorage | +| **会话管理** | 使用 `state` 参数传递 session_id,过期时间 5 分钟 | + +--- + +## 七、常见问题 + +### Q1: 扫码后没有反应 +**A**: 检查 redirect_uri 是否在飞书开放平台白名单中 + +### Q2: 回调接口没有被调用 +**A**: 同上,redirect_uri 必须配置白名单 + +### Q3: 提示 "code 无效" +**A**: code 只能使用一次,不要重复使用测试 code + +### Q4: 二维码不显示 +**A**: 确保 SDK 已加载,DOM 容器已渲染 + +--- + +## 八、相关文件路径 + +``` +/www/x-server-mobile/ +├── backend/ +│ ├── app/controller/AuthController.php # 后端控制器 +│ ├── config/route.php # 路由配置 +│ └── ... +├── frontend/ +│ ├── src/pages/login/QRCodeLogin.tsx # 前端组件 +│ └── index.html # SDK 引入 +└── nginx/ + └── conf.d/xsm.dev.hnxinyouhe.com.conf # Nginx 配置 +``` diff --git a/frontend/index.html b/frontend/index.html index 2d555fe..95e2a1f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ frontend - + diff --git a/frontend/src/components/FeishuAutoLogin.tsx b/frontend/src/components/FeishuAutoLogin.tsx new file mode 100644 index 0000000..56eb9f9 --- /dev/null +++ b/frontend/src/components/FeishuAutoLogin.tsx @@ -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({}) + + 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 ( +
+

企业工具台

+

正在登录...

+
+ {/* 调试信息 */} +
+
URL: {debugInfo.url?.substring(0, 50)}...
+
在飞书中: {debugInfo.isInFeishu ? '✅' : '❌'}
+
tt 对象: {debugInfo.hasTT ? '✅' : '❌'}
+
+ +
+ ) + } + + // 成功状态 + if (status === 'success') { + return ( +
+

✅ 登录成功

+

正在进入应用...

+
+ +
+ ) + } + + // 显示登录选项 + if (status === 'show_options') { + return ( +
+

企业工具台

+

请选择登录方式

+ +
+ {/* 飞书扫码登录 */} + +
+ + {debugInfo.isInFeishu && ( +

+ 💡 检测到在飞书客户端中,但免登功能不可用 +

+ )} + + setShowQRCode(false)} + /> +
+ ) + } + + return null +} diff --git a/frontend/src/components/layout/MobileLayout.tsx b/frontend/src/components/layout/MobileLayout.tsx index 696fe3e..dc26616 100644 --- a/frontend/src/components/layout/MobileLayout.tsx +++ b/frontend/src/components/layout/MobileLayout.tsx @@ -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(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) { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8f72d07..88cb16d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 722e630..d8fcaf7 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index f862af0..0922bdd 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 6210b68..206daa5 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -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": "Ошибка сети, попробуйте позже", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 9fc597d..9209488 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -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": "网络错误,请稍后重试", diff --git a/frontend/src/pages/change-password/change-password.module.css b/frontend/src/pages/change-password/change-password.module.css new file mode 100644 index 0000000..99400f0 --- /dev/null +++ b/frontend/src/pages/change-password/change-password.module.css @@ -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; +} diff --git a/frontend/src/pages/change-password/index.tsx b/frontend/src/pages/change-password/index.tsx new file mode 100644 index 0000000..d7ddda4 --- /dev/null +++ b/frontend/src/pages/change-password/index.tsx @@ -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 ( +
+
+

{t('changePassword.title')}

+ +
+
+ + handleChange('oldPassword', value)} + clearable + /> +
+ +
+ + handleChange('newPassword', value)} + clearable + /> +
+ +
+ + handleChange('confirmPassword', value)} + clearable + /> +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx index 5bcfd13..6dd3982 100644 --- a/frontend/src/pages/dashboard/index.tsx +++ b/frontend/src/pages/dashboard/index.tsx @@ -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 { diff --git a/frontend/src/pages/login/QRCodeLogin.tsx b/frontend/src/pages/login/QRCodeLogin.tsx index 2abd13f..a88f07f 100644 --- a/frontend/src/pages/login/QRCodeLogin.tsx +++ b/frontend/src/pages/login/QRCodeLogin.tsx @@ -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}`) diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index 3e47bac..ed5f988 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -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() {

企业工具台

正在登录...

+
+ {/* 调试信息 */} +
+
调试信息:
+
URL: {window.location.href.substring(0, 60)}...
+
UA: {navigator.userAgent.includes('Lark') ? '✅ 飞书' : '❌ 非飞书'}
+
tt: {window.tt ? '✅ 存在' : '❌ 不存在'}
+ {window.tt && ( +
+ tt属性: {Object.keys(window.tt).join(', ')} +
+ )} +
+
+ + ) + } + + // 邮箱密码登录 + const handlePasswordLogin = () => { + navigate('/login-simple') + } + + + if (loading) { + return ( +
+
+

企业工具台

+

正在登录...

+
+ {/* 调试信息 */} +
+
调试信息:
+
URL: {window.location.href.substring(0, 60)}...
+
UA: {navigator.userAgent.includes('Lark') ? '✅ 飞书' : '❌ 非飞书'}
+
tt: {window.tt ? '✅ 存在' : '❌ 不存在'}
+ {window.tt && ( +
+ tt属性: {Object.keys(window.tt).join(', ')} +
+ )} +
) diff --git a/frontend/src/pages/login/login.module.css b/frontend/src/pages/login/login.module.css index 9cc6817..04b470d 100644 --- a/frontend/src/pages/login/login.module.css +++ b/frontend/src/pages/login/login.module.css @@ -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); + } +} diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx new file mode 100644 index 0000000..4f8d894 --- /dev/null +++ b/frontend/src/pages/settings/index.tsx @@ -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 ( +
+
+

{t('profile.settings')}

+
+ + {settingGroups.map((group) => ( +
+
{group.title}
+
+ {group.items.map((item) => ( +
handleItemClick(item)} + > +
+ {item.icon} +
+
{item.label}
+ {item.description && ( +
{item.description}
+ )} +
+
+ › +
+ ))} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/pages/settings/settings.module.css b/frontend/src/pages/settings/settings.module.css new file mode 100644 index 0000000..b55ba1b --- /dev/null +++ b/frontend/src/pages/settings/settings.module.css @@ -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; +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 4642167..b14efe1 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -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: }, { path: 'dashboard', element: }, + { path: 'profile', element: }, + { path: 'settings', element: }, + { path: 'change-password', element: }, { path: 'business-trip', element: }, { path: 'business-trip/apply', element: }, { path: 'business-trip/apply/select-approver', element: }, @@ -29,7 +35,7 @@ const router = createBrowserRouter([ }, { path: '/login', element: }, { path: '/login-simple', element: }, - { path: '*', element: }, + { path: '*', element: }, ]) export default router diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 28a6752..31076e2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 ========== diff --git a/nginx/x-server-api.conf b/nginx/x-server-api.conf index d73ddc6..662f1af 100644 --- a/nginx/x-server-api.conf +++ b/nginx/x-server-api.conf @@ -1,5 +1,24 @@ # 飞书登录回调代理 # 注意:location 不带 trailing slash,proxy_pass 不带路径 +# 必须在前端代理之前配置 + +# /login 用于飞书 OAuth 回调 +location /login { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /login/callback { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + location /login/qrcode { proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1;