Просмотр исходного кода

主要是完成师生管理
todo:创建页面需要重构 UI

yemeishu 1 месяц назад
Родитель
Сommit
171491fda3

+ 7 - 47
app/Filament/Pages/StudentManagement.php

@@ -10,11 +10,9 @@ use Filament\Actions\Action;
 use Filament\Actions\BulkAction;
 use Filament\Actions\BulkActionGroup;
 use Filament\Actions\CreateAction;
-use Filament\Forms;
-use Filament\Forms\Form;
-use Filament\Pages\Page;
 use Filament\Tables;
 use Filament\Tables\Table;
+use Filament\Pages\Page;
 use Illuminate\Support\Facades\DB;
 use Filament\Tables\Concerns\InteractsWithTable;
 use Filament\Tables\Contracts\HasTable;
@@ -26,7 +24,7 @@ class StudentManagement extends Page implements HasTable
 
     protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-academic-cap';
 
-    protected static ?string $navigationLabel = '生管理';
+    protected static ?string $navigationLabel = '生管理';
 
     protected static string|UnitEnum|null $navigationGroup = '管理';
 
@@ -40,53 +38,15 @@ class StudentManagement extends Page implements HasTable
 
     public function getTitle(): string
     {
-        return '生管理';
+        return '生管理';
     }
 
     public function getBreadcrumb(): string
     {
-        return '学生管理';
-    }
-
-    public function form(Form $form): Form
-    {
-        return $form
-            ->schema([
-                Forms\Components\TextInput::make('name')
-                    ->label('学生姓名')
-                    ->required()
-                    ->maxLength(128),
-
-                Forms\Components\TextInput::make('grade')
-                    ->label('年级')
-                    ->required()
-                    ->maxLength(32),
-
-                Forms\Components\TextInput::make('class_name')
-                    ->label('班级')
-                    ->required()
-                    ->maxLength(64),
-
-                Forms\Components\Select::make('teacher_id')
-                    ->label('指导老师')
-                    ->options(fn () => Teacher::query()
-                        ->with('user')
-                        ->get()
-                        ->mapWithKeys(fn (Teacher $teacher) => [
-                            $teacher->teacher_id => $teacher->user->full_name
-                                ?? $teacher->name
-                                ?? "老师 #{$teacher->teacher_id}",
-                        ])
-                        ->toArray())
-                    ->searchable()
-                    ->required(),
-
-                Forms\Components\Textarea::make('remark')
-                    ->label('备注')
-                ->rows(3),
-            ]);
+        return '师生管理';
     }
 
+  
     public function filterByTeacher(?string $teacherId): void
     {
         $this->selectedTeacherId = $teacherId;
@@ -224,13 +184,13 @@ class StudentManagement extends Page implements HasTable
                 Action::make('view')
                     ->label('查看')
                     ->icon('heroicon-o-eye')
-                    ->url(fn($record): string => route('filament.admin.resources.students.view', $record->student_id))
+                    ->url(fn($record): string => route('filament.admin.resources.students.view', $record))
                     ->openUrlInNewTab(),
 
                 Action::make('edit')
                     ->label('编辑')
                     ->icon('heroicon-o-pencil-square')
-                    ->url(fn($record): string => route('filament.admin.resources.students.edit', $record->student_id))
+                    ->url(fn($record): string => route('filament.admin.resources.students.edit', $record))
                     ->openUrlInNewTab(),
 
                 Action::make('reset_password')

+ 19 - 19
app/Filament/Pages/UploadExamPaper.php

@@ -141,7 +141,7 @@ class UploadExamPaper extends Page
         
         // 如果选择了学生,则筛选该学生的记录
         if (!empty($this->studentId)) {
-            $ocrQuery->where('student_id', $this->studentId);
+            $ocrQuery->where('user_id', $this->studentId);
         }
         
         $ocrRecords = $ocrQuery->take(5)
@@ -152,8 +152,8 @@ class UploadExamPaper extends Page
                     'id' => $record->id,
                     'record_id' => $record->id,
                     'paper_id' => null,
-                    'student_id' => $record->student_id,
-                    'student_name' => $record->student?->name ?? $record->student_id,
+                    'student_id' => $record->user_id,
+                    'student_name' => $record->student?->name ?? $record->user_id,
                     'paper_type' => $record->paper_type_label,
                     'paper_name' => $record->image_filename ?: '未命名图片',
                     'status' => $record->status,
@@ -165,11 +165,11 @@ class UploadExamPaper extends Page
             })->toArray();
 
         // 2. 获取所有Paper记录(包括草稿和已评分)
-        $paperQuery = \App\Models\Paper::with('student')->latest();
-        
+        $paperQuery = \App\Models\Paper::with(['createdByUser'])->latest();
+
         // 如果选择了学生,则筛选该学生的记录
         if (!empty($this->studentId)) {
-            $paperQuery->where('student_id', $this->studentId);
+            $paperQuery->where('created_by', $this->studentId);
         }
         
         $allPapers = $paperQuery->take(5)
@@ -181,17 +181,17 @@ class UploadExamPaper extends Page
 
                 return [
                     'type' => $type,
-                    'id' => $paper->paper_id,
+                    'id' => $paper->id,
                     'record_id' => null,
-                    'paper_id' => $paper->paper_id,
-                    'student_id' => $paper->student_id,
-                    'student_name' => $paper->student?->name ?? $paper->student_id,
+                    'paper_id' => $paper->id,
+                    'student_id' => $paper->created_by,
+                    'student_name' => $paper->createdByUser?->full_name ?? $paper->created_by,
                     'paper_type' => $paperType,
-                    'paper_name' => $paper->paper_name ?? '未命名试卷',
-                    'status' => $paper->status,
-                    'total_questions' => $paper->question_count,
-                    'created_at' => $paper->updated_at->format('Y-m-d H:i'),
-                    'is_completed' => $paper->status === 'completed',
+                    'paper_name' => $paper->title ?? '未命名试卷',
+                    'status' => $paper->difficulty_level,
+                    'total_questions' => 0, // papers表没有question_count字段
+                    'created_at' => $paper->created_at->format('Y-m-d H:i'),
+                    'is_completed' => $paper->difficulty_level !== null,
                     'icon_color' => $iconColor,
                 ];
             })->toArray();
