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

feat(library): 三藏栏目按译本浏览,作品 = book_titles.sn

新路由(/library/tipitaka 下):
- channels:公开译本列表,按类型、语言(?channel_lang=)过滤,SQL 分页
- channels/{uid}:译本收录的作品(分页)
- channels/{uid}/category[/{id}]:译本的分类目录树,每部作品带进度条
- channels/{uid}/work/{sn}:作品详情;work/{sn}/read 302 到第一个有译文的章节
- channels/{uid}/chapter/{book}-{para}:阅读页(channel 从查询串移到路径)
- work/{sn}:一部作品在所有公开译本里的版本
- 首页改为分类两级卡片 + 译本 + 最近更新,换新 hero

旧地址 /tipitaka/{book}-{para}/read?channel=、/tipitaka/{uuid}、?channel=、?book=
一律 301 到新地址,mobile 深链与外部链接不受影响。

目录逻辑在 TipitakaCatalogService:作品名取起点段落的 pali_texts.toc
(book_titles.title 多为丛书名),作品归入 tag 全部命中的最深分类,结果缓存一天。

原分类页的 mock(推荐、活跃译者、作者过滤、计数)换成真实数据;分类页只列公开
channel(原来未选过滤时私有 channel 也会列出),不再要求 last_chapter_completed_at。
作品封面一律用渐变色,TODO 以后从 S3 取。

顺带:删掉无路由且视图不存在的 BookController::show2 及失去调用方的
getBookInfo / loadBook / TipitakaController::flattenWithIds;getBookToc 支持作品区间;
labels 补 commentary / similar。
TODO:progress_chapters 缺 (book, para) 索引,见 TipitakaCatalogService 类注释。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 2 дней назад
Родитель
Сommit
6793b74106
31 измененных файлов с 2661 добавлено и 707 удалено
  1. 133 118
      api-v13/app/Http/Controllers/Library/BookController.php
  2. 235 0
      api-v13/app/Http/Controllers/Library/TipitakaChannelController.php
  3. 249 358
      api-v13/app/Http/Controllers/Library/TipitakaController.php
  4. 563 0
      api-v13/app/Services/TipitakaCatalogService.php
  5. BIN
      api-v13/public/assets/images/hero-tipitaka.jpg
  6. 423 0
      api-v13/resources/css/modules/tipitaka.css
  7. 3 0
      api-v13/resources/lang/en/labels.php
  8. 26 0
      api-v13/resources/lang/en/library.php
  9. 3 0
      api-v13/resources/lang/zh-Hans/labels.php
  10. 22 0
      api-v13/resources/lang/zh-Hans/library.php
  11. 7 2
      api-v13/resources/views/components/ui/card-book.blade.php
  12. 24 0
      api-v13/resources/views/components/ui/progress-bar.blade.php
  13. 4 1
      api-v13/resources/views/components/wiki/search-result-card.blade.php
  14. 11 5
      api-v13/resources/views/library/book/read.blade.php
  15. 2 2
      api-v13/resources/views/library/book/toc.blade.php
  16. 12 0
      api-v13/resources/views/library/tipitaka/_side-tree.blade.php
  17. 40 110
      api-v13/resources/views/library/tipitaka/category.blade.php
  18. 44 0
      api-v13/resources/views/library/tipitaka/channels/_grouped-list.blade.php
  19. 32 0
      api-v13/resources/views/library/tipitaka/channels/_header.blade.php
  20. 24 0
      api-v13/resources/views/library/tipitaka/channels/_row.blade.php
  21. 25 0
      api-v13/resources/views/library/tipitaka/channels/_tree-card.blade.php
  22. 36 0
      api-v13/resources/views/library/tipitaka/channels/_tree-node.blade.php
  23. 91 0
      api-v13/resources/views/library/tipitaka/channels/category.blade.php
  24. 60 0
      api-v13/resources/views/library/tipitaka/channels/index.blade.php
  25. 70 0
      api-v13/resources/views/library/tipitaka/channels/show.blade.php
  26. 89 37
      api-v13/resources/views/library/tipitaka/index.blade.php
  27. 78 69
      api-v13/resources/views/library/tipitaka/show.blade.php
  28. 41 0
      api-v13/resources/views/library/tipitaka/work.blade.php
  29. 22 3
      api-v13/routes/web.php
  30. 283 0
      api-v13/tests/Feature/LibraryTipitakaChannelTest.php
  31. 9 2
      api-v13/tests/Feature/LibraryTipitakaReadTest.php

+ 133 - 118
api-v13/app/Http/Controllers/Library/BookController.php

@@ -11,8 +11,12 @@ use App\Models\Sentence;
 use App\Services\ChapterService;
 use App\Services\ChapterService;
 use App\Services\PaliContentService;
 use App\Services\PaliContentService;
 use App\Services\PaliTextService;
 use App\Services\PaliTextService;
+use App\Services\TipitakaCatalogService;
+use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Cookie;
+use Illuminate\Support\Str;
+use Illuminate\View\View;
 
 
 class BookController extends Controller
 class BookController extends Controller
 {
 {
@@ -29,43 +33,115 @@ class BookController extends Controller
     protected $minChapterLen = 100;
     protected $minChapterLen = 100;
 
 
     /**
     /**
-     * 构造函数,注入文本与段落内容服务
+     * 构造函数,注入文本、段落内容与三藏目录服务
      */
      */
     public function __construct(
     public function __construct(
         protected PaliTextService $paliTextService,
         protected PaliTextService $paliTextService,
-        protected PaliContentService $paliContentService
+        protected PaliContentService $paliContentService,
+        protected TipitakaCatalogService $catalog,
     ) {}
     ) {}
 
 
-    public function show(string $id)
+    /**
+     * 作品详情:某 channel 的某部作品(work_id = book_titles.sn)。该 channel 没收录这部作品时 404。
+     */
+    public function show(string $channel, int $sn): View
     {
     {
-        $bookRaw = $this->loadBook($id);
+        $work = $this->catalog->work($sn);
+        $channelModel = $this->catalog->channel($channel);
+        abort_unless($work && $channelModel, 404);
+        $progress = $this->catalog->channelProgress($channel)["{$work['book']}-{$work['para']}"] ?? null;
+        abort_unless($progress, 404);
+
+        $channelInfo = [
+            'uid' => $channel,
+            'name' => $channelModel->name,
+            'type' => $channelModel->type,
+            'lang' => $channelModel->lang,
+            'owner' => $channelModel->owner,
+        ];
+        $book = $this->catalog->bookCard($work, $channelInfo, $progress);
+        $book['channel'] = $channelInfo;
+        $book['pali_title'] = $work['title'];
+        $book['updated_at'] = $progress['updated_at'];
+        $book['read_url'] = route('library.tipitaka.channels.work.read', ['channel' => $channel, 'sn' => $sn]);
+        $book['contents'] = $this->getBookToc($work['book'], $work['para'], $channel, 2, 2, null, [$work['para'], $work['end']]);
+
+        $names = $this->catalog->localNames($this->catalog->categories(), Cookie::get('language') ?? 'en');
+        $book['categories'] = $work['category_id'] === null ? [] : array_map(fn ($c) => [
+            'name' => $names[$c['name']] ?? $c['name'],
+            'url' => route('library.tipitaka.channels.category', ['channel' => $channel, 'id' => $c['id']]),
+        ], $this->catalog->categoryPath($work['category_id']));
+
+        $otherVersions = array_map(fn ($c) => [
+            ...$c,
+            'url' => route('library.tipitaka.channels.work', ['channel' => $c['uid'], 'sn' => $sn]),
+        ], $this->catalog->workChannels($work, $channel));
+
+        // 同一分类下、本 channel 也收录了的其他作品
+        $channelProgress = $this->catalog->channelProgress($channel);
+        $relatedWorks = $work['category_id'] === null ? [] : collect($this->catalog->worksInCategory($work['category_id']))
+            ->filter(fn ($w) => $w['sn'] !== $sn && isset($channelProgress["{$w['book']}-{$w['para']}"]))
+            ->take(5)
+            ->map(fn ($w) => [
+                'title' => $channelProgress["{$w['book']}-{$w['para']}"]['title'] ?: $w['title'],
+                'url' => route('library.tipitaka.channels.work', ['channel' => $channel, 'sn' => $w['sn']]),
+            ])
+            ->values()
+            ->all();
+
+        return view('library.tipitaka.show', compact('book', 'otherVersions', 'relatedWorks'));
+    }
 
 
-        if (! $bookRaw) {
-            abort(404);
-        }
+    /**
+     * 阅读入口:作品区间内第一个本 channel 有译文的章节(level 2–7),没有就从作品起点读。
+     * 目标随翻译进度变化,所以是 302。
+     */
+    public function readWork(string $channel, int $sn): RedirectResponse
+    {
+        $work = $this->catalog->work($sn);
+        abort_unless($work && ChannelApi::getById($channel), 404);
+
+        $chapterParas = PaliText::where('book', $work['book'])
+            ->whereBetween('paragraph', [$work['para'], $work['end']])
+            ->whereBetween('level', [2, 7])
+            ->pluck('paragraph');
+        $first = ProgressChapter::where('channel_id', $channel)
+            ->where('book', $work['book'])
+            ->whereIn('para', $chapterParas)
+            ->min('para');
+
+        return redirect()->route('library.tipitaka.channels.chapter', [
+            'channel' => $channel,
+            'chapter' => "{$work['book']}-".($first ?? $work['para']),
+        ]);
+    }
 
 
-        // 查询章节
-        $channelId = $bookRaw->channel_id; // 替换为具体的 channel_id 值
-
-        $book = $this->getBookInfo($bookRaw);
-        $book['contents'] = $this->getBookToc($bookRaw->book, $bookRaw->para, $channelId);
-        $book['book_title'] = $this->getBookTitle($bookRaw->book, $bookRaw->para, $channelId);
-        // 获取其他版本
-        $others = ProgressChapter::with('channel.owner')
-            ->where('book', $bookRaw->book)
-            ->where('para', $bookRaw->para)
-            ->whereHas('channel', function ($query) {
-                $query->where('status', 30);
-            })
-            ->where('progress', '>', 0.2)
-            ->get();
+    /**
+     * 旧地址 /library/tipitaka/{book}-{para}/read?channel=x → 新地址(301)。
+     * 没带 channel 的读巴利原文;其余查询参数(如 comm)原样带过去。
+     */
+    public function legacyRead(Request $request, string $id): RedirectResponse
+    {
+        $channelId = $request->query('channel') ?: ChannelApi::getSysChannel('_System_Pali_VRI_');
+        abort_unless(Str::isUuid($channelId) && ChannelApi::getById($channelId), 404);
+
+        return redirect()->route('library.tipitaka.channels.chapter', [
+            'channel' => $channelId,
+            'chapter' => $id,
+            ...$request->except('channel'),
+        ], 301);
+    }
 
 
-        $otherVersions = [];
-        $others->each(function ($book) use (&$otherVersions) {
-            $otherVersions[] = $this->getBookInfo($book);
-        });
+    /**
+     * 旧地址 /library/tipitaka/{progress_chapters.uid} → 该 channel 的作品详情(301)。
+     */
+    public function legacyShow(string $id): RedirectResponse
+    {
+        $row = ProgressChapter::find($id);
+        $work = $row ? $this->catalog->workAt((int) $row->book, (int) $row->para) : null;
+        abort_unless($work, 404);
 
 
-        return view('library.tipitaka.show', compact('book', 'otherVersions'));
+        return redirect()->route('library.tipitaka.channels.work', ['channel' => $row->channel_id, 'sn' => $work['sn']], 301);
     }
     }
 
 
     private function fetchCommentary(int $book, int $paraStart, int $paraEnd, string $channelId)
     private function fetchCommentary(int $book, int $paraStart, int $paraEnd, string $channelId)
@@ -99,14 +175,15 @@ class BookController extends Controller
         );
         );
     }
     }
 
 
-    public function read(Request $request, string $id)
+    /**
+     * 阅读页 /library/tipitaka/channels/{channel}/chapter/{book}-{para}。
+     */
+    public function read(Request $request, string $channel, string $chapter): View
     {
     {
-
-        $channelId = $request->input('channel');
-        $isDefaultChannel = empty($channelId);
-        if ($isDefaultChannel) {
-            $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
-        }
+        $channelId = $channel;
+        $id = $chapter;
+        // 读的是巴利原文(旧地址没带 channel 时也落到这里),提示可切换版本
+        $isDefaultChannel = $channelId === ChannelApi::getSysChannel('_System_Pali_VRI_');
 
 
         abort_unless(preg_match('/^\d+-\d+$/', $id), 404);
         abort_unless(preg_match('/^\d+-\d+$/', $id), 404);
         [$bookId, $paraId] = explode('-', $id);
         [$bookId, $paraId] = explode('-', $id);
@@ -155,14 +232,22 @@ class BookController extends Controller
         $commentaryChannels = array_filter($allChannels, function ($channel) {
         $commentaryChannels = array_filter($allChannels, function ($channel) {
             return $channel['type'] === 'commentary';
             return $channel['type'] === 'commentary';
         });
         });
-        $channels = array_filter($allChannels, function ($channel) {
+        $channels = array_map(fn ($channel) => [
+            ...$channel,
+            'url' => route('library.tipitaka.channels.chapter', ['channel' => $channel['id'], 'chapter' => $id, ...$request->query()]),
+        ], array_filter($allChannels, function ($channel) {
             return $channel['type'] !== 'commentary';
             return $channel['type'] !== 'commentary';
-        });
+        }));
+
+        $work = $this->catalog->workAt($bookId, $paraId);
+        $book['work_url'] = $work
+            ? route('library.tipitaka.channels.work', ['channel' => $channelId, 'sn' => $work['sn']])
+            : null;
 
 
         $editor_link = config('mint.server.dashboard_base_path')
         $editor_link = config('mint.server.dashboard_base_path')
             ."/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
             ."/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
 
 
-        $view = view('library.book.read', compact('book', 'channels', 'editor_link', 'commentaryChannels', 'isDefaultChannel'));
+        $view = view('library.book.read', compact('book', 'channels', 'editor_link', 'commentaryChannels', 'isDefaultChannel', 'channelId'));
 
 
         return $view;
         return $view;
     }
     }
@@ -260,13 +345,6 @@ class BookController extends Controller
         ];
         ];
     }
     }
 
 
-    private function loadBook(string $id)
-    {
-        $book = ProgressChapter::with('channel.owner')->find($id);
-
-        return $book;
-    }
-
     public function toggleTheme(Request $request)
     public function toggleTheme(Request $request)
     {
     {
         $theme = $request->input('theme', 'light');
         $theme = $request->input('theme', 'light');
@@ -275,27 +353,6 @@ class BookController extends Controller
         return response()->json(['status' => 'success']);
         return response()->json(['status' => 'success']);
     }
     }
 
 
-    private function getBookInfo($book)
-    {
-        $title = $book->title;
-        if (empty($title)) {
-            $title = PaliText::where('book', $book->book)
-                ->where('paragraph', $book->para)->first()->toc;
-        }
-
-        return [
-            'id' => $book->uid,
-            'title' => $title,
-            'author' => $book->channel->name,
-            'publisher' => $book->channel->owner,
-            'type' => __('label.'.$book->channel->type),
-            'category_id' => 11,
-            'cover' => '/assets/images/cover/1/214.jpg',
-            'description' => $book->summary ?? '',
-            'language' => __('language.'.$book->channel->lang),
-        ];
-    }
-
     private function getBookTitle(int $book, int $paragraph, string $channelId)
     private function getBookTitle(int $book, int $paragraph, string $channelId)
     {
     {
         $bookTopPara = $this->paliTextService->getBookPara($book, $paragraph)->paragraph;
         $bookTopPara = $this->paliTextService->getBookPara($book, $paragraph)->paragraph;
@@ -316,13 +373,18 @@ class BookController extends Controller
      * @param  array{0: int, 1: int}|null  $activeRange  实际显示的段落区间 [start, end];
      * @param  array{0: int, 1: int}|null  $activeRange  实际显示的段落区间 [start, end];
      *                                                   传入时区间内的所有 chapter 均高亮(active),
      *                                                   传入时区间内的所有 chapter 均高亮(active),
      *                                                   用于聚合显示多个 chapter 的场景。不传则仅高亮选中段落。
      *                                                   用于聚合显示多个 chapter 的场景。不传则仅高亮选中段落。
+     * @param  array{0: int, 1: int}|null  $bounds  目录的段落范围;不传取所在 level-1 节点(整本书)。
+     *                                              作品详情页传作品区间(一个 level-1 节点里可能有多部作品)。
      */
      */
-    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2, ?array $activeRange = null): array
+    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2, ?array $activeRange = null, ?array $bounds = null): array
     {
     {
-        $currBook = $this->bookStart($book, $paragraph);
-
-        $start = $currBook->paragraph;
-        $end = $currBook->paragraph + $currBook->chapter_len - 1;
+        if ($bounds !== null) {
+            [$start, $end] = $bounds;
+        } else {
+            $currBook = $this->bookStart($book, $paragraph);
+            $start = $currBook->paragraph;
+            $end = $currBook->paragraph + $currBook->chapter_len - 1;
+        }
 
 
         $paliTexts = PaliText::where('book', $book)
         $paliTexts = PaliText::where('book', $book)
             ->whereBetween('paragraph', [$start, $end])
             ->whereBetween('paragraph', [$start, $end])
@@ -530,51 +592,4 @@ class BookController extends Controller
             ->orderBy('paragraph', 'desc')
             ->orderBy('paragraph', 'desc')
             ->first();
             ->first();
     }
     }
-
-    public function show2($id)
-    {
-        // Sample book data (replace with database query)
-        $book = [
-            'title' => 'Sample Book Title',
-            'author' => 'John Doe',
-            'category' => 'Fiction',
-            'tags' => ['Adventure', 'Mystery', 'Bestseller'],
-            'toc' => ['Introduction', 'Chapter 1', 'Chapter 2', 'Conclusion'],
-            'content' => [
-                'This is the introduction to the book...',
-                'Chapter 1 content goes here...',
-                'Chapter 2 content goes here...',
-                'Conclusion of the book...',
-            ],
-            'downloads' => [
-                ['format' => 'PDF', 'url' => '#'],
-                ['format' => 'EPUB', 'url' => '#'],
-                ['format' => 'MOBI', 'url' => '#'],
-            ],
-        ];
-
-        // Sample related books (replace with database query)
-        $relatedBooks = [
-            [
-                'title' => 'Related Book 1',
-                'description' => 'A thrilling adventure...',
-                'image' => 'https://via.placeholder.com/300x200',
-                'link' => '#',
-            ],
-            [
-                'title' => 'Related Book 2',
-                'description' => 'A mystery novel...',
-                'image' => 'https://via.placeholder.com/300x200',
-                'link' => '#',
-            ],
-            [
-                'title' => 'Related Book 3',
-                'description' => 'A bestseller...',
-                'image' => 'https://via.placeholder.com/300x200',
-                'link' => '#',
-            ],
-        ];
-
-        return view('library.book.read2', compact('book', 'relatedBooks'));
-    }
 }
 }

+ 235 - 0
api-v13/app/Http/Controllers/Library/TipitakaChannelController.php

