Quellcode durchsuchen

Merge pull request #2496 from visuddhinanda/development

Development
visuddhinanda vor 6 Tagen
Ursprung
Commit
05863dfcc4
48 geänderte Dateien mit 3335 neuen und 755 gelöschten Zeilen
  1. 3 0
      CLAUDE.md
  2. 159 0
      GITMOJI.md
  3. 133 118
      api-v13/app/Http/Controllers/Library/BookController.php
  4. 235 0
      api-v13/app/Http/Controllers/Library/TipitakaChannelController.php
  5. 249 358
      api-v13/app/Http/Controllers/Library/TipitakaController.php
  6. 563 0
      api-v13/app/Services/TipitakaCatalogService.php
  7. 42 0
      api-v13/documents/homepage-image-prompts.md
  8. 181 0
      api-v13/documents/tipitaka-routes-demo.html
  9. BIN
      api-v13/public/assets/images/hero-tipitaka.jpg
  10. 51 0
      api-v13/resources/css/layout/navbar.css
  11. 423 0
      api-v13/resources/css/modules/tipitaka.css
  12. 3 0
      api-v13/resources/lang/en/labels.php
  13. 26 0
      api-v13/resources/lang/en/library.php
  14. 3 0
      api-v13/resources/lang/zh-Hans/labels.php
  15. 22 0
      api-v13/resources/lang/zh-Hans/library.php
  16. 3 10
      api-v13/resources/views/components/language-switcher.blade.php
  17. 6 0
      api-v13/resources/views/components/library/navbar.blade.php
  18. 7 2
      api-v13/resources/views/components/ui/card-book.blade.php
  19. 24 0
      api-v13/resources/views/components/ui/progress-bar.blade.php
  20. 4 1
      api-v13/resources/views/components/wiki/search-result-card.blade.php
  21. 11 5
      api-v13/resources/views/library/book/read.blade.php
  22. 2 2
      api-v13/resources/views/library/book/toc.blade.php
  23. 4 3
      api-v13/resources/views/library/search.blade.php
  24. 12 0
      api-v13/resources/views/library/tipitaka/_side-tree.blade.php
  25. 40 110
      api-v13/resources/views/library/tipitaka/category.blade.php
  26. 44 0
      api-v13/resources/views/library/tipitaka/channels/_grouped-list.blade.php
  27. 32 0
      api-v13/resources/views/library/tipitaka/channels/_header.blade.php
  28. 24 0
      api-v13/resources/views/library/tipitaka/channels/_row.blade.php
  29. 25 0
      api-v13/resources/views/library/tipitaka/channels/_tree-card.blade.php
  30. 36 0
      api-v13/resources/views/library/tipitaka/channels/_tree-node.blade.php
  31. 91 0
      api-v13/resources/views/library/tipitaka/channels/category.blade.php
  32. 60 0
      api-v13/resources/views/library/tipitaka/channels/index.blade.php
  33. 70 0
      api-v13/resources/views/library/tipitaka/channels/show.blade.php
  34. 89 37
      api-v13/resources/views/library/tipitaka/index.blade.php
  35. 78 69
      api-v13/resources/views/library/tipitaka/show.blade.php
  36. 41 0
      api-v13/resources/views/library/tipitaka/work.blade.php
  37. 22 3
      api-v13/routes/web.php
  38. 22 0
      api-v13/tests/Feature/LibraryLanguageSwitcherTest.php
  39. 283 0
      api-v13/tests/Feature/LibraryTipitakaChannelTest.php
  40. 9 2
      api-v13/tests/Feature/LibraryTipitakaReadTest.php
  41. 33 7
      dashboard-v6/src/components/dict/DictComponent.tsx
  42. 4 2
      dashboard-v6/src/components/dict/Dictionary.tsx
  43. 2 0
      dashboard-v6/src/components/editor/Editor.tsx
  44. 20 0
      dashboard-v6/src/components/editor/panels/DictPanel.module.css
  45. 94 1
      dashboard-v6/src/components/editor/panels/DictPanel.tsx
  46. 5 0
      dashboard-v6/src/components/general/SplitLayout/RightToolbar.module.css
  47. 21 12
      dashboard-v6/src/components/general/SplitLayout/RightToolbar.tsx
  48. 24 13
      dashboard-v6/src/pages/workspace/channel/show.tsx

+ 3 - 0
CLAUDE.md