@@ -216,16 +216,16 @@ class UploadExamPaper extends Page
         }
 
         try {
-            return \App\Models\Paper::where('student_id', $this->studentId)
+            return \App\Models\Paper::where('created_by', $this->studentId)
                 ->withCount('questions') // 添加题目计数
                 ->orderBy('created_at', 'desc')
                 ->take(20)
                 ->get()
                 ->map(function($paper) {
                     return [
-                        'paper_id' => $paper->paper_id,
-                        'paper_name' => $paper->paper_name ?? '未命名试卷',
-                        'total_questions' => $paper->questions_count ?? $paper->question_count ?? 0,
+                        'paper_id' => $paper->id,
+                        'paper_name' => $paper->title ?? '未命名试卷',
+                        'total_questions' => $paper->questions_count ?? 0,
                         'total_score' => $paper->total_score ?? 0,
                         'created_at' => $paper->created_at->format('Y-m-d H:i'),
                     ];

+ 98 - 30
app/Filament/Resources/StudentResource.php

@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
 
 use App\Filament\Resources\StudentResource\Pages;
 use App\Models\Student;
+use App\Models\Teacher;
 use BackedEnum;
 use Filament\Forms\Components\Select;
 use Filament\Forms\Components\Textarea;
@@ -12,7 +13,7 @@ use Filament\Resources\Resource;
 use Filament\Schemas\Schema;
 use Filament\Tables;
 use Filament\Tables\Table;
-use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Cache;
 
 class StudentResource extends Resource
 {
@@ -24,33 +25,39 @@ class StudentResource extends Resource
 
     public static function form(Schema $schema): Schema
     {
-        return $schema->components([
+        return $schema->schema([
+            // 学生ID字段在创建时隐藏,编辑时显示但禁用
             TextInput::make('student_id')
                 ->label('学生ID')
-                ->numeric()
-                ->required()
-                ->disabled(fn (?Student $record) => filled($record)),
+                ->disabled()
+                ->hidden(fn (?Student $record) => blank($record))
+                ->formatStateUsing(fn (?Student $record): string => $record?->student_id ?? ''),
             TextInput::make('name')
                 ->label('姓名')
                 ->required()
-                ->maxLength(128),
+                ->maxLength(128)
+                ->placeholder('请输入学生姓名'),
             TextInput::make('grade')
                 ->label('年级')
                 ->required()
-                ->maxLength(32),
+                ->maxLength(32)
+                ->placeholder('例如:高一、高二等'),
             TextInput::make('class_name')
                 ->label('班级')
-                ->required()
-                ->maxLength(64),
+                ->helperText('选填项,如不确定可留空')
+                ->maxLength(64)
+                ->placeholder('例如:1班、2班等'),
             Select::make('teacher_id')
                 ->label('指导老师')
                 ->options(fn () => self::teacherOptions())
                 ->searchable()
                 ->required()
-                ->preload(),
+                ->preload()
+                ->placeholder('请选择指导老师'),
             Textarea::make('remark')
                 ->label('备注')
                 ->rows(3)
+                ->placeholder('请输入备注信息(可选)')
                 ->columnSpanFull(),
         ])->columns(2);
     }
@@ -61,27 +68,53 @@ class StudentResource extends Resource
             ->columns([
                 Tables\Columns\TextColumn::make('student_id')
                     ->label('学生ID')
+                    ->badge()
+                    ->color('primary')
+                    ->copyable()
+                    ->copyMessage('学生ID已复制')
+                    ->copyMessageDuration(1500)
                     ->sortable()
                     ->searchable(),
                 Tables\Columns\TextColumn::make('name')
                     ->label('姓名')
                     ->weight('bold')
-                    ->searchable(),
+                    ->searchable()
+                    ->sortable(),
                 Tables\Columns\TextColumn::make('grade')
                     ->label('年级')
+                    ->badge()
+                    ->color('success')
                     ->sortable(),
                 Tables\Columns\TextColumn::make('class_name')
                     ->label('班级')
-                    ->sortable(),
+                    ->placeholder('未分配')
+                    ->sortable()
+                    ->formatStateUsing(fn ($state) => $state ?: '未分配'),
+                Tables\Columns\TextColumn::make('teacher.user.full_name')
+                    ->label('指导老师')
+                    ->default('未分配')
+                    ->sortable()
+                    ->searchable(),
             ])
             ->filters([
                 Tables\Filters\SelectFilter::make('grade')
                     ->label('年级')
-                    ->options(fn () => self::gradeOptions()),
+                    ->options(fn () => self::gradeOptions())
+                    ->placeholder('全部年级'),
                 Tables\Filters\SelectFilter::make('class_name')
                     ->label('班级')
-                    ->options(fn () => self::classOptions()),
-            ]);
+                    ->options(fn () => self::classOptions())
+                    ->placeholder('全部班级'),
+                Tables\Filters\SelectFilter::make('teacher_id')
+                    ->label('指导老师')
+                    ->options(fn () => self::teacherOptions())
+                    ->placeholder('全部老师'),
+            ])
+            ->actions([])
+            ->bulkActions([])
+            ->emptyStateHeading('暂无学生记录')
+            ->emptyStateDescription('开始创建你的第一个学生吧')
+            ->emptyStateActions([]);
     }
 
     public static function getPages(): array
@@ -96,28 +129,63 @@ class StudentResource extends Resource
 
     protected static function teacherOptions(): array
     {
-        return DB::table('teachers')
-            ->join('users', 'teachers.user_id', '=', 'users.user_id')
-            ->where('users.role', 'teacher')
-            ->pluck('users.full_name', 'teachers.teacher_id')
-            ->toArray();
+        // 使用缓存优化性能,缓存1小时
+        return cache()->remember('teacher_options', 3600, function () {
+            return Teacher::with(['user' => function ($query) {
+                    $query->select('user_id', 'full_name', 'role');
+                }])
+                ->whereHas('user', function ($query) {
+                    $query->where('role', 'teacher');
+                })
+                ->select('teacher_id', 'user_id', 'name')
+                ->get()
+                ->map(function ($teacher) {
+                    return [
+                        'id' => $teacher->teacher_id,
+                        'name' => $teacher->user->full_name ?? $teacher->name
+                    ];
+                })
+                ->pluck('name', 'id')
+                ->toArray();
+        });
     }
 
     protected static function gradeOptions(): array
     {
-        return DB::table('students')
-            ->distinct()
-            ->orderBy('grade')
-            ->pluck('grade', 'grade')
-            ->toArray();
+        // 使用缓存优化性能,缓存30分钟
+        return cache()->remember('grade_options', 1800, function () {
+            return Student::query()
+                ->select('grade')
+                ->distinct()
+                ->whereNotNull('grade')
+                ->orderBy('grade')
+                ->pluck('grade', 'grade')
+                ->toArray();
+        });
     }
 
     protected static function classOptions(): array
     {
-        return DB::table('students')
-            ->distinct()
-            ->orderBy('class_name')
-            ->pluck('class_name', 'class_name')
-            ->toArray();
+        // 使用缓存优化性能,缓存30分钟
+        return Cache::remember('class_options', 1800, function () {
+            return Student::query()
+                ->select('class_name')
+                ->whereNotNull('class_name')
+                ->where('class_name', '!=', '')
+                ->distinct()
+                ->orderBy('class_name')
+                ->pluck('class_name', 'class_name')
+                ->toArray();
+        });
+    }
+
+    /**
+     * 清除相关缓存
+     */
+    public static function clearCaches(): void
+    {
+        Cache::forget('teacher_options');
+        Cache::forget('grade_options');
+        Cache::forget('class_options');
     }
 }

+ 18 - 0
app/Filament/Resources/StudentResource/Pages/CreateStudent.php

@@ -8,4 +8,22 @@ use Filament\Resources\Pages\CreateRecord;
 class CreateStudent extends CreateRecord
 {
     protected static string $resource = StudentResource::class;
+
+    protected function getRedirectUrl(): string
+    {
+        return $this->getResource()::getUrl('index');
+    }
+
+    protected function getCreatedNotificationTitle(): ?string
+    {
+        return '学生创建成功';
+    }
+
+    protected function getCreatedNotification(): ?\Filament\Notifications\Notification
+    {
+        return \Filament\Notifications\Notification::make()
+            ->success()
+            ->title('学生创建成功')
+            ->body('学生信息已成功保存。');
+    }
 }

+ 107 - 0
app/Filament/Resources/TeacherResource/Pages/CreateTeacher.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Filament\Resources\TeacherResource\Pages;
+
+use App\Filament\Resources\TeacherResource;
+use App\Models\Teacher;
+use App\Models\User;
+use Filament\Resources\Pages\CreateRecord;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+
+class CreateTeacher extends CreateRecord
+{
+    protected static string $resource = TeacherResource::class;
+
+    protected function mutateFormDataBeforeCreate(array $data): array
+    {
+        // 生成教师ID
+        $data['teacher_id'] = $this->generateTeacherId();
+
+        return $data;
+    }
+
+    protected function handleRecordCreation(array $data): Teacher
+    {
+        DB::beginTransaction();
+
+        try {
+            // 创建用户记录
+            $userData = [
+                'user_id' => $data['teacher_id'],
+                'username' => $data['user']['username'],
+                'password_hash' => $data['user']['password_hash'],
+                'full_name' => $data['name'],
+                'role' => 'teacher',
+                'is_active' => 1,
+            ];
+
+            // 邮箱是可选的
+            if (!empty($data['user']['email'])) {
+                $userData['email'] = $data['user']['email'];
+            }
+
+            $user = User::create($userData);
+
+            // 创建教师记录
+            $teacherData = [
+                'teacher_id' => $data['teacher_id'],
+                'user_id' => $data['teacher_id'],
+                'name' => $data['name'],
+                'subject' => $data['subject'],
+            ];
+
+            $teacher = Teacher::create($teacherData);
+
+            DB::commit();
+
+            return $teacher;
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+            throw $e;
+        }
+    }
+
+    protected function getRedirectUrl(): string
+    {
+        return $this->getResource()::getUrl('index');
+    }
+
+    protected function getCreatedNotificationTitle(): ?string
+    {
+        return '教师创建成功';
+    }
+
+    protected function getCreatedNotification(): ?\Filament\Notifications\Notification
+    {
+        return \Filament\Notifications\Notification::make()
+            ->success()
+            ->title('教师创建成功')
+            ->body('教师信息已成功保存。');
+    }
+
+    protected function generateTeacherId(): string
+    {
+        $timestamp = time();
+        $sequence = DB::table('teachers')
+            ->where('teacher_id', 'like', "tch_{$timestamp}%")
+            ->count() + 1;
+
+        return "tch_{$timestamp}_{$sequence}";
+    }
+
+    public function getTitle(): string
+    {
+        return '添加教师';
+    }
+
+    public function getBreadcrumbs(): array
+    {
+        return [
+            '#' => '师生管理',
+            static::getResource()::getUrl('index') => '教师管理',
+            static::getResource()::getUrl('create') => '添加教师',
+        ];
+    }
+}

+ 43 - 0
app/Filament/Resources/TeacherResource/Pages/EditTeacher.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Filament\Resources\TeacherResource\Pages;
+
+use App\Filament\Resources\TeacherResource;
+use Filament\Resources\Pages\EditRecord;
+
+class EditTeacher extends EditRecord
+{
+    protected static string $resource = TeacherResource::class;
+
+    protected function getRedirectUrl(): string
+    {
+        return $this->getResource()::getUrl('index');
+    }
+
+    protected function getSavedNotificationTitle(): ?string
+    {
+        return '教师信息已更新';
+    }
+
+    protected function getSavedNotification(): ?\Filament\Notifications\Notification
+    {
+        return \Filament\Notifications\Notification::make()
+            ->success()
+            ->title('教师信息已更新')
+            ->body('教师信息已成功更新。');
+    }
+
+    public function getTitle(): string
+    {
+        return '编辑教师';
+    }
+
+    public function getBreadcrumbs(): array
+    {
+        return [
+            '#' => '师生管理',
+            static::getResource()::getUrl('index') => '教师管理',
+            static::getResource()::getUrl('edit', ['record' => $this->record]) => '编辑教师',
+        ];
+    }
+}

+ 31 - 0
app/Filament/Resources/TeacherResource/Pages/ListTeachers.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Filament\Resources\TeacherResource\Pages;
+
+use App\Filament\Resources\TeacherResource;
+use Filament\Resources\Pages\ListRecords;
+
+class ListTeachers extends ListRecords
+{
+    protected static string $resource = TeacherResource::class;
+
+    protected function getHeaderActions(): array
+    {
+        return [
+            \Filament\Actions\CreateAction::make(),
+        ];
+    }
+
+    public function getTitle(): string
+    {
+        return '教师管理';
+    }
+
+    public function getBreadcrumbs(): array
+    {
+        return [
+            '#' => '师生管理',
+            static::getResource()::getUrl('index') => '教师管理',
+        ];
+    }
+}

+ 21 - 28
app/Models/OCRRecord.php

@@ -13,43 +13,24 @@ class OCRRecord extends Model
     protected $table = 'ocr_records';
 
     protected $fillable = [
-        'exam_id',
-        'student_id',
-        'image_path',
-        'image_filename',
-        'image_size',
-        'image_width',
-        'image_height',
-        'qr_code_data',
+        'user_id',
+        'paper_title',
         'paper_type',
+        'file_path',
+        'image_count',
+        'total_questions',
         'status',
         'error_message',
-        'total_questions',
-        'processed_questions',
-        'confidence_avg',
-        'processed_at',
-        'ai_analyzed_at',
-        'ai_analysis_count',
-    ];
-
-    protected $dates = [
-        'processed_at',
-        'ai_analyzed_at',
         'created_at',
         'updated_at',
+        'analysis_id',
     ];
 
     protected $casts = [
-        'qr_code_data' => 'array',
-        'image_size' => 'integer',
-        'image_width' => 'integer',
-        'image_height' => 'integer',
-        'total_questions' => 'integer',
-        'processed_questions' => 'integer',
-        'confidence_avg' => 'float',
-        'processed_at' => 'datetime',
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
+        'image_count' => 'integer',
+        'total_questions' => 'integer',
     ];
 
     public function questions(): HasMany
@@ -59,7 +40,7 @@ class OCRRecord extends Model
 
     public function student()
     {
-        return $this->belongsTo(Student::class, 'student_id', 'student_id');
+        return $this->belongsTo(Student::class, 'user_id', 'student_id');
     }
 
     public function getStatusBadgeAttribute(): string
@@ -73,6 +54,18 @@ class OCRRecord extends Model
         };
     }
 
