Pārlūkot izejas kodu

fix(api-v13): library/course 课程状态判断与招生数量展示

- 最新课程=可报名且进行中,开放报名=可报名,历史课程=已结束(按报名/课程时间窗口判断)
- number 为招生数量,显示「招生 N 人」,0 时不显示
- 移除报名人数展示及对应聚合查询
- 新增 LibraryCourseTest 覆盖分区与展示
visuddhinanda 1 nedēļu atpakaļ
vecāks
revīzija
42803c5236

+ 45 - 25
api-v13/app/Http/Controllers/Library/CourseController.php

@@ -6,7 +6,6 @@ use App\Http\Api\UserApi;
 use App\Http\Controllers\Controller;
 use App\Http\Controllers\Controller;
 use App\Models\Attachment;
 use App\Models\Attachment;
 use App\Models\Course;
 use App\Models\Course;
-use App\Models\CourseMember;
 use Carbon\Carbon;
 use Carbon\Carbon;
 use Illuminate\Contracts\View\View;
 use Illuminate\Contracts\View\View;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
@@ -44,17 +43,44 @@ class CourseController extends Controller
      */
      */
     public function index(): View
     public function index(): View
     {
     {
-        $today = Carbon::today()->toDateString();
+        $now = Carbon::now();
         $base = Course::where('publicity', 30);
         $base = Course::where('publicity', 30);
 
 
-        $latest = (clone $base)->orderByDesc('created_at')->take(4)->get();
-        $open = (clone $base)->whereDate('start_at', '>', $today)->orderBy('start_at')->get();
-        $history = (clone $base)->whereDate('start_at', '<=', $today)->orderByDesc('start_at')->take(5)->get();
+        // 可报名:当前处于报名窗口内(报名开始 <= 现在 <= 报名结束)
+        $signUpOpen = fn ($query) => $query
+            ->where('sign_up_start_at', '<=', $now)
+            ->where('sign_up_end_at', '>=', $now);
+
+        // 进行中:当前处于课程时间窗口内(课程开始 <= 现在 <= 课程结束)
+        $inProgress = fn ($query) => $query
+            ->where('start_at', '<=', $now)
+            ->where('end_at', '>=', $now);
+
+        // 最新课程:可报名 且 进行中(已结束的课程无论多新都不出现在这里)
+        $latest = (clone $base)
+            ->where($signUpOpen)
+            ->where($inProgress)
+            ->orderByDesc('created_at')
+            ->take(4)
+            ->get();
+
+        // 开放报名:可报名
+        $open = (clone $base)
+            ->where($signUpOpen)
+            ->orderBy('start_at')
+            ->get();
+
+        // 历史课程:已结束(课程时间已过)
+        $history = (clone $base)
+            ->where('end_at', '<', $now)
+            ->orderByDesc('end_at')
+            ->take(5)
+            ->get();
 
 
         $stats = [
         $stats = [
             'total' => (clone $base)->count(),
             'total' => (clone $base)->count(),
-            'open' => (clone $base)->whereDate('start_at', '>', $today)->count(),
-            'closed' => (clone $base)->whereDate('start_at', '<=', $today)->count(),
+            'open' => (clone $base)->where($signUpOpen)->count(),
+            'closed' => (clone $base)->where('end_at', '<', $now)->count(),
         ];
         ];
 
 
         return view('library.course.index', [
         return view('library.course.index', [
@@ -66,16 +92,15 @@ class CourseController extends Controller
     }
     }
 
 
     /**
     /**
-     * 历史课程列表页:分页展示已开课/已结束的公开课程。
+     * 历史课程列表页:分页展示已结束的公开课程。
      */
      */
     public function history(Request $request): View
     public function history(Request $request): View
     {
     {
-        $today = Carbon::today()->toDateString();
         $perPage = 10;
         $perPage = 10;
 
 
         $paginator = Course::where('publicity', 30)
         $paginator = Course::where('publicity', 30)
-            ->whereDate('start_at', '<=', $today)
-            ->orderByDesc('start_at')
+            ->where('end_at', '<', Carbon::now())
+            ->orderByDesc('end_at')
             ->paginate($perPage);
             ->paginate($perPage);
 
 
         $paginator->setCollection($this->present($paginator->getCollection()));
         $paginator->setCollection($this->present($paginator->getCollection()));
@@ -87,7 +112,7 @@ class CourseController extends Controller
     }
     }
 
 
     // -------------------------------------------------------------------------
     // -------------------------------------------------------------------------
-    // 将 Course 集合加工为视图所需数组:批量解析讲师与报名人数,避免 N+1
+    // 将 Course 集合加工为视图所需数组:批量解析讲师,避免 N+1
     // -------------------------------------------------------------------------
     // -------------------------------------------------------------------------
     private function present(Collection $courses): Collection
     private function present(Collection $courses): Collection
     {
     {
@@ -95,13 +120,6 @@ class CourseController extends Controller
             return collect();
             return collect();
         }
         }
 
 
-        // 报名人数:按 course_id 一次聚合
-        $memberCounts = CourseMember::whereIn('course_id', $courses->pluck('id'))
-            ->where('is_current', true)
-            ->selectRaw('course_id, count(*) as cnt')
-            ->groupBy('course_id')
-            ->pluck('cnt', 'course_id');
-
         // 讲师:按 teacher uuid 一次批量解析
         // 讲师:按 teacher uuid 一次批量解析
         $teacherUids = $courses->pluck('teacher')->filter()->unique()->values();
         $teacherUids = $courses->pluck('teacher')->filter()->unique()->values();
         $teachers = $teacherUids->isEmpty()
         $teachers = $teacherUids->isEmpty()
@@ -110,7 +128,7 @@ class CourseController extends Controller
 
 
         $baseUrl = rtrim(config('mint.server.workspace_base_path'), '/');
         $baseUrl = rtrim(config('mint.server.workspace_base_path'), '/');
 
 
-        return $courses->values()->map(function (Course $course, int $index) use ($memberCounts, $teachers, $baseUrl) {
+        return $courses->values()->map(function (Course $course, int $index) use ($teachers, $baseUrl) {
             $teacher = $teachers->get($course->teacher);
             $teacher = $teachers->get($course->teacher);
 
 
             return [
             return [
@@ -128,20 +146,22 @@ class CourseController extends Controller
                 'cover_url' => $this->coverUrl($course),
                 'cover_url' => $this->coverUrl($course),
                 'cover_gradient' => $this->coverGradients[$this->colorIndex($course->id) % count($this->coverGradients)],
                 'cover_gradient' => $this->coverGradients[$this->colorIndex($course->id) % count($this->coverGradients)],
                 'teacher' => $this->formatTeacher($teacher, $index),
                 'teacher' => $this->formatTeacher($teacher, $index),
-                'member_count' => (int) ($memberCounts[$course->id] ?? 0),
-                'status' => $this->statusOf($course->start_at),
+                'status' => $this->statusOf($course),
                 'detail_url' => $baseUrl.'/course/'.$course->id,
                 'detail_url' => $baseUrl.'/course/'.$course->id,
             ];
             ];
         });
         });
     }
     }
 
 
-    private function statusOf(mixed $startAt): string
+    private function statusOf(Course $course): string
     {
     {
-        if (! $startAt) {
+        if (! $course->sign_up_start_at || ! $course->sign_up_end_at) {
             return 'closed';
             return 'closed';
         }
         }
 
 
-        return Carbon::parse($startAt)->startOfDay()->isAfter(Carbon::today()) ? 'open' : 'closed';
+        return Carbon::now()->between(
+            Carbon::parse($course->sign_up_start_at),
+            Carbon::parse($course->sign_up_end_at)
+        ) ? 'open' : 'closed';
     }
     }
 
 
     private function formatTeacher(?array $teacher, int $index): array
     private function formatTeacher(?array $teacher, int $index): array

+ 1 - 2
api-v13/resources/css/modules/library-course.css

@@ -340,8 +340,7 @@
     font-variant-numeric: tabular-nums;
     font-variant-numeric: tabular-nums;
 }
 }
 
 
