diff --git a/CLAUDE.md b/CLAUDE.md index b6afd49..35cb2b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,6 +180,127 @@ docker logs x-server-backend -f ## 已知问题和解决方案 +### 页面间状态丢失问题 ⚠️ 重要! + +#### 问题1:navigate(-1) 不传递 state +**现象:** 使用 `navigate(-1, { state })` 返回上一页时,`location.state` 为 `null` + +**原因:** React Router 在使用 `navigate(-1)` 时,`state` 参数不可靠 + +**解决方案:** 使用 sessionStorage 传递数据 + +#### 问题2:多次选择导致状态丢失 +**现象:** +- 选择审批人后,再选择抄送人,审批人状态丢失 +- 先选择抄送人,再选择审批人,抄送人状态丢失 + +**原因:** +- 每次导航时,目标页面组件会重新挂载 +- 重新挂载时,所有 useState 都会重置为初始值 +- sessionStorage 在第一次读取后被清除,第二次挂载时无数据可恢复 + +**解决方案:** +1. **使用独立的 sessionStorage key**(不要共用一个 key) +2. **不在 useEffect 中立即清除 sessionStorage** +3. **每次挂载时从 sessionStorage 恢复状态** +4. **只在业务操作完成后才清除 sessionStorage**(提交、保存等) + +**正确实现:** + +```tsx +// 选择页面 - 使用独立的 key +const handleConfirm = () => { + if (type === 'approver') { + sessionStorage.setItem('selectedApprovers', JSON.stringify(selectedUsers)) + } else if (type === 'cc') { + sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers)) + } + navigate(-1) +} + +// 申请页面 - 每次挂载时恢复状态 +useEffect(() => { + const approversData = sessionStorage.getItem('selectedApprovers') + const ccData = sessionStorage.getItem('selectedCc') + + if (approversData) { + setApprovers(JSON.parse(approversData)) + } + if (ccData) { + setCcUsers(JSON.parse(ccData)) + } +}, []) + +// 提交/保存成功后清除 +const handleSubmit = async () => { + // ... 提交逻辑 ... + sessionStorage.removeItem('selectedApprovers') + sessionStorage.removeItem('selectedCc') + navigate('/business-trip') +} +``` + +**关键点:** +- ✅ 不同类型数据使用独立的 sessionStorage key +- ✅ 不在 useEffect 中清除 sessionStorage +- ✅ 每次挂载都恢复状态,确保组件重新挂载时状态不丢失 +- ✅ 只在业务操作完成后清除 sessionStorage + +**相关文件:** +- `frontend/src/pages/business-trip/select-approver.tsx` +- `frontend/src/pages/business-trip/select-cc.tsx` +- `frontend/src/pages/business-trip/apply.tsx` + +### Ant Design Mobile DatePicker 点击无反应 +**问题:** DatePicker 组件使用 children render prop 方式时,点击无法弹出选择框 + +**原因:** antd-mobile 5.x 的 DatePicker children render prop 点击事件绑定存在问题 + +**解决方案:** 使用 `visible` 属性手动控制弹框显示 + +```tsx +// ❌ 错误用法 - 点击无反应 + + {(value) => } + + +// ✅ 正确用法 - 使用 visible 属性 +const [visible, setVisible] = useState(false) + +
setVisible(true)}> + {value ? '已选择' : '请选择'} +
+ { + setValue(val) + setVisible(false) + }} + onClose={() => setVisible(false)} + onCancel={() => setVisible(false)} +/> +``` + +**相关文件:** +- `frontend/src/main.tsx` - 需要导入 `import 'antd-mobile/es/global'` +- `frontend/src/pages/business-trip/apply.tsx` - 正确使用示例 + +### Ant Design Mobile 全局样式导入 +**必须导入全局样式:** +```tsx +import 'antd-mobile/es/global' +``` + +否则弹框、遮罩等组件将无法正常显示。 + +### Selector 组件对号图标隐藏 +**问题:** Selector 组件选中时显示对号图标和背景色,想要只保留背景色 + +**解决方案:** 使用 `showCheckMark` 属性 +```tsx + +``` + ### PSR-4 自动加载 - 目录名必须是 PascalCase(Controller、Model、Service、Util) - 不能用小写(controller、model、service、util) diff --git a/backend/app/Bootstrap/Database.php b/backend/app/Bootstrap/Database.php new file mode 100644 index 0000000..268aaed --- /dev/null +++ b/backend/app/Bootstrap/Database.php @@ -0,0 +1,15 @@ +service = new ApprovalService(); + } + + /** + * 获取审批列表 + */ + public function list(Request $request): Response + { + $userId = $this->getUserId($request); + $type = $request->get('type', 'my'); + $page = (int) $request->get('page', 1); + $pageSize = (int) $request->get('page_size', 20); + + $result = $this->service->getList($userId, $type, $page, $pageSize); + + return $this->success($result); + } + + /** + * 获取审批详情 + */ + public function detail(Request $request): Response + { + $id = (int) $request->route('id'); + + $approval = \App\Model\ApprovalOrder::with(['flow', 'applicant', 'records']) + ->findOrFail($id); + + return $this->success($approval); + } + + /** + * 审批通过 + */ + public function approve(Request $request): Response + { + $userId = $this->getUserId($request); + $id = (int) $request->route('id'); + $data = $request->post(); + $data = $data ?? []; + $comment = $data['comment'] ?? null; + + try { + $approval = $this->service->approve($id, $userId, $comment); + return $this->success($approval, '已通过'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 审批拒绝 + */ + public function reject(Request $request): Response + { + $userId = $this->getUserId($request); + $id = (int) $request->route('id'); + $data = $request->post(); + $data = $data ?? []; + $comment = $data['comment'] ?? null; + + try { + $approval = $this->service->reject($id, $userId, $comment); + return $this->success($approval, '已拒绝'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 撤销申请 + */ + public function cancel(Request $request): Response + { + $userId = $this->getUserId($request); + $id = (int) $request->route('id'); + + try { + $approval = $this->service->cancel($id, $userId); + return $this->success($approval, '已撤销'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } +} diff --git a/backend/app/Controller/AuthController.php b/backend/app/Controller/AuthController.php new file mode 100644 index 0000000..f301981 --- /dev/null +++ b/backend/app/Controller/AuthController.php @@ -0,0 +1,111 @@ +post('username'); + $password = $request->post('password'); + + // TODO: 实现真实的登录逻辑 + if (!$username || !$password) { + return $this->error('用户名或密码不能为空'); + } + + // 模拟用户数据(用于测试) + $user = User::first(); + + if (!$user) { + return $this->error('用户不存在'); + } + + $token = JwtUtil::encode([ + 'user_id' => $user->id, + 'company_id' => $user->company_id, + 'username' => $user->name, + ]); + + return $this->success([ + 'token' => $token, + 'user' => $user, + ]); + } + + /** + * 飞书免登 + */ + 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()); + } + } + + /** + * 获取当前用户信息 + */ + public function me(Request $request): Response + { + $userId = $this->getUserId($request); + + if (!$userId) { + return $this->error('未登录', 401); + } + + $user = User::with('company')->find($userId); + + if (!$user) { + return $this->error('用户不存在'); + } + + return $this->success($user); + } +} diff --git a/backend/app/Controller/BaseController.php b/backend/app/Controller/BaseController.php new file mode 100644 index 0000000..74d4e72 --- /dev/null +++ b/backend/app/Controller/BaseController.php @@ -0,0 +1,74 @@ + 0, + 'message' => $message, + 'data' => $data, + ]); + } + + /** + * 错误响应 + */ + protected function error(string $message = 'error', int $code = 1, $data = null): Response + { + return json([ + 'code' => $code, + 'message' => $message, + 'data' => $data, + ]); + } + + /** + * 获取当前用户ID + */ + protected function getUserId(Request $request): ?int + { + // 先检查 request 中是否已注入 + if (isset($request->userId)) { + return $request->userId; + } + + // 从 Authorization header 获取 token + $token = $request->header('Authorization'); + if ($token) { + $token = str_replace('Bearer ', '', $token); + } + + if (!$token) { + return null; + } + + // 解析 token + $payload = \App\Util\JwtUtil::decode($token); + if (!$payload) { + return null; + } + + return (int) ($payload['user_id'] ?? null); + } + + /** + * 验证认证并返回用户ID,失败则抛出异常 + */ + protected function requireAuth(Request $request): int + { + $userId = $this->getUserId($request); + if (!$userId) { + throw new \Exception('未登录或登录已过期', 401); + } + return $userId; + } +} diff --git a/backend/app/Controller/BusinessTripController.php b/backend/app/Controller/BusinessTripController.php new file mode 100644 index 0000000..7ed363c --- /dev/null +++ b/backend/app/Controller/BusinessTripController.php @@ -0,0 +1,165 @@ +service = new BusinessTripService(); + } + + /** + * 获取路由参数 + */ + private function getRouteParam(Request $request, string $name, $default = null) + { + $route = $request->route; + if ($route && method_exists($route, 'param')) { + return $route->param($name) ?? $default; + } + return $default; + } + + /** + * 获取出差申请列表 + */ + public function list(Request $request): Response + { + $userId = $this->getUserId($request); + $type = $request->get('type', 'my'); + $page = (int) $request->get('page', 1); + $pageSize = (int) $request->get('page_size', 20); + + $result = $this->service->getList($userId, $type, $page, $pageSize); + + return $this->success($result); + } + + /** + * 获取出差申请详情 + */ + public function detail(Request $request): Response + { + $id = (int) $this->getRouteParam($request, 'id'); + if (!$id) { + return $this->error('参数错误'); + } + + $detail = $this->service->getDetail($id); + + return $this->success($detail); + } + + /** + * 创建出差申请(草稿) + */ + public function create(Request $request): Response + { + $userId = $this->getUserId($request); + $data = $request->post(); + $data = $data ?? []; + + // 验证必填字段 + if (empty($data['reason'])) { + return $this->error('请输入出差原因'); + } + + if (empty($data['itineraries']) || !is_array($data['itineraries'])) { + return $this->error('请至少添加一条行程'); + } + + // 验证行程数据 + foreach ($data['itineraries'] as $index => $itinerary) { + if (empty($itinerary['start_time'])) { + return $this->error("行程" . ($index + 1) . ":请选择开始时间"); + } + if (empty($itinerary['destination'])) { + return $this->error("行程" . ($index + 1) . ":请输入目的地"); + } + } + + try { + $businessTrip = $this->service->createDraft($userId, $data); + return $this->success($businessTrip, '创建成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 更新出差申请 + */ + public function update(Request $request): Response + { + $userId = $this->getUserId($request); + $id = (int) $this->getRouteParam($request, 'id'); + if (!$id) { + return $this->error('参数错误'); + } + + $data = $request->post(); + $data = $data ?? []; + + try { + $businessTrip = $this->service->update($userId, $id, $data); + return $this->success($businessTrip, '更新成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 提交出差申请 + */ + public function submit(Request $request): Response + { + $userId = $this->getUserId($request); + $id = (int) $this->getRouteParam($request, 'id'); + if (!$id) { + return $this->error('参数错误'); + } + + $data = $request->post(); + $data = $data ?? []; + + $approvers = $data['approvers'] ?? []; + $ccUsers = $data['cc_users'] ?? []; + + if (empty($approvers)) { + return $this->error('请选择审批人'); + } + + try { + $businessTrip = $this->service->submit($userId, $id, $approvers, $ccUsers); + return $this->success($businessTrip, '提交成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 取消出差申请 + */ + public function cancel(Request $request): Response + { + $userId = $this->getUserId($request); + $id = (int) $this->getRouteParam($request, 'id'); + if (!$id) { + return $this->error('参数错误'); + } + + try { + $businessTrip = $this->service->cancel($userId, $id); + return $this->success($businessTrip, '取消成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } +} diff --git a/backend/app/controller/UserController.php b/backend/app/Controller/UserController.php similarity index 56% rename from backend/app/controller/UserController.php rename to backend/app/Controller/UserController.php index b6a9929..8de999c 100644 --- a/backend/app/controller/UserController.php +++ b/backend/app/Controller/UserController.php @@ -1,7 +1,8 @@ getUserId($request); + $user = User::find($userId); - // TODO: 从数据库获取用户信息 - return $this->success([ - 'id' => $userId, - 'name' => '测试用户', - 'email' => 'test@example.com', - 'company_id' => 1, - ]); + if (!$user) { + return $this->error('用户不存在'); + } + + return $this->success($user); } /** @@ -59,4 +59,34 @@ class UserController extends BaseController return $this->success(); } + + /** + * 获取用户列表(用于选择审批人等) + */ + public function list(Request $request): Response + { + $userId = $this->getUserId($request); + $keyword = $request->get('keyword', ''); + $page = (int) $request->get('page', 1); + $pageSize = (int) $request->get('page_size', 50); + + $query = User::active(); + + if ($keyword) { + $query->search($keyword); + } + + $total = $query->count(); + $list = $query->orderBy('id') + ->offset(($page - 1) * $pageSize) + ->limit($pageSize) + ->get(); + + return $this->success([ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + ]); + } } diff --git a/backend/app/Middleware/AuthMiddleware.php b/backend/app/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..e503060 --- /dev/null +++ b/backend/app/Middleware/AuthMiddleware.php @@ -0,0 +1,42 @@ +header('Authorization'); + $token = str_replace('Bearer ', '', $token); + + // 如果没有 token,返回 401 + if (!$token) { + return json([ + 'code' => 401, + 'message' => '未登录', + 'data' => null, + ]); + } + + // 验证 token + $payload = JwtUtil::decode($token); + if (!$payload) { + return json([ + 'code' => 401, + 'message' => '登录已过期', + 'data' => null, + ]); + } + + // 将用户ID注入到请求中 + $request->userId = (int) ($payload['user_id'] ?? 0); + $request->companyId = (int) ($payload['company_id'] ?? 0); + + return $next($request); + } +} diff --git a/backend/app/model/ApprovalFlow.php b/backend/app/Model/ApprovalFlow.php similarity index 93% rename from backend/app/model/ApprovalFlow.php rename to backend/app/Model/ApprovalFlow.php index 3846b16..9150784 100644 --- a/backend/app/model/ApprovalFlow.php +++ b/backend/app/Model/ApprovalFlow.php @@ -1,8 +1,8 @@ 'integer', + 'estimated_cost' => 'decimal:2', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 状态常量 + */ + const STATUS_DRAFT = 'draft'; // 草稿 + const STATUS_PENDING = 'pending'; // 审批中 + const STATUS_APPROVED = 'approved'; // 已通过 + const STATUS_REJECTED = 'rejected'; // 已拒绝 + const STATUS_CANCELLED = 'cancelled'; // 已撤销 + + /** + * 关联用户 + */ + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 关联行程明细 + */ + public function itineraries() + { + return $this->hasMany(BusinessTripItinerary::class, 'business_trip_id'); + } + + /** + * 关联审批单 + */ + public function approvalOrder() + { + return $this->belongsTo(ApprovalOrder::class, 'approval_order_id'); + } + + /** + * 计算总天数 + */ + public function getDurationDays(): int + { + if (!$this->start_date || !$this->end_date) { + return 0; + } + $start = \DateTime::createFromFormat('Y-m-d', $this->start_date); + $end = \DateTime::createFromFormat('Y-m-d', $this->end_date); + if (!$start || !$end) { + return 0; + } + return $end->diff($start)->days + 1; + } + + /** + * 判断是否可以编辑 + */ + public function canEdit(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 判断是否可以提交 + */ + public function canSubmit(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 判断是否可以取消 + */ + public function canCancel(): bool + { + return $this->status === self::STATUS_PENDING; + } +} diff --git a/backend/app/Model/BusinessTripItinerary.php b/backend/app/Model/BusinessTripItinerary.php new file mode 100644 index 0000000..9004113 --- /dev/null +++ b/backend/app/Model/BusinessTripItinerary.php @@ -0,0 +1,59 @@ + 'integer', + 'start_time' => 'datetime', + 'end_time' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 行程类型常量 + */ + const TRIP_TYPE_ONEWAY = 'oneway'; // 单程 + const TRIP_TYPE_ROUNDTRIP = 'roundtrip'; // 往返 + + /** + * 关联出差申请 + */ + public function businessTrip() + { + return $this->belongsTo(BusinessTrip::class, 'business_trip_id'); + } + + /** + * 计算天数 + */ + public function getDurationDays(): int + { + if (!$this->start_time || !$this->end_time) { + return 0; + } + $start = \DateTime::createFromFormat('Y-m-d H:i:s', $this->start_time); + $end = \DateTime::createFromFormat('Y-m-d H:i:s', $this->end_time); + if (!$start || !$end) { + return 0; + } + return $end->diff($start)->days + 1; + } +} diff --git a/backend/app/Model/Company.php b/backend/app/Model/Company.php new file mode 100644 index 0000000..aa65979 --- /dev/null +++ b/backend/app/Model/Company.php @@ -0,0 +1,62 @@ + 'integer', + 'status' => 'integer', + 'feishu_enabled' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 获取启用的公司 + */ + public function scopeActive($query) + { + return $query->where('status', 1); + } + + /** + * 根据 tenant_key 查找 + */ + public function scopeByTenantKey($query, ?string $tenantKey) + { + if ($tenantKey === null) { + return $query; + } + return $query->where('tenant_key', $tenantKey); + } + + /** + * 关联飞书应用配置 + */ + public function feishuApps() + { + return $this->hasMany(CompanyFeishuApp::class, 'company_id'); + } + + /** + * 关联用户 + */ + public function users() + { + return $this->hasMany(User::class, 'company_id'); + } +} diff --git a/backend/app/Model/CompanyFeishuApp.php b/backend/app/Model/CompanyFeishuApp.php new file mode 100644 index 0000000..2334dd6 --- /dev/null +++ b/backend/app/Model/CompanyFeishuApp.php @@ -0,0 +1,47 @@ + 'integer', + 'system_id' => 'integer', + 'status' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 获取启用的应用 + */ + public function scopeActive($query) + { + return $query->where('status', 1); + } + + /** + * 关联公司 + */ + public function company() + { + return $this->belongsTo(Company::class, 'company_id'); + } +} diff --git a/backend/app/model/User.php b/backend/app/Model/User.php similarity index 97% rename from backend/app/model/User.php rename to backend/app/Model/User.php index 6881595..30401f5 100644 --- a/backend/app/model/User.php +++ b/backend/app/Model/User.php @@ -1,8 +1,8 @@ db = Db::connection(); + } + + /** + * 创建出差申请(草稿) + */ + public function createDraft(int $userId, array $data): BusinessTrip + { + return $this->db->transaction(function () use ($userId, $data) { + try { + // 计算最早和最晚时间 + $itineraries = $data['itineraries'] ?? []; + if (empty($itineraries)) { + throw new \Exception('请至少添加一条行程'); + } + + $startDate = null; + $endDate = null; + $destinations = []; + + foreach ($itineraries as $itinerary) { + if ($itinerary['start_time']) { + if (!$startDate || $itinerary['start_time'] < $startDate) { + $startDate = $itinerary['start_time']; + } + } + if ($itinerary['end_time']) { + if (!$endDate || $itinerary['end_time'] > $endDate) { + $endDate = $itinerary['end_time']; + } + } + if (!empty($itinerary['destination'])) { + $destinations[] = $itinerary['destination']; + } + } + + // 创建出差申请 + $businessTrip = BusinessTrip::create([ + 'user_id' => $userId, + 'reason' => $data['reason'] ?? '', + 'start_date' => $startDate ? date('Y-m-d', strtotime($startDate)) : null, + 'end_date' => $endDate ? date('Y-m-d', strtotime($endDate)) : null, + 'destination' => implode(', ', array_unique($destinations)), + 'estimated_cost' => $data['estimated_cost'] ?? 0, + 'status' => BusinessTrip::STATUS_DRAFT, + ]); + + // 创建行程明细 + foreach ($itineraries as $itinerary) { + BusinessTripItinerary::create([ + 'business_trip_id' => $businessTrip->id, + 'start_time' => $itinerary['start_time'], + 'end_time' => $itinerary['end_time'] ?? null, + 'departure' => $itinerary['departure'] ?? '', + 'destination' => $itinerary['destination'] ?? '', + 'transport' => $itinerary['transport'] ?? '', + 'trip_type' => $itinerary['trip_type'] ?? 'oneway', + 'remark' => $itinerary['remark'] ?? '', + ]); + } + + return $businessTrip->load('itineraries'); + } catch (\Exception $e) { + error_log('BusinessTripService::createDraft error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + throw $e; + } + }); + } + + /** + * 提交出差申请 + */ + public function submit(int $userId, int $businessTripId, array $approvers, array $ccUsers = []): BusinessTrip + { + return $this->db->transaction(function () use ($userId, $businessTripId, $approvers, $ccUsers) { + $businessTrip = BusinessTrip::with('itineraries')->findOrFail($businessTripId); + + if ($businessTrip->user_id !== $userId) { + throw new \Exception('无权操作此申请'); + } + + if (!$businessTrip->canSubmit()) { + throw new \Exception('当前状态无法提交'); + } + + // 获取审批流程(简化版:直接使用第一个审批人作为单人审批) + $flow = ApprovalFlow::where('business_type', 'business_trip') + ->where('status', 1) + ->first(); + + if (!$flow) { + // 创建默认流程 + $flow = ApprovalFlow::create([ + 'business_type' => 'business_trip', + 'name' => '出差审批', + 'description' => '出差申请审批流程', + 'status' => 1, + ]); + } + + // 创建审批单 + $approvalOrder = ApprovalOrder::create([ + 'business_type' => 'business_trip', + 'business_id' => $businessTrip->id, + 'title' => '出差申请 - ' . $businessTrip->user->name, + 'applicant_id' => $userId, + 'status' => ApprovalOrder::STATUS_PENDING, + 'current_nodes' => [1], // 简化:使用节点1 + 'flow_id' => $flow->id, + 'form_data' => [ + 'reason' => $businessTrip->reason, + 'start_date' => $businessTrip->start_date, + 'end_date' => $businessTrip->end_date, + 'destination' => $businessTrip->destination, + 'approvers' => $approvers, + 'cc_users' => $ccUsers, + ], + ]); + + // 更新出差申请状态 + $businessTrip->status = BusinessTrip::STATUS_PENDING; + $businessTrip->approval_order_id = $approvalOrder->id; + $businessTrip->save(); + + // 记录提交操作 + ApprovalRecord::create([ + 'order_id' => $approvalOrder->id, + 'node_id' => 1, + 'node_type' => 'single_approve', + 'approver_id' => $userId, + 'action' => 'submit', + 'comment' => null, + ]); + + return $businessTrip->fresh(['itineraries', 'approvalOrder']); + }); + } + + /** + * 获取出差申请列表 + */ + public function getList(int $userId, string $type = 'my', int $page = 1, int $pageSize = 20): array + { + $query = BusinessTrip::with(['user', 'itineraries']); + + switch ($type) { + case 'my': + $query->where('user_id', $userId); + break; + case 'pending': + // 获取待审批的申请 + $pendingOrderIds = ApprovalOrder::where('status', ApprovalOrder::STATUS_PENDING) + ->where('business_type', 'business_trip') + ->pluck('business_id') + ->toArray(); + $query->whereIn('id', $pendingOrderIds); + break; + } + + $total = $query->count(); + $list = $query->orderBy('created_at', 'desc') + ->offset(($page - 1) * $pageSize) + ->limit($pageSize) + ->get(); + + return [ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + ]; + } + + /** + * 获取出差申请详情 + */ + public function getDetail(int $id): BusinessTrip + { + return BusinessTrip::with(['user', 'itineraries']) + ->findOrFail($id); + } + + /** + * 更新出差申请 + */ + public function update(int $userId, int $id, array $data): BusinessTrip + { + $businessTrip = BusinessTrip::findOrFail($id); + + if ($businessTrip->user_id !== $userId) { + throw new \Exception('无权操作此申请'); + } + + if (!$businessTrip->canEdit()) { + throw new \Exception('当前状态无法编辑'); + } + + return $this->db->transaction(function () use ($businessTrip, $data) { + // 更新基本信息 + if (isset($data['reason'])) { + $businessTrip->reason = $data['reason']; + } + + // 更新行程 + if (isset($data['itineraries'])) { + // 删除旧行程 + BusinessTripItinerary::where('business_trip_id', $businessTrip->id)->delete(); + + // 创建新行程 + $startDate = null; + $endDate = null; + $destinations = []; + + foreach ($data['itineraries'] as $itinerary) { + BusinessTripItinerary::create([ + 'business_trip_id' => $businessTrip->id, + 'start_time' => $itinerary['start_time'], + 'end_time' => $itinerary['end_time'] ?? null, + 'departure' => $itinerary['departure'] ?? '', + 'destination' => $itinerary['destination'] ?? '', + 'transport' => $itinerary['transport'] ?? '', + 'trip_type' => $itinerary['trip_type'] ?? 'oneway', + 'remark' => $itinerary['remark'] ?? '', + ]); + + if ($itinerary['start_time']) { + if (!$startDate || $itinerary['start_time'] < $startDate) { + $startDate = $itinerary['start_time']; + } + } + if ($itinerary['end_time']) { + if (!$endDate || $itinerary['end_time'] > $endDate) { + $endDate = $itinerary['end_time']; + } + } + if (!empty($itinerary['destination'])) { + $destinations[] = $itinerary['destination']; + } + } + + // 更新日期和目的地 + $businessTrip->start_date = $startDate ? date('Y-m-d', strtotime($startDate)) : null; + $businessTrip->end_date = $endDate ? date('Y-m-d', strtotime($endDate)) : null; + $businessTrip->destination = implode(', ', array_unique($destinations)); + } + + $businessTrip->save(); + + return $businessTrip->fresh('itineraries'); + }); + } + + /** + * 取消出差申请 + */ + public function cancel(int $userId, int $id): BusinessTrip + { + $businessTrip = BusinessTrip::findOrFail($id); + + if ($businessTrip->user_id !== $userId) { + throw new \Exception('无权操作此申请'); + } + + if (!$businessTrip->canCancel()) { + throw new \Exception('当前状态无法取消'); + } + + $businessTrip->status = BusinessTrip::STATUS_CANCELLED; + $businessTrip->save(); + + // 同时取消审批单 + if ($businessTrip->approval_order_id) { + $approvalOrder = ApprovalOrder::find($businessTrip->approval_order_id); + if ($approvalOrder) { + $approvalOrder->status = ApprovalOrder::STATUS_CANCELLED; + $approvalOrder->current_nodes = []; + $approvalOrder->save(); + } + } + + return $businessTrip->fresh(); + } +} diff --git a/backend/app/service/FeishuService.php b/backend/app/Service/FeishuService.php similarity index 88% rename from backend/app/service/FeishuService.php rename to backend/app/Service/FeishuService.php index a483311..3b3d92a 100644 --- a/backend/app/service/FeishuService.php +++ b/backend/app/Service/FeishuService.php @@ -1,11 +1,11 @@ getPdo()->prepare($sql); - $stmt->execute([$appId]); - $app = $stmt->fetch(\PDO::FETCH_ASSOC); + $app = CompanyFeishuApp::find($appId); - if (!$app) { + if (!$app || $app->status !== 1) { throw new \Exception('飞书应用配置不存在'); } - return $app; + return $app->toArray(); } /** @@ -63,16 +63,17 @@ class FeishuService */ private function getDefaultFeishuApp(int $companyId): array { - $sql = "SELECT * FROM company_feishu_apps WHERE company_id = ? AND system_id = 1 AND status = 1 ORDER BY sort ASC LIMIT 1"; - $stmt = Db::connection()->getPdo()->prepare($sql); - $stmt->execute([$companyId]); - $app = $stmt->fetch(\PDO::FETCH_ASSOC); + $app = CompanyFeishuApp::where('company_id', $companyId) + ->where('system_id', 1) + ->where('status', 1) + ->orderBy('sort') + ->first(); if (!$app) { throw new \Exception('未配置飞书应用'); } - return $app; + return $app->toArray(); } /** @@ -101,14 +102,12 @@ class FeishuService } $app = $this->getAppConfig(); - - // 从缓存获取 $cacheKey = "feishu:access_token:{$app['id']}"; - $token = Cache::get($cacheKey); - if ($token) { - $this->accessToken = $token; - return $token; + // 从内存缓存获取 + if (isset(self::$tokenCache[$cacheKey]) && self::$tokenCache[$cacheKey]['expire'] > time()) { + $this->accessToken = self::$tokenCache[$cacheKey]['token']; + return $this->accessToken; } // 请求新的 token @@ -125,7 +124,10 @@ class FeishuService $expire = $response['expire'] ?? 7200; // 缓存 token(提前5分钟过期) - Cache::set($cacheKey, $token, $expire - 300); + self::$tokenCache[$cacheKey] = [ + 'token' => $token, + 'expire' => time() + $expire - 300, + ]; $this->accessToken = $token; return $token; @@ -290,7 +292,7 @@ class FeishuService $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + // curl_close($ch); // 不再需要,PHP 8.5+ 自动关闭 if ($httpCode !== 200) { throw new \Exception("HTTP 请求失败: {$httpCode}"); diff --git a/backend/app/util/Db.php b/backend/app/Util/Db.php similarity index 91% rename from backend/app/util/Db.php rename to backend/app/Util/Db.php index 657accb..be7a4bc 100644 --- a/backend/app/util/Db.php +++ b/backend/app/Util/Db.php @@ -1,6 +1,6 @@ addConnection([ 'driver' => 'mysql', - 'host' => getenv('DB_HOST') ?: '127.0.0.1', + 'host' => getenv('DB_HOST') ?: '1Panel-mysql-409s', 'port' => getenv('DB_PORT') ?: '3306', 'database' => getenv('DB_DATABASE') ?: 'xserver', 'username' => getenv('DB_USERNAME') ?: 'root', - 'password' => getenv('DB_PASSWORD') ?: '', + 'password' => getenv('DB_PASSWORD') ?: 'mysql_xEsdPa', 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', diff --git a/backend/app/util/JwtUtil.php b/backend/app/Util/JwtUtil.php similarity index 97% rename from backend/app/util/JwtUtil.php rename to backend/app/Util/JwtUtil.php index ceae8c1..a5c8c00 100644 --- a/backend/app/util/JwtUtil.php +++ b/backend/app/Util/JwtUtil.php @@ -1,6 +1,6 @@ post('username'); - $password = $request->post('password'); - - // TODO: 实现真实的登录逻辑 - if (!$username || !$password) { - return $this->error('用户名或密码不能空'); - } - - // 模拟用户数据 - $user = [ - 'user_id' => 1, - 'username' => $username, - 'name' => '测试用户', - 'company_id' => 1, - ]; - - $token = JwtUtil::encode($user); - - return $this->success([ - 'token' => $token, - 'user' => $user, - ]); - } - - /** - * 飞书免登 - */ - public function feishuLogin(Request $request): Response - { - $authCode = $request->post('auth_code'); - $tenantKey = $request->post('tenant_key'); - - // TODO: 实现飞书免登逻辑 - // 1. 根据 tenant_key 获取公司配置 - // 2. 使用 auth_code 换取用户信息 - // 3. 创建或更新用户 - // 4. 生成 JWT Token - - return $this->success([ - 'token' => JwtUtil::encode(['user_id' => 1, 'company_id' => 1]), - 'user' => ['id' => 1, 'name' => '飞书用户', 'company_id' => 1], - ]); - } -} diff --git a/backend/app/controller/BaseController.php b/backend/app/controller/BaseController.php deleted file mode 100644 index aeaa8a1..0000000 --- a/backend/app/controller/BaseController.php +++ /dev/null @@ -1,41 +0,0 @@ - 0, - 'message' => $message, - 'data' => $data, - ]); - } - - /** - * 错误响应 - */ - protected function error(string $message = 'error', int $code = 1, $data = null): Response - { - return json([ - 'code' => $code, - 'message' => $message, - 'data' => $data, - ]); - } - - /** - * 获取当前用户ID - */ - protected function getUserId(Request $request): ?int - { - return $request->userId ?? null; - } -} diff --git a/backend/app/model/Company.php b/backend/app/model/Company.php deleted file mode 100644 index ef1bd3a..0000000 --- a/backend/app/model/Company.php +++ /dev/null @@ -1,96 +0,0 @@ - 'integer', - 'status' => 'integer', - 'feishu_enabled' => 'boolean', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - ]; - - /** - * 关联用户 - */ - public function users() - { - return $this->hasMany(User::class, 'company_id'); - } - - /** - * 获取启用的公司 - */ - public function scopeActive($query) - { - return $query->where('status', 1); - } - - /** - * 根据租户Key查找 - */ - public function scopeByTenantKey($query, string $tenantKey) - { - return $query->where('tenant_key', $tenantKey); - } - - /** - * 根据缩写查找 - */ - public function scopeByAbbr($query, string $abbr) - { - return $query->where('abbr', $abbr); - } - - /** - * 搜索公司 - */ - public function scopeSearch($query, ?string $keyword) - { - if (!$keyword) { - return $query; - } - - return $query->where(function ($q) use ($keyword) { - $q->where('name', 'like', "%{$keyword}%") - ->orWhere('abbr', 'like', "%{$keyword}%"); - }); - } - - /** - * 获取默认飞书应用配置 - */ - public function getDefaultFeishuApp(): ?array - { - $sql = "SELECT * FROM company_feishu_apps WHERE company_id = ? AND system_id = 1 AND status = 1 ORDER BY sort ASC LIMIT 1"; - $stmt = \app\util\Db::connection()->getPdo()->prepare($sql); - $stmt->execute([$this->id]); - return $stmt->fetch(\PDO::FETCH_ASSOC); - } - - /** - * 获取所有飞书应用配置 - */ - public function getFeishuApps(): array - { - $sql = "SELECT * FROM company_feishu_apps WHERE company_id = ? AND status = 1 ORDER BY sort"; - $stmt = \app\util\Db::connection()->getPdo()->prepare($sql); - $stmt->execute([$this->id]); - return $stmt->fetchAll(\PDO::FETCH_ASSOC); - } -} diff --git a/backend/config/bootstrap.php b/backend/config/bootstrap.php new file mode 100644 index 0000000..00ff162 --- /dev/null +++ b/backend/config/bootstrap.php @@ -0,0 +1,5 @@ + [ 'mysql' => [ 'driver' => 'mysql', - 'host' => getenv('DB_HOST') ?: 'mysql', + 'host' => getenv('DB_HOST') ?: '1Panel-mysql-409s', 'port' => getenv('DB_PORT') ?: 3306, 'database' => getenv('DB_DATABASE') ?: 'xserver', 'username' => getenv('DB_USERNAME') ?: 'root', diff --git a/backend/config/route.php b/backend/config/route.php index 3f0b219..c2f0742 100644 --- a/backend/config/route.php +++ b/backend/config/route.php @@ -4,35 +4,37 @@ use Webman\Route; // API 路由 Route::group('/api/v1', function () { - // 认证路由 - Route::post('/auth/login', [app\controller\AuthController::class, 'login']); - Route::post('/auth/feishu-login', [app\controller\AuthController::class, 'feishuLogin']); + // 认证路由(不需要认证) + Route::post('/auth/login', [App\Controller\AuthController::class, 'login']); + Route::post('/auth/feishu-login', [App\Controller\AuthController::class, 'feishuLogin']); // 用户路由(需要认证) Route::group(function () { - Route::get('/user/info', [app\controller\UserController::class, 'info']); - Route::get('/user/menu', [app\controller\UserController::class, 'menu']); - Route::get('/user/companies', [app\controller\UserController::class, 'companies']); - Route::post('/user/switch-company', [app\controller\UserController::class, 'switchCompany']); + Route::get('/user/info', [App\Controller\UserController::class, 'info']); + Route::get('/user/menu', [App\Controller\UserController::class, 'menu']); + Route::get('/user/companies', [App\Controller\UserController::class, 'companies']); + Route::get('/user/list', [App\Controller\UserController::class, 'list']); + Route::post('/user/switch-company', [App\Controller\UserController::class, 'switchCompany']); }); - // 审批路由(待实现) - // Route::group(function () { - // Route::get('/approval/list', [app\controller\ApprovalController::class, 'list']); - // Route::get('/approval/detail/{id}', [app\controller\ApprovalController::class, 'detail']); - // Route::post('/approval/submit', [app\controller\ApprovalController::class, 'submit']); - // Route::post('/approval/approve/{id}', [app\controller\ApprovalController::class, 'approve']); - // Route::post('/approval/reject/{id}', [app\controller\ApprovalController::class, 'reject']); - // Route::post('/approval/cancel/{id}', [app\controller\ApprovalController::class, 'cancel']); - // }); + // 审批路由(需要认证) + Route::group(function () { + Route::get('/approval/list', [App\Controller\ApprovalController::class, 'list']); + Route::get('/approval/detail/{id}', [App\Controller\ApprovalController::class, 'detail']); + Route::post('/approval/approve/{id}', [App\Controller\ApprovalController::class, 'approve']); + Route::post('/approval/reject/{id}', [App\Controller\ApprovalController::class, 'reject']); + Route::post('/approval/cancel/{id}', [App\Controller\ApprovalController::class, 'cancel']); + }); - // 出差审批路由(待实现) - // Route::group(function () { - // Route::get('/business-trip/list', [app\controller\BusinessTripController::class, 'list']); - // Route::get('/business-trip/detail/{id}', [app\controller\BusinessTripController::class, 'detail']); - // Route::post('/business-trip/apply', [app\controller\BusinessTripController::class, 'apply']); - // Route::put('/business-trip/{id}', [app\controller\BusinessTripController::class, 'update']); - // }); + // 出差审批路由(需要认证) + Route::group(function () { + Route::get('/business-trip/list', [App\Controller\BusinessTripController::class, 'list']); + Route::get('/business-trip/detail/{id}', [App\Controller\BusinessTripController::class, 'detail']); + Route::post('/business-trip/create', [App\Controller\BusinessTripController::class, 'create']); + Route::post('/business-trip/update/{id}', [App\Controller\BusinessTripController::class, 'update']); + Route::post('/business-trip/submit/{id}', [App\Controller\BusinessTripController::class, 'submit']); + Route::post('/business-trip/cancel/{id}', [App\Controller\BusinessTripController::class, 'cancel']); + }); }); // 默认路由 diff --git a/docker-compose.yml b/docker-compose.yml index b1f252b..6821048 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: ports: - "8787:8787" environment: - - DB_HOST=mysql + - DB_HOST=1Panel-mysql-409s - DB_PORT=3306 - DB_DATABASE=xserver - DB_USERNAME=root diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 57ec6ca..b91f7f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.62.11", "antd-mobile": "^5.38.1", - "axios": "^1.7.9", + "axios": "^1.13.5", "dayjs": "^1.11.13", "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", diff --git a/frontend/package.json b/frontend/package.json index 2009c3d..243b535 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "dependencies": { "@tanstack/react-query": "^5.62.11", "antd-mobile": "^5.38.1", - "axios": "^1.7.9", + "axios": "^1.13.5", "dayjs": "^1.11.13", "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", diff --git a/frontend/src/index.css b/frontend/src/index.css index 10eed7f..736250f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -13,6 +13,7 @@ html, body { width: 100%; height: 100%; + overflow-x: hidden; } body { @@ -26,6 +27,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-size-adjust: 100%; /* 防止 iOS 自动调整字体大小 */ + overflow-y: auto; } #root { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 97c2376..8f63160 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,13 @@ import { StrictMode, useEffect } from 'react' import { createRoot } from 'react-dom/client' import { RouterProvider } from 'react-router-dom' +import { ConfigProvider } from 'antd-mobile' +import zhCN from 'antd-mobile/es/locales/zh-CN' +import 'antd-mobile/es/global' +// 导入必要的组件样式 +import 'antd-mobile/es/components/popup/popup.css' +import 'antd-mobile/es/components/mask/mask.css' +import 'antd-mobile/es/components/date-picker/date-picker.css' import router from './router' import { initRem } from './utils/rem' import './i18n' @@ -20,6 +27,8 @@ function App() { createRoot(document.getElementById('root')!).render( - + + + ) diff --git a/frontend/src/modules/approval/components/ApprovalAction.tsx b/frontend/src/modules/approval/components/ApprovalAction.tsx index 9029547..ab64954 100644 --- a/frontend/src/modules/approval/components/ApprovalAction.tsx +++ b/frontend/src/modules/approval/components/ApprovalAction.tsx @@ -12,22 +12,50 @@ interface ApprovalActionProps { export default function ApprovalAction({ orderId, status }: ApprovalActionProps) { const { approve, reject, cancel, isApproving, isRejecting, isCancelling } = useApprovalAction() const [comment, setComment] = useState('') - const [showModal, setShowModal] = useState(false) - const [actionType, setActionType] = useState<'approve' | 'reject'>('approve') const canAction = status === 'pending' const canCancel = status === 'pending' || status === 'draft' const handleApprove = () => { - approve({ orderId, comment: comment || undefined }) - setShowModal(false) - setComment('') + Modal.confirm({ + content: ( +
+
确认通过此申请?
+