+    // 兼容性方法:获取 student_id
+    public function getStudentIdAttribute(): ?string
+    {
+        return $this->user_id;
+    }
+
+    // 兼容性方法:设置 student_id
+    public function setStudentIdAttribute(?string $value): void
+    {
+        $this->attributes['user_id'] = $value;
+    }
+
     public function getImageUrlAttribute(): string
     {
         if ($this->image_path && file_exists(public_path($this->image_path))) {

+ 19 - 17
app/Models/Paper.php

@@ -7,36 +7,30 @@ use Illuminate\Database\Eloquent\Model;
 class Paper extends Model
 {
     protected $table = 'papers';
-    protected $primaryKey = 'paper_id';
-    public $incrementing = false;
-    protected $keyType = 'string';
+    protected $primaryKey = 'id';
+    public $incrementing = true;
+    protected $keyType = 'int';
     public $timestamps = true;
     
     const CREATED_AT = 'created_at';
     const UPDATED_AT = 'updated_at';
     
     protected $fillable = [
-        'paper_id',
-        'student_id',
-        'teacher_id',
-        'paper_name',
-        'paper_type',
-        'question_count',
+        'id',
+        'title',
+        'subject',
         'total_score',
-        'status',
-        'difficulty_category',
-        'analysis_summary',
-        'feedback',
-        'completed_at',
+        'duration',
+        'difficulty_level',
+        'created_by',
         'analysis_id', // AI分析记录ID
     ];
     
     protected $casts = [
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
-        'completed_at' => 'datetime',
         'total_score' => 'float',
-        'question_count' => 'integer',
+        'duration' => 'integer',
     ];
     
     /**
@@ -44,7 +38,15 @@ class Paper extends Model
      */
     public function questions()
     {
-        return $this->hasMany(PaperQuestion::class, 'paper_id', 'paper_id');
+        return $this->hasMany(PaperQuestion::class, 'paper_id', 'id');
+    }
+
+    /**
+     * 获取试卷创建者
+     */
+    public function createdByUser()
+    {
+        return $this->belongsTo(User::class, 'created_by', 'user_id');
     }
 
     /**

+ 37 - 1
app/Models/Student.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\Services\StudentIdService;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -16,7 +17,7 @@ class Student extends Model
 
     public $incrementing = false;
 
-    protected $keyType = 'int';
+    protected $keyType = 'string';
 
     protected $fillable = [
         'student_id',
@@ -32,6 +33,41 @@ class Student extends Model
         'updated_at' => 'datetime',
     ];
 
+    protected static function boot()
+    {
+        parent::boot();
+
+        // 在创建学生时自动生成ID并创建用户记录
+        static::creating(function (Student $student) {
+            if (empty($student->student_id)) {
+                $student->student_id = StudentIdService::generateStudentId();
+            }
+
+            // 创建对应的用户记录(需要在学生记录之前创建,因为存在外键约束)
+            $existingUser = \App\Models\User::where('user_id', $student->student_id)->first();
+            if (!$existingUser) {
+                \App\Models\User::create([
+                    'user_id' => $student->student_id,
+                    'username' => $student->student_id,
+                    'password_hash' => \Hash::make('password123'), // 默认密码,实际使用时应该修改
+                    'role' => 'student',
+                    'full_name' => $student->name,
+                    'email' => $student->student_id . '@student.edu',
+                    'is_active' => 1,
+                ]);
+            }
+        });
+
+        // 在学生数据变更后清除缓存
+        static::saved(function (Student $student) {
+            \App\Filament\Resources\StudentResource::clearCaches();
+        });
+
+        static::deleted(function (Student $student) {
+            \App\Filament\Resources\StudentResource::clearCaches();
+        });
+    }
+
     public function teacher(): BelongsTo
     {
         return $this->belongsTo(Teacher::class, 'teacher_id', 'teacher_id');

+ 2 - 0
app/Models/Teacher.php

@@ -19,6 +19,8 @@ class Teacher extends Model
 
     protected $keyType = 'int';
 
+    public $timestamps = false; // 禁用时间戳,因为数据库表没有 updated_at 字段
+
     protected $fillable = [
         'teacher_id',
         'user_id',

+ 27 - 4
app/Models/User.php

@@ -54,11 +54,16 @@ class User extends Authenticatable implements FilamentUser, HasName
         'username',
         'email',
         'password',
+        'password_hash',
         'full_name',
         'role',
         'phone',
         'department',
         'is_active',
+        'remember_token',
+        'last_login',
+        'login_count',
+        'deleted_at',
     ];
 
     /**
@@ -67,7 +72,8 @@ class User extends Authenticatable implements FilamentUser, HasName
      * @var list<string>
      */
     protected $hidden = [
-        'password',
+        'password_hash',
+        'remember_token',
     ];
 
     /**
@@ -83,9 +89,10 @@ class User extends Authenticatable implements FilamentUser, HasName
         ];
     }
 
-    public function canAccessPanel(Panel $panel): bool
+    public function canAccessPanel(?Panel $panel): bool
     {
-        return true; // 所有用户都可以访问面板
+        // 只有激活的用户才能访问面板
+        return $this->is_active === true;
     }
 
     /**
@@ -93,7 +100,23 @@ class User extends Authenticatable implements FilamentUser, HasName
      */
     public function getAuthPassword(): string
     {
-        return (string) ($this->password_hash ?? $this->password ?? '');
+        return (string) $this->password_hash;
+    }
+
+    /**
+     * Get the password attribute (for compatibility).
+     */
+    public function getPasswordAttribute(): string
+    {
+        return $this->password_hash;
+    }
+
+    /**
+     * Set the password attribute (for compatibility).
+     */
+    public function setPasswordAttribute(string $value): void
+    {
+        $this->attributes['password_hash'] = $value;
     }
 
     /**

+ 57 - 0
app/Services/StudentIdService.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+
+class StudentIdService
+{
+    /**
+     * 生成新的学生ID
+     *
+     * @return string
+     */
+    public static function generateStudentId(): string
+    {
+        $timestamp = time();
+
+        // 获取当前时间戳下的序号
+        $sequence = self::getNextSequence($timestamp);
+
+        return "stu_{$timestamp}_{$sequence}";
+    }
+
+    /**
+     * 获取下一个序号
+     *
+     * @param int $timestamp
+     * @return int
+     */
+    private static function getNextSequence(int $timestamp): int
+    {
+        // 查找相同时间戳的最后一个序号
+        $lastStudent = DB::table('students')
+            ->where('student_id', 'like', "stu_{$timestamp}_%")
+            ->orderByRaw('CAST(SUBSTRING_INDEX(student_id, "_", -1) AS UNSIGNED) DESC')
+            ->first();
+
+        if ($lastStudent) {
+            // 提取序号并递增
+            $lastSequence = (int) substr($lastStudent->student_id, strrpos($lastStudent->student_id, '_') + 1);
+            return $lastSequence + 1;
+        }
+
+        return 0;
+    }
+
+    /**
+     * 验证学生ID格式是否正确
+     *
+     * @param string $studentId
+     * @return bool
+     */
+    public static function validateStudentIdFormat(string $studentId): bool
+    {
+        return preg_match('/^stu_\d+_\d+$/', $studentId) === 1;
+    }
+}

+ 1 - 0
composer.json

@@ -8,6 +8,7 @@
     "require": {
         "php": "^8.2",
         "alibabacloud/ocr-api-20210707": "^3.1",
+        "doctrine/dbal": "^4.3",
         "filament/filament": "*",
         "laravel/framework": "^12.0",
         "laravel/tinker": "^2.10.1",

+ 204 - 1
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "75a3ad4e0328e356cd1e3632c5405472",
+    "content-hash": "aef417d00064a6e1855def52b794262a",
     "packages": [
         {
             "name": "adbario/php-dot-notation",
@@ -1188,6 +1188,160 @@
             },
             "time": "2024-07-08T12:26:09+00:00"
         },
+        {
+            "name": "doctrine/dbal",
+            "version": "4.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/dbal.git",
+                "reference": "1a2fbd0e93b8dec7c3d1ac2b6396a7b929b130dc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/1a2fbd0e93b8dec7c3d1ac2b6396a7b929b130dc",
+                "reference": "1a2fbd0e93b8dec7c3d1ac2b6396a7b929b130dc",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/deprecations": "^1.1.5",
+                "php": "^8.2",
+                "psr/cache": "^1|^2|^3",
+                "psr/log": "^1|^2|^3"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "14.0.0",
+                "fig/log-test": "^1",
+                "jetbrains/phpstorm-stubs": "2023.2",
+                "phpstan/phpstan": "2.1.30",
+                "phpstan/phpstan-phpunit": "2.0.7",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "11.5.23",
+                "slevomat/coding-standard": "8.24.0",
+                "squizlabs/php_codesniffer": "4.0.0",
+                "symfony/cache": "^6.3.8|^7.0",
+                "symfony/console": "^5.4|^6.3|^7.0"
+            },
+            "suggest": {
+                "symfony/console": "For helpful console commands such as SQL execution and import of files."
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\DBAL\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Guilherme Blanco",
+                    "email": "guilhermeblanco@gmail.com"
+                },
+                {
+                    "name": "Roman Borschel",
+                    "email": "roman@code-factory.org"
+                },
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de"
+                },
+                {
+                    "name": "Jonathan Wage",
+                    "email": "jonwage@gmail.com"
+                }
+            ],
+            "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
+            "homepage": "https://www.doctrine-project.org/projects/dbal.html",
+            "keywords": [
+                "abstraction",
+                "database",
+                "db2",
+                "dbal",
+                "mariadb",
+                "mssql",
+                "mysql",
+                "oci8",
+                "oracle",
+                "pdo",
+                "pgsql",
+                "postgresql",
+                "queryobject",
+                "sasql",
+                "sql",
+                "sqlite",
+                "sqlserver",
+                "sqlsrv"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/dbal/issues",
+                "source": "https://github.com/doctrine/dbal/tree/4.3.4"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-10-09T09:11:36+00:00"
+        },
+        {
+            "name": "doctrine/deprecations",
+            "version": "1.1.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/deprecations.git",
+                "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+                "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "phpunit/phpunit": "<=7.5 || >=13"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^9 || ^12 || ^13",
+                "phpstan/phpstan": "1.4.10 || 2.1.11",
+                "phpstan/phpstan-phpunit": "^1.0 || ^2",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
+                "psr/log": "^1 || ^2 || ^3"
+            },
+            "suggest": {
+                "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Deprecations\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+            "homepage": "https://www.doctrine-project.org/",
+            "support": {
+                "issues": "https://github.com/doctrine/deprecations/issues",
+                "source": "https://github.com/doctrine/deprecations/tree/1.1.5"
+            },
+            "time": "2025-04-07T20:06:18+00:00"
+        },
         {
             "name": "doctrine/inflector",
             "version": "2.1.0",
@@ -4841,6 +4995,55 @@
             },
             "time": "2025-09-19T23:02:26+00:00"
         },
