如何基于Laravel API实现Flutter校园签到签退界面设计?
一、调整Laravel API响应格式
当前API返回的是逐条分散的签到签退记录,我们需要将同一日期的签到、签退数据合并为单条记录,让前端能直接按日期展示。
1. 修改Controller的index方法
替换原index方法逻辑,先按日期分组再重组数据:
<?php namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Requests\StoreCheckInOutRequest; use App\Http\Requests\UpdateCheckInOutRequest; use App\Http\Resources\V1\CheckInOut\DailyCheckInOutResource; use App\Models\CheckInOut; use App\Services\V1\CheckInOutQuery; use Illuminate\Http\Request; class CheckInOutController extends Controller { /** * Display a listing of the resource. * * @return \Illuminate\Http\Response */ public function index(Request $request) { $filter = new CheckInOutQuery(); $queryItems = $filter->transform($request); //[['column', 'operator', 'value']] if (count($queryItems) == 0) { return response()->json(['message' => '未提供学生ID'], 400); } // 按日期分组并重组数据结构 $dailyRecords = CheckInOut::where($queryItems) ->orderBy('date', 'desc') ->get() ->groupBy('date') ->map(function ($records) { $dailyData = [ 'date' => $records->first()->date, 'check_in' => null, 'check_out' => null ]; foreach ($records as $record) { if ($record->status === 'check_in') { $dailyData['check_in'] = [ 'id' => $record->id, 'time' => $record->time, 'image' => $record->image ]; } elseif ($record->status === 'check_out') { $dailyData['check_out'] = [ 'id' => $record->id, 'time' => $record->time, 'image' => $record->image ]; } } return $dailyData; }) ->values(); // 重置索引,避免分组后的键影响资源集合 return DailyCheckInOutResource::collection($dailyRecords); } // 其他方法保持不变... }
2. 新增DailyCheckInOutResource资源类
在app/Http/Resources/V1/CheckInOut/目录下创建DailyCheckInOutResource.php:
<?php namespace App\Http\Resources\V1\CheckInOut; use Illuminate\Http\Resources\Json\JsonResource; class DailyCheckInOutResource extends JsonResource { /** * Transform the resource into an array. * * @param \Illuminate\Http\Request $request * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ public function toArray($request) { return [ 'date' => date('d-m-Y', strtotime($this->date)), 'check_in' => $this->check_in ? [ 'id' => $this->check_in['id'], 'time' => $this->check_in['time'], 'image' => $this->check_in['image'] ] : null, 'check_out' => $this->check_out ? [ 'id' => $this->check_out['id'], 'time' => $this->check_out['time'], 'image' => $this->check_out['image'] ] : null ]; } }
3. 调整后API响应示例
{ "data": [ { "date": "18-08-2022", "check_in": { "id": 1, "time": "08:49:51", "image": "https://via.placeholder.com/640x480.png/008833?text=kids+et" }, "check_out": { "id": 6, "time": "10:24:38", "image": "https://via.placeholder.com/640x480.png/00ffdd?text=kids+veritatis" } }, { "date": "17-08-2022", "check_in": { "id": 2, "time": "09:01:23", "image": "https://via.placeholder.com/640x480.png/008833?text=kids+aut" }, "check_out": null } ] }
二、Flutter端处理响应实现目标界面
1. 定义数据模型
创建daily_check_in_out.dart文件,映射API返回结构:
class DailyCheckInOut { final String date; final CheckRecord? checkIn; final CheckRecord? checkOut; DailyCheckInOut({ required this.date, this.checkIn, this.checkOut, }); factory DailyCheckInOut.fromJson(Map<String, dynamic> json) { return DailyCheckInOut( date: json['date'], checkIn: json['check_in'] != null ? CheckRecord.fromJson(json['check_in']) : null, checkOut: json['check_out'] != null ? CheckRecord.fromJson(json['check_out']) : null, ); } } class CheckRecord { final int id; final String time; final String image; CheckRecord({ required this.id, required this.time, required this.image, }); factory CheckRecord.fromJson(Map<String, dynamic> json) { return CheckRecord( id: json['id'], time: json['time'], image: json['image'], ); } }
2. 网络请求获取数据
使用http包发起请求并解析响应:
import 'dart:convert'; import 'package:http/http.dart' as http; import 'daily_check_in_out.dart'; Future<List<DailyCheckInOut>> fetchDailyRecords(int childId) async { final response = await http.get( Uri.parse('https://你的API地址/api/v1/check-in-out?child_id=$childId'), headers: {'Accept': 'application/json'}, ); if (response.statusCode == 200) { final List<dynamic> data = json.decode(response.body)['data']; return data.map((json) => DailyCheckInOut.fromJson(json)).toList(); } else { throw Exception('加载记录失败'); } }
3. 实现界面布局
构建按日期展示的签到签退列表,空签退显示占位区域:
import 'package:flutter/material.dart'; import 'daily_check_in_out.dart'; class CheckInOutScreen extends StatefulWidget { final int childId; const CheckInOutScreen({super.key, required this.childId}); @override State<CheckInOutScreen> createState() => _CheckInOutScreenState(); } class _CheckInOutScreenState extends State<CheckInOutScreen> { late Future<List<DailyCheckInOut>> futureRecords; @override void initState() { super.initState(); futureRecords = fetchDailyRecords(widget.childId); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('签到签退记录')), body: FutureBuilder<List<DailyCheckInOut>>( future: futureRecords, builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { final record = snapshot.data![index]; return Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 日期标题 Text( record.date, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), // 签到签退图片行 Row( children: [ // 签到图片区域 Expanded( child: _buildCheckItem('入校签到', record.checkIn), ), const SizedBox(width: 16), // 签退图片区域 Expanded( child: _buildCheckItem('离校签退', record.checkOut), ), ], ), const Divider(height: 32), ], ), ); }, ); } else if (snapshot.hasError) { return Center(child: Text('${snapshot.error}')); } // 加载中状态 return const Center(child: CircularProgressIndicator()); }, ), ); } // 构建单个签到/签退项 Widget _buildCheckItem(String title, CheckRecord? record) { return Column( children: [ Text(title, style: const TextStyle(fontSize: 14)), const SizedBox(height: 8), if (record != null) ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( record.image, height: 120, width: double.infinity, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Container( height: 120, width: double.infinity, alignment: Alignment.center, child: const CircularProgressIndicator(strokeWidth: 2), ); }, errorBuilder: (context, error, stackTrace) { return Container( height: 120, width: double.infinity, color: Colors.grey[200], alignment: Alignment.center, child: const Icon(Icons.error_outline, color: Colors.grey), ); }, ), ) else Container( height: 120, width: double.infinity, decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(8), ), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.camera_alt_outlined, color: Colors.grey), SizedBox(height: 4), Text('无记录', style: TextStyle(color: Colors.grey)), ], ), ), if (record != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text(record.time, style: const TextStyle(fontSize: 12)), ), ], ); } }
4. 核心逻辑说明
- API返回已按日期分组,前端直接遍历列表即可展示
- 签退为空时显示灰色占位容器,搭配提示图标和文字
- 图片加载添加了加载中和错误状态处理,提升用户体验
- 签到签退图片横向并排,各占一半屏幕宽度,符合界面需求
内容的提问来源于stack exchange,提问作者Salman
相关产品推荐
相关产品推荐