@@ -442,6 +442,9 @@ v3 这条只服务单 channel 的只读渲染。前端 `dashboard-v6/src/api/pal
 ## 工作约定
 ## 工作约定
 
 
 - 改完代码留在工作区,等用户 review,不要自动 `git commit`。
 - 改完代码留在工作区,等用户 review,不要自动 `git commit`。
+- **提交信息用 gitmoji**(2026-11-06 起):标题写成 `✨ (library): 说明`——Unicode emoji 代替
+  `feat` / `fix`,范围放括号里,不要再写 `feat(scope):`。emoji 怎么选、范围名用哪些,见根目录
+  `GITMOJI.md`。「功能」与「还债」两个 commit 分别用 ✨ / 🐛 等与 ♻️。
 - 文档文件只在用户明确要求时创建。
 - 文档文件只在用户明确要求时创建。
 - **不要擅自安装或升级任何依赖**(npm、composer 都一样)。需要新依赖就说明理由,
 - **不要擅自安装或升级任何依赖**(npm、composer 都一样)。需要新依赖就说明理由,
   等用户自己装完通知你。
   等用户自己装完通知你。

+ 159 - 0
GITMOJI.md

@@ -0,0 +1,159 @@
+# 提交信息约定:gitmoji
+
+mint 仓库(含 api-v13、dashboard-v6、openapi 等所有子目录)的 commit 标题统一用 [gitmoji](https://gitmoji.dev/) 表示提交意图。
+2026-11-06 起执行,之前的提交不改写。
+
+表情列表整理自 gitmoji 官方数据 [`gitmojis.json`](https://github.com/carloscuesta/gitmoji/blob/master/packages/gitmojis/src/gitmojis.json)(2026-11-06 抓取,共 75 个),
+格式规范整理自 <https://gitmoji.dev/specification>。gitmoji 以后增删表情,以官网为准。
+
+## 格式
+
+官方规范:
+
+```
+<intention> [scope?][:?] <message>
+```
+
+- **intention**:表达提交意图的 emoji,从下面的列表里选。
+- **scope**:可选,说明改动范围。
+- **message**:简短说明改了什么。
+
+mint 的写法:
+
+```
+✨ (library): 三藏栏目按译本浏览,作品 = book_titles.sn
+
+正文:为什么改、改了哪些行为、有没有要注意的副作用(中文)。
+
+Co-Authored-By: …(由 AI 协作提交时)
+```
+
+- **用 Unicode emoji,不用 `:shortcode:`**。终端里的 `git log` 直接能看,标题也更短。
+- **emoji 代替 `feat` / `fix` 等类型词**,不要两个都写(不写 `✨ feat(library): …`)。
+- **范围放括号里、后跟冒号**,与 `git log` 里已有的范围名保持一致,方便检索:
+  `api-v13`、`dashboard-v6`、`openapi`、`api-test`、`dashboard-test`、`plugin`,
+  以及 api-v13 内部的模块:`library`、`reader`、`search`、`mq`、`export`、`annotation`、`term`、`progress`、`i18n`。
+  跨多个范围就省略范围,不要硬凑。
+- **一个 commit 只选一个 emoji**,选最能代表主要意图的那个。
+- 标题与正文用中文,标识符、路径、命令保持原文。
+
+## mint 常用对照
+
+从旧的 `type(scope):` 写法换过来:
+
+| 旧写法 | 用 | 说明 |
+|---|---|---|
+| `feat` | ✨ | 新功能 |
+| `fix` | 🐛 | 修 bug |
+| `fix`(线上急修,1 小时内必须修好) | 🚑️ | 对应 CLAUDE.md「改 v2 只有两个理由」的第 ① 条 |
+| `fix`(小问题、不急) | 🩹 | |
+| `docs` | 📝 | |
+| `refactor` | ♻️ | 「渐进式还债」拆出来的第二个 commit 用这个 |
+| `test` | ✅ | |
+| `perf` | ⚡️ | |
+| `style`(代码格式,如 pint) | 🎨 | |
+| `style`(界面样式) | 💄 | CSS、blade 里的视觉调整 |
+| `chore`(配置) | 🔧 | `config/`、`.env.example` 等 |
+| `chore`(脚本) | 🔨 | `scripts/`、`tools/` |
+
+mint 里经常遇到、值得记住的几个:
+
+| emoji | 什么时候用 |
+|---|---|
+| 🗃️ | migration、刷库、改数据(加列、加索引) |
+| 🚚 | 改路由 / 改名 / 移动文件,例如 v2 → v3 改名、旧地址 301 |
+| 💥 | 破坏性变更:v3 端点改了入参出参、删了字段 |
+| 🔥 | 删除代码或文件 |
+| ⚰️ | 删死代码(没有调用方的方法、不存在的视图) |
+| 🌐 | `resources/lang`、`dashboard-v6/src/locales` 的翻译 |
+| 💬 | 改界面文案(不涉及多语言结构) |
+| 🏷️ | 重新生成 `schema.d.ts`、改 TypeScript 类型、Resource 的 `@return array{…}` |
+| 🦺 | FormRequest 校验规则 |
+| 🛂 | 权限判定、`ChecksChannelEditPower`、agent 授权 |
+| 🍱 | 图片等静态资源(如 hero 图) |
+| 📱 | 移动端 / 窄屏适配 |
+| 🚸 | 改进易用性 |
+| 🥅 | 捕获错误、404 代替 500 |
+| ⬆️ / ➕ / ➖ | 升级 / 新增 / 移除依赖(依赖变更须先经用户同意) |
+| 🔊 / 🔇 | 加 / 删日志 |
+| 🧑‍💻 | 改进开发体验(skill、本地工具链) |
+
+## 完整列表
+
+| emoji | 短码 | 中文说明 | 官方说明 |
+|---|---|---|---|
+| 🎨 | `:art:` | 改进代码结构 / 格式 | Improve structure / format of the code. |
+| ⚡️ | `:zap:` | 提升性能 | Improve performance. |
+| 🔥 | `:fire:` | 删除代码或文件 | Remove code or files. |
+| 🐛 | `:bug:` | 修复 bug | Fix a bug. |
+| 🚑️ | `:ambulance:` | 紧急热修复 | Critical hotfix. |
+| ✨ | `:sparkles:` | 引入新功能 | Introduce new features. |
+| 📝 | `:memo:` | 添加或更新文档 | Add or update documentation. |
+| 🚀 | `:rocket:` | 部署 | Deploy stuff. |
+| 💄 | `:lipstick:` | 添加或更新 UI 与样式文件 | Add or update the UI and style files. |
+| 🎉 | `:tada:` | 项目开端 | Begin a project. |
+| ✅ | `:white_check_mark:` | 添加、更新测试或让测试通过 | Add, update, or pass tests. |
+| 🔒️ | `:lock:` | 修复安全或隐私问题 | Fix security or privacy issues. |
+| 🔐 | `:closed_lock_with_key:` | 添加或更新密钥 | Add or update secrets. |
+| 🔖 | `:bookmark:` | 发版 / 版本标签 | Release / Version tags. |
+| 🚨 | `:rotating_light:` | 修复编译器 / linter 警告 | Fix compiler / linter warnings. |
+| 🚧 | `:construction:` | 进行中的工作 | Work in progress. |
+| 💚 | `:green_heart:` | 修复 CI 构建 | Fix CI Build. |
+| ⬇️ | `:arrow_down:` | 降级依赖 | Downgrade dependencies. |
+| ⬆️ | `:arrow_up:` | 升级依赖 | Upgrade dependencies. |
+| 📌 | `:pushpin:` | 把依赖锁定到指定版本 | Pin dependencies to specific versions. |
+| 👷 | `:construction_worker:` | 添加或更新 CI 构建系统 | Add or update CI build system. |
+| 📈 | `:chart_with_upwards_trend:` | 添加或更新统计 / 埋点代码 | Add or update analytics or track code. |
+| ♻️ | `:recycle:` | 重构代码 | Refactor code. |
+| ➕ | `:heavy_plus_sign:` | 添加依赖 | Add a dependency. |
+| ➖ | `:heavy_minus_sign:` | 移除依赖 | Remove a dependency. |
+| 🔧 | `:wrench:` | 添加或更新配置文件 | Add or update configuration files. |
+| 🔨 | `:hammer:` | 添加或更新开发脚本 | Add or update development scripts. |
+| 🌐 | `:globe_with_meridians:` | 国际化与本地化 | Internationalization and localization. |
+| ✏️ | `:pencil2:` | 修正错别字 | Fix typos. |
+| 💩 | `:poop:` | 写了需要改进的烂代码 | Write bad code that needs to be improved. |
+| ⏪️ | `:rewind:` | 回退改动 | Revert changes. |
+| 🔀 | `:twisted_rightwards_arrows:` | 合并分支 | Merge branches. |
+| 📦️ | `:package:` | 添加或更新编译产物或包 | Add or update compiled files or packages. |
+| 👽️ | `:alien:` | 因外部 API 变化而更新代码 | Update code due to external API changes. |
+| 🚚 | `:truck:` | 移动或重命名资源(文件、路径、路由等) | Move or rename resources (e.g.: files, paths, routes). |
+| 📄 | `:page_facing_up:` | 添加或更新许可证 | Add or update license. |
+| 💥 | `:boom:` | 引入破坏性变更 | Introduce breaking changes. |
+| 🍱 | `:bento:` | 添加或更新静态资源 | Add or update assets. |
+| ♿️ | `:wheelchair:` | 改进无障碍 | Improve accessibility. |
+| 💡 | `:bulb:` | 添加或更新源码注释 | Add or update comments in source code. |
+| 🍻 | `:beers:` | 醉酒写代码 | Write code drunkenly. |
+| 💬 | `:speech_balloon:` | 添加或更新文案与字面量 | Add or update text and literals. |
+| 🗃️ | `:card_file_box:` | 数据库相关改动 | Perform database related changes. |
+| 🔊 | `:loud_sound:` | 添加或更新日志 | Add or update logs. |
+| 🔇 | `:mute:` | 移除日志 | Remove logs. |
+| 👥 | `:busts_in_silhouette:` | 添加或更新贡献者 | Add or update contributor(s). |
+| 🚸 | `:children_crossing:` | 改进用户体验 / 易用性 | Improve user experience / usability. |
+| 🏗️ | `:building_construction:` | 架构调整 | Make architectural changes. |
+| 📱 | `:iphone:` | 响应式设计 | Work on responsive design. |
+| 🤡 | `:clown_face:` | 模拟(mock) | Mock things. |
+| 🥚 | `:egg:` | 添加或更新彩蛋 | Add or update an easter egg. |
+| 🙈 | `:see_no_evil:` | 添加或更新 .gitignore | Add or update a .gitignore file. |
+| 📸 | `:camera_flash:` | 添加或更新快照 | Add or update snapshots. |
+| ⚗️ | `:alembic:` | 实验性改动 | Perform experiments. |
+| 🔍️ | `:mag:` | 改进 SEO | Improve SEO. |
+| 🏷️ | `:label:` | 添加或更新类型 | Add or update types. |
+| 🌱 | `:seedling:` | 添加或更新 seed 文件 | Add or update seed files. |
+| 🚩 | `:triangular_flag_on_post:` | 添加、更新或移除功能开关 | Add, update, or remove feature flags. |
+| 🥅 | `:goal_net:` | 捕获错误 | Catch errors. |
+| 💫 | `:dizzy:` | 添加或更新动画与过渡 | Add or update animations and transitions. |
+| 🗑️ | `:wastebasket:` | 标记待清理的废弃代码 | Deprecate code that needs to be cleaned up. |
+| 🛂 | `:passport_control:` | 授权、角色与权限相关代码 | Work on code related to authorization, roles and permissions. |
+| 🩹 | `:adhesive_bandage:` | 非紧急问题的简单修复 | Simple fix for a non-critical issue. |
+| 🧐 | `:monocle_face:` | 数据探查 / 检查 | Data exploration/inspection. |
+| ⚰️ | `:coffin:` | 删除死代码 | Remove dead code. |
+| 🧪 | `:test_tube:` | 添加一个(预期)失败的测试 | Add a failing test. |
+| 👔 | `:necktie:` | 添加或更新业务逻辑 | Add or update business logic. |
+| 🩺 | `:stethoscope:` | 添加或更新健康检查 | Add or update healthcheck. |
+| 🧱 | `:bricks:` | 基础设施相关改动 | Infrastructure related changes. |
+| 🧑‍💻 | `:technologist:` | 改进开发者体验 | Improve developer experience. |
+| 💸 | `:money_with_wings:` | 赞助或资金相关设施 | Add sponsorships or money related infrastructure. |
+| 🧵 | `:thread:` | 多线程或并发相关代码 | Add or update code related to multithreading or concurrency. |
+| 🦺 | `:safety_vest:` | 校验相关代码 | Add or update code related to validation. |
+| ✈️ | `:airplane:` | 改进离线支持 | Improve offline support. |
+| 🦖 | `:t-rex:` | 添加向后兼容的代码 | Code that adds backwards compatibility. |

+ 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;
+    }
+}

+ 42 - 0
api-v13/documents/homepage-image-prompts.md

@@ -68,3 +68,45 @@ development-{640|1280|2560}.{avif|webp|jpg}
 ```
 ```
 
 
 建议流程:先生成一张 **2560px 宽**的主图,再降采样出 1280 / 640 三档,分别压成 AVIF、WebP、JPEG。
 建议流程:先生成一张 **2560px 宽**的主图,再降采样出 1280 / 640 三档,分别压成 AVIF、WebP、JPEG。
+
+---
+
+## 05 Tipiṭaka · 三藏栏目 Hero
+
+用于 `/library/tipitaka` 顶部通栏(`resources/views/library/tipitaka/index.blade.php`),替换目前与 library 首页共用的 `hero-2.jpg`。
+
+**版式约束(与上面四张卡片不同,先读这条):**
+
+- 通栏高度固定 **250px**,宽度随屏幕:桌面约 1920–2560px(≈ 8:1 的细长横条),手机约 375px(≈ 1.5:1,只剩正中一小块)。`background-size: cover` + `center`,**上下会被大幅裁掉**。
+- 白色大标题「三藏」、副标题和搜索框**居中**压在图上,上面还有一层 20% 黑色遮罩和透明导航栏。所以:
+  - **正中横带**(宽度中间 50%、高度中间 60%)必须是**平静、低对比、中等明度**的质感,不放主体,否则压字看不清。
+  - 主体元素沿水平方向**分散在左右两端**,画面像一条横向展开的长卷。
+  - 手机上只看得到正中,所以正中也要好看:均匀的纸纹 / 光影即可,不能是一块死色。
+- **明度例外**:上面四张要求明亮,这张不行。白字需要背景足够深,整体用**中等偏深**的暖色调(正中亮度约 35–55%),不要出现大片白色高光。
+- 沿用全局禁忌:不出现佛像、寺庙、僧侣、佛塔、念珠、香炉、莲花等宗教符号,不出现可辨认的文字。
+
+- 色调:深檀木棕 + 贝叶暖金(tint `#5A4632`,高光 `#C9A66B`)
+- 主题方向:贝叶写本 / 经夹木板 / 长卷式的横向展开
+
+**提示词(主推):**
+
+> 超宽幅横向长卷构图的静物摄影,俯视角度:深色檀木桌面上,一排排细长的贝叶写本沿水平方向舒展排开,叶片边缘微微卷曲、泛着温润的暖金色,用棉线串起,两端压着素面的深色木质经夹板。贝叶与经夹集中在画面最左和最右两端,向中间逐渐稀疏、虚化,画面正中是一大片平整均匀的深棕木纹与柔和阴影,干净、安静、低对比,留作叠加白色标题。光线是低角度的侧向暖光,从左向右掠过,形成细腻的明暗过渡。整体色调为中等偏深的檀木棕与暖金,哑光质感,浅景深,宁静、庄重、克制。贝叶表面只有模糊的刻痕肌理,没有任何可辨认的文字。画面中不要出现人物、手、佛像、寺庙、佛塔、莲花、念珠、香炉、蜡烛、任何宗教符号、标志、水印、可读文字。
+
+**提示词(备选,更抽象):**
+
+> 超宽幅抽象背景:无数细长的半透明暖金色叶片形光带,沿水平方向层层叠叠地流动,像缓缓展开的长卷,在画面左右两端较为密集、有清晰的纤维质感,向中央逐渐融化成柔和均匀的深琥珀色雾霭。正中区域平静、低对比、色调均匀,用于叠加白色标题文字。整体为中等偏深的檀木棕与琥珀金,哑光,通透,宁静内敛。画面中不要出现任何具象物体、人物、宗教符号、文字、标志、水印。
+
+**English version**(部分生图工具英文效果更好):
+
+> Ultra-wide panoramic still life, top-down view: rows of long, narrow palm-leaf manuscripts laid out horizontally on a dark sandalwood table, edges slightly curled, warm golden tone, bound with cotton string, plain dark wooden cover boards at each end. The manuscripts gather only at the far left and far right edges and fade and blur toward the center; the center is a wide, calm, evenly lit area of dark brown wood grain and soft shadow, low contrast, reserved for white title text. Low raking warm side light from the left. Mid-to-dark sandalwood brown and warm gold palette, matte, shallow depth of field, serene and restrained. Only faint incised texture on the leaves, no legible script. No people, hands, Buddha statues, temples, stupas, lotus, prayer beads, incense, candles, religious symbols, logos, watermarks or readable text.
+
+**交付规格:**
+
+- 生成时选工具支持的**最宽画幅**(21:9 或 3:1),主体保持在**垂直方向中间 60%** 以内,然后从中间裁出 **2560 × 400** 的横条作主图。
+- 文件放 `public/assets/images/`,命名 `hero-tipitaka.jpg`(2560 × 400,≤ 250KB),另出 `hero-tipitaka-1280.jpg` 给窄屏备用。
+- 换图时只改 `tipitaka/index.blade.php` 里的 `hero-2.jpg`,library 首页继续用原图。
+- **2026-11-04 已交付**:用主推提示词在 ChatGPT 生成,原图 2048 × 768 PNG。贝叶本来就只占左右两端、
+  上下留足,`cover` 居中裁切后桌面与手机都不压字,所以没有再裁成横条,直接压成
+  `hero-tipitaka.jpg`(2048 × 768,JPEG q82,177KB)。没出 1280 版:模板是内联 `background-image`,
+  没有按屏宽切图的地方。
+- 验收:在 1920px 和 375px 宽下各看一次,确认白色标题与搜索框清晰可读、正中没有压字的主体。

