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

feat(library): 新增佛教课程首页与历史课程列表页

- 新增 Library/CourseController(最新/开放报名/历史课程查询,历史课程分页)

- 新增课程首页与历史课程列表页 Blade 视图、课程卡片/列表行组件与 library-course.css

- 导航栏新增「课程」入口,补充 en/zh-Hans/zh-Hant 文案
visuddhinanda 2 дней назад
Родитель
Сommit
ff88c79193

+ 193 - 0
api-v13/app/Http/Controllers/Library/CourseController.php

@@ -0,0 +1,193 @@
+<?php
+
+namespace App\Http\Controllers\Library;
+
+use App\Http\Api\UserApi;
+use App\Http\Controllers\Controller;
+use App\Models\Attachment;
+use App\Models\Course;
+use App\Models\CourseMember;
+use Carbon\Carbon;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+class CourseController extends Controller
+{
+    // 无封面时的渐变占位色池:按课程 id 取余,保证同一课程颜色稳定
+    private array $coverGradients = [
+        'linear-gradient(160deg, #2d2010, rgb(150, 104, 40))',
+        'linear-gradient(160deg, #1a2d10, rgb(88, 122, 44))',
+        'linear-gradient(160deg, #0d1f3c, rgb(45, 92, 138))',
+        'linear-gradient(160deg, #2d1020, rgb(140, 60, 96))',
+        'linear-gradient(160deg, #1a1a2d, rgb(72, 70, 128))',
+        'linear-gradient(160deg, #1a2820, rgb(52, 110, 90))',
+    ];
+
+    // 讲师头像文字底色池
+    private array $authorColors = [
+        '#c8860a',
+        '#2e7d32',
+        '#1565c0',
+        '#6a1b9a',
+        '#c62828',
+        '#00695c',
+        '#4e342e',
+        '#37474f',
+    ];
+
+    /**
+     * 课程栏目首页:Hero 由布局渲染,正文含统计条、最新课程、开放报名、历史课程预览。
+     */
+    public function index(): View
+    {
+        $today = Carbon::today()->toDateString();
+        $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();
+
+        $stats = [
+            'total' => (clone $base)->count(),
+            'open' => (clone $base)->whereDate('start_at', '>', $today)->count(),
+            'closed' => (clone $base)->whereDate('start_at', '<=', $today)->count(),
+        ];
+
+        return view('library.course.index', [
+            'latestCourses' => $this->present($latest),
+            'openCourses' => $this->present($open),
+            'historyCourses' => $this->present($history),
+            'stats' => $stats,
+        ]);
+    }
+
+    /**
+     * 历史课程列表页:分页展示已开课/已结束的公开课程。
+     */
+    public function history(Request $request): View
+    {
+        $today = Carbon::today()->toDateString();
+        $perPage = 10;
+
+        $paginator = Course::where('publicity', 30)
+            ->whereDate('start_at', '<=', $today)
+            ->orderByDesc('start_at')
+            ->paginate($perPage);
+
+        $paginator->setCollection($this->present($paginator->getCollection()));
+
+        return view('library.course.history', [
+            'courses' => $paginator,
+            'total' => $paginator->total(),
+        ]);
+    }
+
+    // -------------------------------------------------------------------------
+    // 将 Course 集合加工为视图所需数组:批量解析讲师与报名人数,避免 N+1
+    // -------------------------------------------------------------------------
+    private function present(Collection $courses): Collection
+    {
+        if ($courses->isEmpty()) {
+            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 一次批量解析
+        $teacherUids = $courses->pluck('teacher')->filter()->unique()->values();
+        $teachers = $teacherUids->isEmpty()
+            ? collect()
+            : collect(UserApi::getListByUuid($teacherUids->all()))->keyBy('id');
+
+        $baseUrl = rtrim(config('mint.server.workspace_base_path'), '/');
+
+        return $courses->values()->map(function (Course $course, int $index) use ($memberCounts, $teachers, $baseUrl) {
+            $teacher = $teachers->get($course->teacher);
+
+            return [
+                'id' => $course->id,
+                'title' => $course->title,
+                'subtitle' => $course->subtitle,
+                'summary' => $course->summary,
+                'number' => (int) $course->number,
+                'join' => $course->join,
+                'start_at' => $course->start_at,
+                'end_at' => $course->end_at,
+                'sign_up_end_at' => $course->sign_up_end_at,
+                'start_date' => $course->start_at ? Carbon::parse($course->start_at)->format('Y-m-d') : null,
+                'sign_up_end_date' => $course->sign_up_end_at ? Carbon::parse($course->sign_up_end_at)->format('Y-m-d') : null,
+                'cover_url' => $this->coverUrl($course),
+                'cover_gradient' => $this->coverGradients[$this->colorIndex($course->id) % count($this->coverGradients)],
+                'teacher' => $this->formatTeacher($teacher, $index),
+                'member_count' => (int) ($memberCounts[$course->id] ?? 0),
+                'status' => $this->statusOf($course->start_at),
+                'detail_url' => $baseUrl.'/course/'.$course->id,
+            ];
+        });
+    }
+
+    private function statusOf(mixed $startAt): string
+    {
+        if (! $startAt) {
+            return 'closed';
+        }
+
+        return Carbon::parse($startAt)->startOfDay()->isAfter(Carbon::today()) ? 'open' : 'closed';
+    }
+
+    private function formatTeacher(?array $teacher, int $index): array
+    {
+        $name = $teacher['nickName'] ?? null;
+        if (! $name || $name === 'unknown') {
+            return ['name' => null, 'avatar' => null, 'initials' => null, 'color' => null];
+        }
+
+        return [
+            'name' => $name,
+            'avatar' => $teacher['avatar'] ?? null,
+            'initials' => mb_substr($name, 0, 2),
+            'color' => $this->authorColors[$index % count($this->authorColors)],
+        ];
+    }
+
+    // -------------------------------------------------------------------------
+    // 封面 URL:cover 可能是文件名(Storage),也可能是附件 uuid
+    // -------------------------------------------------------------------------
+    private function coverUrl(Course $course): ?string
+    {
+        $cover = $course->cover;
+        if (! $cover) {
+            return null;
+        }
+
+        // 附件 uuid:取 _m 中图
+        if (Str::isUuid($cover)) {
+            $attachment = Attachment::find($cover);
+            if ($attachment) {
+                return Storage::disk('public')->url($attachment->bucket.'/'.$attachment->id.'_m.jpg');
+            }
+
+            return null;
+        }
+
+        $thumb = str_replace('.jpg', '_m.jpg', $cover);
+
+        return App::environment(['local', 'testing'])
+            ? Storage::url($thumb)
+            : Storage::temporaryUrl($thumb, now()->addDays(6));
+    }
+
+    private function colorIndex(string $id): int
+    {
+        return hexdec(substr(str_replace('-', '', $id), 0, 4)) % 255;
+    }
+}

+ 2 - 1
api-v13/config/mint.php

@@ -61,7 +61,8 @@ return [
         ],
         ],
         'assets' => env('ASSETS_SERVER', 'localhost:9999'),
         'assets' => env('ASSETS_SERVER', 'localhost:9999'),
 
 