@@ -0,0 +1,235 @@
+<?php
+
+namespace App\Http\Controllers\Library;
+
+use App\Http\Controllers\Controller;
+use App\Models\Channel;
+use App\Models\UserInfo;
+use App\Services\TipitakaCatalogService;
+use Illuminate\Http\Request;
+use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cookie;
+use Illuminate\View\View;
+
+/**
+ * library 三藏栏目:按译本(channel)浏览。
+ *
+ * - /library/tipitaka/channels                         公开 channel 列表,按 type 分组
+ * - /library/tipitaka/channels/{channel}               该 channel 收录的作品(分页)
+ * - /library/tipitaka/channels/{channel}/category/{id} 该 channel 的分类目录树,每部作品带进度
+ */
+class TipitakaChannelController extends Controller
+{
+    private const PER_PAGE = 8;
+
+    private const CHANNELS_PER_PAGE = 20;
+
+    public function __construct(
+        protected TipitakaCatalogService $catalog,
+    ) {}
+
+    /**
+     * 公开 channel 列表:一个列表,按收录作品数降序,分页。可用 ?type= / ?channel_lang= 过滤。
+     *
+     * 语言过滤不能叫 lang:SetLocale 中间件用 ?lang= 切换界面语言。
+     */
+    public function index(Request $request): View
+    {
+        $selectedType = $request->query('type', 'all');
+        $selectedLang = $request->query('channel_lang', 'all');
+        $type = $selectedType === 'all' ? null : $selectedType;
+        $lang = $selectedLang === 'all' ? null : $selectedLang;
+        $paginator = $this->catalog->publicChannels($lang, self::CHANNELS_PER_PAGE, $type)->withQueryString();
+        $owners = $this->owners($paginator->getCollection()->pluck('owner_uid')->all());
+
+        $channels = $paginator->getCollection()->map(fn ($c) => [
+            ...$c,
+            'owner' => $owners->get($c['owner_uid']),
+            'url' => route('library.tipitaka.channels.show', ['channel' => $c['uid']]),
+        ])->all();
+        $types = $this->catalog->filterOptions($this->catalog->publicChannelCounts('type', ['lang' => $lang]), 'labels');
+        $languages = $this->catalog->filterOptions($this->catalog->publicChannelCounts('lang', ['type' => $type]));
+
+        return view('library.tipitaka.channels.index', compact('channels', 'paginator', 'types', 'languages', 'selectedType', 'selectedLang'));
+    }
+
+    /**
+     * 单个 channel 收录的作品,默认按经典顺序(book_titles.sn),可 ?sort=updated|progress。
+     */
+    public function show(Request $request, string $channel): View
+    {
+        $channelInfo = $this->channelInfo($channel);
+        $progress = $this->catalog->channelProgress($channel);
+        $works = $this->worksOf($progress);
+
+        $sort = $request->query('sort', 'canon');
+        $works = match ($sort) {
+            'updated' => $works->sortByDesc(fn ($w) => $progress["{$w['book']}-{$w['para']}"]['updated_at']),
+            'progress' => $works->sortByDesc(fn ($w) => $progress["{$w['book']}-{$w['para']}"]['progress']),
+            default => $works,
+        };
+
+        $page = LengthAwarePaginator::resolveCurrentPage();
+        $paginator = (new LengthAwarePaginator(
+            $works->forPage($page, self::PER_PAGE)->values(),
+            $works->count(),
+            self::PER_PAGE,
+            $page,
+            ['path' => $request->url()]
+        ))->withQueryString();
+
+        $books = $paginator->getCollection()
+            ->map(fn ($w) => $this->catalog->bookCard($w, $channelInfo, $progress["{$w['book']}-{$w['para']}"]))
+            ->all();
+
+        $sortList = [
+            ['key' => 'canon', 'label' => __('library.sort_canon')],
+            ['key' => 'updated', 'label' => __('library.badge_updated')],
+            ['key' => 'progress', 'label' => __('library.sort_progress')],
+        ];
+
+        $topCategories = $this->topCategories($works->all());
+
+        return view('library.tipitaka.channels.show', compact('channelInfo', 'books', 'paginator', 'sort', 'sortList', 'topCategories'));
+    }
+
+    /**
+     * 单个 channel 的分类目录树。顶部切换「全部」与四个藏(不带 id = 全部),树里的节点只是分组(不可点),
+     * 末级分类是卡片,卡片里每部作品一条进度条。?all=1 时也列出未收录的作品(置灰)。
+     */
+    public function category(Request $request, string $channel, ?int $id = null): View
+    {
+        $channelInfo = $this->channelInfo($channel);
+        // 全部:虚拟根节点,下面挂四个藏
+        $node = $id === null ? ['id' => 0, 'name' => __('library.all')] : $this->catalog->category($id);
+        abort_unless($node, 404);
+
+        $progress = $this->catalog->channelProgress($channel);
+        $showAll = $request->boolean('all');
+        $names = $this->catalog->localNames($this->catalog->categories(), Cookie::get('language') ?? 'en');
+
+        $tree = $this->buildNode($node, $progress, $names, $showAll, $channel);
+        $topCategories = $this->topCategories($this->worksOf($progress)->all());
+        $currentTop = $id === null ? null : $this->catalog->categoryPath($id)[0]['id'];
+
+        return view('library.tipitaka.channels.category', compact('channelInfo', 'tree', 'topCategories', 'currentTop', 'showAll'));
+    }
+
+    /**
+     * 递归构造目录树的一个节点。本 channel 在该节点下没有作品、且不显示未收录时返回 null。
+     *
+     * @param  array<string, array{progress: float, title: string|null}>  $progress
+     * @param  array<string, string>  $names
+     * @return array{id: int, name: string, pali: string, leaf: bool, mine: int, total: int, avg: int, works: list<array>, children: list<array>}|null
+     */
+    private function buildNode(array $node, array $progress, array $names, bool $showAll, string $channel): ?array
+    {
+        $children = $this->catalog->children($node['id']);
+        $subtree = $this->catalog->worksInCategory($node['id']);
+        $mine = array_values(array_filter($subtree, fn ($w) => isset($progress["{$w['book']}-{$w['para']}"])));
+        if (! $showAll && empty($mine)) {
+            return null;
+        }
+
+        // 直接挂在本节点上的作品(末级分类的全部作品,或非末级分类直接挂的少数几部)
+        $own = array_filter($subtree, fn ($w) => $w['category_id'] === $node['id']);
+        $works = [];
+        foreach ($own as $work) {
+            $row = $progress["{$work['book']}-{$work['para']}"] ?? null;
+            if (! $row && ! $showAll) {
+                continue;
+            }
+            $works[] = [
+                'sn' => $work['sn'],
+                'title' => ! empty($row['title']) ? $row['title'] : $work['title'],
+                'progress' => $row ? (int) round($row['progress'] * 100) : null,
+                'url' => route('library.tipitaka.channels.work', ['channel' => $channel, 'sn' => $work['sn']]),
+            ];
+        }
+
+        $sum = array_sum(array_map(fn ($w) => $progress["{$w['book']}-{$w['para']}"]['progress'], $mine));
+
+        return [
+            'id' => $node['id'],
+            'name' => $names[$node['name']] ?? $node['name'],
+            'pali' => $node['name'],
+            'leaf' => empty($children),
+            'mine' => count($mine),
+            'total' => count($subtree),
+            'avg' => $mine ? (int) round($sum / count($mine) * 100) : 0,
+            'works' => $works,
+            'children' => array_values(array_filter(array_map(
+                fn ($child) => $this->buildNode($child, $progress, $names, $showAll, $channel),
+                $children
+            ))),
+        ];
+    }
+
+    /**
+     * channel 头部信息;不存在 404。
+     *
+     * @return array{uid: string, name: string, type: string, lang: string, owner: UserInfo|null, works: int, updated_at: string|null}
+     */
+    private function channelInfo(string $uid): array
+    {
+        $channel = $this->catalog->channel($uid);
+        abort_unless($channel instanceof Channel, 404);
+        $progress = $this->catalog->channelProgress($uid);
+
+        return [
+            'uid' => $uid,
+            'name' => $channel->name,
+            'type' => $channel->type,
+            'lang' => $channel->lang,
+            'owner' => $channel->owner,
+            'works' => count($progress),
+            'updated_at' => collect($progress)->max('updated_at'),
+        ];
+    }
+
+    /**
+     * 该 channel 收录的作品,按 sn 排序。
+     *
+     * @param  array<string, mixed>  $progress
+     * @return Collection<int, array<string, mixed>>
+     */
+    private function worksOf(array $progress): Collection
+    {
+        return collect($this->catalog->works())
+            ->filter(fn ($w) => isset($progress["{$w['book']}-{$w['para']}"]))
+            ->values();
+    }
+
+    /**
+     * 四个藏及本 channel 在其下的作品数。
+     *
+     * @return list<array{id: int, name: string, count: int}>
+     */
+    private function topCategories(array $works): array
+    {
+        $tops = $this->catalog->children(0);
+        $names = $this->catalog->localNames($tops, Cookie::get('language') ?? 'en');
+
+        return array_map(function ($top) use ($works, $names) {
+            $ids = $this->catalog->subtreeIds($top['id']);
+
+            return [
+                'id' => $top['id'],
+                'name' => $names[$top['name']] ?? $top['name'],
+                'count' => count(array_filter($works, fn ($w) => in_array($w['category_id'], $ids, true))),
+            ];
+        }, $tops);
+    }
+
+    /**
+     * @param  list<string>  $uids
+     * @return Collection<string, UserInfo>
+     */
+    private function owners(array $uids): Collection
+    {
+        return UserInfo::whereIn('userid', array_unique($uids))
+            ->get(['userid', 'username', 'nickname'])
+            ->keyBy(fn ($u) => (string) $u->userid);
+    }
+}

+ 249 - 358
api-v13/app/Http/Controllers/Library/TipitakaController.php

@@ -2,433 +2,324 @@
 
 
 namespace App\Http\Controllers\Library;
 namespace App\Http\Controllers\Library;
 
 
-use App\Http\Api\ChannelApi;
 use App\Http\Controllers\Controller;
 use App\Http\Controllers\Controller;
-use App\Models\PaliText;
 use App\Models\ProgressChapter;
 use App\Models\ProgressChapter;
-use App\Models\Tag;
-use App\Models\TagMap;
-use App\Services\TermService;
+use App\Services\TipitakaCatalogService;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Cookie;
 use Illuminate\Support\Facades\Cookie;
-use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
+use Illuminate\View\View;
 
 
+/**
+ * library 三藏栏目:首页与按分类浏览(不限 channel)。
+ *
+ * 按译本浏览见 TipitakaChannelController,作品详情与阅读见 BookController。
+ */
 class TipitakaController extends Controller
 class TipitakaController extends Controller
 {
 {
-    // 封面渐变色池:uid 首字节取余循环,保证同一文集颜色稳定
-    private array $coverGradients = [
-        'linear-gradient(160deg, #2d1020, #ae6b8b)',
-        'linear-gradient(160deg, #1a2d10,rgba(75, 114, 36, 0.61))',
-        'linear-gradient(160deg, #0d1f3c,rgb(55, 98, 150))',
-        'linear-gradient(160deg, #2d1020,rgb(151, 69, 94))',
-        'linear-gradient(160deg, #1a1a2d,rgb(76, 68, 146))',
-        'linear-gradient(160deg, #1a2820,rgb(55, 124, 99))',
-    ];
+    private const PER_PAGE = 8;
 
 
-    /**
-     * 构造函数,注入 TermService
-     */
     public function __construct(
     public function __construct(
-        protected TermService $termService,
+        protected TipitakaCatalogService $catalog,
     ) {}
     ) {}
 
 
-    // -------------------------------------------------------------------------
-    // 从 uid / id 字符串中提取一个稳定的整数,用于色池取余
-    // -------------------------------------------------------------------------
-    private function colorIndex(string $uid): int
+    /**
+     * 三藏首页:分类(两级卡片)、译本、最近更新。
+     *
+     * 旧地址 /library/tipitaka?channel=x(dashboard「我的 channel」在用)301 到该 channel 的页面。
+     */
+    public function index(Request $request): View|RedirectResponse
     {
     {
-        return hexdec(substr(str_replace('-', '', $uid), 0, 4)) % 255;
-    }
-
-    protected static int $nextId = 1;
+        if (Str::isUuid($request->query('channel'))) {
+            return redirect()->route('library.tipitaka.channels.show', ['channel' => $request->query('channel')], 301);
+        }
 
 
-    // app/Http/Controllers/Library/CategoryController.php
-    // category() 方法修改版
-    // 变更:
-    //   1. $id 改为可选参数,无参数时显示顶级分类(首页复用)
-    //   2. 新增 $filters 过滤参数(type / lang / author / sort)
-    //   3. 新增右边栏数据:$recommended / $activeAuthors
-    //   4. 新增 $filterOptions(过滤器选项 + 计数)
-    //   5. 新增 $totalCount
+        $locale = Cookie::get('language') ?? 'en';
+        $names = $this->catalog->localNames($this->catalog->categories(), $locale);
 
 
-    public function index(Request $request, ?int $id = null)
-    {
+        $categoryData = array_map(fn ($top) => [
+            'category' => [...$top, 'local_name' => $names[$top['name']] ?? $top['name']],
+            'children' => array_map(
+                fn ($child) => [...$child, 'local_name' => $names[$child['name']] ?? $child['name']],
+                $this->catalog->children($top['id'])
+            ),
+        ], $this->catalog->children(0));
 
 
-        $locale = Cookie::get('language') ?? 'en';
+        $channels = $this->catalog->publicChannels(null, 8)->items();
+        $recentBooks = $this->recentBooks($this->catalog->works(), 12);
 
 
-        $categories = $this->loadCategories();
+        return view('library.tipitaka.index', compact('categoryData', 'channels', 'recentBooks'));
+    }
 
 
-        // ── 当前分类 ──────────────────────────────────────────
-        if ($id) {
-            $currentCategory = collect($categories)->firstWhere('id', $id);
-            if (! $currentCategory) {
-                abort(404);
-            }
-            $breadcrumbs = $this->getBreadcrumbs($currentCategory, $categories);
-        } else {
-            // 首页:虚拟顶级分类
-            $currentCategory = ['id' => null, 'name' => '三藏'];
-            $breadcrumbs = [];
+    /**
+     * 分类页:子分类 + 过滤器 + 该分类下所有公开 channel 的作品版本(分页)。
+     *
+     * 末级分类的「子分类」位置列出该类下的作品,点开进作品版本页。
+     * 旧链接带 ?channel= 的 301 到该 channel 的分类视图,带 ?book= 的 301 到作品版本页。
+     */
+    public function category(Request $request, int $id): View|RedirectResponse
+    {
+        if (Str::isUuid($request->query('channel'))) {
+            return redirect()->route('library.tipitaka.channels.category', ['channel' => $request->query('channel'), 'id' => $id], 301);
+        }
+        if (preg_match('/^(\d+)-(\d+)$/', (string) $request->query('book'), $m)
+            && ($work = $this->catalog->workAt((int) $m[1], (int) $m[2]))) {
+            return redirect()->route('library.tipitaka.work', ['sn' => $work['sn']], 301);
         }
         }
 
 
-        // ── 子分类 ─────────────────────────────────────────────
-        $subCategories = array_values(array_filter(
-            $categories,
-            fn ($cat) => $cat['parent_id'] == $id
-        ));
-        if (count($subCategories) === 0 && ! $request->has('book')) {
-            $paliBooks = $this->getPaliBooks($categories, $id);
-            foreach ($paliBooks as $value) {
+        $currentCategory = $this->catalog->category($id);
+        abort_unless($currentCategory, 404);
+
+        $localize = $this->localizer();
+        $currentCategory = $localize($currentCategory);
+        $breadcrumbs = array_map($localize, $this->catalog->categoryPath($id));
+        $works = $this->catalog->worksInCategory($id);
+
+        $subCategories = array_map(fn ($c) => [
+            ...$localize($c),
+            'url' => route('library.tipitaka.category', ['id' => $c['id']]),
+            'is_work' => false,
+        ], $this->catalog->children($id));
+        if (empty($subCategories)) {
+            foreach ($works as $work) {
                 $subCategories[] = [
                 $subCategories[] = [
-                    'id' => $id,
-                    'name' => $value->text,
-                    'book' => "{$value->book}-{$value->paragraph}",
+                    'name' => $work['title'],
+                    'url' => route('library.tipitaka.work', ['sn' => $work['sn']]),
+                    'is_work' => true,
                 ];
                 ];
             }
             }
         }
         }
-        $allNames = array_map(fn ($item) => $item['name'], $subCategories);
-
-        // 去重
-        $allNames = array_values(array_unique($allNames));
-
-        // 查词典
-        $terms = $this->termService->glossaryByLemma($allNames, $locale);
-        // 构建映射
-        $termMap = [];
-        if ($terms) {
-            foreach ($terms as $term) {
-                $termMap[$term->word] = $term->meaning;
-            }
-        }
-        // 回填
-        foreach ($subCategories as $key => $cat) {
-            $name = $cat['name'] ?? null;
-            $subCategories[$key]['name'] = $termMap[$name] ?? $name;
-        }
-
-        // ── 过滤参数 ────────────────────────────────────────────
-        $selectedType = request('type', 'all');
-        $selectedLang = request('lang', 'all');
-        $selectedAuthor = request('author', 'all');
-        $selectedSort = request('sort', 'new');
-        $selectedChannel = request('channel', 'all');
-
-        // ── 当前频道(提供 channel 参数时) ──────────────────────
-        $currentChannel = $selectedChannel !== 'all'
-            ? (ChannelApi::getById($selectedChannel) ?: null)
-            : null;
-
-        $sortList = [
-            ['key' => 'new',         'label' => __('library.badge_updated')],
-            ['key' => 'progress',    'label' => '完成度'],
-        ];
-
-        $selected = [
-            'type' => $selectedType,
-            'lang' => $selectedLang,
-            'author' => $selectedAuthor,
-            'sort' => $selectedSort,
-            'channel' => $selectedChannel,
-        ];
-        if ($request->has('book')) {
-            $selected['book'] = $request->input('book');
-        }
 
 
-        // ── 书籍列表(过滤+排序,真实实现替换此处) ──────────────
-        $categoryBooks = $this->getBooks($categories, $id, $selected);
+        $sideTree = $this->sideTree(0, array_column($breadcrumbs, 'id'), $id, $localize);
+        $recentBooks = $this->recentBooks($works, 5);
+        $activeChannels = $this->activeChannels($works, 5);
 
 
-        $totalCount = count($categoryBooks);
-
-        // ── 过滤器选项(mock,真实实现从书籍数据聚合) ────────────
-        $filterOptions = [
-            'types' => $this->filterTypes(),
-            'languages' => $this->filterLanguages(),
-            'authors' => $this->getAuthorOptions($categoryBooks),
-        ];
-
-        // ── 右边栏:本周推荐(mock) ────────────────────────────
-        $recommended = $this->mockRecommended();
-
-        // ── 右边栏:活跃译者(mock) ────────────────────────────
-        $activeAuthors = $this->mockActiveAuthors();
-
-        $types = $this->types();
-
-        return view('library.tipitaka.category', compact(
-            'currentCategory',
-            'subCategories',
-            'categoryBooks',
-            'breadcrumbs',
-            'types',
-            'selected',
-            'filterOptions',
-            'totalCount',
-            'recommended',
-            'activeAuthors',
-            'sortList',
-            'currentChannel'
-        ));
+        return view('library.tipitaka.category', [
+            ...$this->versionList($request, $works),
+            ...compact('currentCategory', 'subCategories', 'breadcrumbs', 'sideTree', 'recentBooks', 'activeChannels'),
+        ]);
     }
     }
 
 
-    private function filterLanguages()
+    /**
+     * 作品版本页:一部作品(book_titles.sn)在所有公开 channel 里的版本。
+     * 版式同译本列表页:按 type 分组,左栏可按语言过滤,每行带该版本的进度。
+     */
+    public function work(Request $request, int $sn): View
     {
     {
-        return [
-            ['value' => 'all',  'label' => '全部',   'count' => 0],
-            ['value' => 'zh-Hans',   'label' => '简体中文',   'count' => 0],
-            ['value' => 'zh-Hant',   'label' => '繁体中文',   'count' => 0],
-            ['value' => 'pi',   'label' => '巴利语', 'count' => 0],
-            ['value' => 'en',   'label' => '英语',   'count' => 0],
-        ];
+        $work = $this->catalog->work($sn);
+        abort_unless($work, 404);
+
+        $all = array_map(fn ($c) => [
+            ...$c,
+            'url' => route('library.tipitaka.channels.work', ['channel' => $c['uid'], 'sn' => $sn]),
+        ], $this->catalog->workChannels($work));
+        // 语言过滤不能叫 lang:SetLocale 中间件用 ?lang= 切换界面语言
+        $selectedLang = $request->query('channel_lang', 'all');
+        $versions = $selectedLang === 'all' ? $all : array_values(array_filter($all, fn ($c) => $c['lang'] === $selectedLang));
+        $groups = $this->catalog->groupByType($versions);
+        $languages = $this->catalog->filterOptions(collect($all)->countBy('lang')->all());
+
+        $localize = $this->localizer();
+        $breadcrumbs = $work['category_id'] === null
+            ? []
+            : array_map($localize, $this->catalog->categoryPath($work['category_id']));
+        $kind = $work['kind'] ? __('library.'.$work['kind']) : null;
+
+        return view('library.tipitaka.work', compact('work', 'breadcrumbs', 'groups', 'languages', 'selectedLang', 'kind'));
     }
     }
 
 
-    private function filterTypes()
+    /**
+     * 分类页的版本列表:过滤器选项、当前过滤、排序与分页后的书卡片。
+     *
+     * @param  list<array<string, mixed>>  $works
+     * @return array{selected: array{type: string, lang: string, sort: string}, filterOptions: array, sortList: array, paginator: LengthAwarePaginator, categoryBooks: list<array>, totalCount: int}
+     */
+    private function versionList(Request $request, array $works): array
     {
     {
-        return [
-            ['value' => 'all',         'label' => '全部',    'count' => 0],
-            ['value' => 'original',    'label' => '原文',    'count' => 0],
-            ['value' => 'translation', 'label' => '译文',    'count' => 0],
-            ['value' => 'nissaya',     'label' => 'Nissaya', 'count' => 0],
+        $selected = [
+            'type' => $request->query('type', 'all'),
+            // 不能叫 lang:SetLocale 中间件用 ?lang= 切换界面语言
+            'lang' => $request->query('channel_lang', 'all'),
+            'sort' => $request->query('sort', 'new'),
         ];
         ];
-    }
 
 
-    private function mockRecommended()
-    {
-        return [
-            ['id' => 1, 'title' => '相应部·因缘篇',  'category' => '经藏'],
-            ['id' => 2, 'title' => '法句经',          'category' => '经藏'],
-            ['id' => 3, 'title' => '清净道论',        'category' => '注释'],
-            ['id' => 4, 'title' => '律藏·波罗夷',    'category' => '律藏'],
-            ['id' => 5, 'title' => '长部·梵网经',    'category' => '经藏'],
+        $base = $this->versionsQuery($works);
+        $filterOptions = [
+            'types' => $this->filterOptions(clone $base, 'type', 'labels'),
+            'languages' => $this->filterOptions(clone $base, 'lang', 'language'),
         ];
         ];
-    }
 
 
-    private function mockActiveAuthors()
-    {
-        return [
-            [
-                'name' => 'Bhikkhu Bodhi',
-                'avatar' => null,
-                'color' => '#2d5a8e',
-                'initials' => 'BB',
-                'count' => 24,
-            ],
-            [
-                'name' => 'Bhikkhu Sujato',
-                'avatar' => null,
-                'color' => '#5a2d8e',
-                'initials' => 'BS',
-                'count' => 18,
-            ],
-        ];
-    }
+        $query = (clone $base)->select('progress_chapters.*')->with('channel.owner')
+            ->when($selected['type'] !== 'all', fn ($q) => $q->whereHas('channel', fn ($c) => $c->where('type', $selected['type'])))
+            ->when($selected['lang'] !== 'all', fn ($q) => $q->whereHas('channel', fn ($c) => $c->where('lang', $selected['lang'])));
+        $query = $selected['sort'] === 'progress'
+            ? $query->orderByDesc('progress')
+            : $query->orderByDesc('updated_at');
 
 
-    // ── 辅助:从书籍列表聚合作者选项(mock,真实实现替换) ─────────
-    private function getAuthorOptions(array $books): array
-    {
-        // TODO: 从 $books 聚合真实作者列表
-        return [
-            ['value' => 'all',             'label' => '全部作者',      'count' => count($books)],
-            ['value' => 'bhikkhu-bodhi',   'label' => 'Bhikkhu Bodhi', 'count' => 0],
-            ['value' => 'bhikkhu-sujato',  'label' => 'Bhikkhu Sujato', 'count' => 0],
-            ['value' => 'buddhaghosa',     'label' => 'Buddhaghosa',   'count' => 0],
-            ['value' => 'bhikkhu-brahmali', 'label' => 'Bhikkhu Brahmali', 'count' => 0],
-        ];
-    }
+        $paginator = $query->paginate(self::PER_PAGE)->withQueryString();
 
 
-    private function types()
-    {
         return [
         return [
-            ['id' => '1', 'name' => 'sutta'],
-            ['id' => '48', 'name' => 'vinaya'],
-            ['id' => '66', 'name' => 'abhidhamma'],
-            ['id' => '82', 'name' => 'añña'],
+            'selected' => $selected,
+            'filterOptions' => $filterOptions,
+            'sortList' => [
+                ['key' => 'new', 'label' => __('library.badge_updated')],
+                ['key' => 'progress', 'label' => __('library.sort_progress')],
+            ],
+            'paginator' => $paginator,
+            'categoryBooks' => $this->toCards($paginator->getCollection(), $works),
+            'totalCount' => $paginator->total(),
         ];
         ];
     }
     }
 
 
-    private function subCategories($categories, int $id)
+    /**
+     * 左栏分类树:顶级分类全列;沿当前分类的路径逐级展开,当前分类也展开它的下一级。
+     *
+     * @param  list<int>  $pathIds  从顶级到当前分类的 id
+     * @return list<array{id: int, name: string, active: bool, open: bool, children: list<array>}>
+     */
+    private function sideTree(int $parentId, array $pathIds, int $currentId, \Closure $localize): array
     {
     {
-        return array_filter($categories, function ($cat) use ($id) {
-            return $cat['parent_id'] == $id;
-        });
+        return array_map(fn ($c) => [
+            'id' => $c['id'],
+            'name' => $localize($c)['name'],
+            'active' => $c['id'] === $currentId,
+            // 在当前路径上(祖先或自身),加粗以区分层级
+            'open' => in_array($c['id'], $pathIds, true),
+            'children' => in_array($c['id'], $pathIds, true)
+                ? $this->sideTree($c['id'], $pathIds, $currentId, $localize)
+                : [],
+        ], $this->catalog->children($parentId));
     }
     }
 
 
-    private function getBooksIdInCat(array $categories, ?string $id)
-    {
-        if ($id) {
-            $currentCategory = collect($categories)->firstWhere('id', $id);
-            if (! $currentCategory) {
-                abort(404);
-            }
-            // 标签查章节
-            $tagNames = $currentCategory['tag'];
-            $booksChapter = PaliText::withAllTags($tagNames)
-                ->where('level', 1)->get();
-        } else {
-            $booksChapter = PaliText::select(['book', 'paragraph'])
-                ->where('level', 1)
-                ->get();
-        }
-
-        $chapters = [];
-        foreach ($booksChapter as $key => $value) {
-            $chapters[] = [$value->book, $value->paragraph];
-        }
-
-        return $chapters;
-    }
-
-    private function getPaliBooks(array $categories, string $id)
+    /**
+     * 分类名本地化:查术语表,查不到用巴利名。
+     *
+     * @return \Closure(array): array
+     */
+    private function localizer(): \Closure
     {
     {
-        $chapters = $this->getBooksIdInCat($categories, $id);
-
-        $books = PaliText::whereIns(['book', 'paragraph'], $chapters)->get();
+        $names = $this->catalog->localNames($this->catalog->categories(), Cookie::get('language') ?? 'en');
 
 
-        return $books;
+        return fn (array $c) => [...$c, 'name' => $names[$c['name']] ?? $c['name']];
     }
     }
 
 
-    private function getBooks(array $categories, ?string $id, array $filters)
+    /**
+     * 这些作品在公开 channel 里的全部版本(progress_chapters 的作品根节点行)。
+     *
+     * 两步:作品根节点 (book, para) 取自目录缓存,不查库;再和 progress_chapters 做 join。
+     * 根节点写成 VALUES 临时表 join,而不是 whereIns 的 (book, para) in ((..),(..)…):
+     * 没有索引时都要全表扫描,但 join 走哈希,281 个根节点时 94ms → 20ms(2026-11-06 实测)。
+     * 需要模型的调用方要自己 select('progress_chapters.*'),否则 work_roots 的列会混进来。
+     *
+     * TODO: 缺 progress_chapters (book, para) 索引,仍是全表扫描,见 TipitakaCatalogService 类注释。
+     *
+     * @param  list<array{book: int, para: int}>  $works
+     */
+    private function versionsQuery(array $works): Builder
     {
     {
-        // 根据分类获取书号
-        if (isset($filters['book'])) {
-            $chapters = [explode('-', $filters['book'])];
-        } else {
-            $chapters = $this->getBooksIdInCat($categories, $id);
+        $query = ProgressChapter::query()->whereHas('channel', fn ($q) => $q->where('status', 30));
+        if (empty($works)) {
+            return $query->whereRaw('1 = 0');
         }
         }
 
 
-        $table = ProgressChapter::with('channel.owner')
-            ->whereHas('channel', function ($query) use ($filters) {
-                if ($filters['type'] !== 'all') {
-                    $query->where('type', $filters['type'])
-                        ->where('status', 30);
-                }
-
-                if ($filters['lang'] !== 'all') {
-                    $query->where('lang', $filters['lang'])
-                        ->where('status', 30);
-                }
-
-                if ($filters['channel'] !== 'all' && Str::isUuid($filters['channel'])) {
-                    $query->where('uid', $filters['channel']);
-                }
-            })
-            ->whereNotNull('last_chapter_completed_at')
-            ->whereIns(['book', 'para'], $chapters);
-        if ($filters['sort'] === 'new') {
-            $table = $table->orderBy('last_chapter_completed_at', 'desc');
-        } elseif ($filters['sort'] === 'progress') {
-            $table = $table->orderBy('progress', 'desc');
-        }
-        $books = $table->take(100)->get();
+        // book / para 是目录里的整数,强转后直接拼进 SQL,不会注入
+        $values = implode(',', array_map(fn ($w) => '('.(int) $w['book'].','.(int) $w['para'].')', $works));
 
 
-        return $this->getBooksInfo($books);
+        return $query->join(
+            DB::raw("(values {$values}) as work_roots(book, para)"),
+            fn ($join) => $join->on('work_roots.book', '=', 'progress_chapters.book')
+                ->on('work_roots.para', '=', 'progress_chapters.para')
+        );
     }
     }
 
 
-    private function getBooksInfo(mixed $books)
+    /**
+     * 过滤器选项:按 channel 的某一列聚合版本数。
+     *
+     * @return list<array{value: string, label: string, count: int}>
+     */
+    private function filterOptions(Builder $base, string $column, string $langFile): array
     {
     {
-        $pali = PaliText::where('level', 1)->get();
-        // 获取该分类下的书籍
-        $categoryBooks = [];
-        $books->each(function ($book) use (&$categoryBooks, $pali) {
-            $title = $book->title;
-            if (empty($title)) {
-                $title = $pali->firstWhere('book', $book->book)->toc;
-            }
-
-            $pcd_book_id = $pali->first(function ($item) use ($book) {
-                return $item->book == $book->book
-                    && $item->paragraph == $book->para;
-            })?->pcd_book_id;
+        $rows = $base->join('channels', 'channels.uid', '=', 'progress_chapters.channel_id')
+            ->groupBy("channels.{$column}")
+            ->orderByRaw('count(*) desc')
+            ->get([DB::raw("channels.{$column} as value"), DB::raw('count(*) as count')]);
 
 
-            $coverFile = "/assets/images/cover/zh-hans/1/{$pcd_book_id}.png";
-            if (File::exists(public_path($coverFile))) {
-                $coverUrl = $coverFile;
-            } else {
-                $coverUrl = null;
-            }
-            $colorIdx = $this->colorIndex($book->uid);
-            $subTitle = $this->getBookType($book->book, $book->para);
-
-            $categoryBooks[] = [
-                'id' => $book->uid,
-                'title' => $title,
-                'author' => $book->channel->name,
-                'subTitle' => $subTitle,
-                'publisher' => $book->channel->owner,
-                'completed_chapters' => $book->completed_chapters,
-                'type' => __('labels.'.$book->channel->type),
-                'cover' => $coverUrl,
-                'cover_gradient' => $this->coverGradients[$colorIdx % count($this->coverGradients)],
-                'description' => $book->summary ?? '比库戒律的详细说明',
-                'language' => __('language.'.$book->channel->lang),
-            ];
-        });
-
-        return $categoryBooks;
+        return [
+            ['value' => 'all', 'label' => __('library.all'), 'count' => (int) $rows->sum('count')],
+            ...$rows->map(fn ($row) => [
+                'value' => $row->value,
+                'label' => __("{$langFile}.{$row->value}"),
+                'count' => (int) $row->count,
+            ])->all(),
+        ];
     }
     }
 
 
-    private function getBookType(int $book, int $para)
+    /**
+     * progress_chapters 行 → 书卡片。
+     *
+     * @param  iterable<ProgressChapter>  $rows
+     * @param  list<array<string, mixed>>  $works
+     * @return list<array<string, mixed>>
+     */
+    private function toCards(iterable $rows, array $works): array
     {
     {
-
-        $paliTextUuid = PaliText::where('book', $book)->where('paragraph', $para)->value('uid');
-        $tagIds = TagMap::where('anchor_id', $paliTextUuid)->select('tag_id')->get();
-        $tags = Tag::whereIn('id', $tagIds)->select('name')->get();
-        foreach ($tags as $key => $tag) {
-            if (in_array($tag->name, ['pāḷi', 'aṭṭhakathā', 'ṭīkā'])) {
-                return __('library.'.$tag->name);
+        $byRoot = collect($works)->keyBy(fn ($w) => "{$w['book']}-{$w['para']}");
+        $cards = [];
+        foreach ($rows as $row) {
+            $work = $byRoot->get("{$row->book}-{$row->para}");
+            if (! $work || ! $row->channel) {
+                continue;
             }
             }
+            $cards[] = $this->catalog->bookCard($work, [
+                'uid' => (string) $row->channel_id,
+                'name' => $row->channel->name,
+                'type' => $row->channel->type,
+                'lang' => $row->channel->lang,
+                'owner' => $row->channel->owner,
+            ], ['progress' => (float) $row->progress, 'title' => $row->title, 'summary' => $row->summary]);
         }
         }
 
 
-        return null;
+        return $cards;
     }
     }
 
 
-    private function loadCategories()
-    {
-        $json = file_get_contents(public_path('data/category/default.json'));
-        $tree = json_decode($json, true);
-        $flat = self::flattenWithIds($tree);
-
-        return $flat;
-    }
-
-    public static function flattenWithIds(array $tree, int $parentId = 0, int $level = 1): array
+    /**
+     * 这些作品最近更新的版本。
+     *
+     * @return list<array<string, mixed>>
+     */
+    private function recentBooks(array $works, int $limit): array
     {
     {
+        $rows = $this->versionsQuery($works)->select('progress_chapters.*')->with('channel.owner')
+            ->orderByDesc('updated_at')
+            ->take($limit)
+            ->get();
 
 
-        $flat = [];
-
-        foreach ($tree as $node) {
-            $currentId = self::$nextId++;
-
-            $item = [
-                'id' => $currentId,
-                'parent_id' => $parentId,
-                'name' => $node['name'] ?? null,
-                'tag' => $node['tag'] ?? [],
-                'description' => '佛教戒律经典',
-                'level' => $level,
-            ];
-
-            $flat[] = $item;
-
-            if (isset($node['children']) && is_array($node['children'])) {
-                $childrenLevel = $level + 1;
-                $flat = array_merge($flat, self::flattenWithIds($node['children'], $currentId, $childrenLevel));
-            }
-        }
-
-        return $flat;
+        return $this->toCards($rows, $works);
     }
     }
 
 
-    private function getBreadcrumbs($category, $categories)
+    /**
+     * 这些作品里收录最多的公开 channel,给右栏「本类译本」用。
+     *
+     * @return list<array{uid: string, name: string, lang: string, count: int}>
+     */
+    private function activeChannels(array $works, int $limit): array
     {
     {
-        $breadcrumbs = [];
-        $current = $category;
-
-        while ($current) {
-            array_unshift($breadcrumbs, $current);
-            $current = collect($categories)->firstWhere('id', $current['parent_id']);
-        }
-
-        return $breadcrumbs;
+        return $this->versionsQuery($works)
+            ->join('channels', 'channels.uid', '=', 'progress_chapters.channel_id')
+            ->groupBy('channels.uid', 'channels.name', 'channels.lang')
+            ->orderByRaw('count(*) desc')
+            ->take($limit)
+            ->get([DB::raw('channels.uid as uid'), 'channels.name', 'channels.lang', DB::raw('count(*) as count')])
+            ->map(fn ($row) => [
+                'uid' => (string) $row->uid,
+                'name' => $row->name,
+                'lang' => $row->lang,
+                'count' => (int) $row->count,
+            ])
+            ->all();
     }
     }
 }
 }