Datei-Diff unterdrückt, da er zu groß ist
+ 181 - 0
api-v13/documents/tipitaka-routes-demo.html


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


+ 51 - 0
api-v13/resources/css/layout/navbar.css

@@ -37,6 +37,29 @@
     color: var(--wp-ink-muted);
     color: var(--wp-ink-muted);
 }
 }
 
 
+/* ── 站点 logo(回 wikipali 首页) ── */
+.anthology-breadcrumb-bar .bc-logo {
+    display: inline-flex;
+    align-items: center;
+    flex-shrink: 0;
+}
+
+.anthology-breadcrumb-bar .bc-logo__img {
+    display: block;
+    height: 40px;
+    width: auto;
+}
+
+/* 默认浅色导航栏用深色字版;hero 上的白字版在下面「Hero 覆盖状态」里切换 */
+.anthology-breadcrumb-bar .bc-logo__img--light {
+    display: none;
+}
+
+/* logo 之后的面包屑紧贴 logo,不被 space-between 推到中间 */
+.anthology-breadcrumb-bar .bc-logo + .breadcrumb {
+    margin-right: auto;
+}
+
 /* ── Hero 覆盖状态:breadcrumb bar 透明悬浮于 hero 之上 ── */
 /* ── Hero 覆盖状态:breadcrumb bar 透明悬浮于 hero 之上 ── */
 .hero-wrapper:has(.hero-section) .anthology-breadcrumb-bar {
 .hero-wrapper:has(.hero-section) .anthology-breadcrumb-bar {
     position: absolute;
     position: absolute;
@@ -48,6 +71,14 @@
     border-bottom: none;
     border-bottom: none;
 }
 }
 
 