-        'dashboard_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:3000/my'),
+        'dashboard_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:4000/pcd-v2026'),
+        'workspace_base_path' => env('DASHBOARD_BASE_PATH'.'/workspace', 'http://127.0.0.1:4000/pcd-v2026/workspace'),
 
 
         'cdn_urls' => explode(',', env('CDN_URLS', 'https://www.wikipali.cc/downloads')),
         'cdn_urls' => explode(',', env('CDN_URLS', 'https://www.wikipali.cc/downloads')),
 
 

+ 467 - 0
api-v13/resources/css/modules/library-course.css

@@ -0,0 +1,467 @@
+/* resources/css/modules/library-course.css
+   课程栏目专属样式。
+   视觉语言沿用 --wp-* 暖金 token,与 anthology / library-index 一致。
+*/
+
+/* ══════════════════════════════════════════
+   〇、区块通用结构(课程页独立声明,不依赖 library-index.css)
+   ══════════════════════════════════════════ */
+
+.lib-section {
+    margin-top: 3rem;
+    margin-bottom: 0.5rem;
+}
+
+.lib-section__header {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+    padding-bottom: 0.75rem;
+    border-bottom: 1px solid var(--wp-border);
+}
+
+.lib-section__title {
+    font-size: 1rem;
+    font-weight: 600;
+    color: var(--wp-ink);
+    margin: 0;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    flex: 1;
+}
+
+.lib-section__title .ti {
+    font-size: 1.125rem;
+    color: var(--wp-brand);
+}
+
+.lib-section__more {
+    font-size: 0.8125rem;
+    color: var(--wp-brand);
+    text-decoration: none;
+    white-space: nowrap;
+    display: flex;
+    align-items: center;
+    gap: 0.25rem;
+    flex-shrink: 0;
+}
+
+.lib-section__more:hover {
+    text-decoration: underline;
+}
+
+/* ══════════════════════════════════════════
+   一、统计条
+   ══════════════════════════════════════════ */
+
+.course-stats {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 1rem;
+    margin-top: 1.5rem;
+}
+
+.course-stat {
+    background: var(--wp-card-bg);
+    border: 1px solid var(--wp-border);
+    border-radius: 10px;
+    padding: 1.25rem 1rem;
+    text-align: center;
+}
+
+.course-stat__value {
+    font-size: 1.75rem;
+    font-weight: 700;
+    color: var(--wp-brand);
+    line-height: 1.2;
+    font-variant-numeric: tabular-nums;
+}
+
+.course-stat__label {
+    font-size: 0.75rem;
+    color: var(--wp-ink-muted);
+    margin-top: 0.375rem;
+}
+
+/* ══════════════════════════════════════════
+   二、最新课程卡片网格
+   ══════════════════════════════════════════ */
+
+.course-grid {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    gap: 1rem;
+}
+
+.course-card {
+    display: flex;
+    flex-direction: column;
+    background: var(--wp-card-bg);
+    border: 1px solid var(--wp-border);
+    border-radius: 10px;
+    overflow: hidden;
+    text-decoration: none;
+    color: inherit;
+    transition:
+        box-shadow 0.2s,
+        transform 0.2s;
+    height: 100%;
+}
+
+.course-card:hover {
+    box-shadow:
+        0 8px 24px rgba(200, 134, 10, 0.12),
+        0 2px 8px rgba(0, 0, 0, 0.06);
+    transform: translateY(-2px);
+    color: inherit;
+    text-decoration: none;
+}
+
+.course-card__cover {
+    position: relative;
+    aspect-ratio: 16 / 10;
+    background-size: cover;
+    background-position: center;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+}
+
+.course-card__cover-img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+.course-card__cover-fallback {
+    color: rgba(255, 255, 255, 0.92);
+    font-family: 'Noto Serif SC', 'Noto Serif', Georgia, serif;
+    font-size: 1.05rem;
+    font-weight: 600;
+    padding: 0 1rem;
+    text-align: center;
+    line-height: 1.4;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+.course-card__body {
+    padding: 1rem 1.1rem 1.1rem;
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+    flex: 1;
+}
+
+.course-card__title {
+    font-family: 'Noto Serif SC', 'Noto Serif', Georgia, serif;
+    font-size: 1rem;
+    font-weight: 600;
+    color: var(--wp-ink);
+    line-height: 1.4;
+    margin: 0;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    min-height: 2.8em;
+}
+
+.course-card:hover .course-card__title {
+    color: var(--wp-brand);
+}
+
+.course-card__teacher {
+    min-height: 1.5rem;
+}
+
+.course-card__foot {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    font-size: 0.75rem;
+    color: var(--wp-ink-muted);
+    margin-top: auto;
+    font-variant-numeric: tabular-nums;
+}
+
+/* ══════════════════════════════════════════
+   三、状态徽章
+   ══════════════════════════════════════════ */
+
+.course-badge {
+    display: inline-flex;
+    align-items: center;
+    font-size: 0.6875rem;
+    font-weight: 600;
+    line-height: 1;
+    padding: 4px 9px;
+    border-radius: 20px;
+    white-space: nowrap;
+}
+
+.course-badge--open {
+    background: #eaf3de;
+    color: #3b6d11;
+    border: 1px solid #c0dd97;
+}
+
+.course-badge--closed {
+    background: #f1efe8;
+    color: #6b6a64;
+    border: 1px solid #d8d6cc;
+}
+
+.course-card__cover .course-badge {
+    position: absolute;
+    top: 0.75rem;
+    left: 0.75rem;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+}
+
+/* ══════════════════════════════════════════
+   四、横向列表行(开放报名 / 历史课程)
+   ══════════════════════════════════════════ */
+
+.course-list {
+    display: grid;
+    grid-template-columns: 1fr;
+    gap: 1rem;
+}
+
+/* 桌面端改为两列,避免横向卡片过宽、中间留白 */
+@media (min-width: 992px) {
+    .course-list {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+}
+
+.course-row {
+    display: flex;
+    align-items: center;
+    gap: 1rem;
+    background: var(--wp-card-bg);
+    border: 1px solid var(--wp-border);
+    border-radius: 10px;
+    padding: 0.85rem 1.1rem;
+    text-decoration: none;
+    color: inherit;
+    transition:
+        box-shadow 0.2s,
+        transform 0.2s,
+        border-color 0.2s;
+}
+
+.course-row:hover {
+    box-shadow: 0 6px 20px rgba(200, 134, 10, 0.1);
+    transform: translateY(-1px);
+    border-color: var(--wp-brand-light);
+    color: inherit;
+    text-decoration: none;
+}
+
+.course-row__thumb {
+    width: 112px;
+    height: 72px;
+    flex-shrink: 0;
+    border-radius: 6px;
+    overflow: hidden;
+    background-size: cover;
+    background-position: center;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.course-row__thumb img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+.course-row__thumb span {
+    color: rgba(255, 255, 255, 0.9);
+    font-size: 0.72rem;
+    font-weight: 600;
+    padding: 0 0.5rem;
+    text-align: center;
+    line-height: 1.3;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+.course-row__main {
+    flex: 1;
+    min-width: 0;
+}
+
+.course-row__title {
+    font-size: 0.95rem;
+    font-weight: 600;
+    color: var(--wp-ink);
+    line-height: 1.4;
+    margin: 0 0 0.2rem;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.course-row:hover .course-row__title {
+    color: var(--wp-brand);
+}
+
+.course-row__summary {
+    font-size: 0.78rem;
+    color: var(--wp-ink-muted);
+    line-height: 1.5;
+    margin-bottom: 0.45rem;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+.course-row__meta {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 0.25rem 0.9rem;
+    font-size: 0.75rem;
+    color: var(--wp-ink-muted);
+    font-variant-numeric: tabular-nums;
+}
+
+.course-row__period,
+.course-row__members,
+.course-row__date {
+    white-space: nowrap;
+}
+
+.course-row__side {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-end;
+    gap: 0.5rem;
+    flex-shrink: 0;
+}
+
+.course-row__cta {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.15rem;
+    font-size: 0.78rem;
+    font-weight: 600;
+    color: var(--wp-brand);
+}
+
+/* ══════════════════════════════════════════
+   五、历史课程列表页头部
+   ══════════════════════════════════════════ */
+
+.course-page-header {
+    background: linear-gradient(135deg, var(--wp-ink) 0%, #2d2010 100%);
+    padding: 2.25rem 0 2rem;
+    position: relative;
+    overflow: hidden;
+}
+
+.course-page-header::before {
+    content: '课';
+    font-family: 'Noto Serif SC', serif;
+    font-size: 16rem;
+    font-weight: 700;
+    color: rgba(255, 255, 255, 0.03);
+    position: absolute;
+    right: -1rem;
+    top: -2.5rem;
+    line-height: 1;
+    pointer-events: none;
+}
+
+.course-page-header h1 {
+    font-family: 'Noto Serif SC', 'Noto Serif', Georgia, serif;
+    font-size: 1.75rem;
+    font-weight: 600;
+    color: #fff;
+    margin: 0 0 0.3rem;
+    letter-spacing: 0.08em;
+}
+
+.course-page-header p {
+    color: rgba(255, 255, 255, 0.45);
+    font-size: 0.85rem;
+    margin: 0;
+}
+
+.course-count-badge {
+    background: var(--wp-brand);
+    color: var(--wp-ink);
+    font-size: 0.75rem;
+    font-weight: 700;
+    padding: 2px 9px;
+    border-radius: 20px;
+    margin-left: 0.6rem;
+    vertical-align: middle;
+    font-variant-numeric: tabular-nums;
+}
+
+/* ══════════════════════════════════════════
+   六、响应式
+   ══════════════════════════════════════════ */
+
+@media (max-width: 1199px) {
+    .course-grid {
+        grid-template-columns: repeat(3, minmax(0, 1fr));
+    }
+}
+
+@media (max-width: 991px) {
+    .course-grid {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+}
+
+@media (max-width: 575px) {
+    .course-grid {
+        grid-template-columns: 1fr;
+    }
+
+    .course-stats {
+        gap: 0.5rem;
+    }
+
+    .course-stat {
+        padding: 0.9rem 0.5rem;
+    }
+
+    .course-stat__value {
+        font-size: 1.4rem;
+    }
+
+    .course-row {
+        gap: 0.75rem;
+        padding: 0.75rem;
+    }
+
+    .course-row__thumb {
+        width: 84px;
+        height: 56px;
+    }
+
+    .course-row__summary {
+        display: none;
+    }
+
+    .course-row__side {
+        gap: 0.4rem;
+    }
+}

+ 23 - 0
api-v13/resources/lang/en/library.php

@@ -134,4 +134,27 @@ return [
     'cancel' => 'Cancel',
     'cancel' => 'Cancel',
     'confirm' => 'OK',
     'confirm' => 'OK',
     'no_toc' => 'This book has no table of contents',
     'no_toc' => 'This book has no table of contents',
+
+    // course (course/index.blade.php + course/history.blade.php)
+    'course' => 'Courses',
+    'course_hero_title' => 'Courses',
+    'course_hero_subtitle' => 'Study the Canon with Pāli experts from around the world',
+    'course_search_placeholder' => 'Search courses, teachers…',
+    'course_stat_total' => 'Public Courses',
+    'course_stat_open' => 'Open Enrollment',
+    'course_stat_closed' => 'Completed',
+    'course_section_latest' => 'Latest Courses',
+    'course_section_open' => 'Open Enrollment',
+    'course_section_history' => 'Past Courses',
+    'course_view_all' => 'View All',
+    'course_status_open' => 'Enrolling',
+    'course_status_closed' => 'Ended',
+    'course_period' => 'Cohort :n',
+    'course_members' => ':n learners',
+    'course_signup' => 'Enroll',
+    'course_no_latest' => 'No courses yet',
+    'course_no_open' => 'No courses open for enrollment',
+    'course_no_history' => 'No past courses',
+    'course_history_title' => 'Past Courses',
+    'course_history_subtitle' => 'Completed public courses — revisit past study groups',
 ];
 ];

+ 1 - 0
api-v13/resources/lang/en/site.php

@@ -23,6 +23,7 @@ return [
         'tipitaka' => 'Tipitaka',
         'tipitaka' => 'Tipitaka',
         'wiki' => 'Wiki',
         'wiki' => 'Wiki',
         'anthology' => 'Anthology',
         'anthology' => 'Anthology',
+        'course' => 'Courses',
         'download' => 'Download',
         'download' => 'Download',
 
 
         'menu' => 'Menu',
         'menu' => 'Menu',

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

@@ -137,4 +137,27 @@ return [
     'pāḷi' => '巴利原典',
     'pāḷi' => '巴利原典',
     'aṭṭhakathā' => '义注',
     'aṭṭhakathā' => '义注',
     'ṭīkā' => '复注',
     'ṭīkā' => '复注',
+
+    // course (course/index.blade.php + course/history.blade.php)
+    'course' => '课程',
+    'course_hero_title' => '课程',
+    'course_hero_subtitle' => '跟随世界各地的巴利专家研读圣典',
+    'course_search_placeholder' => '搜索课程、讲师…',
+    'course_stat_total' => '公开课程',
+    'course_stat_open' => '开放报名',
+    'course_stat_closed' => '已结课',
+    'course_section_latest' => '最新课程',
+    'course_section_open' => '开放报名',
+    'course_section_history' => '历史课程',
+    'course_view_all' => '查看全部',
+    'course_status_open' => '报名中',
+    'course_status_closed' => '已结束',
+    'course_period' => '第 :n 期',
+    'course_members' => ':n 人',
+    'course_signup' => '报名',
+    'course_no_latest' => '暂无课程',
+    'course_no_open' => '暂无开放报名的课程',
+    'course_no_history' => '暂无历史课程',
+    'course_history_title' => '历史课程',
+    'course_history_subtitle' => '已结束的公开课程,回顾往期研读',
 ];
 ];

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

@@ -23,6 +23,7 @@ return [
         'tipitaka' => '三藏',
         'tipitaka' => '三藏',
         'wiki' => '百科',
         'wiki' => '百科',
         'anthology' => '文集',
         'anthology' => '文集',
+        'course' => '课程',
         'download' => '下载',
         'download' => '下载',
 
 
         'menu' => '导航',
         'menu' => '导航',

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

@@ -134,4 +134,27 @@ return [
     'cancel' => '取消',
     'cancel' => '取消',
     'confirm' => '確定',
     'confirm' => '確定',
     'no_toc' => '本書無目錄',
     'no_toc' => '本書無目錄',
+
+    // course (course/index.blade.php + course/history.blade.php)
+    'course' => '課程',
+    'course_hero_title' => '課程',
+    'course_hero_subtitle' => '跟隨世界各地的巴利專家研讀聖典',
+    'course_search_placeholder' => '搜尋課程、講師…',
+    'course_stat_total' => '公開課程',
+    'course_stat_open' => '開放報名',
+    'course_stat_closed' => '已結課',
+    'course_section_latest' => '最新課程',
+    'course_section_open' => '開放報名',
+    'course_section_history' => '歷史課程',
+    'course_view_all' => '查看全部',
+    'course_status_open' => '報名中',
+    'course_status_closed' => '已結束',
+    'course_period' => '第 :n 期',
+    'course_members' => ':n 人',
+    'course_signup' => '報名',
+    'course_no_latest' => '暫無課程',
+    'course_no_open' => '暫無開放報名的課程',
+    'course_no_history' => '暫無歷史課程',
+    'course_history_title' => '歷史課程',
+    'course_history_subtitle' => '已結束的公開課程,回顧往期研讀',
 ];
 ];

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

@@ -23,6 +23,7 @@ return [
         'tipitaka' => '三藏',
         'tipitaka' => '三藏',
         'wiki' => '百科',
         'wiki' => '百科',
         'anthology' => '文集',
         'anthology' => '文集',
+        'course' => '課程',
         'download' => '下載',
         'download' => '下載',
         'menu' => '導航',
         'menu' => '導航',
         'open_menu' => '打開導航',
         'open_menu' => '打開導航',

+ 52 - 0
api-v13/resources/views/components/library/course-card.blade.php

@@ -0,0 +1,52 @@
+{{-- resources/views/components/library/course-card.blade.php
+     课程封面卡片(用于「最新课程」推荐位网格)。
+     Props: $course — 由 Library\CourseController 加工后的课程数组。
+--}}
+@props(['course'])
+
+@php
+    $teacher = $course['teacher'] ?? [];
+    $status = $course['status'] ?? 'closed';
+    $statusKey = $status === 'open' ? 'course_status_open' : 'course_status_closed';
+    $cover = $course['cover_url'] ?? null;
+@endphp
+
+<a href="{{ $course['detail_url'] }}" class="course-card" target="_blank" rel="noopener">
+
+    {{-- 封面 --}}
+    <div class="course-card__cover" style="{{ empty($cover) ? 'background:'.$course['cover_gradient'] : '' }}">
+        @if($cover)
+        <img src="{{ $cover }}" alt="{{ $course['title'] }}" loading="lazy" class="course-card__cover-img">
+        @else
+        <span class="course-card__cover-fallback">{{ $course['title'] }}</span>
+        @endif
+
+        <span class="course-badge course-badge--{{ $status }}">
+            {{ __("library.{$statusKey}") }}
+        </span>
+    </div>
+
+    {{-- 正文 --}}
+    <div class="course-card__body">
+        <h3 class="course-card__title">{{ $course['title'] }}</h3>
+
+        <div class="course-card__teacher">
+            @if(!empty($teacher['name']))
+            <x-ui.author-avatar
+                :avatar="$teacher['avatar'] ?? null"
+                :color="$teacher['color']"
+                :initials="$teacher['initials']"
+                :name="$teacher['name']"
+                size="sm" />
+            @endif
+        </div>
+
+        <div class="course-card__foot">
+            @if($course['number'] > 0)
+            <span class="course-card__period">{{ __('library.course_period', ['n' => $course['number']]) }}</span>
+            @endif
+            <span class="course-card__members">{{ __('library.course_members', ['n' => $course['member_count']]) }}</span>
+        </div>
+    </div>
+
+</a>

+ 68 - 0
api-v13/resources/views/components/library/course-row.blade.php

@@ -0,0 +1,68 @@
+{{-- resources/views/components/library/course-row.blade.php
+     课程横向列表行(用于「开放报名」与「历史课程」列表)。
+     Props: $course — 由 Library\CourseController 加工后的课程数组。
+--}}
+@props(['course'])
+
+@php
+    $teacher = $course['teacher'] ?? [];
+    $status = $course['status'] ?? 'closed';
+    $statusKey = $status === 'open' ? 'course_status_open' : 'course_status_closed';
+    $cover = $course['cover_url'] ?? null;
+@endphp
+
+<a href="{{ $course['detail_url'] }}" class="course-row" target="_blank" rel="noopener">
+
+    {{-- 缩略图 --}}
+    <div class="course-row__thumb" style="{{ empty($cover) ? 'background:'.$course['cover_gradient'] : '' }}">
+        @if($cover)
+        <img src="{{ $cover }}" alt="{{ $course['title'] }}" loading="lazy">
+        @else
+        <span>{{ $course['title'] }}</span>
+        @endif
+    </div>
+
+    {{-- 主体 --}}
+    <div class="course-row__main">
+        <h3 class="course-row__title">{{ $course['title'] }}</h3>
+
+        @if(!empty($course['summary']))
+        <div class="course-row__summary">{{ $course['summary'] }}</div>
+        @endif
+
+        <div class="course-row__meta">
+            @if(!empty($teacher['name']))
+            <span class="course-row__teacher">
+                <x-ui.author-avatar
+                    :avatar="$teacher['avatar'] ?? null"
+                    :color="$teacher['color']"
+                    :initials="$teacher['initials']"
+                    :name="$teacher['name']"
+                    size="sm" />
+            </span>
+            @endif
+
+            @if($course['number'] > 0)
+            <span class="course-row__period">{{ __('library.course_period', ['n' => $course['number']]) }}</span>
+            @endif
+
+            <span class="course-row__members">{{ __('library.course_members', ['n' => $course['member_count']]) }}</span>
+
+            @if($course['start_date'])
+            <span class="course-row__date">{{ $course['start_date'] }}</span>
+            @endif
+        </div>
+    </div>
+
+    {{-- 右侧:状态 + 报名引导 --}}
+    <div class="course-row__side">
+        <span class="course-badge course-badge--{{ $status }}">
+            {{ __("library.{$statusKey}") }}
+        </span>
+
+        @if($status === 'open')
+        <span class="course-row__cta">{{ __('library.course_signup') }} <i class="ti ti-chevron-right"></i></span>
+        @endif
+    </div>
+
+</a>

+ 2 - 0
api-v13/resources/views/components/library/header.blade.php

@@ -12,6 +12,7 @@
                 <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
                 <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
                 <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
                 <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
                 <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
                 <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
+                <li><a href="{{ route('library.course') }}">课程</a></li>
                 <li><a href="{{ route('library.download') }}">下载</a></li>
                 <li><a href="{{ route('library.download') }}">下载</a></li>
                 <li>
                 <li>
                     <x-language-switcher />
                     <x-language-switcher />
@@ -43,6 +44,7 @@
         <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
         <li><a href="{{ route('library.tipitaka.index') }}">三藏</a></li>
         <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
         <li><a href="{{ route('library.wiki.home') }}">百科</a></li>
         <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
         <li><a href="{{ route('library.anthology.index') }}">文集</a></li>
+        <li><a href="{{ route('library.course') }}">课程</a></li>
         <li><a href="{{ route('library.download') }}">下载</a></li>
         <li><a href="{{ route('library.download') }}">下载</a></li>
         <li style="padding:1rem 0.25rem;">
         <li style="padding:1rem 0.25rem;">
             <x-language-switcher />
             <x-language-switcher />

+ 12 - 0
api-v13/resources/views/components/library/navbar.blade.php

@@ -35,6 +35,13 @@
                     </a>
                     </a>
                 </li>
                 </li>
 
 
+                <li>
+                    <a href="{{ route('library.course') }}"
+                        class="{{ request()->routeIs('library.course', 'library.course.history') ? 'active' : '' }}">
+                        {{ __('site.nav.course') }}
+                    </a>
+                </li>
+
                 <li>
                 <li>
                     <a href="{{ route('library.download') }}"
                     <a href="{{ route('library.download') }}"
                         class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
                         class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
@@ -89,6 +96,11 @@
                 {{ __('site.nav.anthology') }}
                 {{ __('site.nav.anthology') }}
             </a></li>
             </a></li>
 
 
+        <li><a href="{{ route('library.course') }}"
+                class="{{ request()->routeIs('library.course', 'library.course.history') ? 'active' : '' }}">
+                {{ __('site.nav.course') }}
+            </a></li>
+
         <li><a href="{{ route('library.download') }}"
         <li><a href="{{ route('library.download') }}"
                 class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
                 class="{{ request()->routeIs('library.download') ? 'active' : '' }}">
                 {{ __('site.nav.download') }}
                 {{ __('site.nav.download') }}

+ 59 - 0
api-v13/resources/views/library/course/history.blade.php

@@ -0,0 +1,59 @@
+{{-- resources/views/library/course/history.blade.php
+     历史课程列表页(分页)。
+--}}
+@extends('layouts.library')
+
+@section('title', __('library.course_history_title') . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/library-course.css')
+@endpush
+
+@section('breadcrumb')
+<li class="breadcrumb-item">
+    <a href="{{ route('library.home') }}">{{ __('library.home') }}</a>
+</li>
+<li class="breadcrumb-item">
+    <a href="{{ route('library.course') }}">{{ __('library.course') }}</a>
+</li>
+<li class="breadcrumb-item active">{{ __('library.course_history_title') }}</li>
+@endsection
+
+@section('hero')
+<div class="course-page-header">
+    <div class="container-xl">
+        <h1>
+            {{ __('library.course_history_title') }}
+            <span class="course-count-badge">{{ $total }}</span>
+        </h1>
+        <p>{{ __('library.course_history_subtitle') }}</p>
+    </div>
+</div>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl">
+
+        <div class="lib-section">
+            @if($courses->isNotEmpty())
+            <div class="course-list">
+                @foreach($courses as $course)
+                <x-library.course-row :course="$course" />
+                @endforeach
+            </div>
+
+            {{-- 分页 --}}
+            <div class="d-flex justify-content-center mt-3">
+                {{ $courses->links('library.anthology.pagination') }}
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_history')" />
+            </div>
+            @endif
+        </div>
+
+    </div>
+</div>
+@endsection

+ 128 - 0
api-v13/resources/views/library/course/index.blade.php

@@ -0,0 +1,128 @@
+{{-- resources/views/library/course/index.blade.php
+     课程栏目首页。
+     区块:Hero → 统计条 → 最新课程 → 开放报名 → 历史课程预览
+--}}
+@extends('layouts.library')
+
+@section('title', __('library.course') . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/library-course.css')
+@endpush
+
+@section('breadcrumb')
+<li class="breadcrumb-item">
+    <a href="{{ route('library.home') }}">{{ __('library.home') }}</a>
+</li>
+<li class="breadcrumb-item active">{{ __('library.course') }}</li>
+@endsection
+
+{{-- Hero --}}
+@section('hero')
+<section class="hero-section"
+    style="background-image: url('{{ URL::asset('assets/images/hero-1.jpg') }}')">
+    <div class="hero-overlay"></div>
+    <div class="hero-content">
+        <h1 class="hero-title">{{ __('library.course_hero_title') }}</h1>
+        <p class="hero-subtitle">{{ __('library.course_hero_subtitle') }}</p>
+        <div class="search-box">
+            <x-ui.search-input
+                :placeholder="__('library.course_search_placeholder')"
+                size="lg" />
+        </div>
+    </div>
+</section>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl">
+
+        {{-- ── 统计条 ── --}}
+        <div class="course-stats">
+            <div class="course-stat">
+                <div class="course-stat__value">{{ $stats['total'] }}</div>
+                <div class="course-stat__label">{{ __('library.course_stat_total') }}</div>
+            </div>
+            <div class="course-stat">
+                <div class="course-stat__value">{{ $stats['open'] }}</div>
+                <div class="course-stat__label">{{ __('library.course_stat_open') }}</div>
+            </div>
+            <div class="course-stat">
+                <div class="course-stat__value">{{ $stats['closed'] }}</div>
+                <div class="course-stat__label">{{ __('library.course_stat_closed') }}</div>
+            </div>
+        </div>
+
+        {{-- ── 一、最新课程 ── --}}
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-trending-up"></i>
+                    {{ __('library.course_section_latest') }}
+                </h2>
+            </div>
+
+            @if($latestCourses->isNotEmpty())
+            <div class="course-grid">
+                @foreach($latestCourses as $course)
+                <x-library.course-card :course="$course" />
+                @endforeach
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_latest')" />
+            </div>
+            @endif
+        </div>
+
+        {{-- ── 二、开放报名 ── --}}
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-calendar-event"></i>
+                    {{ __('library.course_section_open') }}
+                </h2>
+            </div>
+
+            @if($openCourses->isNotEmpty())
+            <div class="course-list">
+                @foreach($openCourses as $course)
+                <x-library.course-row :course="$course" />
+                @endforeach
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_open')" />
+            </div>
+            @endif
+        </div>
+
+        {{-- ── 三、历史课程 ── --}}
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-history"></i>
+                    {{ __('library.course_section_history') }}
+                </h2>
+                <a href="{{ route('library.course.history') }}" class="lib-section__more">
+                    {{ __('library.course_view_all') }} <i class="ti ti-arrow-right"></i>
+                </a>
+            </div>
+
+            @if($historyCourses->isNotEmpty())
+            <div class="course-list">
+                @foreach($historyCourses as $course)
+                <x-library.course-row :course="$course" />
+                @endforeach
+            </div>
+            @else
+            <div class="wiki-card">
+                <x-ui.empty-state :title="__('library.course_no_history')" />
+            </div>
+            @endif
+        </div>
+
+    </div>
+</div>
+@endsection

+ 12 - 14
api-v13/routes/web.php

@@ -1,19 +1,20 @@
 <?php
 <?php
 
 
-use Illuminate\Support\Facades\Route;
-use Illuminate\Support\Facades\File;
-use App\Http\Controllers\WbwAnalysisController;
-use App\Http\Controllers\PageIndexController;
 use App\Http\Controllers\AssetsController;
 use App\Http\Controllers\AssetsController;
 use App\Http\Controllers\BlogController;
 use App\Http\Controllers\BlogController;
 use App\Http\Controllers\DownloadController;
 use App\Http\Controllers\DownloadController;
 use App\Http\Controllers\Library\AnthologyController;
 use App\Http\Controllers\Library\AnthologyController;
 use App\Http\Controllers\Library\AnthologyReadController;
 use App\Http\Controllers\Library\AnthologyReadController;
 use App\Http\Controllers\Library\BookController;
 use App\Http\Controllers\Library\BookController;
-use App\Http\Controllers\Library\WikiController;
-use App\Http\Controllers\Library\SearchController;
+use App\Http\Controllers\Library\CourseController;
 use App\Http\Controllers\Library\HomeController;
 use App\Http\Controllers\Library\HomeController;
+use App\Http\Controllers\Library\SearchController;
 use App\Http\Controllers\Library\TipitakaController;
 use App\Http\Controllers\Library\TipitakaController;
+use App\Http\Controllers\Library\WikiController;
+use App\Http\Controllers\PageIndexController;
+use App\Http\Controllers\WbwAnalysisController;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Route;
 
 
 /*
 /*
 |--------------------------------------------------------------------------
 |--------------------------------------------------------------------------
@@ -38,7 +39,6 @@ Route::get('/export/wbw', function () {
     return view('export_wbw', ['sentences' => []]);
     return view('export_wbw', ['sentences' => []]);
 });
 });
 
 
-
 Route::get('/privacy/{file}', function (string $file) {
 Route::get('/privacy/{file}', function (string $file) {
     $path = base_path("documents/mobile/privacy/{$file}.md");
     $path = base_path("documents/mobile/privacy/{$file}.md");
 
 
@@ -54,13 +54,10 @@ Route::get('/book/{id}', function ($id) {
 });
 });
 Route::redirect('/privacy', '/privacy/index');
 Route::redirect('/privacy', '/privacy/index');
 
 
-
-
-
 Route::post('/theme/toggle', [BookController::class, 'toggleTheme'])->name('theme.toggle');
 Route::post('/theme/toggle', [BookController::class, 'toggleTheme'])->name('theme.toggle');
 Route::post('/logout', function () {
 Route::post('/logout', function () {
     // Handle logout
     // Handle logout
-    //Auth::logout();
+    // Auth::logout();
     return redirect('/login');
     return redirect('/login');
 })->name('logout');
 })->name('logout');
 
 
@@ -76,11 +73,12 @@ Route::prefix('library')->name('library.')->group(function () {
     Route::get('/wiki/{lang}', [WikiController::class, 'index'])->name('wiki.index');
     Route::get('/wiki/{lang}', [WikiController::class, 'index'])->name('wiki.index');
     Route::get('/wiki/{lang}/{word}', [WikiController::class, 'show'])->name('wiki.show');
     Route::get('/wiki/{lang}/{word}', [WikiController::class, 'show'])->name('wiki.show');
 
 
-    Route::get('/course', [DownloadController::class, 'index'])->name('course');
+    Route::get('/course', [CourseController::class, 'index'])->name('course');
+    Route::get('/course/history', [CourseController::class, 'history'])->name('course.history');
     Route::get('/download', [DownloadController::class, 'index'])->name('download');
     Route::get('/download', [DownloadController::class, 'index'])->name('download');
     // 文集
     // 文集
-    Route::get('/anthology',          [AnthologyController::class, 'index'])->name('anthology.index');
-    Route::get('/anthology/{id}',     [AnthologyController::class, 'show'])->name('anthology.show');
+    Route::get('/anthology', [AnthologyController::class, 'index'])->name('anthology.index');
+    Route::get('/anthology/{id}', [AnthologyController::class, 'show'])->name('anthology.show');
     Route::get(
     Route::get(
         '/anthology/{anthology}/read/{article}',
         '/anthology/{anthology}/read/{article}',
         [AnthologyReadController::class, 'read']
         [AnthologyReadController::class, 'read']

+ 1 - 0
api-v13/vite.config.ts

@@ -15,6 +15,7 @@ export default defineConfig({
                 'resources/css/modules/wiki.css',
                 'resources/css/modules/wiki.css',
                 'resources/css/modules/tipitaka.css',
                 'resources/css/modules/tipitaka.css',
                 'resources/css/modules/anthology.css',
                 'resources/css/modules/anthology.css',
+                'resources/css/modules/library-course.css',
                 'resources/css/reader.css', // 全站阅读页(待建)
                 'resources/css/reader.css', // 全站阅读页(待建)
                 'resources/js/app.js',
                 'resources/js/app.js',
                 'resources/js/reader.js',
                 'resources/js/reader.js',