+ 563 - 0
api-v13/app/Services/TipitakaCatalogService.php

@@ -0,0 +1,563 @@
+<?php
+
+namespace App\Services;
+
+use App\Facades\RedisCache;
+use App\Models\BookTitle;
+use App\Models\Channel;
+use App\Models\PaliText;
+use App\Models\ProgressChapter;
+use App\Models\TagMap;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * library 三藏栏目的目录:分类树与「作品」。
+ *
+ * 作品 = book_titles 的一行,URL 里用 sn 作 id。作品名取起点段落的 pali_texts.toc;
+ * book_titles.title 很多存的是丛书名(如 141-3 是 samantapāsādikā,实为 (SP) Cūḷavagga-aṭṭhakathā),
+ * 只作 series 保留。作品的段落区间是
+ * [paragraph, 同书下一条 book_titles.paragraph - 1],且不超过它所在 level-1 节点的末尾
+ * (book 209 vajirabuddhi-ṭīkā 一个 level-1 节点里有 5 部作品)。
+ *
+ * 分类来自 public/data/category/default.json,id 是按文件顺序深度优先的流水号,
+ * 与 HomeController::flattenWithIds() 编号一致(/library/tipitaka/category/28 = 小部)。
+ * 作品归入「包含它所在 level-1 节点全部 tag」的最深一级分类。
+ *
+ * 某 channel 是否收录某作品,看作品起点 (book, paragraph) 在 progress_chapters 里有没有该
+ * channel 的行;该行的 progress 就是整部作品的进度。
+ *
+ * TODO: 给 progress_chapters 加 (book, para) 索引(只加索引,不改列;migration 由用户决定,2026-11-04 定为以后做)。
+ *       现在按作品根节点查版本只能全表扫描(本地 12.6 万行)。2026-11-04 在事务里临时建索引实测(测完回滚),
+ *       各页 SQL 合计:三藏首页 132→14ms、分类「经藏」292→19ms、分类末级 76→9ms、译本列表 67→13ms、
+ *       作品详情 17→5ms。受影响的查询:本类的 publicChannelsQuery()、workChannels(),以及
+ *       TipitakaController::versionsQuery()。
+ *       顺带可考虑 pali_texts (level) 索引:buildWorks() 冷启动里 `where level = 1` 占 ~106ms(一天一次,不急)。
+ */
+class TipitakaCatalogService
+{
+    /**
+     * 作品目录的缓存 key。分类 json 或 tag 有改动,等 TTL 过期,或手动 forget 这个 key。
+     */
+    public const CACHE_KEY = '/library/tipitaka/catalog/v2';
+
+    private const CACHE_TTL = 86400;
+
+    /** @var list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>|null */
+    private ?array $categories = null;
+
+    /** @var list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>|null */
+    private ?array $works = null;
+
+    /** @var array<string, array<string, array>> channel uid => channelProgress() 的结果 */
+    private array $progress = [];
+
+    /** @var array<int, list<array>>|null parent_id => 子分类;分类树页对每个节点都要查,建一次索引 */
+    private ?array $childrenIndex = null;
+
+    /** @var array<int, list<int>> 分类 id => subtreeIds() 的结果 */
+    private array $subtrees = [];
+
+    /** @var array<int, list<array>> 分类 id => worksInCategory() 的结果 */
+    private array $categoryWorks = [];
+
+    /**
+     * 封面渐变色池,按作品 sn 取余,保证同一作品颜色稳定。
+     */
+    private const COVER_GRADIENTS = [
+        'linear-gradient(160deg, #2d1020, #ae6b8b)',
+        'linear-gradient(160deg, #1a2d10, rgba(75, 114, 36, 0.61))',
+        'linear-gradient(160deg, #0d1f3c, rgb(55, 98, 150))',
+        'linear-gradient(160deg, #2d1020, rgb(151, 69, 94))',
+        'linear-gradient(160deg, #1a1a2d, rgb(76, 68, 146))',
+        'linear-gradient(160deg, #1a2820, rgb(55, 124, 99))',
+    ];
+
+    /**
+     * channel 头像底色池,按 uid 取余,保证同一 channel 颜色稳定。
+     */
+    private const AVATAR_COLORS = ['#206bc4', '#7a3fc4', '#2fb344', '#c77700', '#d63939', '#0ca678', '#4263eb', '#ae3ec9'];
+
+    public function __construct(protected TermService $termService) {}
+
+    public static function channelColor(string $uid): string
+    {
+        return self::AVATAR_COLORS[hexdec(substr(md5($uid), 0, 4)) % count(self::AVATAR_COLORS)];
+    }
+
+    /**
+     * 拍平的分类表,编号规则同 HomeController::flattenWithIds()。
+     *
+     * 不直接调那个静态方法:它的计数器是静态的,同一请求里调第二次编号就错位了。
+     *
+     * @return list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>
+     */
+    public function categories(): array
+    {
+        if ($this->categories !== null) {
+            return $this->categories;
+        }
+
+        $tree = json_decode(file_get_contents(public_path('data/category/default.json')), true);
+        $flat = [];
+        $nextId = 1;
+        $walk = function (array $nodes, int $parentId, int $level) use (&$walk, &$flat, &$nextId): void {
+            foreach ($nodes as $node) {
+                $id = $nextId++;
+                $flat[] = [
+                    'id' => $id,
+                    'parent_id' => $parentId,
+                    'name' => $node['name'] ?? '',
+                    'tag' => $node['tag'] ?? [],
+                    'level' => $level,
+                ];
+                if (! empty($node['children']) && is_array($node['children'])) {
+                    $walk($node['children'], $id, $level + 1);
+                }
+            }
+        };
+        $walk($tree, 0, 1);
+
+        return $this->categories = $flat;
+    }
+
+    /**
+     * @return array{id: int, parent_id: int, name: string, tag: list<string>, level: int}|null
+     */
+    public function category(int $id): ?array
+    {
+        // 编号是从 1 开始的连续流水号,下标 = id - 1
+        $category = $this->categories()[$id - 1] ?? null;
+
+        return $category && $category['id'] === $id ? $category : null;
+    }
+
+    /**
+     * @return list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>
+     */
+    public function children(int $parentId): array
+    {
+        if ($this->childrenIndex === null) {
+            $this->childrenIndex = [];
+            foreach ($this->categories() as $c) {
+                $this->childrenIndex[$c['parent_id']][] = $c;
+            }
+        }
+
+        return $this->childrenIndex[$parentId] ?? [];
+    }
+
+    /**
+     * 从顶级到该分类的路径(含自身)。
+     *
+     * @return list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>
+     */
+    public function categoryPath(int $id): array
+    {
+        $path = [];
+        $current = $this->category($id);
+        while ($current) {
+            array_unshift($path, $current);
+            $current = $current['parent_id'] ? $this->category($current['parent_id']) : null;
+        }
+
+        return $path;
+    }
+
+    /**
+     * 该分类自身及全部后代的 id。
+     *
+     * @return list<int>
+     */
+    public function subtreeIds(int $id): array
+    {
+        if (isset($this->subtrees[$id])) {
+            return $this->subtrees[$id];
+        }
+        $ids = [$id];
+        foreach ($this->children($id) as $child) {
+            $ids = array_merge($ids, $this->subtreeIds($child['id']));
+        }
+
+        return $this->subtrees[$id] = $ids;
+    }
+
+    /**
+     * 分类的本地化名字:查术语表,查不到用巴利名。
+     *
+     * @param  list<array{name: string}>  $categories
+     * @return array<string, string> 巴利名 => 显示名
+     */
+    public function localNames(array $categories, string $lang): array
+    {
+        $names = array_values(array_unique(array_filter(array_column($categories, 'name'))));
+        $map = array_combine($names, $names) ?: [];
+        foreach ($this->termService->glossaryByLemma($names, $lang) ?? [] as $term) {
+            if (! empty($term->meaning)) {
+                $map[$term->word] = $term->meaning;
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * 全部作品,按 sn 排序。
+     *
+     * @return list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>
+     */
+    public function works(): array
+    {
+        if ($this->works !== null) {
+            return $this->works;
+        }
+
+        return $this->works = RedisCache::remember(self::CACHE_KEY, self::CACHE_TTL, fn () => $this->buildWorks());
+    }
+
+    /**
+     * 按 sn 取作品。sn 有重复时(如 276)取 id 最小的那条。
+     *
+     * @return array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}|null
+     */
+    public function work(int $sn): ?array
+    {
+        // FIXME: book_titles.sn 不唯一(276 同时是 214-3 与 214-2162),后一条按 sn 访问不到
+        return collect($this->works())->firstWhere('sn', $sn);
+    }
+
+    /**
+     * 段落所在的作品。
+     *
+     * @return array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}|null
+     */
+    public function workAt(int $book, int $para): ?array
+    {
+        return collect($this->works())
+            ->first(fn ($w) => $w['book'] === $book && $para >= $w['para'] && $para <= $w['end']);
+    }
+
+    /**
+     * 分类(含后代)下的作品。
+     *
+     * @return list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>
+     */
+    public function worksInCategory(int $categoryId): array
+    {
+        if (isset($this->categoryWorks[$categoryId])) {
+            return $this->categoryWorks[$categoryId];
+        }
+        $ids = array_flip($this->subtreeIds($categoryId));
+
+        return $this->categoryWorks[$categoryId] = array_values(array_filter(
+            $this->works(),
+            fn ($w) => $w['category_id'] !== null && isset($ids[$w['category_id']])
+        ));
+    }
+
+    /**
+     * 某 channel 收录的作品及其进度,key 为 "book-para"(sn 有重复,不能当 key)。
+     *
+     * @return array<string, array{uid: string, progress: float, title: string|null, summary: string|null, updated_at: string|null}>
+     */
+    public function channelProgress(string $channelUid): array
+    {
+        return $this->progress[$channelUid] ??= $this->loadChannelProgress($channelUid);
+    }
+
+    /**
+     * @return array<string, array{uid: string, progress: float, title: string|null, summary: string|null, updated_at: string|null}>
+     */
+    private function loadChannelProgress(string $channelUid): array
+    {
+        $roots = array_map(fn ($w) => [$w['book'], $w['para']], $this->works());
+        if (empty($roots)) {
+            return [];
+        }
+
+        return ProgressChapter::where('channel_id', $channelUid)
+            ->whereIns(['book', 'para'], $roots)
+            ->get(['uid', 'book', 'para', 'progress', 'title', 'summary', 'updated_at'])
+            ->mapWithKeys(fn ($row) => ["{$row->book}-{$row->para}" => [
+                'uid' => (string) $row->uid,
+                'progress' => (float) $row->progress,
+                'title' => $row->title,
+                'summary' => $row->summary,
+                'updated_at' => $row->updated_at?->toDateString(),
+            ]])
+            ->all();
+    }
+
+    /**
+     * 公开 channel 的列表(分页),每个带收录作品数与最近更新时间,按作品数降序。
+     *
+     * 在 SQL 里分页:线上 channel 很多,不能全取出来再切。
+     *
+     * @return LengthAwarePaginator<int, array{uid: string, name: string, type: string, lang: string, owner_uid: string, works: int, updated_at: string|null}>
+     */
+    public function publicChannels(?string $lang = null, int $perPage = 20, ?string $type = null): LengthAwarePaginator
+    {
+        $paginator = $this->publicChannelsQuery()
+            ->when($lang, fn ($q) => $q->where('c.lang', $lang))
+            ->when($type, fn ($q) => $q->where('c.type', $type))
+            ->groupBy('c.uid', 'c.name', 'c.type', 'c.lang', 'c.owner_uid')
+            ->orderByRaw('count(distinct pc.uid) desc')
+            ->orderBy('c.uid')
+            ->select([
+                'c.uid', 'c.name', 'c.type', 'c.lang', 'c.owner_uid',
+                DB::raw('count(distinct pc.uid) as works'),
+                DB::raw('max(pc.updated_at) as updated_at'),
+            ])
+            ->paginate($perPage);
+
+        $paginator->setCollection($paginator->getCollection()->map(fn ($row) => [
+            'uid' => (string) $row->uid,
+            'name' => $row->name,
+            'type' => $row->type,
+            'lang' => $row->lang,
+            'owner_uid' => (string) $row->owner_uid,
+            'works' => (int) $row->works,
+            'updated_at' => $row->updated_at ? substr($row->updated_at, 0, 10) : null,
+        ]));
+
+        return $paginator;
+    }
+
+    /**
+     * 公开 channel 按某一列(lang / type)计数,给过滤项用。可再按另一列过滤,
+     * 这样选了语言后,类型的数量是该语言下的数量(反之亦然)。
+     *
+     * @param  'lang'|'type'  $column
+     * @param  array<'lang'|'type', string|null>  $filters
+     * @return array<string, int> 值 => channel 数,按数量降序
+     */
+    public function publicChannelCounts(string $column, array $filters = []): array
+    {
+        return $this->publicChannelsQuery()
+            ->when($filters['lang'] ?? null, fn ($q, $lang) => $q->where('c.lang', $lang))
+            ->when($filters['type'] ?? null, fn ($q, $type) => $q->where('c.type', $type))
+            ->groupBy("c.{$column}")
+            ->orderByRaw('count(distinct c.uid) desc')
+            ->selectRaw("c.{$column} as value, count(distinct c.uid) as n")
+            ->pluck('n', 'value')
+            ->map(fn ($n) => (int) $n)
+            ->all();
+    }
+
+    /**
+     * 收录了作品(作品根节点有 progress_chapters 行)的公开 channel。
+     *
+     * TODO: 缺 progress_chapters (book, para) 索引,join book_titles 时全表扫描,见类注释。
+     */
+    private function publicChannelsQuery(): Builder
+    {
+        return DB::table('progress_chapters as pc')
+            ->join('book_titles as b', function ($join) {
+                $join->on('b.book', '=', 'pc.book')->on('b.paragraph', '=', 'pc.para');
+            })
+            ->join('channels as c', 'c.uid', '=', 'pc.channel_id')
+            ->where('c.status', 30);
+    }
+
+    /**
+     * channel 分组顺序;没列出的 type 排在后面。
+     */
+    private const TYPE_ORDER = ['translation', 'original', 'nissaya', 'commentary'];
+
+    /**
+     * 把 channel 行按 type 分组(作品版本页用)。
+     *
+     * @param  list<array{type: string}>  $channels
+     * @return list<array{type: string, label: string, channels: list<array>}>
+     */
+    public function groupByType(array $channels): array
+    {
+        $rank = fn (string $type) => array_search($type, self::TYPE_ORDER, true) === false ? 99 : array_search($type, self::TYPE_ORDER, true);
+
+        return collect($channels)
+            ->groupBy('type')
+            ->sortBy(fn ($items, $type) => $rank($type))
+            ->map(fn ($items, $type) => ['type' => $type, 'label' => __('labels.'.$type), 'channels' => $items->values()->all()])
+            ->values()
+            ->all();
+    }
+
+    /**
+     * 过滤项:「全部」+ 按数量降序的各值。$langFile 决定标签翻译(language / labels)。
+     *
+     * @param  array<string, int>  $counts  值 => 数量
+     * @return list<array{value: string, label: string, count: int}>
+     */
+    public function filterOptions(array $counts, string $langFile = 'language'): array
+    {
+        arsort($counts);
+
+        return collect($counts)
+            ->map(fn ($count, $value) => ['value' => $value, 'label' => __("{$langFile}.{$value}"), 'count' => $count])
+            ->values()
+            ->prepend(['value' => 'all', 'label' => __('library.all'), 'count' => array_sum($counts)])
+            ->all();
+    }
+
+    /**
+     * 收录了该作品的其他公开 channel。
+     *
+     * @return list<array{uid: string, name: string, lang: string, type: string, owner: mixed, progress: int, updated_at: string|null}>
+     */
+    public function workChannels(array $work, ?string $exceptChannel = null): array
+    {
+        return ProgressChapter::with('channel.owner')
+            ->where('book', $work['book'])
+            ->where('para', $work['para'])
+            ->when($exceptChannel, fn ($q) => $q->where('channel_id', '<>', $exceptChannel))
+            ->whereHas('channel', fn ($q) => $q->where('status', 30))
+            ->orderByDesc('progress')
+            ->get()
+            ->map(fn ($row) => [
+                'uid' => (string) $row->channel_id,
+                'name' => $row->channel->name,
+                'lang' => $row->channel->lang,
+                'type' => $row->channel->type,
+                'owner' => $row->channel->owner,
+                'progress' => (int) round($row->progress * 100),
+                'updated_at' => $row->updated_at?->toDateString(),
+            ])
+            ->all();
+    }
+
+    /**
+     * channel 的展示信息;不存在返回 null。
+     *
+     * 与阅读页一致,不限 status:用户自己的私有 channel 也能从 dashboard 点进来看。
+     */
+    public function channel(string $uid): ?Channel
+    {
+        return Channel::with('owner')->where('uid', $uid)->first();
+    }
+
+    /**
+     * 作品的封面图。目前一律返回 null,页面用渐变色 + 书名作封面(coverGradient())。
+     *
+     * TODO: 以后从 S3 取封面(2026-11-06 定)。原来按 pcd_book_id 找本地
+     *       public/assets/images/cover/zh-hans/1/{id}.png,已停用;$work['pcd_book_id'] 仍在目录里,可作 S3 的 key。
+     */
+    public function coverUrl(array $work): ?string
+    {
+        return null;
+    }
+
+    public function coverGradient(array $work): string
+    {
+        return self::COVER_GRADIENTS[$work['sn'] % count(self::COVER_GRADIENTS)];
+    }
+
+    /**
+     * 书卡片(x-ui.card-book)的数据:某 channel 的某部作品。
+     *
+     * @param  array{uid: string, name: string, type: string, lang: string, owner?: mixed}  $channel
+     * @param  array{progress: float, title: string|null, summary?: string|null}|null  $progress  progress_chapters 那一行
+     * @return array<string, mixed>
+     */
+    public function bookCard(array $work, array $channel, ?array $progress): array
+    {
+        $title = ! empty($progress['title']) ? $progress['title'] : $work['title'];
+
+        return [
+            'id' => $work['sn'],
+            'url' => route('library.tipitaka.channels.work', ['channel' => $channel['uid'], 'sn' => $work['sn']]),
+            'title' => $title,
+            'author' => $channel['name'],
+            'publisher' => $channel['owner'] ?? null,
+            'subTitle' => $work['kind'] ? __('library.'.$work['kind']) : null,
+            'type' => __('labels.'.$channel['type']),
+            'language' => __('language.'.$channel['lang']),
+            'cover' => $this->coverUrl($work),
+            'cover_gradient' => $this->coverGradient($work),
+            'description' => $progress['summary'] ?? '',
+            'progress' => $progress ? (int) round($progress['progress'] * 100) : null,
+        ];
+    }
+
+    /**
+     * @return list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>
+     */
+    private function buildWorks(): array
+    {
+        $level1 = PaliText::where('level', 1)
+            ->orderBy('book')->orderBy('paragraph')
+            ->get(['uid', 'book', 'paragraph', 'chapter_len', 'pcd_book_id']);
+
+        // level-1 节点 uid => tag 名集合
+        $tagsByUid = TagMap::query()
+            ->join('tags', 'tags.id', '=', 'tag_maps.tag_id')
+            ->whereIn('tag_maps.anchor_id', $level1->pluck('uid'))
+            ->get(['tag_maps.anchor_id', 'tags.name'])
+            ->groupBy('anchor_id')
+            ->map(fn ($rows) => $rows->pluck('name')->all());
+
+        // 深的分类排前面,下面取第一个命中的即为最深一级
+        $categories = collect($this->categories())->filter(fn ($c) => ! empty($c['tag']))->sortByDesc('level')->values()->all();
+        $titles = BookTitle::orderBy('sn')->orderBy('id')->get(['sn', 'book', 'paragraph', 'title']);
+
+        // 先按书建索引:在 Eloquent 集合上逐条 where() 是 O(n²),冷启动要 3 秒
+        $level1ByBook = [];
+        foreach ($level1 as $node) {
+            $level1ByBook[(int) $node->book][] = $node;
+        }
+        $titleParasByBook = [];
+        foreach ($titles as $t) {
+            $titleParasByBook[(int) $t->book][] = (int) $t->paragraph;
+        }
+        // 作品起点段落的目录标题 = 作品名
+        $tocs = PaliText::query()
+            ->whereIns(['book', 'paragraph'], $titles->map(fn ($t) => [$t->book, $t->paragraph])->all())
+            ->get(['book', 'paragraph', 'toc'])
+            ->mapWithKeys(fn ($p) => ["{$p->book}-{$p->paragraph}" => trim((string) $p->toc)]);
+
+        $works = [];
+        foreach ($titles as $title) {
+            $book = (int) $title->book;
+            $para = (int) $title->paragraph;
+
+            // 所在 level-1 节点:同书里起点 ≤ 作品起点的最后一个(已按段落升序)
+            $top = null;
+            foreach ($level1ByBook[$book] ?? [] as $node) {
+                if ($node->paragraph <= $para) {
+                    $top = $node;
+                }
+            }
+            $end = $top ? $top->paragraph + $top->chapter_len - 1 : $para;
+            $next = array_filter($titleParasByBook[$book] ?? [], fn ($p) => $p > $para);
+            if ($next) {
+                $end = min($end, min($next) - 1);
+            }
+
+            $tags = $top ? ($tagsByUid[(string) $top->uid] ?? []) : [];
+            $best = null;
+            if ($tags) {
+                foreach ($categories as $category) {
+                    if (empty(array_diff($category['tag'], $tags))) {
+                        $best = $category;
+                        break;
+                    }
+                }
+            }
+            $kind = collect(['pāḷi', 'aṭṭhakathā', 'ṭīkā'])->first(fn ($k) => in_array($k, $tags, true));
+
+            $works[] = [
+                'sn' => (int) $title->sn,
+                'book' => $book,
+                'para' => $para,
+                'end' => (int) $end,
+                'title' => ($tocs["{$book}-{$para}"] ?? '') ?: trim($title->title),
+                'series' => trim($title->title),
+                'category_id' => $best['id'] ?? null,
+                'pcd_book_id' => $top?->pcd_book_id ? (int) $top->pcd_book_id : null,
+                'kind' => $kind,
+            ];
+        }
+
+        return $works;
+    }
+}

BIN
api-v13/public/assets/images/hero-tipitaka.jpg


+ 423 - 0
api-v13/resources/css/modules/tipitaka.css

@@ -450,3 +450,426 @@
     line-height: 1.4;
     line-height: 1.4;
     color: var(--tblr-secondary);
     color: var(--tblr-secondary);
 }
 }
+
+/* ══════════════════════════════════════════
+   两栏(无右边栏):channel 列表 / channel 作品列表
+   ══════════════════════════════════════════ */
+
+.wiki-layout--two {
+    grid-template-columns: 200px 1fr;
+    grid-template-areas: 'left main';
+}
+
+@media (max-width: 768px) {
+    .wiki-layout--two {
+        grid-template-columns: 1fr;
+        grid-template-areas: 'main' 'left';
+    }
+}
+
+/* ══════════════════════════════════════════
+   进度条(x-ui.progress-bar)
+   ══════════════════════════════════════════ */
+
+.ui-progress {
+    display: flex;
+    align-items: center;
+    gap: 0.4rem;
+    min-width: 0;
+}
+
+.ui-progress__bar {
+    flex: 1;
+    height: 6px;
+}
+
+.ui-progress__label {
+    font-size: 0.7rem;
+    color: var(--tblr-secondary);
+    white-space: nowrap;
+    min-width: 2.2em;
+    text-align: right;
+}
+
+.card-book__progress {
+    margin-top: 0.4rem;
+}
+
+.tipitaka-toc-summary {
+    font-size: 0.75rem;
+    color: var(--tblr-secondary);
+    margin-left: 0.5rem;
+}
+
+.wiki-toc-list .toc-disabled .toc-title {
+    color: var(--tblr-disabled-color, #aab);
+}
+
+.tipitaka-work-pali {
+    color: var(--tblr-secondary);
+    font-size: 0.875rem;
+}
+
+.tipitaka-pager {
+    display: flex;
+    justify-content: center;
+    margin-top: 1rem;
+}
+
+/* ══════════════════════════════════════════
+   译本:首页磁贴 / 列表行 / channel 页头
+   ══════════════════════════════════════════ */
+
+.tipitaka-channel-tiles {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 0.75rem;
+}
+
+.tipitaka-channel-tile,
+.tipitaka-channel-row {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    color: var(--tblr-body-color);
+    text-decoration: none;
+}
+
+.tipitaka-channel-tile {
+    padding: 0.7rem 0.8rem;
+    border: 1px solid var(--tblr-border-color);
+    border-radius: var(--tblr-border-radius);
+    background: var(--tblr-bg-surface);
+}
+
+.tipitaka-channel-tile:hover {
+    border-color: var(--tblr-primary);
+    text-decoration: none;
+}
+
+.tipitaka-channel-tile__info,
+.tipitaka-channel-row__info {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    flex: 1;
+}
+
+.tipitaka-channel-tile__name,
+.tipitaka-channel-row__name {
+    font-weight: 600;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.tipitaka-channel-tile__meta,
+.tipitaka-channel-row__meta,
+.tipitaka-channel-row__count {
+    font-size: 0.75rem;
+    color: var(--tblr-secondary);
+}
+
+.tipitaka-channel-row {
+    padding: 0.65rem 0.25rem;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
+
+.tipitaka-channel-row:last-child {
+    border-bottom: 0;
+}
+
+.tipitaka-channel-row:hover {
+    background: var(--tblr-bg-surface-secondary);
+    text-decoration: none;
+}
+
+.tipitaka-channel-head {
+    margin-top: 1.5rem;
+}
+
+.tipitaka-channel-head__main {
+    display: flex;
+    align-items: center;
+    gap: 1rem;
+}
+
+.tipitaka-channel-head__name {
+    margin: 0;
+    font-size: 1.35rem;
+}
+
+.tipitaka-channel-head__meta {
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+}
+
+.tipitaka-channel-tabs {
+    display: flex;
+    gap: 1.25rem;
+    border-bottom: 1px solid var(--tblr-border-color);
+    margin-top: 1rem;
+}
+
+.tipitaka-channel-tabs a {
+    padding: 0.5rem 0;
+    color: var(--tblr-secondary);
+    border-bottom: 2px solid transparent;
+    text-decoration: none;
+}
+
+.tipitaka-channel-tabs a.active {
+    color: var(--tblr-primary);
+    border-color: var(--tblr-primary);
+    font-weight: 600;
+}
+
+/* ══════════════════════════════════════════
+   channel 分类目录树(参考 documents/tipitaka_org.html)
+   ══════════════════════════════════════════ */
+
+.tipitaka-tree-bar {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    align-items: center;
+    justify-content: space-between;
+    margin: 1rem 0 0;
+}
+
+.tipitaka-tree-bar__actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+
+.tipitaka-tree {
+    padding: 1rem 0 3rem;
+}
+
+.tipitaka-tree ul {
+    list-style: none;
+    margin: 0;
+    padding-left: 26px;
+}
+
+.tipitaka-tree ul.tipitaka-tree__root {
+    padding-left: 0;
+}
+
+.tipitaka-tree li[data-node] {
+    position: relative;
+    margin: 8px 0;
+}
+
+.tipitaka-tree ul ul > li[data-node]::before {
+    content: '';
+    position: absolute;
+    left: -16px;
+    top: 0;
+    bottom: -8px;
+    border-left: 1px solid var(--tblr-border-color);
+}
+
+.tipitaka-tree ul ul > li[data-node]:last-child::before {
+    bottom: auto;
+    height: 22px;
+}
+
+.tipitaka-tree ul ul > li[data-node]::after {
+    content: '';
+    position: absolute;
+    left: -16px;
+    top: 22px;
+    width: 14px;
+    border-top: 1px solid var(--tblr-border-color);
+}
+
+.tipitaka-tree__node {
+    display: inline-flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 10px;
+    padding: 7px 12px;
+    border-radius: 8px;
+    border: 1px solid var(--tblr-border-color);
+    background: var(--tblr-bg-surface);
+}
+
+.tipitaka-tree__toggle {
+    width: 20px;
+    height: 20px;
+    padding: 0;
+    border: 0;
+    background: none;
+    color: var(--tblr-secondary);
+    font-size: 12px;
+    cursor: pointer;
+}
+
+.tipitaka-tree__name {
+    font-weight: 700;
+}
+
+.tipitaka-tree__count {
+    font-size: 12px;
+    color: var(--tblr-secondary);
+}
+
+.tipitaka-tree__progress {
+    width: 110px;
+}
+
+.tipitaka-tree__level-0 > .tipitaka-tree__node {
+    background: var(--tblr-dark, #1d273b);
+    border-color: var(--tblr-dark, #1d273b);
+    color: #fff;
+}
+
+.tipitaka-tree__level-0 > .tipitaka-tree__node .tipitaka-tree__toggle,
+.tipitaka-tree__level-0 > .tipitaka-tree__node .tipitaka-tree__count,
+.tipitaka-tree__level-0 > .tipitaka-tree__node .ui-progress__label {
+    color: #cfd6e0;
+}
+
+.tipitaka-tree__level-1 > .tipitaka-tree__node {
+    background: #f3e2c7;
+    border-color: #c4761c;
+}
+
+.tipitaka-tree__level-2 > .tipitaka-tree__node {
+    background: var(--tblr-primary-lt, #e8f0fa);
+    border-color: var(--tblr-primary);
+}
+
+.tipitaka-tree__cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+    gap: 10px;
+    padding: 8px 0 4px 26px;
+}
+
+.tipitaka-tree__root > li > .tipitaka-tree__cards:only-of-type {
+    padding-left: 0;
+}
+
+.tipitaka-tree__card {
+    background: var(--tblr-bg-surface);
+    border: 1px solid var(--tblr-border-color);
+    border-radius: 8px;
+    padding: 8px 12px;
+}
+
+.tipitaka-tree__card-title {
+    font-weight: 600;
+    font-size: 13px;
+    margin-bottom: 4px;
+}
+
+.tipitaka-tree__card ol {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.tipitaka-tree__card li {
+    border-top: 1px dotted var(--tblr-border-color);
+}
+
+.tipitaka-tree__card li:first-child {
+    border-top: 0;
+}
+
+.tipitaka-tree__work {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    padding: 4px 0;
+    font-size: 13px;
+    color: var(--tblr-body-color);
+    text-decoration: none;
+}
+
+a.tipitaka-tree__work:hover {
+    color: var(--tblr-primary);
+    text-decoration: none;
+}
+
+.tipitaka-tree__work-title {
+    flex: 1;
+    min-width: 0;
+    overflow-wrap: anywhere;
+}
+
+.tipitaka-tree__work-progress {
+    width: 90px;
+    flex: none;
+}
+
+.tipitaka-tree__card li.is-missing .tipitaka-tree__work {
+    color: var(--tblr-disabled-color, #aab);
+}
+
+.tipitaka-tree__work-missing {
+    font-size: 11px;
+}
+
+.tipitaka-tree li.collapsed > ul,
+.tipitaka-tree li.collapsed > .tipitaka-tree__cards {
+    display: none;
+}
+
+/* ══════════════════════════════════════════
+   三藏首页 hero:底图是深色檀木(hero-tipitaka.jpg),
+   Tabler 给 h1 单独设了标题色,会盖掉 .hero-content 继承的白色,这里显式改回白字
+   ══════════════════════════════════════════ */
+
+.hero-section--dark .hero-title,
+.hero-section--dark .hero-subtitle {
+    color: #fff;
+}
+
+.tipitaka-channel-row__progress {
+    width: 140px;
+    flex: none;
+}
+
+/* 分类页左栏:顶级加粗;当前路径上的祖先加粗;下一级字号更小、颜色更浅、缩进并带竖线与圆点 */
+.tipitaka-side-tree > li > a {
+    font-weight: 600;
+}
+
+/* 叠两个类:.wiki-cat-list 的 margin/padding: 0 加载在后,同优先级会盖掉缩进 */
+.wiki-cat-list.tipitaka-side-tree__children {
+    margin: 2px 0 6px 0.75rem;
+    padding-left: 0.5rem;
+    border-left: 2px solid var(--tblr-border-color);
+}
+
+.tipitaka-side-tree .tipitaka-side-tree__children > li > a {
+    font-weight: 400;
+    font-size: 0.75rem;
+    color: var(--tblr-secondary);
+    padding: 3px 8px;
+}
+
+.tipitaka-side-tree .tipitaka-side-tree__children > li > a::before {
+    content: '';
+    flex: none;
+    width: 4px;
+    height: 4px;
+    margin-right: 6px;
+    border-radius: 50%;
+    background: currentColor;
+    opacity: 0.6;
+}
+
+.tipitaka-side-tree .tipitaka-side-tree__children > li > a.is-open {
+    font-weight: 600;
+    color: var(--tblr-body-color);
+}
+
+.tipitaka-side-tree .tipitaka-side-tree__children > li > a.active {
+    color: var(--tblr-primary);
+    font-weight: 600;
+}

+ 3 - 0
api-v13/resources/lang/en/labels.php

@@ -1,9 +1,12 @@
 <?php
 <?php
+
 return [
 return [
     'home' => 'Home',
     'home' => 'Home',
     'translation' => 'Translation',
     'translation' => 'Translation',
     'original' => 'Original',
     'original' => 'Original',
     'nissaya' => 'Nissaya',
     'nissaya' => 'Nissaya',
+    'commentary' => 'Commentary',
+    'similar' => 'Parallels',
 
 
     'script_convertor' => 'Pāli Script Converter',
     'script_convertor' => 'Pāli Script Converter',
     'input_script' => 'Input Script',
     'input_script' => 'Input Script',

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

@@ -179,4 +179,30 @@ return [
     // reader: 译文评估标注(reader.js 弹出层)
     // reader: 译文评估标注(reader.js 弹出层)
     'assessment_title' => 'Assessment',
     'assessment_title' => 'Assessment',
     'assessment_method' => 'Method: :method',
     'assessment_method' => 'Method: :method',
+
+    'pāḷi' => 'Pāḷi',
+    'aṭṭhakathā' => 'Aṭṭhakathā',
+    'ṭīkā' => 'Ṭīkā',
+
+    // Tipiṭaka: browse by channel, work detail, channel catalogue tree
+    'tipitaka_hero_subtitle' => 'Pāḷi · translations · commentaries · Nissaya — browse by category or by translation',
+    'channels' => 'Translations',
+    'channel' => 'Translation',
+    'work_unit' => 'works',
+    'channel_unit' => 'translations',
+    'version_unit' => 'versions',
+    'page_of' => 'page :page of :last',
+    'sort_progress' => 'Progress',
+    'sort_canon' => 'Canonical order',
+    'category_channels' => 'Translations in this category',
+    'progress' => 'Progress',
+    'related_works' => 'Related works',
+    'all_works' => 'All works',
+    'by_category' => 'By category',
+    'no_channels' => 'No translations yet',
+    'collapse_to_cards' => 'Collapse to groups',
+    'only_included' => 'Included only',
+    'show_missing' => 'Show missing works',
+    'no_works_in_category' => 'This translation has no works in this category',
+    'not_included' => 'not included',
 ];
 ];

+ 3 - 0
api-v13/resources/lang/zh-Hans/labels.php

@@ -1,9 +1,12 @@
 <?php
 <?php
+
 return [
 return [
     'home' => '首页',
     'home' => '首页',
     'translation' => '译文',
     'translation' => '译文',
     'original' => '原文',
     'original' => '原文',
     'nissaya' => '缅文逐词',
     'nissaya' => '缅文逐词',
+    'commentary' => '义注',
+    'similar' => '相似段落',
 
 
     'script_convertor' => '巴利文字转换器',
     'script_convertor' => '巴利文字转换器',
     'input_script' => '输入文字',
     'input_script' => '输入文字',

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

@@ -182,4 +182,26 @@ return [
     // reader: 译文评估标注(reader.js 弹出层)
     // reader: 译文评估标注(reader.js 弹出层)
     'assessment_title' => '译文评估',
     'assessment_title' => '译文评估',
     'assessment_method' => '方法::method',
     'assessment_method' => '方法::method',
+
+    // 三藏:按译本浏览、作品详情、channel 目录树
+    'tipitaka_hero_subtitle' => '原文 · 译文 · 义注 · Nissaya,按分类或按译本浏览',
+    'channels' => '译本',
+    'channel' => '译本',
+    'work_unit' => '部',
+    'channel_unit' => '个译本',
+    'version_unit' => '个版本',
+    'page_of' => '第 :page / :last 页',
+    'sort_progress' => '完成度',
+    'sort_canon' => '经典顺序',
+    'category_channels' => '本类译本',
+    'progress' => '完成度',
+    'related_works' => '相关作品',
+    'all_works' => '全部作品',
+    'by_category' => '按分类',
+    'no_channels' => '暂无译本',
+    'collapse_to_cards' => '折叠到各部',
+    'only_included' => '只看已收录',
+    'show_missing' => '显示未收录作品',
+    'no_works_in_category' => '该译本在此分类下没有作品',
+    'not_included' => '未收录',
 ];
 ];

+ 7 - 2
api-v13/resources/views/components/ui/card-book.blade.php

@@ -5,12 +5,13 @@
        $book — 书籍数据数组,包含:
        $book — 书籍数据数组,包含:
                id, title, author, cover(可选),
                id, title, author, cover(可选),
                cover_gradient(可选), publisher(object, 可选),
                cover_gradient(可选), publisher(object, 可选),
-               language(可选), type(可选)
+               language(可选), type(可选),
+               url(可选,默认旧的 library.tipitaka.show), progress(可选,0–100,给出时显示进度条)
 --}}
 --}}
 @props(['book'])
 @props(['book'])
 
 
 <div class="card-book">
 <div class="card-book">
-    <a href="{{ route('library.tipitaka.show', $book['id']) }}" class="card-book__link">
+    <a href="{{ $book['url'] ?? route('library.tipitaka.show', $book['id']) }}" class="card-book__link">
 
 
         {{-- 封面 --}}
         {{-- 封面 --}}
         <x-ui.book-cover
         <x-ui.book-cover
@@ -49,6 +50,10 @@
                 <span class="card-book__badge">§{{ $book['completed_chapters'] }}</span>
                 <span class="card-book__badge">§{{ $book['completed_chapters'] }}</span>
                 @endif
                 @endif
             </div>
             </div>
+
+            @isset($book['progress'])
+            <x-ui.progress-bar :value="$book['progress']" class="card-book__progress" />
+            @endisset
         </div>
         </div>
 
 
     </a>
     </a>

+ 24 - 0
api-v13/resources/views/components/ui/progress-bar.blade.php

@@ -0,0 +1,24 @@
+{{-- resources/views/components/ui/progress-bar.blade.php
+     细进度条 + 百分比。≥80 绿、≥30 黄、其余红(与作品目录页的章节进度一致)。
+     Props:
+       $value     — 0–100
+       $showLabel — 是否在右侧显示百分比(默认 true)
+--}}
+@props([
+'value' => 0,
+'showLabel' => true,
+])
+
+@php
+$value = max(0, min(100, (int) $value));
+$color = $value >= 80 ? 'bg-green' : ($value >= 30 ? 'bg-yellow' : 'bg-red');
+@endphp
+
+<div {{ $attributes->merge(['class' => 'ui-progress']) }} title="{{ $value }}%">
+    <div class="progress ui-progress__bar">
+        <div class="progress-bar {{ $color }}" style="width: {{ $value }}%"></div>
+    </div>
+    @if($showLabel)
+    <span class="ui-progress__label">{{ $value }}%</span>
+    @endif
+</div>

+ 4 - 1
api-v13/resources/views/components/wiki/search-result-card.blade.php

@@ -10,7 +10,10 @@
             {{ route('library.wiki.show', [$result['lang'], $result['id']]) }}
             {{ route('library.wiki.show', [$result['lang'], $result['id']]) }}
             @endif
             @endif
             @if($result['type']==='tipitaka')
             @if($result['type']==='tipitaka')
-            {{ route('library.tipitaka.read', ['id' => $result['chapter']['id'], 'channel' => $result['chapter']['channel']]) }}
+            {{-- channel 是从 OpenSearch 文档 id 里拆出来的,不是 uuid 时生成新路由会抛异常,退回旧地址 --}}
+            {{ \Illuminate\Support\Str::isUuid($result['chapter']['channel'] ?? null)
+                ? route('library.tipitaka.channels.chapter', ['channel' => $result['chapter']['channel'], 'chapter' => $result['chapter']['id']])
+                : url('/library/tipitaka/'.$result['chapter']['id'].'/read') }}
             @endif
             @endif
             ">
             ">
             {{ $result['title'] }}
             {{ $result['title'] }}

+ 11 - 5
api-v13/resources/views/library/book/read.blade.php

@@ -164,14 +164,14 @@
     <div class="offcanvas-body">
     <div class="offcanvas-body">
         <div class="list-group list-group-flush">
         <div class="list-group list-group-flush">
             @foreach($channels as $channel)
             @foreach($channels as $channel)
-            @php $isActive = request('channel') == $channel['id']; @endphp
+            @php $isActive = ($channelId ?? request('channel')) == $channel['id']; @endphp
             @if($isActive)
             @if($isActive)
             <div class="list-group-item active pe-none">
             <div class="list-group-item active pe-none">
                 <div class="fw-bold">{{ $channel['name'] }}</div>
                 <div class="fw-bold">{{ $channel['name'] }}</div>
                 <small class="opacity-75">{{ __('language.' . $channel['lang']) }}</small>
                 <small class="opacity-75">{{ __('language.' . $channel['lang']) }}</small>
             </div>
             </div>
             @else
             @else
-            <a href="{{ request()->fullUrlWithQuery(['channel' => $channel['id']]) }}"
+            <a href="{{ $channel['url'] ?? request()->fullUrlWithQuery(['channel' => $channel['id']]) }}"
                 class="list-group-item list-group-item-action">
                 class="list-group-item list-group-item-action">
                 <div class="fw-bold">{{ $channel['name'] }}</div>
                 <div class="fw-bold">{{ $channel['name'] }}</div>
                 <small class="text-muted">{{ __('language.' . $channel['lang']) }}</small>
                 <small class="text-muted">{{ __('language.' . $channel['lang']) }}</small>
@@ -201,7 +201,13 @@
         {{-- TOC 侧边栏(tablet+) --}}
         {{-- TOC 侧边栏(tablet+) --}}
         <div class="toc-sidebar card">
         <div class="toc-sidebar card">
             <div class="card-body">
             <div class="card-body">
-                <h5>{{ $book['book_title'] }}</h5>
+                <h5>
+                    @if(!empty($book['work_url']))
+                    <a href="{{ $book['work_url'] }}" class="text-reset">{{ $book['book_title'] }}</a>
+                    @else
+                    {{ $book['book_title'] }}
+                    @endif
+                </h5>
                 @include('library.book.toc', ['toc' => $book['toc'] ?? []])
                 @include('library.book.toc', ['toc' => $book['toc'] ?? []])
             </div>
             </div>
         </div>
         </div>
@@ -255,7 +261,7 @@
                         <li class="page-item page-prev">
                         <li class="page-item page-prev">
                             <a class="page-link" href="{{ isset($anthologyId)
                             <a class="page-link" href="{{ isset($anthologyId)
                             ? route('library.anthology.read', ['anthology' => $anthologyId, 'article' => $book['pagination']['prev']['id'], 'channel' => request('channel')])
                             ? route('library.anthology.read', ['anthology' => $anthologyId, 'article' => $book['pagination']['prev']['id'], 'channel' => request('channel')])
-                            : route('library.tipitaka.read', ['id' => $book['pagination']['prev']['id'], 'channel' => request('channel')]) }}">
+                            : route('library.tipitaka.channels.chapter', ['channel' => $channelId, 'chapter' => $book['pagination']['prev']['id']]) }}">
                                 <div class="row align-items-center">
                                 <div class="row align-items-center">
                                     <div class="col-auto">
                                     <div class="col-auto">
                                         <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                                         <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -274,7 +280,7 @@
                         <li class="page-item page-next">
                         <li class="page-item page-next">
                             <a class="page-link" href="{{ isset($anthologyId)
                             <a class="page-link" href="{{ isset($anthologyId)
                             ? route('library.anthology.read', ['anthology' => $anthologyId, 'article' => $book['pagination']['next']['id'], 'channel' => request('channel')])
                             ? route('library.anthology.read', ['anthology' => $anthologyId, 'article' => $book['pagination']['next']['id'], 'channel' => request('channel')])
-                            : route('library.tipitaka.read', ['id' => $book['pagination']['next']['id'], 'channel' => request('channel')]) }}">
+                            : route('library.tipitaka.channels.chapter', ['channel' => $channelId, 'chapter' => $book['pagination']['next']['id']]) }}">
                                 <div class="row align-items-center">
                                 <div class="row align-items-center">
                                     <div class="col">
                                     <div class="col">
                                         <div class="page-item-subtitle">{{ __('library.next_article') }}</div>
                                         <div class="page-item-subtitle">{{ __('library.next_article') }}</div>