+        {
+            "name": "psr/cache",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/cache.git",
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for caching libraries",
+            "keywords": [
+                "cache",
+                "psr",
+                "psr-6"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/cache/tree/3.0.0"
+            },
+            "time": "2021-02-03T23:26:27+00:00"
+        },
         {
             "name": "psr/clock",
             "version": "1.0.0",

+ 12 - 4
database/factories/TeacherFactory.php

@@ -3,6 +3,7 @@
 namespace Database\Factories;
 
 use App\Models\Teacher;
+use App\Models\User;
 use Illuminate\Database\Eloquent\Factories\Factory;
 
 class TeacherFactory extends Factory
@@ -11,11 +12,18 @@ class TeacherFactory extends Factory
 
     public function definition(): array
     {
+        // 创建对应的用户记录
+        $user = User::factory()->create([
+            'role' => 'teacher',
+        ]);
+
+        $teacherId = 'teacher_' . $this->faker->unique()->numberBetween(1000, 9999);
+
         return [
-            'teacher_id' => $this->faker->unique()->numberBetween(1000, 9999),
-            'user_id' => null,
-            'name' => $this->faker->name(),
-            'subject' => $this->faker->randomElement(['数学', '语文', '英语']),
+            'teacher_id' => $teacherId,
+            'user_id' => $user->user_id,
+            'name' => $user->full_name,
+            'subject' => $this->faker->randomElement(['数学', '语文', '英语', '物理', '化学', '生物']),
         ];
     }
 }