-.course-row__period,
-.course-row__members,
+.course-row__quota,
 .course-row__date {
 .course-row__date {
     white-space: nowrap;
     white-space: nowrap;
 }
 }

+ 1 - 1
api-v13/resources/lang/en/library.php

@@ -154,7 +154,7 @@ return [
     'course_view_all' => 'View All',
     'course_view_all' => 'View All',
     'course_status_open' => 'Enrolling',
     'course_status_open' => 'Enrolling',
     'course_status_closed' => 'Ended',
     'course_status_closed' => 'Ended',
-    'course_period' => 'Cohort :n',
+    'course_quota' => 'Capacity :n',
     'course_members' => ':n learners',
     'course_members' => ':n learners',
     'course_signup' => 'Enroll',
     'course_signup' => 'Enroll',
     'course_no_latest' => 'No courses yet',
     'course_no_latest' => 'No courses yet',

+ 1 - 1
api-v13/resources/lang/lo/library.php

@@ -153,7 +153,7 @@ return [
     'course_view_all' => 'ເບິ່ງທັງໝົດ',
     'course_view_all' => 'ເບິ່ງທັງໝົດ',
     'course_status_open' => 'ຮັບສະໝັກ',
     'course_status_open' => 'ຮັບສະໝັກ',
     'course_status_closed' => 'ສິ້ນສຸດແລ້ວ',
     'course_status_closed' => 'ສິ້ນສຸດແລ້ວ',
-    'course_period' => 'ຮຸ່ນທີ :n',
+    'course_quota' => ':n ບ່ອນ',
     'course_members' => 'ຜູ້ຮຽນ :n ຄົນ',
     'course_members' => 'ຜູ້ຮຽນ :n ຄົນ',
     'course_signup' => 'ສະໝັກຮຽນ',
     'course_signup' => 'ສະໝັກຮຽນ',
     'course_no_latest' => 'ຍັງບໍ່ມີຫຼັກສູດ',
     'course_no_latest' => 'ຍັງບໍ່ມີຫຼັກສູດ',

+ 1 - 1
api-v13/resources/lang/vi/library.php

@@ -153,7 +153,7 @@ return [
     'course_view_all' => 'Xem tất cả',
     'course_view_all' => 'Xem tất cả',
     'course_status_open' => 'Đang ghi danh',
     'course_status_open' => 'Đang ghi danh',
     'course_status_closed' => 'Đã kết thúc',
     'course_status_closed' => 'Đã kết thúc',
-    'course_period' => 'Khóa :n',
+    'course_quota' => ':n chỗ',
     'course_members' => ':n học viên',
     'course_members' => ':n học viên',
     'course_signup' => 'Ghi danh',
     'course_signup' => 'Ghi danh',
     'course_no_latest' => 'Chưa có khóa học nào',
     'course_no_latest' => 'Chưa có khóa học nào',

+ 1 - 1
api-v13/resources/lang/zh-Hans/library.php

@@ -157,7 +157,7 @@ return [
     'course_view_all' => '查看全部',
     'course_view_all' => '查看全部',
     'course_status_open' => '报名中',
     'course_status_open' => '报名中',
     'course_status_closed' => '已结束',
     'course_status_closed' => '已结束',
-    'course_period' => '第 :n 期',
+    'course_quota' => '招生 :n 人',
     'course_members' => ':n 人',
     'course_members' => ':n 人',
     'course_signup' => '报名',
     'course_signup' => '报名',
     'course_no_latest' => '暂无课程',
     'course_no_latest' => '暂无课程',

+ 1 - 1
api-v13/resources/lang/zh-Hant/library.php

@@ -154,7 +154,7 @@ return [
     'course_view_all' => '查看全部',
     'course_view_all' => '查看全部',
     'course_status_open' => '報名中',
     'course_status_open' => '報名中',
     'course_status_closed' => '已結束',
     'course_status_closed' => '已結束',
-    'course_period' => '第 :n 期',
+    'course_quota' => '招生 :n 人',
     'course_members' => ':n 人',
     'course_members' => ':n 人',
     'course_signup' => '報名',
     'course_signup' => '報名',
     'course_no_latest' => '暫無課程',
     'course_no_latest' => '暫無課程',

+ 1 - 2
api-v13/resources/views/components/library/course-card.blade.php

@@ -43,9 +43,8 @@
 
 
         <div class="course-card__foot">
         <div class="course-card__foot">
             @if($course['number'] > 0)
             @if($course['number'] > 0)
-            <span class="course-card__period">{{ __('library.course_period', ['n' => $course['number']]) }}</span>
+            <span class="course-card__quota">{{ __('library.course_quota', ['n' => $course['number']]) }}</span>
             @endif
             @endif
-            <span class="course-card__members">{{ __('library.course_members', ['n' => $course['member_count']]) }}</span>
         </div>
         </div>
     </div>
     </div>
 
 

+ 1 - 3
api-v13/resources/views/components/library/course-row.blade.php

@@ -43,11 +43,9 @@
             @endif
             @endif
 
 
             @if($course['number'] > 0)
             @if($course['number'] > 0)
-            <span class="course-row__period">{{ __('library.course_period', ['n' => $course['number']]) }}</span>
+            <span class="course-row__quota">{{ __('library.course_quota', ['n' => $course['number']]) }}</span>
             @endif
             @endif
 
 
-            <span class="course-row__members">{{ __('library.course_members', ['n' => $course['member_count']]) }}</span>
-
             @if($course['start_date'])
             @if($course['start_date'])
             <span class="course-row__date">{{ $course['start_date'] }}</span>
             <span class="course-row__date">{{ $course['start_date'] }}</span>
             @endif
             @endif

+ 163 - 0
api-v13/tests/Feature/LibraryCourseTest.php

@@ -0,0 +1,163 @@
+<?php
+
+use App\Models\Course;
+use Carbon\Carbon;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 造一门公开课程(publicity=30),返回模型。
+ *
+ * 覆盖报名窗口与课程窗口用四个字段:sign_up_start_at / sign_up_end_at
+ * 决定「可报名」,start_at / end_at 决定「进行中 / 已结束」。
+ */
+function makeLibraryCourse(array $overrides = []): Course
+{
+    $course = new Course;
+    $course->forceFill(array_merge([
+        'id' => (string) Str::uuid(),
+        'title' => '测试课程',
+        'studio_id' => (string) Str::uuid(),
+        'publicity' => 30,
+    ], $overrides))->save();
+
+    return $course;
+}
+
+it('partitions courses by sign-up window and course window', function () {
+    $now = Carbon::now();
+
+    makeLibraryCourse([
+        'title' => '进行中可报名',
+        'sign_up_start_at' => $now->copy()->subDays(10),
+        'sign_up_end_at' => $now->copy()->addDays(10),
+        'start_at' => $now->copy()->subDays(5),
+        'end_at' => $now->copy()->addDays(5),
+    ]);
+
+    // 已结束:课程时间已过(报名也早已结束),无论多新都不进「最新课程」
+    makeLibraryCourse([
+        'title' => '已结束课程',
+        'sign_up_start_at' => $now->copy()->subDays(40),
+        'sign_up_end_at' => $now->copy()->subDays(20),
+        'start_at' => $now->copy()->subDays(30),
+        'end_at' => $now->copy()->subDays(1),
+    ]);
+
+    // 未开始但可报名:只进「开放报名」
+    makeLibraryCourse([
+        'title' => '未开始可报名',
+        'sign_up_start_at' => $now->copy()->subDays(1),
+        'sign_up_end_at' => $now->copy()->addDays(10),
+        'start_at' => $now->copy()->addDays(5),
+        'end_at' => $now->copy()->addDays(20),
+    ]);
+
+    // 进行中但报名已结束:三个区块都不该出现
+    makeLibraryCourse([
+        'title' => '进行中报名已结束',
+        'sign_up_start_at' => $now->copy()->subDays(20),
+        'sign_up_end_at' => $now->copy()->subDays(1),
+        'start_at' => $now->copy()->subDays(5),
+        'end_at' => $now->copy()->addDays(5),
+    ]);
+
+    $response = $this->get('/library/course')->assertOk();
+
+    // 最新课程 = 可报名 且 进行中
+    $response->assertViewHas('latestCourses', function ($courses) {
+        $titles = $courses->pluck('title')->all();
+
+        return in_array('进行中可报名', $titles, true)
+            && ! in_array('已结束课程', $titles, true)
+            && ! in_array('未开始可报名', $titles, true)
+            && ! in_array('进行中报名已结束', $titles, true);
+    });
+
+    // 开放报名 = 可报名
+    $response->assertViewHas('openCourses', function ($courses) {
+        $titles = $courses->pluck('title')->all();
+
+        return in_array('进行中可报名', $titles, true)
+            && in_array('未开始可报名', $titles, true)
+            && ! in_array('已结束课程', $titles, true)
+            && ! in_array('进行中报名已结束', $titles, true);
+    });
+
+    // 历史课程 = 已结束
+    $response->assertViewHas('historyCourses', function ($courses) {
+        $titles = $courses->pluck('title')->all();
+
+        return in_array('已结束课程', $titles, true)
+            && ! in_array('进行中可报名', $titles, true)
+            && ! in_array('未开始可报名', $titles, true)
+            && ! in_array('进行中报名已结束', $titles, true);
+    });
+
+    // 统计条:open=可报名数量,closed=已结束数量
+    $response->assertViewHas('stats', function ($stats) {
+        return $stats['total'] === 4
+            && $stats['open'] === 2
+            && $stats['closed'] === 1;
+    });
+});
+
+it('history page lists only ended courses', function () {
+    $now = Carbon::now();
+
+    makeLibraryCourse([
+        'title' => '已结束课程',
+        'start_at' => $now->copy()->subDays(30),
+        'end_at' => $now->copy()->subDays(1),
+    ]);
+
+    makeLibraryCourse([
+        'title' => '进行中可报名',
+        'sign_up_start_at' => $now->copy()->subDays(10),
+        'sign_up_end_at' => $now->copy()->addDays(10),
+        'start_at' => $now->copy()->subDays(5),
+        'end_at' => $now->copy()->addDays(5),
+    ]);
+
+    $this->get('/library/course/history')
+        ->assertOk()
+        ->assertViewHas('courses', function ($paginator) {
+            $titles = $paginator->getCollection()->pluck('title')->all();
+
+            return in_array('已结束课程', $titles, true)
+                && ! in_array('进行中可报名', $titles, true);
+        });
+});
+
+it('renders enrollment quota instead of a cohort number', function () {
+    $now = Carbon::now();
+
+    makeLibraryCourse([
+        'title' => '限名额课程',
+        'number' => 2000,
+        'sign_up_start_at' => $now->copy()->subDays(10),
+        'sign_up_end_at' => $now->copy()->addDays(10),
+        'start_at' => $now->copy()->subDays(5),
+        'end_at' => $now->copy()->addDays(5),
+    ]);
+
+    makeLibraryCourse([
+        'title' => '不限名额课程',
+        'number' => 0,
+        'sign_up_start_at' => $now->copy()->subDays(10),
+        'sign_up_end_at' => $now->copy()->addDays(10),
+        'start_at' => $now->copy()->subDays(5),
+        'end_at' => $now->copy()->addDays(5),
+    ]);
+
+    $this->get('/library/course?lang=zh-Hans')
+        ->assertOk()
+        ->assertSee('招生 2000 人')
+        ->assertDontSee('第 2000 期')
+        ->assertDontSee('招生 0 人')
+        // 报名人数不再展示
+        ->assertDontSee('course-card__members')
+        ->assertDontSee('course-row__members');
+});