+ 2 - 2
api-v13/resources/views/library/book/toc.blade.php

@@ -1,6 +1,6 @@
 {{-- resources/views/library/book/toc.blade.php
 {{-- resources/views/library/book/toc.blade.php
      TOC 列表局部视图,在 offcanvas 和侧边栏中复用。
      TOC 列表局部视图,在 offcanvas 和侧边栏中复用。
-     变量:$toc array,$anthologyId(可选)
+     变量:$toc array,$anthologyId(可选,文集阅读),$channelId(可选,三藏阅读)
      每个 item:active(当前章节高亮)、hide(折叠模式下隐藏的节点)。
      每个 item:active(当前章节高亮)、hide(折叠模式下隐藏的节点)。
      折叠/展开由 .toc-tree 上的 .toc-expanded 类切换(见 reader.js / reader.css)。
      折叠/展开由 .toc-tree 上的 .toc-expanded 类切换(见 reader.js / reader.css)。
 --}}
 --}}
@@ -26,7 +26,7 @@
         <a href="{{ route('library.anthology.read', ['anthology' => $anthologyId, 'article' => $item['id'], 'channel' => request('channel')]) }}"
         <a href="{{ route('library.anthology.read', ['anthology' => $anthologyId, 'article' => $item['id'], 'channel' => request('channel')]) }}"
             title="{{ $item['title'] }}">{{ $item['title'] }}</a>
             title="{{ $item['title'] }}">{{ $item['title'] }}</a>
         @else
         @else