+ 28 - 0
database/migrations/2025_11_26_014422_make_class_name_optional_in_students_table.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('students', function (Blueprint $table) {
+            $table->string('class_name', 64)->nullable()->change();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('students', function (Blueprint $table) {
+            $table->string('class_name', 64)->nullable(false)->change();
+        });
+    }
+};

+ 29 - 104
resources/views/filament/pages/student-management.blade.php

@@ -1,57 +1,30 @@
 <x-filament-panels::page>
     <div class="space-y-6">
-        <!-- 页面头部 -->
-        <div class="flex items-center justify-between">
-            <div>
-                <h2 class="text-2xl font-bold text-gray-900 dark:text-white">
-                    学生管理
-                </h2>
-                <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
-                    管理所有学生信息、查看学习进度和登录状态
-                </p>
-            </div>
-            <div class="flex gap-2">
-                <a href="{{ route('filament.admin.resources.students.create') }}"
-                   class="filament-button inline-flex items-center justify-center gap-2 rounded-md border border-transparent bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2">
-                    <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
-                    </svg>
-                    添加新学生
-                </a>
-                <a href="{{ route('filament.admin.pages.student-dashboard') }}"
-                   class="filament-button inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
-                    <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
-                    </svg>
-                    学生仪表板
-                </a>
-            </div>
+        <!-- 快速操作按钮 -->
+        <div class="flex justify-end gap-2">
+            <a href="{{ route('filament.admin.resources.students.create') }}"
+               class="btn btn-primary">
+                <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
+                </svg>
+                添加新学生
+            </a>
+            <a href="{{ route('filament.admin.resources.teachers.create') }}"
+               class="btn btn-success">
+                <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
+                </svg>
+                添加新老师
+            </a>
+            <a href="{{ route('filament.admin.pages.student-dashboard') }}"
+               class="btn btn-ghost">
+                <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
+                </svg>
+                学生仪表板
+            </a>
         </div>
 