+.hero-wrapper:has(.hero-section) .bc-logo__img--dark {
+    display: none;
+}
+
+.hero-wrapper:has(.hero-section) .bc-logo__img--light {
+    display: block;
+}
+
 .hero-wrapper:has(.hero-section) .bc-nav li a {
 .hero-wrapper:has(.hero-section) .bc-nav li a {
     color: white;
     color: white;
 }
 }
@@ -117,3 +148,23 @@
         align-items: center;
         align-items: center;
     }
     }
 }
 }
+
+/* 窄屏:面包屑可收缩、超出截断,保证 logo 与右侧菜单按钮都在屏内 */
+@media (max-width: 768px) {
+    .anthology-breadcrumb-bar .bc-logo + .breadcrumb {
+        flex-shrink: 1;
+        flex-wrap: nowrap;
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+    }
+
+    /* 各项本身不压缩,只在末尾截掉,避免分隔符和文字挤在一起 */
+    .anthology-breadcrumb-bar .bc-logo + .breadcrumb .breadcrumb-item {
+        flex-shrink: 0;
+    }
+
+    .anthology-breadcrumb-bar .bc-logo__img {
+        height: 32px;
+    }
+}

+ 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' => '未收录',
 ];
 ];

+ 3 - 10
api-v13/resources/views/components/language-switcher.blade.php