-        <a href="{{ route('library.tipitaka.read', ['id' => $item['id'], 'channel' => request('channel')]) }}"
+        <a href="{{ route('library.tipitaka.channels.chapter', ['channel' => $item['channel'] ?? $channelId, 'chapter' => $item['id']]) }}"
             title="{{ $item['title'] }}">{{ $item['title'] }}</a>
             title="{{ $item['title'] }}">{{ $item['title'] }}</a>
         @endif
         @endif
         @else
         @else

+ 12 - 0
api-v13/resources/views/library/tipitaka/_side-tree.blade.php

@@ -0,0 +1,12 @@
+{{-- 分类页左栏的分类树(递归)。变量:$nodes(TipitakaController::sideTree()) --}}
+<ul class="wiki-cat-list tipitaka-side-tree {{ ($nested ?? false) ? 'tipitaka-side-tree__children' : '' }}">
+    @foreach($nodes as $node)
+    <li>
+        <a href="{{ route('library.tipitaka.category', ['id' => $node['id']]) }}"
+            class="{{ $node['active'] ? 'active' : '' }} {{ $node['open'] ? 'is-open' : '' }}">{{ $node['name'] }}</a>
+        @if(!empty($node['children']))
+        @include('library.tipitaka._side-tree', ['nodes' => $node['children'], 'nested' => true])
+        @endif
+    </li>
+    @endforeach
+</ul>

+ 40 - 110
api-v13/resources/views/library/tipitaka/category.blade.php

@@ -1,6 +1,6 @@
 {{-- resources/views/library/tipitaka/category.blade.php
 {{-- resources/views/library/tipitaka/category.blade.php
-     Tipitaka 分类页 / 首页($currentCategory['id'] 为 null 时是首页)
-     三栏:左边栏(大分类)/ 中间(子分类+过滤器+书籍列表)/ 右边栏(推荐+译者)
+     三藏分类页(不限 channel)。
+     三栏:左边栏(分类树,沿当前路径展开)/ 中间(子分类 + 过滤器 + 版本列表,分页)/ 右边栏(最近更新 + 本类译本)
 --}}
 --}}
 @extends('layouts.library')
 @extends('layouts.library')
 
 
@@ -14,7 +14,6 @@
 <li class="breadcrumb-item">
 <li class="breadcrumb-item">
     <a href="{{ route('library.home') }}">{{ __('library.home') }}</a>
     <a href="{{ route('library.home') }}">{{ __('library.home') }}</a>
 </li>
 </li>
-@if($currentCategory['id'])
 <li class="breadcrumb-item">
 <li class="breadcrumb-item">
     <a href="{{ route('library.tipitaka.index') }}">{{ __('library.tipitaka') }}</a>
     <a href="{{ route('library.tipitaka.index') }}">{{ __('library.tipitaka') }}</a>
 </li>
 </li>
@@ -23,53 +22,27 @@
 <li class="breadcrumb-item active">{{ $breadcrumb['name'] }}</li>
 <li class="breadcrumb-item active">{{ $breadcrumb['name'] }}</li>
 @else
 @else
 <li class="breadcrumb-item">
 <li class="breadcrumb-item">
-    <a href="{{ route('library.tipitaka.category', array_filter(['id' => $breadcrumb['id'], 'channel' => request('channel')])) }}">
-        {{ $breadcrumb['name'] }}
-    </a>
+    <a href="{{ route('library.tipitaka.category', ['id' => $breadcrumb['id']]) }}">{{ $breadcrumb['name'] }}</a>
 </li>
 </li>
 @endif
 @endif
 @endforeach
 @endforeach
-@else
-<li class="breadcrumb-item active">{{ __('library.tipitaka') }}</li>
-@endif
 @endsection
 @endsection
 
 
 @section('content')
 @section('content')
 <div class="page-body">
 <div class="page-body">
     <div class="container-xl wiki-layout">
     <div class="container-xl wiki-layout">
 
 
-        {{-- ══ 左边栏:大分类导航 ══ --}}
+        {{-- ══ 左边栏:分类树,沿当前路径展开 ══ --}}
         <aside class="wiki-sidebar-left">
         <aside class="wiki-sidebar-left">
-
-
-
-            {{-- 大分类 --}}
             <div class="wiki-sidebar-section">
             <div class="wiki-sidebar-section">
                 <div class="wiki-sidebar-title">{{ __('library.categories') }}</div>
                 <div class="wiki-sidebar-title">{{ __('library.categories') }}</div>
-                <ul class="wiki-cat-list">
-                    <li>
-                        <a href="{{ route('library.tipitaka.index', array_filter(['channel' => request('channel')])) }}"
-                            class="{{ !$currentCategory['id'] ? 'active' : '' }}">
-                            {{ __('library.all') }}
-                        </a>
-                    </li>
-                    @foreach($types as $type)
-                    <li>
-                        <a href="{{ route('library.tipitaka.category', array_filter(['id' => $type['id'], 'channel' => request('channel')])) }}"
-                            class="{{ ($currentCategory['id'] ?? null) == $type['id'] ? 'active' : '' }}">
-                            {{ $type['name'] }}
-                        </a>
-                    </li>
-                    @endforeach
-                </ul>
+                @include('library.tipitaka._side-tree', ['nodes' => $sideTree])
             </div>
             </div>
-
         </aside>
         </aside>
 
 
         {{-- ══ 主内容 ══ --}}
         {{-- ══ 主内容 ══ --}}
         <main class="wiki-main">
         <main class="wiki-main">
 
 
-            {{-- 搜索框 --}}
             <div>
             <div>
                 <x-ui.search-input
                 <x-ui.search-input
                     :value="request('q')"
                     :value="request('q')"
@@ -77,21 +50,22 @@
                     :hidden-fields="['resource_type' => 'tipitaka']" />
                     :hidden-fields="['resource_type' => 'tipitaka']" />
             </div>
             </div>
 
 
+            {{-- 子分类;末级分类列出其下作品,点开进作品版本页 --}}
+            @if(count($subCategories) > 0)
+            <div class="wiki-card tipitaka-subcategories">
+                <div class="tipitaka-subcategory-grid">
+                    @foreach($subCategories as $sub)
+                    <a href="{{ $sub['url'] }}" class="tipitaka-subcategory-item">
+                        <i class="ti {{ $sub['is_work'] ? 'ti-book' : 'ti-folder' }} tipitaka-subcategory-icon" aria-hidden="true"></i>
+                        <span class="tipitaka-subcategory-name">{{ $sub['name'] }}</span>
+                    </a>
+                    @endforeach
+                </div>
+            </div>
+            @endif
 
 
-
-            {{-- 2. 过滤器区 --}}
+            {{-- 过滤器 --}}
             <div class="wiki-card tipitaka-filters">
             <div class="wiki-card tipitaka-filters">
-
-                @if($currentChannel)
-                {{-- 当前频道 --}}
-                <div class="tipitaka-filter-row">
-                    <span class="tipitaka-filter-label">{{ __('library.current_channel') }}</span>
-                    <div class="tipitaka-filter-pills">
-                        <span class="tipitaka-pill tipitaka-pill--active">{{ $currentChannel['name'] }}</span>
-                    </div>
-                </div>
-                @else
-                {{-- 类型 --}}
                 <div class="tipitaka-filter-row">
                 <div class="tipitaka-filter-row">
                     <span class="tipitaka-filter-label">{{ __('library.type') }}</span>
                     <span class="tipitaka-filter-label">{{ __('library.type') }}</span>
                     <div class="tipitaka-filter-pills">
                     <div class="tipitaka-filter-pills">