-        <!-- 当前筛选提示 -->
-        @if($selectedTeacherName)
-            <div class="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3 flex items-center justify-between text-sm text-primary-800 dark:border-primary-500/40 dark:bg-primary-500/10 dark:text-primary-100">
-                <div>
-                    当前正在查看 <span class="font-semibold">{{ $selectedTeacherName }}</span> 的学生列表
-                </div>
-                <button
-                    wire:click="resetTeacherFilter"
-                    class="inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wider"
-                >
-                    清除筛选
-                </button>
-            </div>
-        @endif
-
-        <!-- 统计概览 -->
-        <x-filament-widgets::widgets
-            :widgets="$this->getHeaderWidgets()"
-            :columns="[
-                'md' => 2,
-                'xl' => 3,
-            ]"
-        />
-
         <!-- 老师概览 -->
         <div class="space-y-4">
             <div class="flex flex-wrap items-center justify-between gap-3">
@@ -60,12 +33,11 @@
                     <p class="text-sm text-gray-500 dark:text-gray-400">以老师为核心查看所带学生与近期动态</p>
                 </div>
                 <div class="flex gap-2">
-                    <button
-                        wire:click="resetTeacherFilter"
-                        class="inline-flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
+                    <a href="{{ route('filament.admin.resources.teachers.index') }}"
+                       class="inline-flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
                     >
                         查看全部老师
-                    </button>
+                    </a>
                 </div>
             </div>
 
@@ -137,53 +109,6 @@
             </div>
         </div>
 
