TipitakaChannelController.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. <?php
  2. namespace App\Http\Controllers\Library;
  3. use App\Http\Controllers\Controller;
  4. use App\Models\Channel;
  5. use App\Models\UserInfo;
  6. use App\Services\TipitakaCatalogService;
  7. use Illuminate\Http\Request;
  8. use Illuminate\Pagination\LengthAwarePaginator;
  9. use Illuminate\Support\Collection;
  10. use Illuminate\Support\Facades\Cookie;
  11. use Illuminate\View\View;
  12. /**
  13. * library 三藏栏目:按译本(channel)浏览。
  14. *
  15. * - /library/tipitaka/channels 公开 channel 列表,按 type 分组
  16. * - /library/tipitaka/channels/{channel} 该 channel 收录的作品(分页)
  17. * - /library/tipitaka/channels/{channel}/category/{id} 该 channel 的分类目录树,每部作品带进度
  18. */
  19. class TipitakaChannelController extends Controller
  20. {
  21. private const PER_PAGE = 8;
  22. private const CHANNELS_PER_PAGE = 20;
  23. public function __construct(
  24. protected TipitakaCatalogService $catalog,
  25. ) {}
  26. /**
  27. * 公开 channel 列表:一个列表,按收录作品数降序,分页。可用 ?type= / ?channel_lang= 过滤。
  28. *
  29. * 语言过滤不能叫 lang:SetLocale 中间件用 ?lang= 切换界面语言。
  30. */
  31. public function index(Request $request): View
  32. {
  33. $selectedType = $request->query('type', 'all');
  34. $selectedLang = $request->query('channel_lang', 'all');
  35. $type = $selectedType === 'all' ? null : $selectedType;
  36. $lang = $selectedLang === 'all' ? null : $selectedLang;
  37. $paginator = $this->catalog->publicChannels($lang, self::CHANNELS_PER_PAGE, $type)->withQueryString();
  38. $owners = $this->owners($paginator->getCollection()->pluck('owner_uid')->all());
  39. $channels = $paginator->getCollection()->map(fn ($c) => [
  40. ...$c,
  41. 'owner' => $owners->get($c['owner_uid']),
  42. 'url' => route('library.tipitaka.channels.show', ['channel' => $c['uid']]),
  43. ])->all();
  44. $types = $this->catalog->filterOptions($this->catalog->publicChannelCounts('type', ['lang' => $lang]), 'labels');
  45. $languages = $this->catalog->filterOptions($this->catalog->publicChannelCounts('lang', ['type' => $type]));
  46. return view('library.tipitaka.channels.index', compact('channels', 'paginator', 'types', 'languages', 'selectedType', 'selectedLang'));
  47. }
  48. /**
  49. * 单个 channel 收录的作品,默认按经典顺序(book_titles.sn),可 ?sort=updated|progress。
  50. */
  51. public function show(Request $request, string $channel): View
  52. {
  53. $channelInfo = $this->channelInfo($channel);
  54. $progress = $this->catalog->channelProgress($channel);
  55. $works = $this->worksOf($progress);
  56. $sort = $request->query('sort', 'canon');
  57. $works = match ($sort) {
  58. 'updated' => $works->sortByDesc(fn ($w) => $progress["{$w['book']}-{$w['para']}"]['updated_at']),
  59. 'progress' => $works->sortByDesc(fn ($w) => $progress["{$w['book']}-{$w['para']}"]['progress']),
  60. default => $works,
  61. };
  62. $page = LengthAwarePaginator::resolveCurrentPage();
  63. $paginator = (new LengthAwarePaginator(
  64. $works->forPage($page, self::PER_PAGE)->values(),
  65. $works->count(),
  66. self::PER_PAGE,
  67. $page,
  68. ['path' => $request->url()]
  69. ))->withQueryString();
  70. $books = $paginator->getCollection()
  71. ->map(fn ($w) => $this->catalog->bookCard($w, $channelInfo, $progress["{$w['book']}-{$w['para']}"]))
  72. ->all();
  73. $sortList = [
  74. ['key' => 'canon', 'label' => __('library.sort_canon')],
  75. ['key' => 'updated', 'label' => __('library.badge_updated')],
  76. ['key' => 'progress', 'label' => __('library.sort_progress')],
  77. ];
  78. $topCategories = $this->topCategories($works->all());
  79. return view('library.tipitaka.channels.show', compact('channelInfo', 'books', 'paginator', 'sort', 'sortList', 'topCategories'));
  80. }
  81. /**
  82. * 单个 channel 的分类目录树。顶部切换「全部」与四个藏(不带 id = 全部),树里的节点只是分组(不可点),
  83. * 末级分类是卡片,卡片里每部作品一条进度条。?all=1 时也列出未收录的作品(置灰)。
  84. */
  85. public function category(Request $request, string $channel, ?int $id = null): View
  86. {
  87. $channelInfo = $this->channelInfo($channel);
  88. // 全部:虚拟根节点,下面挂四个藏
  89. $node = $id === null ? ['id' => 0, 'name' => __('library.all')] : $this->catalog->category($id);
  90. abort_unless($node, 404);
  91. $progress = $this->catalog->channelProgress($channel);
  92. $showAll = $request->boolean('all');
  93. $names = $this->catalog->localNames($this->catalog->categories(), Cookie::get('language') ?? 'en');
  94. $tree = $this->buildNode($node, $progress, $names, $showAll, $channel);
  95. $topCategories = $this->topCategories($this->worksOf($progress)->all());
  96. $currentTop = $id === null ? null : $this->catalog->categoryPath($id)[0]['id'];
  97. return view('library.tipitaka.channels.category', compact('channelInfo', 'tree', 'topCategories', 'currentTop', 'showAll'));
  98. }
  99. /**
  100. * 递归构造目录树的一个节点。本 channel 在该节点下没有作品、且不显示未收录时返回 null。
  101. *
  102. * @param array<string, array{progress: float, title: string|null}> $progress
  103. * @param array<string, string> $names
  104. * @return array{id: int, name: string, pali: string, leaf: bool, mine: int, total: int, avg: int, works: list<array>, children: list<array>}|null
  105. */
  106. private function buildNode(array $node, array $progress, array $names, bool $showAll, string $channel): ?array
  107. {
  108. $children = $this->catalog->children($node['id']);
  109. $subtree = $this->catalog->worksInCategory($node['id']);
  110. $mine = array_values(array_filter($subtree, fn ($w) => isset($progress["{$w['book']}-{$w['para']}"])));
  111. if (! $showAll && empty($mine)) {
  112. return null;
  113. }
  114. // 直接挂在本节点上的作品(末级分类的全部作品,或非末级分类直接挂的少数几部)
  115. $own = array_filter($subtree, fn ($w) => $w['category_id'] === $node['id']);
  116. $works = [];
  117. foreach ($own as $work) {
  118. $row = $progress["{$work['book']}-{$work['para']}"] ?? null;
  119. if (! $row && ! $showAll) {
  120. continue;
  121. }
  122. $works[] = [
  123. 'sn' => $work['sn'],
  124. 'title' => ! empty($row['title']) ? $row['title'] : $work['title'],
  125. 'progress' => $row ? (int) round($row['progress'] * 100) : null,
  126. 'url' => route('library.tipitaka.channels.work', ['channel' => $channel, 'sn' => $work['sn']]),
  127. ];
  128. }
  129. $sum = array_sum(array_map(fn ($w) => $progress["{$w['book']}-{$w['para']}"]['progress'], $mine));
  130. return [
  131. 'id' => $node['id'],
  132. 'name' => $names[$node['name']] ?? $node['name'],
  133. 'pali' => $node['name'],
  134. 'leaf' => empty($children),
  135. 'mine' => count($mine),
  136. 'total' => count($subtree),
  137. 'avg' => $mine ? (int) round($sum / count($mine) * 100) : 0,
  138. 'works' => $works,
  139. 'children' => array_values(array_filter(array_map(
  140. fn ($child) => $this->buildNode($child, $progress, $names, $showAll, $channel),
  141. $children
  142. ))),
  143. ];
  144. }
  145. /**
  146. * channel 头部信息;不存在 404。
  147. *
  148. * @return array{uid: string, name: string, type: string, lang: string, owner: UserInfo|null, works: int, updated_at: string|null}
  149. */
  150. private function channelInfo(string $uid): array
  151. {
  152. $channel = $this->catalog->channel($uid);
  153. abort_unless($channel instanceof Channel, 404);
  154. $progress = $this->catalog->channelProgress($uid);
  155. return [
  156. 'uid' => $uid,
  157. 'name' => $channel->name,
  158. 'type' => $channel->type,
  159. 'lang' => $channel->lang,
  160. 'owner' => $channel->owner,
  161. 'works' => count($progress),
  162. 'updated_at' => collect($progress)->max('updated_at'),
  163. ];
  164. }
  165. /**
  166. * 该 channel 收录的作品,按 sn 排序。
  167. *
  168. * @param array<string, mixed> $progress
  169. * @return Collection<int, array<string, mixed>>
  170. */
  171. private function worksOf(array $progress): Collection
  172. {
  173. return collect($this->catalog->works())
  174. ->filter(fn ($w) => isset($progress["{$w['book']}-{$w['para']}"]))
  175. ->values();
  176. }
  177. /**
  178. * 四个藏及本 channel 在其下的作品数。
  179. *
  180. * @return list<array{id: int, name: string, count: int}>
  181. */
  182. private function topCategories(array $works): array
  183. {
  184. $tops = $this->catalog->children(0);
  185. $names = $this->catalog->localNames($tops, Cookie::get('language') ?? 'en');
  186. return array_map(function ($top) use ($works, $names) {
  187. $ids = $this->catalog->subtreeIds($top['id']);
  188. return [
  189. 'id' => $top['id'],
  190. 'name' => $names[$top['name']] ?? $top['name'],
  191. 'count' => count(array_filter($works, fn ($w) => in_array($w['category_id'], $ids, true))),
  192. ];
  193. }, $tops);
  194. }
  195. /**
  196. * @param list<string> $uids
  197. * @return Collection<string, UserInfo>
  198. */
  199. private function owners(array $uids): Collection
  200. {
  201. return UserInfo::whereIn('userid', array_unique($uids))
  202. ->get(['userid', 'username', 'nickname'])
  203. ->keyBy(fn ($u) => (string) $u->userid);
  204. }
  205. }