@@ -99,98 +73,57 @@
                         <a href="{{ request()->fullUrlWithQuery(['type' => $opt['value'], 'page' => null]) }}"
                         <a href="{{ request()->fullUrlWithQuery(['type' => $opt['value'], 'page' => null]) }}"
                             class="tipitaka-pill {{ $selected['type'] === $opt['value'] ? 'tipitaka-pill--active' : '' }}">
                             class="tipitaka-pill {{ $selected['type'] === $opt['value'] ? 'tipitaka-pill--active' : '' }}">
                             {{ $opt['label'] }}
                             {{ $opt['label'] }}
-                            @if($opt['count'] > 0)
                             <span class="tipitaka-pill-count">{{ $opt['count'] }}</span>
                             <span class="tipitaka-pill-count">{{ $opt['count'] }}</span>
-                            @endif
                         </a>
                         </a>
                         @endforeach
                         @endforeach
                     </div>
                     </div>
                 </div>
                 </div>
 
 
-                {{-- 语言 --}}
                 <div class="tipitaka-filter-row">
                 <div class="tipitaka-filter-row">
                     <span class="tipitaka-filter-label">{{ __('library.language') }}</span>
                     <span class="tipitaka-filter-label">{{ __('library.language') }}</span>
                     <div class="tipitaka-filter-pills">
                     <div class="tipitaka-filter-pills">
                         @foreach($filterOptions['languages'] as $opt)
                         @foreach($filterOptions['languages'] as $opt)
-                        <a href="{{ request()->fullUrlWithQuery(['lang' => $opt['value'], 'page' => null]) }}"
+                        <a href="{{ request()->fullUrlWithQuery(['channel_lang' => $opt['value'], 'page' => null]) }}"
                             class="tipitaka-pill {{ $selected['lang'] === $opt['value'] ? 'tipitaka-pill--active' : '' }}">
                             class="tipitaka-pill {{ $selected['lang'] === $opt['value'] ? 'tipitaka-pill--active' : '' }}">
                             {{ $opt['label'] }}
                             {{ $opt['label'] }}
-                            @if($opt['count'] > 0)
                             <span class="tipitaka-pill-count">{{ $opt['count'] }}</span>
                             <span class="tipitaka-pill-count">{{ $opt['count'] }}</span>
-                            @endif
                         </a>
                         </a>
                         @endforeach
                         @endforeach
                     </div>
                     </div>
                 </div>
                 </div>
-                @endif
-
-                {{-- 作者 --}}
-                <div class="tipitaka-filter-row" style="display:none">
-                    <span class="tipitaka-filter-label">{{ __('library.author') }}</span>
-                    <select class="form-select form-select-sm tipitaka-author-select"
-                        onchange="window.location=this.value">
-                        @foreach($filterOptions['authors'] as $opt)
-                        <option value="{{ request()->fullUrlWithQuery(['author' => $opt['value'], 'page' => null]) }}"
-                            {{ $selected['author'] === $opt['value'] ? 'selected' : '' }}>
-                            {{ $opt['label'] }}@if($opt['count'] > 0)({{ $opt['count'] }})@endif
-                        </option>
-                        @endforeach
-                    </select>
-                </div>
 
 
-                {{-- 清除过滤器 --}}
-                @php
-                $hasFilter = $selected['type'] !== 'all'
-                || $selected['lang'] !== 'all'
-                || $selected['author'] !== 'all'
-                || $selected['channel'] !== 'all';
-                @endphp
-                @if($hasFilter)
+                @if($selected['type'] !== 'all' || $selected['lang'] !== 'all')
                 <div class="tipitaka-filter-clear">
                 <div class="tipitaka-filter-clear">
-                    <a href="{{ rtrim(request()->fullUrlWithQuery(['type' => null, 'lang' => null, 'author' => null, 'channel' => null, 'page' => null]), '?') }}"
+                    <a href="{{ route('library.tipitaka.category', ['id' => $currentCategory['id']]) }}"
                         class="tipitaka-clear-btn">
                         class="tipitaka-clear-btn">
                         <i class="ti ti-x"></i> {{ __('library.clear_filters') }}
                         <i class="ti ti-x"></i> {{ __('library.clear_filters') }}
                     </a>
                     </a>
                 </div>
                 </div>
                 @endif
                 @endif
-
             </div>
             </div>
 
 
-            {{-- 1. 子分类 --}}
-            @if(count($subCategories) > 0)
-            <div class="wiki-card tipitaka-subcategories">
-                <div class="tipitaka-subcategory-grid">
-                    @foreach($subCategories as $sub)
-                    <a href="{{ route('library.tipitaka.category', array_filter(['id' => $sub['id'], 'book' => $sub['book'] ?? null, 'channel' => request('channel')])) }}"
-                        class="tipitaka-subcategory-item">
-                        <i class="ti {{ !empty($sub['book']) ? 'ti-book' : 'ti-folder' }} tipitaka-subcategory-icon" aria-hidden="true"></i>
-                        <span class="tipitaka-subcategory-name">{{ $sub['name'] }}</span>
-                    </a>
-                    @endforeach
-                </div>
-            </div>
-            @endif
 
 
-            {{-- 3. 排序 + 结果数 --}}
+            {{-- 排序 + 结果数 --}}
             <div class="tipitaka-sort-bar">
             <div class="tipitaka-sort-bar">
                 <span class="tipitaka-sort-bar__count">
                 <span class="tipitaka-sort-bar__count">
-                    {{ __('library.total_prefix') }} <strong>{{ $totalCount }}</strong> {{ __('library.book_unit') }}
+                    {{ __('library.total_prefix') }} <strong>{{ $totalCount }}</strong> {{ __('library.version_unit') }}
                 </span>
                 </span>
                 <div class="tipitaka-sort-bar__right">
                 <div class="tipitaka-sort-bar__right">
                     <span class="tipitaka-sort-bar__label">{{ __('library.sort') }}</span>
                     <span class="tipitaka-sort-bar__label">{{ __('library.sort') }}</span>
                     <select class="form-select form-select-sm tipitaka-sort-select"
                     <select class="form-select form-select-sm tipitaka-sort-select"
                         onchange="window.location=this.value">
                         onchange="window.location=this.value">
                         @foreach($sortList as $sort)
                         @foreach($sortList as $sort)
-                        <option value="{{ request()->fullUrlWithQuery(['sort' => $sort['key']]) }}"
+                        <option value="{{ request()->fullUrlWithQuery(['sort' => $sort['key'], 'page' => null]) }}"
                             {{ $selected['sort'] === $sort['key'] ? 'selected' : '' }}>{{ $sort['label'] }}</option>
                             {{ $selected['sort'] === $sort['key'] ? 'selected' : '' }}>{{ $sort['label'] }}</option>
                         @endforeach
                         @endforeach
                     </select>
                     </select>
                 </div>
                 </div>
             </div>
             </div>
 
 
-            {{-- 4. 书籍列表 --}}
+            {{-- 版本列表 --}}
             <div class="wiki-card">
             <div class="wiki-card">
                 <x-ui.book-grid :books="$categoryBooks" />
                 <x-ui.book-grid :books="$categoryBooks" />
+                <div class="tipitaka-pager">{{ $paginator->links('library.anthology.pagination') }}</div>
             </div>
             </div>
 
 
         </main>
         </main>
@@ -198,16 +131,15 @@
         {{-- ══ 右边栏 ══ --}}
         {{-- ══ 右边栏 ══ --}}
         <aside class="wiki-sidebar-right">
         <aside class="wiki-sidebar-right">
 
 
-            {{-- 本周推荐 --}}
-            @if(!empty($recommended))
+            @if(!empty($recentBooks))
             <div class="wiki-sidebar-section">
             <div class="wiki-sidebar-section">
-                <div class="wiki-sidebar-title">{{ __('library.weekly_picks') }}</div>
+                <div class="wiki-sidebar-title">{{ __('library.recent_updates') }}</div>
                 <ul class="wiki-cat-list">
                 <ul class="wiki-cat-list">
-                    @foreach($recommended as $item)
+                    @foreach($recentBooks as $item)
                     <li>
                     <li>
-                        <a href="{{ route('library.tipitaka.show', $item['id']) }}">
+                        <a href="{{ $item['url'] }}">
                             {{ $item['title'] }}
                             {{ $item['title'] }}
-                            <span class="wiki-cat-count">{{ $item['category'] }}</span>
+                            <span class="wiki-cat-count">{{ $item['author'] }}</span>
                         </a>
                         </a>
                     </li>
                     </li>
                     @endforeach
                     @endforeach
@@ -215,23 +147,21 @@
             </div>
             </div>
             @endif
             @endif
 
 
-            {{-- 活跃译者 --}}
-            @if(!empty($activeAuthors))
+            @if(!empty($activeChannels))
             <div class="wiki-sidebar-section">
             <div class="wiki-sidebar-section">
-                <div class="wiki-sidebar-title">{{ __('library.active_authors') }}</div>
+                <div class="wiki-sidebar-title">{{ __('library.category_channels') }}</div>
                 <ul class="tipitaka-author-list">
                 <ul class="tipitaka-author-list">
-                    @foreach($activeAuthors as $author)
+                    @foreach($activeChannels as $channel)
                     <li>
                     <li>
-                        <a href="{{ request()->fullUrlWithQuery(['author' => \Str::slug($author['name']), 'page' => null]) }}"
+                        <a href="{{ route('library.tipitaka.channels.category', ['channel' => $channel['uid'], 'id' => $currentCategory['id']]) }}"
                             class="tipitaka-author-item">
                             class="tipitaka-author-item">
                             <x-ui.author-avatar
                             <x-ui.author-avatar
-                                :avatar="$author['avatar'] ?? null"
-                                :color="$author['color']"
-                                :initials="$author['initials']"
+                                :color="\App\Services\TipitakaCatalogService::channelColor($channel['uid'])"
+                                :initials="mb_substr($channel['name'], 0, 1)"
                                 size="sm" />
                                 size="sm" />
                             <div class="tipitaka-author-item__info">
                             <div class="tipitaka-author-item__info">
-                                <span class="tipitaka-author-item__name">{{ $author['name'] }}</span>
-                                <span class="tipitaka-author-item__count">{{ $author['count'] }} {{ __('library.book_unit') }}</span>
+                                <span class="tipitaka-author-item__name">{{ $channel['name'] }}</span>
+                                <span class="tipitaka-author-item__count">{{ __('language.'.$channel['lang']) }} · {{ $channel['count'] }} {{ __('library.work_unit') }}</span>
                             </div>
                             </div>
                         </a>
                         </a>
                     </li>
                     </li>

+ 44 - 0
api-v13/resources/views/library/tipitaka/channels/_grouped-list.blade.php

@@ -0,0 +1,44 @@
+{{-- 按 type 分组的 channel 列表 + 左栏(类型锚点、语言过滤)。作品版本页用。
+     变量:$groups(TipitakaCatalogService::groupByType(),每行要有 url)、$languages、$selectedLang --}}
+        <aside class="wiki-sidebar-left">
+            <div class="wiki-sidebar-section">
+                <div class="wiki-sidebar-title">{{ __('library.type') }}</div>
+                <ul class="wiki-cat-list">
+                    @foreach($groups as $group)
+                    <li>
+                        <a href="#type-{{ $group['type'] }}">
+                            {{ $group['label'] }}
+                            <span class="wiki-cat-count">{{ count($group['channels']) }}</span>
+                        </a>
+                    </li>
+                    @endforeach
+                </ul>
+            </div>
+            <div class="wiki-sidebar-section">
+                <div class="wiki-sidebar-title">{{ __('library.language') }}</div>
+                <div class="tipitaka-filter-pills">
+                    @foreach($languages as $opt)
+                    <a href="{{ request()->fullUrlWithQuery(['channel_lang' => $opt['value'] === 'all' ? null : $opt['value']]) }}"
+                        class="tipitaka-pill {{ $selectedLang === $opt['value'] ? 'tipitaka-pill--active' : '' }}">
+                        {{ $opt['label'] }}
+                        <span class="tipitaka-pill-count">{{ $opt['count'] }}</span>
+                    </a>
+                    @endforeach
+                </div>
+            </div>
+        </aside>
+
+        <main class="wiki-main">
+            @forelse($groups as $group)
+            <div class="wiki-card" id="type-{{ $group['type'] }}">
+                <div class="wiki-sidebar-title">
+                    {{ $group['label'] }} <span class="wiki-cat-count">{{ count($group['channels']) }}</span>
+                </div>
+                @foreach($group['channels'] as $channel)
+                @include('library.tipitaka.channels._row', ['channel' => $channel])
+                @endforeach
+            </div>
+            @empty
+            <div class="wiki-card"><x-ui.empty-state :title="__('library.no_channels')" /></div>
+            @endforelse
+        </main>

+ 32 - 0
api-v13/resources/views/library/tipitaka/channels/_header.blade.php

@@ -0,0 +1,32 @@
+{{-- 单个 channel 页的头部:头像、名字、元信息与「全部作品 / 按分类」两个标签。
+     变量:$channelInfo(TipitakaChannelController::channelInfo())、$tab(all | category)、$topCategories --}}
+<div class="wiki-card tipitaka-channel-head">
+    <div class="tipitaka-channel-head__main">
+        <x-ui.author-avatar
+            :color="\App\Services\TipitakaCatalogService::channelColor($channelInfo['uid'])"
+            :initials="mb_substr($channelInfo['name'], 0, 1)"
+            size="lg" />
+        <div>
+            <h1 class="tipitaka-channel-head__name">
+                {{ $channelInfo['name'] }}
+                <span class="card-book__badge">{{ __('labels.'.$channelInfo['type']) }}</span>
+            </h1>
+            <div class="tipitaka-channel-head__meta">
+                {{ __('language.'.$channelInfo['lang']) }}
+                @if($channelInfo['owner'])
+                · <a href="{{ route('blog.index', ['user' => $channelInfo['owner']->username]) }}">{{ $channelInfo['owner']->nickname }}</a>
+                @endif
+                · {{ $channelInfo['works'] }} {{ __('library.work_unit') }}
+                @if($channelInfo['updated_at'])
+                · {{ __('library.updated') }} {{ $channelInfo['updated_at'] }}
+                @endif
+            </div>
+        </div>
+    </div>
+    <div class="tipitaka-channel-tabs">
+        <a href="{{ route('library.tipitaka.channels.show', ['channel' => $channelInfo['uid']]) }}"
+            class="{{ $tab === 'all' ? 'active' : '' }}">{{ __('library.all_works') }}</a>
+        <a href="{{ route('library.tipitaka.channels.category', ['channel' => $channelInfo['uid']]) }}"
+            class="{{ $tab === 'category' ? 'active' : '' }}">{{ __('library.by_category') }}</a>
+    </div>
+</div>

+ 24 - 0
api-v13/resources/views/library/tipitaka/channels/_row.blade.php

@@ -0,0 +1,24 @@
+{{-- channel 列表的一行。变量:$channel{uid, name, type, lang, owner?, updated_at?, url, works? | progress?}、$showType(可选,显示类型标签)
+     有 progress(作品版本页)时右侧显示进度条,否则显示收录作品数。 --}}
+<a href="{{ $channel['url'] }}" class="tipitaka-channel-row">
+    <x-ui.author-avatar
+        :color="\App\Services\TipitakaCatalogService::channelColor($channel['uid'])"
+        :initials="mb_substr($channel['name'], 0, 1)"
+        size="sm" />
+    <div class="tipitaka-channel-row__info">
+        <div class="tipitaka-channel-row__name">
+            {{ $channel['name'] }}
+            @if($showType ?? false)<span class="card-book__badge">{{ __('labels.'.$channel['type']) }}</span>@endif
+        </div>
+        <div class="tipitaka-channel-row__meta">
+            {{ __('language.'.$channel['lang']) }}
+            @if(!empty($channel['owner'])) · {{ $channel['owner']->nickname }} @endif
+            @if(!empty($channel['updated_at'])) · {{ __('library.updated') }} {{ $channel['updated_at'] }} @endif
+        </div>
+    </div>
+    @isset($channel['progress'])
+    <x-ui.progress-bar :value="$channel['progress']" class="tipitaka-channel-row__progress" />
+    @else
+    <div class="tipitaka-channel-row__count">{{ $channel['works'] }} {{ __('library.work_unit') }}</div>
+    @endisset
+</a>

+ 25 - 0
api-v13/resources/views/library/tipitaka/channels/_tree-card.blade.php

@@ -0,0 +1,25 @@
+{{-- 目录树里的一张作品卡片。变量:$title(可为 null)、$works(list<{sn, title, progress|null, url}>) --}}
+@if(!empty($works))
+<div class="tipitaka-tree__card">
+    @if($title)
+    <div class="tipitaka-tree__card-title">{{ $title }}</div>
+    @endif
+    <ol>
+        @foreach($works as $work)
+        <li data-work="{{ $work['title'] }}" class="{{ $work['progress'] === null ? 'is-missing' : '' }}">
+            @if($work['progress'] === null)
+            <span class="tipitaka-tree__work">
+                <span class="tipitaka-tree__work-title">{{ $work['title'] }}</span>
+                <span class="tipitaka-tree__work-missing">{{ __('library.not_included') }}</span>
+            </span>
+            @else
+            <a href="{{ $work['url'] }}" class="tipitaka-tree__work">
+                <span class="tipitaka-tree__work-title">{{ $work['title'] }}</span>
+                <x-ui.progress-bar :value="$work['progress']" class="tipitaka-tree__work-progress" />
+            </a>
+            @endif
+        </li>
+        @endforeach
+    </ol>
+</div>
+@endif

+ 36 - 0
api-v13/resources/views/library/tipitaka/channels/_tree-node.blade.php

@@ -0,0 +1,36 @@
+{{-- 目录树的一个节点(递归)。变量:$node(TipitakaChannelController::buildNode())、$level --}}
+<li class="tipitaka-tree__level-{{ min($level, 2) }}" data-node>
+    <span class="tipitaka-tree__node">
+        <button type="button" class="tipitaka-tree__toggle" data-tree-toggle aria-label="{{ __('library.expand_all') }}">▾</button>
+        <span class="tipitaka-tree__name">{{ $node['name'] }}</span>
+        @if($node['name'] !== $node['pali'])
+        <span class="tipitaka-tree__count">{{ $node['pali'] }}</span>
+        @endif
+        <span class="tipitaka-tree__count">{{ $node['mine'] }}@if(request()->boolean('all')) / {{ $node['total'] }}@endif {{ __('library.work_unit') }}</span>
+        <x-ui.progress-bar :value="$node['avg']" class="tipitaka-tree__progress" />
+    </span>
+
+    @php
+    $leaves = array_filter($node['children'], fn ($c) => $c['leaf']);
+    $branches = array_filter($node['children'], fn ($c) => ! $c['leaf']);
+    @endphp
+
+    @if(!empty($node['works']) || !empty($leaves))
+    <div class="tipitaka-tree__cards">
+        @if(!empty($node['works']))
+        @include('library.tipitaka.channels._tree-card', ['title' => $node['leaf'] ? $node['name'] : null, 'works' => $node['works']])
+        @endif
+        @foreach($leaves as $leaf)
+        @include('library.tipitaka.channels._tree-card', ['title' => $leaf['name'], 'works' => $leaf['works']])
+        @endforeach
+    </div>
+    @endif
+
+    @if(!empty($branches))
+    <ul>
+        @foreach($branches as $child)
+        @include('library.tipitaka.channels._tree-node', ['node' => $child, 'level' => $level + 1])
+        @endforeach
+    </ul>
+    @endif
+</li>

+ 91 - 0
api-v13/resources/views/library/tipitaka/channels/category.blade.php