@@ -2,16 +2,9 @@
 $currentLocale = app()->getLocale();
 $currentLocale = app()->getLocale();
 $languages = config('mint.languages');
 $languages = config('mint.languages');
 $currentLanguage = $languages[$currentLocale] ?? 'en';
 $currentLanguage = $languages[$currentLocale] ?? 'en';
-// 首页等无名路由用当前 URL 追加 ?lang= ,具名路由仍按路由参数生成,保持原有链接形式
-$localeUrl = function (string $locale) {
-    $routeName = Route::currentRouteName();
-
-    if ($routeName) {
-        return route($routeName, array_merge(request()->route()->parameters(), ['lang' => $locale]));
-    }
-
-    return request()->fullUrlWithQuery(['lang' => $locale]);
-};
+// 当前地址原样保留(路径参数与查询串都不动),只替换 ?lang=(SetLocale 中间件只认查询串)。
+// 不能按路由参数重新生成:wiki 的路径参数也叫 {lang},会被换成界面语言;查询串(筛选、页码、搜索词)也会丢。
+$localeUrl = fn (string $locale) => request()->fullUrlWithQuery(['lang' => $locale]);
 @endphp
 @endphp
 
 
 <div class="language-switcher">
 <div class="language-switcher">

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

@@ -2,6 +2,12 @@
     <div class="container-xl">
     <div class="container-xl">
         <div class="bc-inner">
         <div class="bc-inner">
 
 
+            {{-- 站点 logo,回 wikipali 首页。浅色导航栏用深色字版;压在 hero 上时(导航透明)换白字版,见 navbar.css --}}
+            <a href="{{ url('/') }}" class="bc-logo" aria-label="WikiPāli">
+                <img src="{{ asset('assets/images/wikipali_logo_dark.svg') }}" alt="WikiPāli" class="bc-logo__img bc-logo__img--dark">
+                <img src="{{ asset('assets/images/wikipali_logo.svg') }}" alt="" aria-hidden="true" class="bc-logo__img bc-logo__img--light">
+            </a>
+
             <ol class="breadcrumb mb-0">
             <ol class="breadcrumb mb-0">
                 @yield('breadcrumb')
                 @yield('breadcrumb')
             </ol>
             </ol>

+ 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

+ 4 - 3
api-v13/resources/views/library/search.blade.php

