feat: 完善鉴权和密码功能,添加飞书通知模块
- 完善Token过期/不存在时跳转登录重新认证 - 实现正常的账号密码登录验证(邮箱/手机号+密码) - 用户初始化密码设为工号(sn) - 添加修改密码功能(API+前端页面) - 修复扫码登录后跳转问题(zustand persist格式) - 创建飞书通知服务 FeishuNotificationService - 设置页面添加修改密码入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录成功</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.icon {
|
||||
font-size: 60px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
p {
|
||||
margin: 10px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">✅</div>
|
||||
<h1>登录成功!</h1>
|
||||
<p>欢迎回来,{$userName}</p>
|
||||
<p>正在跳转到应用...</p>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<script>
|
||||
// 保存 token 到 localStorage
|
||||
try {
|
||||
localStorage.setItem('token', '$token');
|
||||
localStorage.setItem('i18nextLng', 'zh');
|
||||
console.log('✅ Token saved to localStorage');
|
||||
|
||||
// 保存用户信息到 zustand persist storage(正确的格式)
|
||||
const userState = {
|
||||
state: {
|
||||
user: $userJson,
|
||||
token: '$token',
|
||||
currentCompany: null,
|
||||
isLoggedIn: true
|
||||
},
|
||||
version: 0
|
||||
};
|
||||
localStorage.setItem('user-storage', JSON.stringify(userState));
|
||||
console.log('✅ User state saved to localStorage:', userState);
|
||||
|
||||
// 通知父窗口登录成功(如果在弹窗中)
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({
|
||||
type: 'feishu-login-success',
|
||||
data: { token: '$token', user: $userJson }
|
||||
}, '*');
|
||||
setTimeout(function() {
|
||||
window.close();
|
||||
}, 500);
|
||||
} else {
|
||||
// 否则跳转到首页
|
||||
setTimeout(function() {
|
||||
window.location.href = '/dashboard';
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to save data:', e);
|
||||
// 如果 localStorage 不可用,使用 sessionStorage
|
||||
sessionStorage.setItem('token', '$token');
|
||||
setTimeout(function() {
|
||||
window.location.href = '/dashboard';
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], $html);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
@@ -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, '密码修改成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表(用于选择审批人等)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
|
||||
namespace app\service;
|
||||
|
||||
use app\model\User;
|
||||
use app\model\Company;
|
||||
use app\util\FeishuUtil;
|
||||
use app\util\HttpClient;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 飞书通知服务
|
||||
* 提供多种卡片类型的消息通知功能
|
||||
*/
|
||||
class FeishuNotificationService
|
||||
{
|
||||
private ?int $companyId = null;
|
||||
private ?int $appId = null;
|
||||
|
||||
/**
|
||||
* 设置当前操作的公司
|
||||
*/
|
||||
public function setCompany(int $companyId): self
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] ?? [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 审批路由(需要认证)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<!-- 飞书扫码登录 SDK -->
|
||||
<!-- 飞书扫码登录 SDK(飞书外扫码登录用) -->
|
||||
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import QRCodeLogin from '@/pages/login/QRCodeLogin'
|
||||
|
||||
// 声明飞书 JSAPI 类型
|
||||
declare global {
|
||||
interface Window {
|
||||
tt?: any
|
||||
ttjsBridge?: any
|
||||
}
|
||||
}
|
||||
|
||||
export default function FeishuAutoLogin() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isLoggedIn } = useUserStore()
|
||||
const [status, setStatus] = useState<'checking' | 'success' | 'failed' | 'show_options'>('checking')
|
||||
const [showQRCode, setShowQRCode] = useState(false)
|
||||
const [debugInfo, setDebugInfo] = useState<any>({})
|
||||
|
||||
useEffect(() => {
|
||||
// 如果已登录,直接跳转
|
||||
if (isLoggedIn) {
|
||||
navigate('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent
|
||||
const currentUrl = window.location.href
|
||||
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
|
||||
|
||||
console.log('=== 首页免登检测 ===')
|
||||
console.log('URL:', currentUrl)
|
||||
console.log('在飞书中:', isInFeishu)
|
||||
console.log('window.tt:', !!window.tt)
|
||||
|
||||
setDebugInfo({
|
||||
url: currentUrl,
|
||||
isInFeishu,
|
||||
hasTT: !!window.tt,
|
||||
})
|
||||
|
||||
// 检查 URL 中是否有 auth_code
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const authCode = urlParams.get('auth_code')
|
||||
|
||||
if (authCode) {
|
||||
console.log('✅ 发现 auth_code,直接登录')
|
||||
handleLogin(authCode)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isInFeishu) {
|
||||
console.log('不在飞书中,显示登录选项')
|
||||
setStatus('show_options')
|
||||
return
|
||||
}
|
||||
|
||||
// 在飞书中,尝试免登
|
||||
tryFeishuAutoLogin()
|
||||
|
||||
function tryFeishuAutoLogin() {
|
||||
const appId = 'cli_a8033d607cba500d'
|
||||
|
||||
// 打印 tt 对象的所有方法
|
||||
if (window.tt) {
|
||||
console.log('tt 对象的方法:')
|
||||
for (let key in window.tt) {
|
||||
if (typeof window.tt[key] === 'function') {
|
||||
console.log(' -', key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方式1: tt.requestAuthCode
|
||||
if (window.tt && typeof window.tt.requestAuthCode === 'function') {
|
||||
console.log('使用 tt.requestAuthCode')
|
||||
window.tt.requestAuthCode({
|
||||
appId,
|
||||
success: (res: any) => {
|
||||
console.log('✅ 获取到授权码:', res.code?.substring(0, 20))
|
||||
handleLogin(res.code)
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('❌ 免登失败:', err)
|
||||
setStatus('show_options')
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 方式2: ttjsBridge
|
||||
if (window.ttjsBridge && typeof window.ttjsBridge.getAuthCode === 'function') {
|
||||
console.log('使用 ttjsBridge.getAuthCode')
|
||||
window.ttjsBridge.getAuthCode({
|
||||
success: (code: string) => {
|
||||
console.log('✅ 获取到授权码:', code?.substring(0, 20))
|
||||
handleLogin(code)
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('❌ 免登失败:', err)
|
||||
setStatus('show_options')
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// tt API 不可用,显示登录选项
|
||||
console.log('⚠️ tt API 不可用')
|
||||
setStatus('show_options')
|
||||
}
|
||||
|
||||
async function handleLogin(authCode: string) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/feishu-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
auth_code: authCode,
|
||||
tenant_key: '14e7bc0fe5985740',
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 0) {
|
||||
console.log('✅ 登录成功')
|
||||
login(result.data.user, result.data.token)
|
||||
setStatus('success')
|
||||
setTimeout(() => navigate('/dashboard'), 500)
|
||||
} else {
|
||||
console.error('❌ 登录失败:', result.message)
|
||||
setStatus('show_options')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录异常:', error)
|
||||
setStatus('show_options')
|
||||
}
|
||||
}
|
||||
}, [isLoggedIn, login, navigate])
|
||||
|
||||
// 检测中的状态
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white'
|
||||
}}>
|
||||
<h1>企业工具台</h1>
|
||||
<p style={{ marginTop: 20 }}>正在登录...</p>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: '4px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginTop: 20
|
||||
}}></div>
|
||||
{/* 调试信息 */}
|
||||
<div style={{
|
||||
marginTop: 30,
|
||||
padding: 10,
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<div>URL: {debugInfo.url?.substring(0, 50)}...</div>
|
||||
<div>在飞书中: {debugInfo.isInFeishu ? '✅' : '❌'}</div>
|
||||
<div>tt 对象: {debugInfo.hasTT ? '✅' : '❌'}</div>
|
||||
</div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 成功状态
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white'
|
||||
}}>
|
||||
<h1>✅ 登录成功</h1>
|
||||
<p style={{ marginTop: 20 }}>正在进入应用...</p>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: '4px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginTop: 20
|
||||
}}></div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 显示登录选项
|
||||
if (status === 'show_options') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: 20
|
||||
}}>
|
||||
<h1 style={{ marginBottom: 10 }}>企业工具台</h1>
|
||||
<p style={{ marginBottom: 40, opacity: 0.9 }}>请选择登录方式</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: '100%', maxWidth: 320 }}>
|
||||
{/* 飞书扫码登录 */}
|
||||
<button
|
||||
onClick={() => setShowQRCode(true)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
width: '100%',
|
||||
padding: 16,
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 28 }}>📱</span>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: '#333', marginBottom: 2 }}>飞书扫码登录</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>使用飞书 APP 扫码登录</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{debugInfo.isInFeishu && (
|
||||
<p style={{ marginTop: 30, fontSize: 12, opacity: 0.7, textAlign: 'center' }}>
|
||||
💡 检测到在飞书客户端中,但免登功能不可用
|
||||
</p>
|
||||
)}
|
||||
|
||||
<QRCodeLogin
|
||||
visible={showQRCode}
|
||||
onClose={() => setShowQRCode(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import TabBar from './TabBar'
|
||||
import Header from './Header'
|
||||
import styles from './MobileLayout.module.css'
|
||||
@@ -8,10 +9,26 @@ export default function MobileLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const mainRef = useRef<HTMLDivElement>(null)
|
||||
const { token } = useUserStore()
|
||||
|
||||
const showHeader = false // 所有页面都不显示 Header
|
||||
const showTabBar = false // 暂时隐藏底部 Tab
|
||||
|
||||
// 认证检查:如果没有 token,跳转到登录页
|
||||
useEffect(() => {
|
||||
// 跳过登录页面本身的检查
|
||||
if (location.pathname === '/login' || location.pathname === '/login-simple') {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 localStorage 中是否有 token
|
||||
const storedToken = localStorage.getItem('token')
|
||||
if (!storedToken) {
|
||||
console.log('🔐 未登录,跳转到登录页')
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
}, [location.pathname, navigate])
|
||||
|
||||
// 路由变化时滚动到顶部
|
||||
useEffect(() => {
|
||||
if (mainRef.current) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ошибка сети, попробуйте позже",
|
||||
|
||||
@@ -138,7 +138,23 @@
|
||||
"language": "语言设置",
|
||||
"switchCompany": "切换公司",
|
||||
"notifications": "消息通知",
|
||||
"security": "账号安全"
|
||||
"security": "账号安全",
|
||||
"changePassword": "修改密码"
|
||||
},
|
||||
"changePassword": {
|
||||
"title": "修改密码",
|
||||
"oldPassword": "原密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"oldPasswordPlaceholder": "请输入原密码",
|
||||
"newPasswordPlaceholder": "请输入新密码(至少6位)",
|
||||
"confirmPasswordPlaceholder": "请再次输入新密码",
|
||||
"submit": "确认修改",
|
||||
"success": "密码修改成功",
|
||||
"oldPasswordError": "原密码错误",
|
||||
"passwordMismatch": "两次输入的密码不一致",
|
||||
"passwordTooShort": "新密码长度不能少于6位",
|
||||
"fillAllFields": "请填写完整信息"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
@@ -150,9 +166,8 @@
|
||||
"loginFailed": "登录失败"
|
||||
},
|
||||
"settings": {
|
||||
"language": "语言",
|
||||
"theme": "主题",
|
||||
"notifications": "通知设置"
|
||||
"title": "设置",
|
||||
"language": "语言"
|
||||
},
|
||||
"error": {
|
||||
"networkError": "网络错误,请稍后重试",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Input, Button, Toast } from 'antd-mobile'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { userApi } from '@/services/api'
|
||||
import styles from './change-password.module.css'
|
||||
|
||||
export default function ChangePassword() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const [formData, setFormData] = useState({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.oldPassword || !formData.newPassword || !formData.confirmPassword) {
|
||||
return t('changePassword.fillAllFields')
|
||||
}
|
||||
if (formData.newPassword.length < 6) {
|
||||
return t('changePassword.passwordTooShort')
|
||||
}
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
return t('changePassword.passwordMismatch')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const error = validateForm()
|
||||
if (error) {
|
||||
Toast.show(error)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await userApi.changePassword({
|
||||
old_password: formData.oldPassword,
|
||||
new_password: formData.newPassword,
|
||||
confirm_password: formData.confirmPassword,
|
||||
})
|
||||
Toast.show(t('changePassword.success'))
|
||||
setTimeout(() => {
|
||||
navigate(-1)
|
||||
}, 1500)
|
||||
} catch (error: any) {
|
||||
const message = error.message || t('error.unknownError')
|
||||
Toast.show(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>{t('changePassword.title')}</h1>
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('changePassword.oldPassword')}</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('changePassword.oldPasswordPlaceholder')}
|
||||
value={formData.oldPassword}
|
||||
onChange={(value) => handleChange('oldPassword', value)}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('changePassword.newPassword')}</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('changePassword.newPasswordPlaceholder')}
|
||||
value={formData.newPassword}
|
||||
onChange={(value) => handleChange('newPassword', value)}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('changePassword.confirmPassword')}</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('changePassword.confirmPasswordPlaceholder')}
|
||||
value={formData.confirmPassword}
|
||||
onChange={(value) => handleChange('confirmPassword', value)}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
size="large"
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
className={styles.submitBtn}
|
||||
>
|
||||
{t('changePassword.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import MobileLayout from '@/components/layout/MobileLayout'
|
||||
import Dashboard from '@/pages/dashboard'
|
||||
import Profile from '@/pages/profile'
|
||||
import Settings from '@/pages/settings'
|
||||
import ChangePassword from '@/pages/change-password'
|
||||
import BusinessTripList from '@/pages/business-trip/list'
|
||||
import BusinessTripApply from '@/pages/business-trip/apply'
|
||||
import BusinessTripDetail from '@/pages/business-trip/detail'
|
||||
@@ -18,6 +21,9 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to='/dashboard' replace /> },
|
||||
{ path: 'dashboard', element: <Dashboard /> },
|
||||
{ path: 'profile', element: <Profile /> },
|
||||
{ path: 'settings', element: <Settings /> },
|
||||
{ path: 'change-password', element: <ChangePassword /> },
|
||||
{ path: 'business-trip', element: <BusinessTripList /> },
|
||||
{ path: 'business-trip/apply', element: <BusinessTripApply /> },
|
||||
{ path: 'business-trip/apply/select-approver', element: <SelectApprover /> },
|
||||
@@ -29,7 +35,7 @@ const router = createBrowserRouter([
|
||||
},
|
||||
{ path: '/login', element: <Login /> },
|
||||
{ path: '/login-simple', element: <SimpleLogin /> },
|
||||
{ path: '*', element: <Navigate to='/dashboard' replace /> },
|
||||
{ path: '*', element: <Navigate to='/login' replace /> },
|
||||
])
|
||||
|
||||
export default router
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user