-        <!-- 快速操作 -->
-        <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
-            <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
-                <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
-                    快速导入
-                </h3>
-                <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
-                    通过Excel文件批量导入学生信息
-                </p>
-                <button class="inline-flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700">
-                    <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
-                    </svg>
-                    批量导入
-                </button>
-            </div>
-
-            <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
-                <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
-                    学习报告
-                </h3>
-                <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
-                    查看学生的学习进度和成绩分析
-                </p>
-                <button class="inline-flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700">
-                    <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v1a3 3 0 003 3h0a3 3 0 003-3v-1m3-10V4a3 3 0 00-3-3H9a3 3 0 00-3 3v6h12z"></path>
-                    </svg>
-                    生成报告
-                </button>
-            </div>
-
-            <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
-                <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
-                    批量通知
-                </h3>
-                <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
-                    向所有学生发送重要通知或作业
-                </p>
-                <button class="inline-flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700">
-                    <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
-                    </svg>
-                    发送通知
-                </button>
-            </div>
-        </div>
 
         <!-- 数据表格 -->
         <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
@@ -199,9 +124,9 @@
                 <div class="text-sm text-blue-800 dark:text-blue-200">
                     <p class="font-medium mb-1">使用说明</p>
                     <ul class="list-disc list-inside space-y-1 text-blue-700 dark:text-blue-300">
-                        <li>点击学生姓名可以查看详细信息</li>
-                        <li>可以按年级、班级、指导老师进行筛选</li>
-                        <li>支持批量重置学生密码(默认密码:student123)</li>
+                        <li>可以点击按钮添加新学生或新老师</li>
+                        <li>查看老师概览了解各老师所带学生情况</li>
+                        <li>支持按年级、班级、指导老师筛选学生数据</li>
                         <li>表格会自动刷新显示最新数据</li>
                     </ul>
                 </div>

+ 249 - 0
tests/Feature/StudentResourceTest.php

@@ -0,0 +1,249 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Student;
+use App\Models\User;
+use App\Models\Teacher;
+use App\Services\StudentIdService;
+use Illuminate\Foundation\Testing\WithFaker;
+use Tests\TestCase;
+
+class StudentResourceTest extends TestCase
+{
+    use WithFaker;
+
+    private User $adminUser;
+    private Teacher $teacher;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        // 手动创建管理员用户(避免RefreshDatabase的问题)
+        $this->adminUser = User::create([
+            'user_id' => 'test_admin_' . uniqid(),
+            'username' => 'admin_' . uniqid(),
+            'full_name' => 'Admin User',
+            'email' => 'admin' . uniqid() . '@test.com',
+            'password_hash' => \Hash::make('password'),
+            'role' => 'admin',
+            'is_active' => 1,
+        ]);
+
+        // 手动创建老师用户和老师记录
+        $teacherUser = User::create([
+            'user_id' => 'test_teacher_user_' . uniqid(),
+            'username' => 'teacher_' . uniqid(),
+            'full_name' => 'Test Teacher',
+            'email' => 'teacher' . uniqid() . '@test.com',
+            'password_hash' => \Hash::make('password'),
+            'role' => 'teacher',
+            'is_active' => 1,
+        ]);
+
+        $this->teacher = Teacher::create([
+            'teacher_id' => 'test_teacher_' . uniqid(),
+            'user_id' => $teacherUser->user_id,
+            'name' => 'Test Teacher',
+            'subject' => '数学',
+        ]);
+    }
+
+    /**
+     * 测试学生ID生成服务
+     */
+    public function test_student_id_generation(): void
+    {
+        $studentId1 = StudentIdService::generateStudentId();
+        $studentId2 = StudentIdService::generateStudentId();
+
+        // 验证格式
+        $this->assertTrue(StudentIdService::validateStudentIdFormat($studentId1));
+        $this->assertTrue(StudentIdService::validateStudentIdFormat($studentId2));
+
+        // 验证唯一性
+        $this->assertNotEquals($studentId1, $studentId2);
+
+        // 验证格式模式
+        $this->assertMatchesRegularExpression('/^stu_\d+_\d+$/', $studentId1);
+    }
+
+    /**
+     * 测试学生创建功能
+     */
+    public function test_create_student(): void
+    {
+        $studentData = [
+            'name' => '测试学生',
+            'grade' => '高一',
+            'class_name' => '1班',
+            'teacher_id' => $this->teacher->teacher_id,
+            'remark' => '测试备注',
+        ];
+
+        $student = new Student();
+        $student->fill($studentData);
+        $student->save();
+
+        // 验证学生记录
+        $this->assertDatabaseHas('students', [
+            'name' => '测试学生',
+            'grade' => '高一',
+            'class_name' => '1班',
+            'teacher_id' => $this->teacher->teacher_id,
+        ]);
+
+        // 验证学生ID自动生成
+        $this->assertNotNull($student->student_id);
+        $this->assertTrue(StudentIdService::validateStudentIdFormat($student->student_id));
+
+        // 验证对应的用户记录被创建
+        $this->assertDatabaseHas('users', [
+            'user_id' => $student->student_id,
+            'username' => $student->student_id,
+            'role' => 'student',
+            'full_name' => '测试学生',
+        ]);
+    }
+
+    /**
+     * 测试创建学生时班级字段为可选
+     */
+    public function test_create_student_without_class(): void
+    {
+        $student = new Student();
+        $student->name = '无班级学生';
+        $student->grade = '高二';
+        $student->teacher_id = $this->teacher->teacher_id;
+        $student->save();
+
+        $this->assertNull($student->class_name);
+        $this->assertDatabaseHas('students', [
+            'student_id' => $student->student_id,
+            'class_name' => null,
+        ]);
+    }
+
+    /**
+     * 测试学生更新功能
+     */
+    public function test_update_student(): void
+    {
+        // 先创建一个学生
+        $student = Student::create([
+            'student_id' => StudentIdService::generateStudentId(),
+            'name' => '原姓名',
+            'grade' => '高一',
+            'teacher_id' => $this->teacher->teacher_id,
+        ]);
+
+        // 更新学生信息
+        $student->update([
+            'name' => '新姓名',
+            'grade' => '高二',
+            'class_name' => '2班',
+        ]);
+
+        $this->assertDatabaseHas('students', [
+            'student_id' => $student->student_id,
+            'name' => '新姓名',
+            'grade' => '高二',
+            'class_name' => '2班',
+        ]);
+    }
+
+    /**
+     * 测试学生删除功能
+     */
+    public function test_delete_student(): void
+    {
+        $student = Student::create([
+            'student_id' => StudentIdService::generateStudentId(),
+            'name' => '待删除学生',
+            'grade' => '高一',
+            'teacher_id' => $this->teacher->teacher_id,
+        ]);
+
+        $studentId = $student->student_id;
+
+        // 验证记录存在
+        $this->assertDatabaseHas('students', ['student_id' => $studentId]);
+        $this->assertDatabaseHas('users', ['user_id' => $studentId]);
+
+        // 删除学生
+        $student->delete();
+
+        // 验证记录被删除
+        $this->assertDatabaseMissing('students', ['student_id' => $studentId]);
+        // 注意:由于外键约束,用户记录可能也会被删除
+    }
+
+    /**
+     * 测试学生和老师的关系
+     */
+    public function test_student_teacher_relationship(): void
+    {
+        $student = Student::create([
+            'student_id' => StudentIdService::generateStudentId(),
+            'name' => '关联测试学生',
+            'grade' => '高一',
+            'teacher_id' => $this->teacher->teacher_id,
+        ]);
+
+        // 测试关系
+        $this->assertEquals($this->teacher->teacher_id, $student->teacher_id);
+        $this->assertInstanceOf(Teacher::class, $student->teacher);
+    }
+
+    /**
+     * 测试空班级字段的显示
+     */
+    public function test_empty_class_name_display(): void
+    {
+        $student = Student::create([
+            'student_id' => StudentIdService::generateStudentId(),
+            'name' => '无班级学生',
+            'grade' => '高三',
+            'teacher_id' => $this->teacher->teacher_id,
+        ]);
+
+        // 测试班级字段为空时的处理
+        $this->assertNull($student->class_name);
+
+        // 模拟表格显示逻辑
+        $displayValue = $student->class_name ?: '未分配';
+        $this->assertEquals('未分配', $displayValue);
+    }
+
+    /**
+     * 测试批量创建学生
+     */
+    public function test_bulk_create_students(): void
+    {
+        $students = [];
+        for ($i = 1; $i <= 5; $i++) {
+            $student = new Student();
+            $student->name = "测试学生{$i}";
+            $student->grade = '高一';
+            $student->teacher_id = $this->teacher->teacher_id;
+            $student->save();
+            $students[] = $student;
+        }
+
+        // 验证所有学生都被创建
+        $this->assertCount(5, $students);
+
+        // 验证每个学生的ID都是唯一的
+        $studentIds = array_map(fn($s) => $s->student_id, $students);
+        $this->assertEquals(count($studentIds), count(array_unique($studentIds)));
+
+        // 验证对应的用户记录都被创建
+        foreach ($students as $student) {
+            $this->assertDatabaseHas('users', [
+                'user_id' => $student->student_id,
+                'role' => 'student',
+            ]);
+        }
+    }
+}