@@ -1,6 +1,6 @@
 {{-- resources/views/library/search.blade.php
 {{-- resources/views/library/search.blade.php
      全站统一搜索结果页。
      全站统一搜索结果页。
-     路由:/library/search?q=&type=&category=&lang=
+     路由:/library/search?q=&type=&category=&language=(不能用 lang:那是界面语言)
      原 wiki/search.blade.php 移至此处,@extends 路径更新,
      原 wiki/search.blade.php 移至此处,@extends 路径更新,
      搜索框改用 <x-ui.search-input>,空状态改用 <x-ui.empty-state>。
      搜索框改用 <x-ui.search-input>,空状态改用 <x-ui.empty-state>。
 --}}
 --}}
@@ -51,7 +51,8 @@
     routeName="library.search"
     routeName="library.search"
     :queryParams="array_filter([
     :queryParams="array_filter([
                 'q'        => $query,
                 'q'        => $query,
-                'lang'     => $lang,
+                // 控制器读的是 language;lang 是界面语言(SetLocale)
+                'language' => $lang,
                 'category' => $category !== 'all' ? $category : null,
                 'category' => $category !== 'all' ? $category : null,
             ])" />
             ])" />
 @endif
 @endif
@@ -103,7 +104,7 @@
     <div class="wiki-sidebar-title">{{ __('library.did_you_mean') }}</div>
     <div class="wiki-sidebar-title">{{ __('library.did_you_mean') }}</div>
     <ul class="wiki-related-list">
     <ul class="wiki-related-list">
         <li>
         <li>
-            <a href="{{ route('library.search', ['q' => substr($query, 0, -1), 'lang' => $lang]) }}">
+            <a href="{{ route('library.search', ['q' => substr($query, 0, -1), 'language' => $lang]) }}">
                 {{ substr($query, 0, -1) }}
                 {{ substr($query, 0, -1) }}
             </a>
             </a>
         </li>
         </li>

+ 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');

+ 22 - 0
api-v13/tests/Feature/LibraryLanguageSwitcherTest.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 语言切换器(x-language-switcher):保留当前地址的路径与查询串,只替换 ?lang=。
+ *
+ * 以前按路由参数重新生成地址:筛选、页码、搜索词会丢;wiki 的路径参数也叫 {lang},会被换成界面语言。
+ */
+it('keeps the current query string when switching the ui language', function () {
+    $this->get('/library/tipitaka/channels?type=translation&page=2')
+        ->assertOk()
+        ->assertSee('/library/tipitaka/channels?type=translation&amp;page=2&amp;lang=zh-Hans', false);
+});
+
+it('replaces an existing lang parameter instead of adding a second one', function () {
+    $this->get('/library/tipitaka/channels?lang=my&type=translation')
+        ->assertOk()
+        ->assertSee('/library/tipitaka/channels?lang=zh-Hans&amp;type=translation', false);
+});

+ 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();
 });
 });

+ 33 - 7
dashboard-v6/src/components/dict/DictComponent.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
 
 
 import { useAppSelector } from "../../hooks";
 import { useAppSelector } from "../../hooks";
 import { lookup, lookupWord, myDictIsDirty } from "../../reducers/command";
 import { lookup, lookupWord, myDictIsDirty } from "../../reducers/command";
@@ -9,26 +9,52 @@ import Dictionary from "./Dictionary";
 
 
 export interface IWidgetDict {
 export interface IWidgetDict {
   word?: string;
   word?: string;
+  /**
+   * 是否跟随全局查词(redux `lookup`),默认 true。
+   * 右边栏开了多个字典 tab 时只有当前 tab 跟随,其余保持各自的词。
+   */
+  follow?: boolean;
+  /** 首次挂载时不显示当前全局查词,从空白开始(新开的 tab 用) */
+  blank?: boolean;
+  /** 显示的词变化时回调(含首次挂载),给外层做 tab 标题用 */
+  onWordChange?: (word?: string) => void;
 }
 }
 
 