@@ -0,0 +1,91 @@
+{{-- resources/views/library/tipitaka/channels/category.blade.php
+     单个 channel 的分类目录树(无边栏)。样式参考 documents/tipitaka_org.html。
+     顶部一排:左边切换「全部」与四个藏,右边是展开 / 折叠 / 显示未收录。
+     树里的节点只是分组(不可点);末级分类是卡片,卡片里每部作品一条进度条。
+     变量:$channelInfo、$tree(TipitakaChannelController::buildNode(),可能为 null;全部时是虚拟根节点)、
+           $topCategories、$currentTop(全部时为 null)、$showAll
+--}}
+@extends('layouts.library')
+
+@section('title', $channelInfo['name'] . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/tipitaka.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.tipitaka.index') }}">{{ __('library.tipitaka') }}</a></li>
+<li class="breadcrumb-item"><a href="{{ route('library.tipitaka.channels.index') }}">{{ __('library.channels') }}</a></li>
+<li class="breadcrumb-item"><a href="{{ route('library.tipitaka.channels.show', ['channel' => $channelInfo['uid']]) }}">{{ $channelInfo['name'] }}</a></li>
+<li class="breadcrumb-item active">{{ $tree['name'] ?? __('library.by_category') }}</li>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl">
+        @include('library.tipitaka.channels._header', ['tab' => 'category'])
+
+        <div class="tipitaka-tree-bar">
+            <div class="tipitaka-filter-pills">
+                <a href="{{ route('library.tipitaka.channels.category', array_filter(['channel' => $channelInfo['uid'], 'all' => $showAll ? 1 : null])) }}"
+                    class="tipitaka-pill {{ $currentTop === null ? 'tipitaka-pill--active' : '' }}">
+                    {{ __('library.all') }} <span class="tipitaka-pill-count">{{ array_sum(array_column($topCategories, 'count')) }}</span>
+                </a>
+                @foreach($topCategories as $top)
+                <a href="{{ route('library.tipitaka.channels.category', array_filter(['channel' => $channelInfo['uid'], 'id' => $top['id'], 'all' => $showAll ? 1 : null])) }}"
+                    class="tipitaka-pill {{ $currentTop === $top['id'] ? 'tipitaka-pill--active' : '' }}">
+                    {{ $top['name'] }} <span class="tipitaka-pill-count">{{ $top['count'] }}</span>
+                </a>
+                @endforeach
+            </div>
+            <div class="tipitaka-tree-bar__actions">
+                <button type="button" class="btn btn-sm" data-tree-fold="expand">{{ __('library.expand_all') }}</button>
+                <button type="button" class="btn btn-sm" data-tree-fold="collapse">{{ __('library.collapse_to_cards') }}</button>
+                <a class="btn btn-sm" href="{{ request()->fullUrlWithQuery(['all' => $showAll ? null : 1]) }}">
+                    {{ $showAll ? __('library.only_included') : __('library.show_missing') }}
+                </a>
+            </div>
+        </div>
+
+        <div class="tipitaka-tree" data-tree>
+            @if($tree)
+            <ul class="tipitaka-tree__root">
+                {{-- 全部:不画虚拟根节点,四个藏直接作为顶层 --}}
+                @foreach($currentTop === null ? $tree['children'] : [$tree] as $root)
+                @include('library.tipitaka.channels._tree-node', ['node' => $root, 'level' => 0])
+                @endforeach
+            </ul>
+            @else
+            <div class="wiki-card"><x-ui.empty-state :title="__('library.no_works_in_category')" /></div>
+            @endif
+        </div>
+    </div>
+</div>
+@endsection
+
+@push('scripts')
+<script>
+    (function() {
+        const tree = document.querySelector('[data-tree]');
+        if (!tree) return;
+        const setCollapsed = (li, collapsed) => {
+            li.classList.toggle('collapsed', collapsed);
+            const toggle = li.querySelector(':scope > .tipitaka-tree__node [data-tree-toggle]');
+            if (toggle) toggle.textContent = collapsed ? '▸' : '▾';
+        };
+        tree.addEventListener('click', (e) => {
+            const toggle = e.target.closest('[data-tree-toggle]');
+            if (!toggle) return;
+            const li = toggle.closest('li');
+            setCollapsed(li, !li.classList.contains('collapsed'));
+        });
+        document.querySelectorAll('[data-tree-fold]').forEach((btn) => btn.addEventListener('click', () => {
+            const collapse = btn.dataset.treeFold === 'collapse';
+            // 折叠到卡片:只折叠直接带卡片、没有下级分组的节点
+            tree.querySelectorAll('li[data-node]').forEach((li) => setCollapsed(li,
+                collapse && li.querySelector(':scope > .tipitaka-tree__cards') !== null && li.querySelector(':scope > ul') === null));
+        }));
+    })();
+</script>
+@endpush

+ 60 - 0
api-v13/resources/views/library/tipitaka/channels/index.blade.php

@@ -0,0 +1,60 @@
+{{-- resources/views/library/tipitaka/channels/index.blade.php
+     公开 channel 列表:一个列表,按收录作品数降序,分页(线上 channel 多,不全部列出)。
+     变量:$channels(当前页)、$paginator、$types / $languages(过滤项)、$selectedType、$selectedLang
+--}}
+@extends('layouts.library')
+
+@section('title', __('library.channels') . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/tipitaka.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.tipitaka.index') }}">{{ __('library.tipitaka') }}</a></li>
+<li class="breadcrumb-item active">{{ __('library.channels') }}</li>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl wiki-layout wiki-layout--two">
+
+        <aside class="wiki-sidebar-left">
+            @foreach([['key' => 'type', 'title' => __('library.type'), 'options' => $types, 'selected' => $selectedType],
+                      ['key' => 'channel_lang', 'title' => __('library.language'), 'options' => $languages, 'selected' => $selectedLang]] as $filter)
+            <div class="wiki-sidebar-section">
+                <div class="wiki-sidebar-title">{{ $filter['title'] }}</div>
+                <div class="tipitaka-filter-pills">
+                    @foreach($filter['options'] as $opt)
+                    <a href="{{ request()->fullUrlWithQuery([$filter['key'] => $opt['value'] === 'all' ? null : $opt['value'], 'page' => null]) }}"
+                        class="tipitaka-pill {{ $filter['selected'] === $opt['value'] ? 'tipitaka-pill--active' : '' }}">
+                        {{ $opt['label'] }}
+                        <span class="tipitaka-pill-count">{{ $opt['count'] }}</span>
+                    </a>
+                    @endforeach
+                </div>
+            </div>
+            @endforeach
+        </aside>
+
+        <main class="wiki-main">
+            <div class="tipitaka-sort-bar">
+                <span class="tipitaka-sort-bar__count">
+                    {{ __('library.total_prefix') }} <strong>{{ $paginator->total() }}</strong> {{ __('library.channel_unit') }}
+                    · {{ __('library.page_of', ['page' => $paginator->currentPage(), 'last' => $paginator->lastPage()]) }}
+                </span>
+            </div>
+            <div class="wiki-card">
+                @forelse($channels as $channel)
+                @include('library.tipitaka.channels._row', ['channel' => $channel, 'showType' => true])
+                @empty
+                <x-ui.empty-state :title="__('library.no_channels')" />
+                @endforelse
+                <div class="tipitaka-pager">{{ $paginator->links('library.anthology.pagination') }}</div>
+            </div>
+        </main>
+
+    </div>
+</div>
+@endsection

+ 70 - 0
api-v13/resources/views/library/tipitaka/channels/show.blade.php

@@ -0,0 +1,70 @@
+{{-- resources/views/library/tipitaka/channels/show.blade.php
+     单个 channel 收录的作品(分页)。
+     变量:$channelInfo、$books(书卡片)、$paginator、$sort、$sortList、$topCategories
+--}}
+@extends('layouts.library')
+
+@section('title', $channelInfo['name'] . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/tipitaka.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.tipitaka.index') }}">{{ __('library.tipitaka') }}</a></li>
+<li class="breadcrumb-item"><a href="{{ route('library.tipitaka.channels.index') }}">{{ __('library.channels') }}</a></li>
+<li class="breadcrumb-item active">{{ $channelInfo['name'] }}</li>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl">
+        @include('library.tipitaka.channels._header', ['tab' => 'all'])
+    </div>
+    <div class="container-xl wiki-layout wiki-layout--two">
+
+        <aside class="wiki-sidebar-left">
+            <div class="wiki-sidebar-section">
+                <div class="wiki-sidebar-title">{{ __('library.categories') }}</div>
+                <ul class="wiki-cat-list">
+                    @foreach($topCategories as $top)
+                    @if($top['count'] > 0)
+                    <li>
+                        <a href="{{ route('library.tipitaka.channels.category', ['channel' => $channelInfo['uid'], 'id' => $top['id']]) }}">
+                            {{ $top['name'] }}
+                            <span class="wiki-cat-count">{{ $top['count'] }}</span>
+                        </a>
+                    </li>
+                    @endif
+                    @endforeach
+                </ul>
+            </div>
+        </aside>
+
+        <main class="wiki-main">
+            <div class="tipitaka-sort-bar">
+                <span class="tipitaka-sort-bar__count">
+                    {{ __('library.total_prefix') }} <strong>{{ $paginator->total() }}</strong> {{ __('library.work_unit') }}
+                    · {{ __('library.page_of', ['page' => $paginator->currentPage(), 'last' => $paginator->lastPage()]) }}
+                </span>
+                <div class="tipitaka-sort-bar__right">
+                    <span class="tipitaka-sort-bar__label">{{ __('library.sort') }}</span>
+                    <div class="tipitaka-filter-pills">
+                        @foreach($sortList as $item)
+                        <a href="{{ request()->fullUrlWithQuery(['sort' => $item['key'] === 'canon' ? null : $item['key'], 'page' => null]) }}"
+                            class="tipitaka-pill {{ $sort === $item['key'] ? 'tipitaka-pill--active' : '' }}">{{ $item['label'] }}</a>
+                        @endforeach
+                    </div>
+                </div>
+            </div>
+
+            <div class="wiki-card">
+                <x-ui.book-grid :books="$books" />
+                <div class="tipitaka-pager">{{ $paginator->links('library.anthology.pagination') }}</div>
+            </div>
+        </main>
+
+    </div>
+</div>
+@endsection

+ 89 - 37
api-v13/resources/views/library/tipitaka/index.blade.php

@@ -1,22 +1,28 @@
-{{-- resources/views/library/index.blade.php
-     Library 门户首页。
+{{-- resources/views/library/tipitaka/index.blade.php
+     三藏首页:分类(复用 library 首页的两级卡片)→ 译本 → 最近更新。
+     变量:$categoryData(顶级分类 + 下一级)、$channels(公开 channel,按作品数)、$recentBooks(书卡片)
 --}}
 --}}
 @extends('layouts.library')
 @extends('layouts.library')
 
 
-@section('title', __('library.site_name'))
+@section('title', __('library.tipitaka') . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite(['resources/css/modules/library-index.css', 'resources/css/modules/tipitaka.css'])
+@endpush
 
 
 @section('hero')
 @section('hero')
-<section class="hero-section"
-         style="background-image: url('{{ URL::asset('assets/images/hero-2.jpg') }}')">
+{{-- 底图是深色的,hero-section--dark 把标题改成白字 --}}
+<section class="hero-section hero-section--dark"
+    style="background-image: url('{{ URL::asset('assets/images/hero-tipitaka.jpg') }}')">
     <div class="hero-overlay"></div>
     <div class="hero-overlay"></div>
     <div class="hero-content">
     <div class="hero-content">
-        <h1 class="hero-title">{{ __('library.site_name') }}</h1>
-        <p class="hero-subtitle">{{ __('library.portal_hero_subtitle') }}</p>
+        <h1 class="hero-title">{{ __('library.tipitaka') }}</h1>
+        <p class="hero-subtitle">{{ __('library.tipitaka_hero_subtitle') }}</p>
         <div class="search-box">
         <div class="search-box">
             <x-ui.search-input
             <x-ui.search-input
                 :placeholder="__('library.search_tipitaka')"
                 :placeholder="__('library.search_tipitaka')"
-                size="lg"
-            />
+                :hidden-fields="['resource_type' => 'tipitaka']"
+                size="lg" />
         </div>
         </div>
     </div>
     </div>
 </section>
 </section>
@@ -26,40 +32,86 @@
 <div class="page-body">
 <div class="page-body">
     <div class="container-xl">
     <div class="container-xl">
 
 
-        {{-- 分类卡片 --}}
-        <div class="row g-4 mt-1">
-            @foreach($categoryData as $data)
-            <div class="col-12 col-md-6 col-lg-3">
-                <div class="wiki-card h-100">
-                    <div class="wiki-sidebar-title" style="margin-bottom: 0.75rem;">
-                        <i class="ti ti-book me-1"></i>
-                        {{ $data['category']['name'] }}
-                        <a href="{{ route('library.tipitaka.category', ['id' => $data['category']['id']]) }}"
-                           class="btn btn-sm btn-primary ms-auto" style="float:right; font-size: 0.75rem;">
-                            {{ __('library.more') }} <i class="ti ti-arrow-right"></i>
-                        </a>
-                    </div>
-                    <ul class="wiki-cat-list">
-                        @foreach($data['children'] as $child)
-                        <li>
-                            <a href="{{ route('library.tipitaka.category', ['id' => $child['id']]) }}">
-                                {{ $child['name'] }}
+        {{-- ── 分类 ── --}}
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-books"></i>
+                    {{ __('library.categories') }}
+                </h2>
+            </div>
+            <div class="row g-3">
+                @foreach($categoryData as $data)
+                <div class="col-6 col-md-3">
+                    <div class="wiki-card h-100">
+                        <div class="lib-cat-card__head">
+                            <span class="lib-cat-card__name">{{ $data['category']['local_name'] }}</span>
+                            <a href="{{ route('library.tipitaka.category', ['id' => $data['category']['id']]) }}"
+                                class="lib-cat-card__more">
+                                {{ __('library.more') }} <i class="ti ti-arrow-right"></i>
                             </a>
                             </a>
-                        </li>
-                        @endforeach
-                    </ul>
+                        </div>
+                        <ul class="wiki-cat-list">
+                            @foreach($data['children'] as $child)
+                            <li>
+                                <a href="{{ route('library.tipitaka.category', ['id' => $child['id']]) }}">
+                                    {{ $child['local_name'] }}
+                                </a>
+                            </li>
+                            @endforeach
+                        </ul>
+                    </div>
                 </div>
                 </div>
+                @endforeach
+            </div>
+        </div>
+
+        {{-- ── 译本 ── --}}
+        @if(!empty($channels))
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-language"></i>
+                    {{ __('library.channels') }}
+                </h2>
+                <a href="{{ route('library.tipitaka.channels.index') }}" class="lib-section__more">
+                    {{ __('library.view_all') }} <i class="ti ti-arrow-right"></i>
+                </a>
+            </div>
+            <div class="tipitaka-channel-tiles">
+                @foreach($channels as $channel)
+                <a href="{{ route('library.tipitaka.channels.show', ['channel' => $channel['uid']]) }}"
+                    class="tipitaka-channel-tile">
+                    <x-ui.author-avatar
+                        :color="\App\Services\TipitakaCatalogService::channelColor($channel['uid'])"
+                        :initials="mb_substr($channel['name'], 0, 1)"
+                        size="sm" />
+                    <span class="tipitaka-channel-tile__info">
+                        <span class="tipitaka-channel-tile__name">{{ $channel['name'] }}</span>
+                        <span class="tipitaka-channel-tile__meta">
+                            {{ __('language.'.$channel['lang']) }} · {{ $channel['works'] }} {{ __('library.work_unit') }}
+                        </span>
+                    </span>
+                </a>
+                @endforeach
             </div>
             </div>
-            @endforeach
         </div>
         </div>
+        @endif
 
 
-        {{-- 最近更新 --}}
-        @isset($update)
-        <div class="wiki-card mt-4">
-            <div class="wiki-sidebar-title" style="margin-bottom: 1rem;">{{ __('library.recent_updates') }}</div>
-            <x-ui.book-grid :books="$update" />
+        {{-- ── 最近更新 ── --}}
+        @if(!empty($recentBooks))
+        <div class="lib-section">
+            <div class="lib-section__header">
+                <h2 class="lib-section__title">
+                    <i class="ti ti-clock"></i>
+                    {{ __('library.recent_updates') }}
+                </h2>
+            </div>
+            <div class="wiki-card">
+                <x-ui.book-grid :books="$recentBooks" />
+            </div>
         </div>
         </div>
-        @endisset
+        @endif
 
 
     </div>
     </div>
 </div>
 </div>

+ 78 - 69
api-v13/resources/views/library/tipitaka/show.blade.php

@@ -1,4 +1,8 @@
-{{-- resources/views/library/tipitaka/show.blade.php --}}
+{{-- resources/views/library/tipitaka/show.blade.php
+     作品详情:某 channel 的某部作品。
+     变量:$book(TipitakaCatalogService::bookCard() + channel / pali_title / contents / categories / read_url)、
+           $otherVersions(同作品的其他公开 channel)、$relatedWorks(同分类、本 channel 收录的其他作品)
+--}}
 @extends('layouts.library')
 @extends('layouts.library')
 
 
 @section('title', $book['title'] . ' · ' . __('library.site_name'))
 @section('title', $book['title'] . ' · ' . __('library.site_name'))
@@ -14,6 +18,12 @@
 <li class="breadcrumb-item">
 <li class="breadcrumb-item">
     <a href="{{ route('library.tipitaka.index') }}">{{ __('library.tipitaka') }}</a>
     <a href="{{ route('library.tipitaka.index') }}">{{ __('library.tipitaka') }}</a>
 </li>
 </li>
+<li class="breadcrumb-item">
+    <a href="{{ route('library.tipitaka.channels.index') }}">{{ __('library.channels') }}</a>
+</li>
+<li class="breadcrumb-item">
+    <a href="{{ route('library.tipitaka.channels.show', ['channel' => $book['channel']['uid']]) }}">{{ $book['channel']['name'] }}</a>
+</li>
 <li class="breadcrumb-item active">{{ $book['title'] }}</li>
 <li class="breadcrumb-item active">{{ $book['title'] }}</li>
 @endsection
 @endsection
 
 
@@ -25,23 +35,22 @@
         <aside class="wiki-sidebar-left">
         <aside class="wiki-sidebar-left">
             <div class="wiki-sidebar-section" style="padding: 1rem;">
             <div class="wiki-sidebar-section" style="padding: 1rem;">
 
 
-                {{-- 封面 --}}
                 <x-ui.book-cover
                 <x-ui.book-cover
-                    :image="$book['cover'] ?? null"
-                    :gradient="$book['cover_gradient'] ?? 'linear-gradient(135deg, #2d2010 0%, #1a1208 100%)'"
-                    :title="$book['title']"
+                    :image="$book['cover']"
+                    :gradient="$book['cover_gradient']"
+                    :title="$book['pali_title']"
+                    :subtitle="$book['subTitle'] ?? ''"
                     size="md"
                     size="md"
                     :style3d="false"
                     :style3d="false"
                     style="width: 100%; min-width: unset; height: 220px;" />
                     style="width: 100%; min-width: unset; height: 220px;" />
 
 
-                {{-- 操作按钮 --}}
                 <div style="margin-top: 1rem;">
                 <div style="margin-top: 1rem;">
-                    <a href="{{ route('library.tipitaka.read', $book['id']) }}"
-                        class="btn btn-primary w-100 mb-2">
+                    <a href="{{ $book['read_url'] }}" class="btn btn-primary w-100 mb-2">
                         <i class="ti ti-book-2 me-1"></i>
                         <i class="ti ti-book-2 me-1"></i>
                         {{ __('library.read_online') }}
                         {{ __('library.read_online') }}
                     </a>
                     </a>
-                    <button class="btn btn-outline-secondary w-100">
+                    {{-- FIXME: 下载还没有实现 --}}
+                    <button class="btn btn-outline-secondary w-100" disabled>
                         <i class="ti ti-download me-1"></i>
                         <i class="ti ti-download me-1"></i>
                         {{ __('library.download') }}
                         {{ __('library.download') }}
                     </button>
                     </button>
@@ -53,23 +62,27 @@
         {{-- 主内容区 --}}
         {{-- 主内容区 --}}
         <main class="wiki-main">
         <main class="wiki-main">
 
 
-            {{-- 书籍信息 --}}
             <div class="wiki-card">
             <div class="wiki-card">
                 <div class="wiki-entry-header">
                 <div class="wiki-entry-header">
                     <div class="wiki-entry-title">{{ $book['title'] }}</div>
                     <div class="wiki-entry-title">{{ $book['title'] }}</div>
+                    @if($book['pali_title'] !== $book['title'])
+                    <div class="tipitaka-work-pali">{{ $book['pali_title'] }}</div>
+                    @endif
                 </div>
                 </div>
 
 
                 <table class="wiki-meta-table" style="margin-bottom: 1.25rem;">
                 <table class="wiki-meta-table" style="margin-bottom: 1.25rem;">
                     <tr>
                     <tr>
-                        <td>{{ __('library.author') }}</td>
-                        <td>{{ $book['author'] }}</td>
+                        <td>{{ __('library.channel') }}</td>
+                        <td>
+                            <a href="{{ route('library.tipitaka.channels.show', ['channel' => $book['channel']['uid']]) }}">{{ $book['channel']['name'] }}</a>
+                            <span class="card-book__badge">{{ $book['type'] }}</span>
+                        </td>
                     </tr>
                     </tr>
-                    @if(isset($book['publisher']))
+                    @if(!empty($book['publisher']))
                     <tr>
                     <tr>
                         <td>{{ __('library.publisher') }}</td>
                         <td>{{ __('library.publisher') }}</td>
                         <td>
                         <td>
-                            <a href="{{ route('blog.index', ['user' => $book['publisher']->username]) }}"
-                                style="color: var(--tblr-primary); text-decoration: none;">
+                            <a href="{{ route('blog.index', ['user' => $book['publisher']->username]) }}">
                                 {{ $book['publisher']->nickname }}
                                 {{ $book['publisher']->nickname }}
                             </a>
                             </a>
                         </td>
                         </td>
@@ -77,8 +90,28 @@
                     @endif
                     @endif
                     <tr>
                     <tr>
                         <td>{{ __('library.language') }}</td>
                         <td>{{ __('library.language') }}</td>
-                        <td>{{ $book['language'] ?? __('library.pali') }}</td>
+                        <td>{{ $book['language'] }}</td>
                     </tr>
                     </tr>
+                    @if(!empty($book['categories']))
+                    <tr>
+                        <td>{{ __('library.category') }}</td>
+                        <td>
+                            @foreach($book['categories'] as $category)
+                            <a href="{{ $category['url'] }}">{{ $category['name'] }}</a>@if(!$loop->last) › @endif
+                            @endforeach
+                        </td>
+                    </tr>
+                    @endif
+                    <tr>
+                        <td>{{ __('library.progress') }}</td>
+                        <td><x-ui.progress-bar :value="$book['progress']" style="max-width: 240px" /></td>
+                    </tr>
+                    @if(!empty($book['updated_at']))
+                    <tr>
+                        <td>{{ __('library.updated') }}</td>
+                        <td>{{ $book['updated_at'] }}</td>
+                    </tr>
+                    @endif
                 </table>
                 </table>
 
 
                 @if(!empty($book['description']))
                 @if(!empty($book['description']))
@@ -88,45 +121,28 @@
                 @endif
                 @endif
             </div>
             </div>
 
 
-            {{-- 目录 --}}
-            @if(isset($book['contents']) && count($book['contents']) > 0)
+            {{-- 目录:无译文的章节置灰不可点 --}}
+            @if(!empty($book['contents']))
             <div class="wiki-card">
             <div class="wiki-card">
                 <div class="wiki-sidebar-title" style="margin-bottom: 1rem;">{{ __('library.toc') }}</div>
                 <div class="wiki-sidebar-title" style="margin-bottom: 1rem;">{{ __('library.toc') }}</div>
                 <ul class="wiki-toc-list">
                 <ul class="wiki-toc-list">
                     @foreach($book['contents'] as $chapter)
                     @foreach($book['contents'] as $chapter)
-                    <li>
-                        <a href="{{ route('library.tipitaka.read', $chapter['id']) }}?channel={{ $chapter['channel'] }}" target="_blank">
+                    <li class="{{ $chapter['disabled'] ? 'toc-disabled' : '' }}">
+                        @if($chapter['disabled'])
+                        <div class="toc-item">
+                            <div class="toc-title">{{ $chapter['title'] }}</div>
+                        </div>
+                        @else
+                        <a href="{{ route('library.tipitaka.channels.chapter', ['channel' => $chapter['channel'], 'chapter' => $chapter['id']]) }}">
                             <div class="toc-item">
                             <div class="toc-item">
-                                <div class="toc-title">
-                                    {{ $chapter['title'] }}
-                                </div>
-
-                                <div class="toc-progress">
-                                    @php
-                                    $p = $chapter['progress'];
-                                    $color = $p >= 80 ? 'bg-green' : ($p >= 30 ? 'bg-yellow' : 'bg-red');
-                                    @endphp
-
-                                    <div class="progress" style="height: 6px;">
-                                        <div
-                                            class="progress-bar {{ $color }}"
-                                            style="width: {{ $p }}%">
-                                        </div>
-                                    </div>
-                                    <div class="toc-progress-text">
-                                        {{ $p }}%
-                                    </div>
-                                </div>
+                                <div class="toc-title">{{ $chapter['title'] }}</div>
+                                <x-ui.progress-bar :value="$chapter['progress']" class="toc-progress" />
                             </div>
                             </div>
-                            @if(isset($chapter['summary']))
-                            <span style="font-size: 0.75rem; color: var(--tblr-secondary); margin-left: 0.5rem;" class="line2">
-                                {{ $chapter['summary'] }}
-                            </span>
-                            @endif
-                            @if(isset($chapter['progress']) && $chapter['progress'] > 0)
-
+                            @if(!empty($chapter['summary']))
+                            <span class="line2 tipitaka-toc-summary">{{ $chapter['summary'] }}</span>
                             @endif
                             @endif
                         </a>
                         </a>
+                        @endif
                     </li>
                     </li>
                     @endforeach
                     @endforeach
                 </ul>
                 </ul>
@@ -138,33 +154,15 @@
         {{-- 右侧边栏 --}}
         {{-- 右侧边栏 --}}
         <aside class="wiki-sidebar-right">
         <aside class="wiki-sidebar-right">
 
 
-            {{-- 书籍元信息 --}}
-            <div class="wiki-sidebar-section">
-                <div class="wiki-sidebar-title">{{ __('library.book_info') }}</div>
-                <table class="wiki-meta-table">
-                    <tr>
-                        <td>{{ __('library.language') }}</td>
-                        <td>{{ $book['language'] ?? __('library.pali') }}</td>
-                    </tr>
-                    @if(!empty($book['type']))
-                    <tr>
-                        <td>{{ __('library.type') }}</td>
-                        <td>{{ $book['type'] }}</td>
-                    </tr>
-                    @endif
-                </table>
-            </div>
-
-            {{-- 其他版本 --}}
-            @if(!empty($otherVersions) && count($otherVersions) > 0)
+            @if(!empty($otherVersions))
             <div class="wiki-sidebar-section">
             <div class="wiki-sidebar-section">
                 <div class="wiki-sidebar-title">{{ __('library.other_versions') }}</div>
                 <div class="wiki-sidebar-title">{{ __('library.other_versions') }}</div>
                 <ul class="wiki-related-list">
                 <ul class="wiki-related-list">
                     @foreach($otherVersions as $version)
                     @foreach($otherVersions as $version)
                     <li>
                     <li>
-                        <a href="{{ route('library.tipitaka.show', $version['id']) }}">
-                            {{ $version['title'] }}
-                            <span class="wiki-related-zh">{{ $version['language'] ?? __('library.pali') }}</span>
+                        <a href="{{ $version['url'] }}">
+                            {{ $version['name'] }}
+                            <span class="wiki-related-zh">{{ __('language.'.$version['lang']) }}</span>
                         </a>
                         </a>
                     </li>
                     </li>
                     @endforeach
                     @endforeach
@@ -172,6 +170,17 @@
             </div>
             </div>
             @endif
             @endif
 
 
+            @if(!empty($relatedWorks))
+            <div class="wiki-sidebar-section">
+                <div class="wiki-sidebar-title">{{ __('library.related_works') }}</div>
+                <ul class="wiki-related-list">
+                    @foreach($relatedWorks as $related)
+                    <li><a href="{{ $related['url'] }}">{{ $related['title'] }}</a></li>
+                    @endforeach
+                </ul>
+            </div>
+            @endif
+
         </aside>
         </aside>
 
 
     </div>
     </div>

+ 41 - 0
api-v13/resources/views/library/tipitaka/work.blade.php

@@ -0,0 +1,41 @@
+{{-- resources/views/library/tipitaka/work.blade.php
+     作品版本页:一部作品在所有公开译本里的版本,版式同译本列表页(按 type 分组 + 语言过滤)。
+     变量:$work(TipitakaCatalogService::work())、$breadcrumbs(分类路径)、$kind、$groups、$languages、$selectedLang
+--}}
+@extends('layouts.library')
+
+@section('title', $work['title'] . ' · ' . __('library.site_name'))
+
+@push('styles')
+@vite('resources/css/modules/tipitaka.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.tipitaka.index') }}">{{ __('library.tipitaka') }}</a></li>
+@foreach($breadcrumbs as $breadcrumb)
+<li class="breadcrumb-item">
+    <a href="{{ route('library.tipitaka.category', ['id' => $breadcrumb['id']]) }}">{{ $breadcrumb['name'] }}</a>
+</li>
+@endforeach
+<li class="breadcrumb-item active">{{ $work['title'] }}</li>
+@endsection
+
+@section('content')
+<div class="page-body">
+    <div class="container-xl">
+        <div class="wiki-card tipitaka-channel-head">
+            <div class="wiki-entry-header">
+                <div class="wiki-entry-title">{{ $work['title'] }}</div>
+                <div class="tipitaka-work-pali">
+                    @if($kind){{ $kind }} · @endif
+                    {{ __('library.total_prefix') }} {{ $languages[0]['count'] }} {{ __('library.version_unit') }}
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="container-xl wiki-layout wiki-layout--two">
+        @include('library.tipitaka.channels._grouped-list')
+    </div>
+</div>
+@endsection

+ 22 - 3
api-v13/routes/web.php

@@ -9,6 +9,7 @@ use App\Http\Controllers\Library\BookController;
 use App\Http\Controllers\Library\CourseController;
 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\SearchController;
+use App\Http\Controllers\Library\TipitakaChannelController;
 use App\Http\Controllers\Library\TipitakaController;
 use App\Http\Controllers\Library\TipitakaController;
 use App\Http\Controllers\Library\WikiController;
 use App\Http\Controllers\Library\WikiController;
 use App\Http\Controllers\PageIndexController;
 use App\Http\Controllers\PageIndexController;
@@ -65,9 +66,27 @@ Route::prefix('library')->name('library.')->group(function () {
     Route::get('/', [HomeController::class, 'index'])->name('home');
     Route::get('/', [HomeController::class, 'index'])->name('home');
 
 
     Route::get('/tipitaka', [TipitakaController::class, 'index'])->name('tipitaka.index');
     Route::get('/tipitaka', [TipitakaController::class, 'index'])->name('tipitaka.index');
-    Route::get('/tipitaka/category/{id}', [TipitakaController::class, 'index'])->name('tipitaka.category');
-    Route::get('/tipitaka/{id}', [BookController::class, 'show'])->name('tipitaka.show');
-    Route::get('/tipitaka/{id}/read', [BookController::class, 'read'])->name('tipitaka.read');
+    Route::get('/tipitaka/category/{id}', [TipitakaController::class, 'category'])
+        ->whereNumber('id')->name('tipitaka.category');
+    // 作品版本页:一部作品在所有公开译本里的版本
+    Route::get('/tipitaka/work/{sn}', [TipitakaController::class, 'work'])->whereNumber('sn')->name('tipitaka.work');
+
+    // 按译本浏览:channel → 分类 / 作品 → 章节
+    Route::get('/tipitaka/channels', [TipitakaChannelController::class, 'index'])->name('tipitaka.channels.index');
+    Route::prefix('/tipitaka/channels/{channel}')->whereUuid('channel')->name('tipitaka.channels.')->group(function () {
+        Route::get('/', [TipitakaChannelController::class, 'show'])->name('show');
+        // 不带 id = 全部分类
+        Route::get('/category/{id?}', [TipitakaChannelController::class, 'category'])->whereNumber('id')->name('category');
+        Route::get('/work/{sn}', [BookController::class, 'show'])->whereNumber('sn')->name('work');
+        Route::get('/work/{sn}/read', [BookController::class, 'readWork'])->whereNumber('sn')->name('work.read');
+        Route::get('/chapter/{chapter}', [BookController::class, 'read'])
+            ->where('chapter', '\d+-\d+')->name('chapter');
+    });
+
+    // 旧地址,301 到新地址。mobile 深链、dashboard 与外部分享的链接都还在用,不要删
+    Route::get('/tipitaka/{id}/read', [BookController::class, 'legacyRead'])
+        ->where('id', '\d+-\d+')->name('tipitaka.read');
+    Route::get('/tipitaka/{id}', [BookController::class, 'legacyShow'])->whereUuid('id')->name('tipitaka.show');
 
 
     Route::get('/wiki', [WikiController::class, 'home'])->name('wiki.home');
     Route::get('/wiki', [WikiController::class, 'home'])->name('wiki.home');
     Route::get('/wiki/{lang}', [WikiController::class, 'index'])->name('wiki.index');
     Route::get('/wiki/{lang}', [WikiController::class, 'index'])->name('wiki.index');

+ 283 - 0
api-v13/tests/Feature/LibraryTipitakaChannelTest.php

@@ -0,0 +1,283 @@
+<?php
+
+use App\Models\BookTitle;
+use App\Models\Channel;
+use App\Models\PaliText;
+use App\Models\ProgressChapter;
+use App\Models\Tag;
+use App\Models\TagMap;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * library 三藏栏目的新路由:按译本浏览、作品详情、阅读入口与旧地址 301。
+ *
+ * 分类用真实的 public/data/category/default.json:id 3 = 长部 › 戒蕴品,
+ * tag 为 sutta + dīghanikāya + sīlakkhandhavagga。
+ */
+function libPaliText(int $book, int $paragraph, int $level, int $chapterLen, string $toc = ''): PaliText
+{
+    $row = new PaliText;
+    $row->forceFill([
+        'book' => $book,
+        'paragraph' => $paragraph,
+        'level' => $level,
+        'class' => '',
+        'toc' => $toc,
+        'text' => '',
+        'html' => '',
+        'pcd_book_id' => 0,
+        'chapter_len' => $chapterLen,
+        'chapter_strlen' => 100,
+        'uid' => (string) Str::uuid(),
+    ])->save();
+
+    return $row;
+}
+
+function libTagPaliText(PaliText $text, array $names): void
+{
+    foreach ($names as $name) {
+        $tag = Tag::where('name', $name)->first();
+        if (! $tag) {
+            $tag = new Tag;
+            $tag->forceFill(['id' => (string) Str::uuid(), 'name' => $name, 'color' => 0, 'owner_id' => (string) Str::uuid()])->save();
+        }
+        (new TagMap)->forceFill([
+            'id' => (string) Str::uuid(), 'table_name' => 'pali_texts', 'anchor_id' => $text->uid, 'tag_id' => $tag->id,
+        ])->save();
+    }
+}
+
+function libProgress(string $channel, int $book, int $para, float $progress, ?string $title = null): ProgressChapter
+{
+    $row = new ProgressChapter;
+    $row->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'book' => $book,
+        'para' => $para,
+        'lang' => 'zh-Hans',
+        'channel_id' => $channel,
+        'progress' => $progress,
+        'title' => $title,
+        'all_trans' => 0,
+        'public' => 0,
+    ])->save();
+
+    return $row;
+}
+
+/**
+ * 一部作品(sn 9100 = book 9100 的 3–102 段,两个章节 4、50),归入分类 3;
+ * channel 收录了作品根节点(40%)和第二个章节。
+ *
+ * @return array{channel: string, sn: int}
+ */
+function libTipitakaFixture(): array
+{
+    $root = libPaliText(9100, 3, 1, 100, 'Sīlakkhandhavaggapāḷi');
+    libPaliText(9100, 4, 2, 46, '1. Brahmajālasuttaṃ');
+    libPaliText(9100, 50, 2, 53, '2. Sāmaññaphalasuttaṃ');
+    libTagPaliText($root, ['sutta', 'dīghanikāya', 'sīlakkhandhavagga', 'pāḷi']);
+    // book_titles.title 存的是丛书名,作品名取起点段落的 toc
+    (new BookTitle)->forceFill(['sn' => 9100, 'book' => 9100, 'paragraph' => 3, 'title' => 'dīghanikāyapāḷi'])->save();
+
+    $channel = makeChannel(makeStudio('lib-owner'), 'lib channel');
+    libProgress($channel, 9100, 3, 0.4, '戒蕴品');
+    libProgress($channel, 9100, 50, 0.8, '沙门果经');
+
+    return ['channel' => $channel, 'sn' => 9100];
+}
+
+it('lists public channels with their work counts', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+    $private = makeChannel(makeStudio('lib-private'), 'secret channel');
+    Channel::where('uid', $private)->update(['status' => 10]);
+    libProgress($private, 9100, 3, 0.9);
+
+    $this->get('/library/tipitaka/channels')
+        ->assertOk()
+        ->assertSee('lib channel')
+        ->assertSee("/library/tipitaka/channels/{$channel}", false)
+        ->assertDontSee('secret channel');
+
+    // 类型过滤:fixture 的 channel 是 translation
+    $this->get('/library/tipitaka/channels?type=nissaya')->assertOk()->assertDontSee('lib channel');
+    $this->get('/library/tipitaka/channels?type=translation')->assertOk()->assertSee('lib channel');
+
+    // 语言过滤用 channel_lang;?lang= 是界面语言,不过滤列表
+    $this->get('/library/tipitaka/channels?channel_lang=en')->assertOk()->assertDontSee('lib channel');
+    $this->get('/library/tipitaka/channels?lang=en')->assertOk()->assertSee('lib channel');
+});
+
+it('lists the works of a channel with progress', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+
+    $this->get("/library/tipitaka/channels/{$channel}")
+        ->assertOk()
+        ->assertSee('戒蕴品')
+        ->assertSee("/library/tipitaka/channels/{$channel}/work/9100", false)
+        ->assertSee('40%');
+});
+
+it('returns 404 for an unknown channel', function () {
+    $this->get('/library/tipitaka/channels/'.Str::uuid())->assertNotFound();
+});
+
+it('shows the work under its category in the channel tree', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+    $other = makeChannel(makeStudio('lib-other'), 'other channel');
+
+    $this->get("/library/tipitaka/channels/{$channel}/category/1")
+        ->assertOk()
+        ->assertSee('戒蕴品')
+        ->assertSee("/library/tipitaka/channels/{$channel}/work/9100", false);
+
+    // 没收录的 channel 默认不列,?all=1 时置灰列出
+    $this->get("/library/tipitaka/channels/{$other}/category/1")
+        ->assertOk()
+        ->assertDontSee('Sīlakkhandhavaggapāḷi');
+    $this->get("/library/tipitaka/channels/{$other}/category/1?all=1")
+        ->assertOk()
+        ->assertSee('Sīlakkhandhavaggapāḷi')
+        ->assertSee('is-missing');
+});
+
+it('shows every top category when no category is given', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+
+    // 「全部」:四个藏都作为顶层,本 channel 只在经藏下有作品,所以只画出经藏这一棵
+    $this->get("/library/tipitaka/channels/{$channel}/category")
+        ->assertOk()
+        ->assertSee('戒蕴品')
+        ->assertSee("/library/tipitaka/channels/{$channel}/category/1", false);
+});
+
+it('returns 404 for an unknown category', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+
+    $this->get("/library/tipitaka/channels/{$channel}/category/99999")->assertNotFound();
+    $this->get('/library/tipitaka/category/99999')->assertNotFound();
+});
+
+it('shows the work detail with its chapters', function () {
+    ['channel' => $channel, 'sn' => $sn] = libTipitakaFixture();
+
+    $this->get("/library/tipitaka/channels/{$channel}/work/{$sn}")
+        ->assertOk()
+        ->assertSee('戒蕴品')
+        ->assertSee("/library/tipitaka/channels/{$channel}/work/{$sn}/read", false)
+        // 有译文的章节可点,没有的不出链接
+        ->assertSee("/library/tipitaka/channels/{$channel}/chapter/9100-50", false)
+        ->assertDontSee("/library/tipitaka/channels/{$channel}/chapter/9100-4\"", false);
+});
+
+it('returns 404 when the channel does not include the work', function () {
+    ['sn' => $sn] = libTipitakaFixture();
+    $other = makeChannel(makeStudio('lib-other'), 'other channel');
+
+    $this->get("/library/tipitaka/channels/{$other}/work/{$sn}")->assertNotFound();
+    $this->get("/library/tipitaka/channels/{$other}/work/424242")->assertNotFound();
+});
+
+it('redirects the read entry to the first translated chapter', function () {
+    ['channel' => $channel, 'sn' => $sn] = libTipitakaFixture();
+
+    $this->get("/library/tipitaka/channels/{$channel}/work/{$sn}/read")
+        ->assertStatus(302)
+        ->assertRedirect("/library/tipitaka/channels/{$channel}/chapter/9100-50");
+});
+
+it('falls back to the start of the work when no chapter is translated', function () {
+    ['sn' => $sn] = libTipitakaFixture();
+    $other = makeChannel(makeStudio('lib-other'), 'other channel');
+
+    $this->get("/library/tipitaka/channels/{$other}/work/{$sn}/read")
+        ->assertRedirect("/library/tipitaka/channels/{$other}/chapter/9100-3");
+});
+
+it('redirects the legacy reader url permanently and keeps the other query', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+
+    $this->get("/library/tipitaka/9100-50/read?channel={$channel}&comm=abc")
+        ->assertStatus(301)
+        ->assertRedirect("/library/tipitaka/channels/{$channel}/chapter/9100-50?comm=abc");
+});
+
+it('redirects the legacy work url permanently', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+    $uid = ProgressChapter::where('channel_id', $channel)->where('para', 3)->value('uid');
+
+    $this->get("/library/tipitaka/{$uid}")
+        ->assertStatus(301)
+        ->assertRedirect("/library/tipitaka/channels/{$channel}/work/9100");
+    $this->get('/library/tipitaka/'.Str::uuid())->assertNotFound();
+});
+
+it('lists the versions of a category and links them to the channel work page', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+
+    $this->get('/library/tipitaka/category/3')
+        ->assertOk()
+        ->assertSee('lib channel')
+        ->assertSee("/library/tipitaka/channels/{$channel}/work/9100", false);
+});
+
+it('links the works of a leaf category to the work versions page', function () {
+    libTipitakaFixture();
+
+    $this->get('/library/tipitaka/category/3')
+        ->assertOk()
+        ->assertSee('/library/tipitaka/work/9100', false)
+        ->assertSee('Sīlakkhandhavaggapāḷi')
+        ->assertDontSee('dīghanikāyapāḷi');
+    // 旧的 ?book= 过滤 301 到作品版本页
+    $this->get('/library/tipitaka/category/3?book=9100-3')
+        ->assertStatus(301)
+        ->assertRedirect('/library/tipitaka/work/9100');
+});
+
+it('lists every public version of a work grouped by type', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+    $english = makeChannel(makeStudio('lib-en'), 'english channel');
+    Channel::where('uid', $english)->update(['lang' => 'en']);
+    libProgress($english, 9100, 3, 0.25);
+
+    $this->get('/library/tipitaka/work/9100')
+        ->assertOk()
+        ->assertSee('lib channel')
+        ->assertSee('english channel')
+        ->assertSee("/library/tipitaka/channels/{$channel}/work/9100", false)
+        ->assertSee('40%')
+        ->assertSee('id="type-translation"', false);
+
+    $this->get('/library/tipitaka/work/9100?channel_lang=en')
+        ->assertOk()
+        ->assertSee('english channel')
+        ->assertDontSee('lib channel');
+
+    $this->get('/library/tipitaka/work/424242')->assertNotFound();
+});
+
+it('redirects the legacy channel query to the channel pages', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+
+    $this->get("/library/tipitaka?channel={$channel}")
+        ->assertStatus(301)
+        ->assertRedirect("/library/tipitaka/channels/{$channel}");
+    $this->get("/library/tipitaka/category/3?channel={$channel}")
+        ->assertStatus(301)
+        ->assertRedirect("/library/tipitaka/channels/{$channel}/category/3");
+});
+
+it('renders the tipitaka home page', function () {
+    libTipitakaFixture();
+
+    $this->get('/library/tipitaka')
+        ->assertOk()
+        ->assertSee('lib channel')
+        ->assertSee('/library/tipitaka/category/1', false);
+});

