You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何基于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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.22 00:54:09