-const DictComponentWidget = ({ word }: IWidgetDict) => {
+const DictComponentWidget = ({
+  word,
+  follow = true,
+  blank = false,
+  onWordChange,
+}: IWidgetDict) => {
   const search = useAppSelector(lookupWord);
   const search = useAppSelector(lookupWord);
   const myDictDirty = useAppSelector(myDictIsDirty);
   const myDictDirty = useAppSelector(myDictIsDirty);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (myDictDirty) {
+    // 多个实例时只让跟随的那个提示,避免重复弹出
+    if (myDictDirty && follow) {
       notification.warning({
       notification.warning({
         message: "用户词典有未保存内容,请保存后再查词",
         message: "用户词典有未保存内容,请保存后再查词",
       });
       });
     }
     }
-  }, [myDictDirty]);
+  }, [myDictDirty, follow]);
 
 
-  // 直接从 redux state 派生展示的词,无需本地 state
-  const wordSearch = typeof search === "string" && !myDictDirty ? search : word;
+  const live = typeof search === "string" && !myDictDirty ? search : word;
+
+  // 渲染期派生:全局查词变化时,只有跟随的实例更新自己显示的词
+  const [prevLive, setPrevLive] = useState(live);
+  const [shown, setShown] = useState(blank ? undefined : live);
+  if (live !== prevLive) {
+    setPrevLive(live);
+    if (follow) setShown(live);
+  }
+
+  useEffect(() => {
+    onWordChange?.(shown);
+  }, [shown, onWordChange]);
 
 
   return (
   return (
     <Dictionary
     <Dictionary
-      word={wordSearch}
+      word={shown}
       compact={true}
       compact={true}
       onSearch={(value) => {
       onSearch={(value) => {
         console.debug("onSearch", value);
         console.debug("onSearch", value);

+ 4 - 2
dashboard-v6/src/components/dict/Dictionary.tsx

@@ -53,6 +53,8 @@ function parseQuery(value?: string): IQuery {
 const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
 const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
   const [split, setSplit] = useState<string>();
   const [split, setSplit] = useState<string>();
   const [container, setContainer] = useState<HTMLDivElement | null>(null);
   const [container, setContainer] = useState<HTMLDivElement | null>(null);
+  // 右边栏可同时开多个字典 tab,不能用全局 id 定位顶部
+  const topRef = useRef<HTMLDivElement>(null);
   // 首次挂载就带着 word 时(如从 URL 进入)直接按它查,不必等 prop 变化
   // 首次挂载就带着 word 时(如从 URL 进入)直接按它查,不必等 prop 变化
   const [initial] = useState(() => parseQuery(word));
   const [initial] = useState(() => parseQuery(word));
   const [wordSearch, setWordSearch] = useState(initial.word);
   const [wordSearch, setWordSearch] = useState(initial.word);
@@ -89,7 +91,7 @@ const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
       mounted.current = true;
       mounted.current = true;
       return;
       return;
     }
     }
-    document.getElementById("pcd_dict_top")?.scrollIntoView();
+    topRef.current?.scrollIntoView();
   }, [wordSearch, dictType, wordId]);
   }, [wordSearch, dictType, wordId]);
 
 
   const wordInputChange = (value: string | undefined) => {
   const wordInputChange = (value: string | undefined) => {
@@ -123,7 +125,7 @@ const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
 
 
   return (
   return (
     <div ref={setContainer}>
     <div ref={setContainer}>
-      <div id="pcd_dict_top"></div>
+      <div ref={topRef}></div>
       <Affix
       <Affix
         offsetTop={0}
         offsetTop={0}
         target={compact ? () => container : undefined}
         target={compact ? () => container : undefined}

+ 2 - 0
dashboard-v6/src/components/editor/Editor.tsx

@@ -118,6 +118,8 @@ export default function Editor({
       key: "dict",
       key: "dict",
       icon: <DictIcon />,
       icon: <DictIcon />,
       label: "字典",
       label: "字典",
+      // DictPanel 自带 tabs 顶栏(含关闭按钮)
+      hideHeader: true,
       content: <DictPanel />,
       content: <DictPanel />,
     },
     },
     {
     {

+ 20 - 0
dashboard-v6/src/components/editor/panels/DictPanel.module.css

@@ -0,0 +1,20 @@
+/* 字典面板顶栏:tabs + 关闭按钮一行,滚动时吸顶 */
+.tabs > :global(.ant-tabs-nav) {
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  margin: 0;
+  padding: 4px 4px 0 8px;
+  min-height: 40px;
+  background: var(--ant-color-bg-container, #fff);
+}
+
+/* 长词截断,悬停看全文 */
+.label {
+  display: inline-block;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  vertical-align: bottom;
+}

+ 94 - 1
dashboard-v6/src/components/editor/panels/DictPanel.tsx

@@ -1,14 +1,107 @@
+import { CloseOutlined } from "@ant-design/icons";
+import { Button, Tabs } from "antd";
+import { useCallback, useState } from "react";
+import { useIntl } from "react-intl";
 import DictComponent from "../../dict/DictComponent";
 import DictComponent from "../../dict/DictComponent";
+import { useSplitLayout } from "../../general/SplitLayout";
+import styles from "./DictPanel.module.css";
 
 
 /**
 /**
  * 字典面板
  * 字典面板
  * 封装成独立组件,让 React 管理生命周期,
  * 封装成独立组件,让 React 管理生命周期,
  * 避免内联 JSX 在 rightTabs 重建时丢失内部状态。
  * 避免内联 JSX 在 rightTabs 重建时丢失内部状态。
+ *
+ * 顶栏是一行 tabs + 最右侧关闭按钮(替代右边栏通用的「字典」标题栏)。
+ * 点 + 新开一个空白查词 tab;全局查词(点经文里的词)只送到当前 tab,
+ * 其余 tab 保持各自的词,方便对照。tab 标题显示该 tab 查的词,没查过显示「字典」。
  */
  */
+
+/**
+ * 从查词串取 tab 标题。普通词原样返回;高级格式(`type:term id:xxx`)
+ * 只认 `word:`,取不到返回 undefined,由调用方保留原标题。
+ */
+function labelOf(value?: string): string | undefined {
+  if (!value) return undefined;
+  if (!value.includes(":")) return value;
+  return value.match(/(?:^|\s)word:(\S+)/)?.[1];
+}
+
 export default function DictPanel() {
 export default function DictPanel() {
+  const intl = useIntl();
+  const { closeRightPanel } = useSplitLayout();
+  const [tabs, setTabs] = useState<string[]>(["1"]);
+  const [activeKey, setActiveKey] = useState("1");
+  const [nextKey, setNextKey] = useState(2);
+  /** tab key → 该 tab 查的词 */
+  const [labels, setLabels] = useState<Record<string, string>>({});
+
+  // 值没变时返回原对象,React 跳过重渲染,子组件每次渲染回调也不会循环
+  const setWord = useCallback((key: string, word?: string) => {
+    const label = labelOf(word);
+    if (label === undefined) return;
+    setLabels((prev) =>
+      prev[key] === label ? prev : { ...prev, [key]: label },
+    );
+  }, []);
+
+  const title = intl.formatMessage({ id: "columns.library.dict.title" });
+
+  const add = () => {
+    const key = String(nextKey);
+    setNextKey(nextKey + 1);
+    setTabs([...tabs, key]);
+    setActiveKey(key);
+  };
+
+  const remove = (key: string) => {
+    const index = tabs.indexOf(key);
+    const rest = tabs.filter((t) => t !== key);
+    setTabs(rest);
+    if (key === activeKey) {
+      setActiveKey(rest[Math.max(0, index - 1)]);
+    }
+  };
+
   return (
   return (
     <div className="dict_component">
     <div className="dict_component">
-      <DictComponent />
+      <Tabs
+        className={styles.tabs}
+        type="editable-card"
+        size="small"
+        activeKey={activeKey}
+        onChange={setActiveKey}
+        onEdit={(key, action) => {
+          if (action === "add") add();
+          else if (typeof key === "string") remove(key);
+        }}
+        tabBarExtraContent={{
+          right: (
+            <Button
+              type="text"
+              size="small"
+              icon={<CloseOutlined />}
+              onClick={closeRightPanel}
+              title="关闭面板"
+            />
+          ),
+        }}
+        items={tabs.map((key) => ({
+          key,
+          label: (
+            <span className={styles.label} title={labels[key]}>
+              {labels[key] ?? title}
+            </span>
+          ),
+          closable: tabs.length > 1,
+          children: (
+            <DictComponent
+              follow={key === activeKey}
+              blank={key !== "1"}
+              onWordChange={(word) => setWord(key, word)}
+            />
+          ),
+        }))}
+      />
     </div>
     </div>
   );
   );
 }
 }

+ 5 - 0
dashboard-v6/src/components/general/SplitLayout/RightToolbar.module.css

@@ -59,6 +59,11 @@
   flex-direction: column;
   flex-direction: column;
 }
 }
 
 
+/* 面板自带顶栏(hideHeader)时占满整个高度 */
+.panelBodyFull {
+  height: 100vh;
+}
+
 /* ── 纵向图标工具栏 ── */
 /* ── 纵向图标工具栏 ── */
 .toolbar {
 .toolbar {
   display: flex;
   display: flex;

+ 21 - 12
dashboard-v6/src/components/general/SplitLayout/RightToolbar.tsx

@@ -16,6 +16,11 @@ export interface RightToolbarTab {
    * - 不传则点击图标不展开面板(纯按钮行为)。
    * - 不传则点击图标不展开面板(纯按钮行为)。
    */
    */
   content?: ReactNode;
   content?: ReactNode;
+  /**
+   * 不渲染面板 header(标题 + 关闭按钮)。
+   * 面板自己提供顶栏时用,关闭按钮通过 useSplitLayout().closeRightPanel 自行放置。
+   */
+  hideHeader?: boolean;
 }
 }
 
 
 interface RightToolbarProps {
 interface RightToolbarProps {
@@ -56,20 +61,24 @@ export default function RightToolbar({
       {panelOpen && (
       {panelOpen && (
         <div className={styles.panel}>
         <div className={styles.panel}>
           {/* 面板 header */}
           {/* 面板 header */}
-          <div className={styles.panelHeader}>
-            <span className={styles.panelTitle}>{activeTab!.label}</span>
-            <Button
-              type="text"
-              size="small"
-              icon={<CloseOutlined />}
-              onClick={onClose}
-              className={styles.closeBtn}
-              title="关闭面板"
-            />
-          </div>
+          {!activeTab!.hideHeader && (
+            <div className={styles.panelHeader}>
+              <span className={styles.panelTitle}>{activeTab!.label}</span>
+              <Button
+                type="text"
+                size="small"
+                icon={<CloseOutlined />}
+                onClick={onClose}
+                className={styles.closeBtn}
+                title="关闭面板"
+              />
+            </div>
+          )}
 
 
           {/* 各 tab 内容:懒创建,隐藏不销毁 */}
           {/* 各 tab 内容:懒创建,隐藏不销毁 */}
-          <div className={styles.panelBody}>
+          <div
+            className={`${styles.panelBody} ${activeTab!.hideHeader ? styles.panelBodyFull : ""}`}
+          >
             {tabs
             {tabs
               .filter((t) => t.content && mounted.has(t.key))
               .filter((t) => t.content && mounted.has(t.key))
               .map((t) => (
               .map((t) => (

+ 24 - 13
dashboard-v6/src/pages/workspace/channel/show.tsx

@@ -1,8 +1,8 @@
 import { useEffect, useState } from "react";
 import { useEffect, useState } from "react";
 import { useNavigate, useParams } from "react-router";
 import { useNavigate, useParams } from "react-router";
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
-import { Button, Card, Tabs } from "antd";
-import { TeamOutlined } from "@ant-design/icons";
+import { Button, Card, Space, Tabs } from "antd";
+import { ExportOutlined, TeamOutlined } from "@ant-design/icons";
 
 
 import { get } from "../../../request";
 import { get } from "../../../request";
 
 
@@ -39,17 +39,28 @@ const Widget = () => {
         title={title}
         title={title}
         extra={
         extra={
           channelId ? (
           channelId ? (
-            <ShareModal
-              trigger={
-                <Button icon={<TeamOutlined />}>
-                  {intl.formatMessage({
-                    id: "buttons.share",
-                  })}
-                </Button>
-              }
-              resId={channelId}
-              resType={EResType.channel}
-            />
+            <Space>
+              <Button
+                icon={<ExportOutlined />}
+                href={`${import.meta.env.VITE_APP_API_SERVER}/library/tipitaka/channels/${channelId}`}
+                target="_blank"
+              >
+                {intl.formatMessage({
+                  id: "buttons.open.in.library",
+                })}
+              </Button>
+              <ShareModal
+                trigger={
+                  <Button icon={<TeamOutlined />}>
+                    {intl.formatMessage({
+                      id: "buttons.share",
+                    })}
+                  </Button>
+                }
+                resId={channelId}
+                resType={EResType.channel}
+              />
+            </Space>
           ) : undefined
           ) : undefined
         }
         }
       >
       >

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.