+ 9 - 2
api-v13/tests/Feature/LibraryTipitakaReadTest.php

@@ -6,7 +6,8 @@ use Illuminate\Support\Str;
 uses(RefreshDatabase::class);
 uses(RefreshDatabase::class);
 
 
 /**
 /**
- * 阅读页 /library/tipitaka/{book}-{para}/read 的目标不存在时返回 404,而不是 500。
+ * 阅读页的目标不存在时返回 404,而不是 500。
+ * 旧地址 /library/tipitaka/{book}-{para}/read 先校验 channel 再 301;新地址见最后两条。
  */
  */
 it('returns 404 when the channel does not exist', function () {
 it('returns 404 when the channel does not exist', function () {
     $this->get('/library/tipitaka/165-123/read?channel='.Str::uuid())
     $this->get('/library/tipitaka/165-123/read?channel='.Str::uuid())
@@ -24,6 +25,12 @@ it('returns 404 when the id is malformed', function () {
 });
 });
 
 
 it('returns 404 when the paragraph is not a chapter', function () {
 it('returns 404 when the paragraph is not a chapter', function () {
-    $this->get('/library/tipitaka/99999-1/read?channel='.makeChannel(makeStudio('me'), 'c'))
+    $channel = makeChannel(makeStudio('me'), 'c');
+    $this->get("/library/tipitaka/channels/{$channel}/chapter/99999-1")
+        ->assertNotFound();
+});
+
+it('returns 404 on the new reader url when the channel does not exist', function () {
+    $this->get('/library/tipitaka/channels/'.Str::uuid().'/chapter/165-123')
         ->assertNotFound();
         ->assertNotFound();
 });
 });