+ 103 - 0
tests/Unit/StudentIdServiceTest.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\StudentIdService;
+use PHPUnit\Framework\TestCase;
+
+class StudentIdServiceTest extends TestCase
+{
+    /**
+     * 测试学生ID生成功能
+     */
+    public function test_generate_student_id(): void
+    {
+        $studentId = StudentIdService::generateStudentId();
+
+        // 验证格式
+        $this->assertTrue(StudentIdService::validateStudentIdFormat($studentId));
+
+        // 验证格式模式
+        $this->assertMatchesRegularExpression('/^stu_\d+_\d+$/', $studentId);
+
+        // 验证组成部分
+        $parts = explode('_', $studentId);
+        $this->assertEquals('stu', $parts[0]);
+        $this->assertIsNumeric($parts[1]); // 时间戳
+        $this->assertIsNumeric($parts[2]); // 序号
+    }
+
+    /**
+     * 测试生成多个学生ID的唯一性
+     */
+    public function test_generate_multiple_student_ids(): void
+    {
+        $ids = [];
+        $count = 10;
+
+        for ($i = 0; $i < $count; $i++) {
+            $id = StudentIdService::generateStudentId();
+            $this->assertTrue(StudentIdService::validateStudentIdFormat($id));
+            $this->assertNotContains($id, $ids); // 确保唯一性
+            $ids[] = $id;
+        }
+
+        $this->assertCount($count, $ids);
+        $this->assertEquals(count($ids), count(array_unique($ids)));
+    }
+
+    /**
+     * 测试学生ID格式验证功能
+     */
+    public function test_validate_student_id_format(): void
+    {
+        // 有效的格式
+        $validIds = [
+            'stu_1234567890_0',
+            'stu_1609459200_1',
+            'stu_9999999999_999',
+        ];
+
+        foreach ($validIds as $id) {
+            $this->assertTrue(StudentIdService::validateStudentIdFormat($id), "ID {$id} 应该是有效的");
+        }
+
+        // 无效的格式
+        $invalidIds = [
+            'stu_abc_0',           // 非数字时间戳
+            'stu_1234567890',      // 缺少序号
+            'stu_1234567890_abc',  // 非数字序号
+            'teacher_123_0',       // 错误前缀
+            '1234567890_0',        // 缺少前缀
+            '',                    // 空字符串
+            'stu_',               // 缺少时间戳和序号
+            'stu__0',             // 缺少时间戳
+        ];
+
+        foreach ($invalidIds as $id) {
+            $this->assertFalse(StudentIdService::validateStudentIdFormat($id), "ID {$id} 应该是无效的");
+        }
+    }
+
+    /**
+     * 测试边界情况
+     */
+    public function test_edge_cases(): void
+    {
+        // 测试生成ID的时间戳部分是否在合理范围内
+        $id = StudentIdService::generateStudentId();
+        $parts = explode('_', $id);
+        $timestamp = (int)$parts[1];
+
+        // 验证时间戳是否在最近10年内(合理性检查)
+        $tenYearsAgo = time() - (10 * 365 * 24 * 60 * 60);
+        $tenYearsLater = time() + (10 * 365 * 24 * 60 * 60);
+
+        $this->assertGreaterThanOrEqual($tenYearsAgo, $timestamp);
+        $this->assertLessThanOrEqual($tenYearsLater, $timestamp);
+
+        // 验证序号是非负整数
+        $sequence = (int)$parts[2];
+        $this->assertGreaterThanOrEqual(0, $sequence);
+    }
+}