TipitakaController.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. <?php
  2. namespace App\Http\Controllers\Library;
  3. use App\Http\Controllers\Controller;
  4. use App\Models\ProgressChapter;
  5. use App\Services\TipitakaCatalogService;
  6. use Illuminate\Contracts\Pagination\LengthAwarePaginator;
  7. use Illuminate\Database\Eloquent\Builder;
  8. use Illuminate\Http\RedirectResponse;
  9. use Illuminate\Http\Request;
  10. use Illuminate\Support\Facades\Cookie;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Str;
  13. use Illuminate\View\View;
  14. /**
  15. * library 三藏栏目:首页与按分类浏览(不限 channel)。
  16. *
  17. * 按译本浏览见 TipitakaChannelController,作品详情与阅读见 BookController。
  18. */
  19. class TipitakaController extends Controller
  20. {
  21. private const PER_PAGE = 8;
  22. public function __construct(
  23. protected TipitakaCatalogService $catalog,
  24. ) {}
  25. /**
  26. * 三藏首页:分类(两级卡片)、译本、最近更新。
  27. *
  28. * 旧地址 /library/tipitaka?channel=x(dashboard「我的 channel」在用)301 到该 channel 的页面。
  29. */
  30. public function index(Request $request): View|RedirectResponse
  31. {
  32. if (Str::isUuid($request->query('channel'))) {
  33. return redirect()->route('library.tipitaka.channels.show', ['channel' => $request->query('channel')], 301);
  34. }
  35. $locale = Cookie::get('language') ?? 'en';
  36. $names = $this->catalog->localNames($this->catalog->categories(), $locale);
  37. $categoryData = array_map(fn ($top) => [
  38. 'category' => [...$top, 'local_name' => $names[$top['name']] ?? $top['name']],
  39. 'children' => array_map(
  40. fn ($child) => [...$child, 'local_name' => $names[$child['name']] ?? $child['name']],
  41. $this->catalog->children($top['id'])
  42. ),
  43. ], $this->catalog->children(0));
  44. $channels = $this->catalog->publicChannels(null, 8)->items();
  45. $recentBooks = $this->recentBooks($this->catalog->works(), 12);
  46. return view('library.tipitaka.index', compact('categoryData', 'channels', 'recentBooks'));
  47. }
  48. /**
  49. * 分类页:子分类 + 过滤器 + 该分类下所有公开 channel 的作品版本(分页)。
  50. *
  51. * 末级分类的「子分类」位置列出该类下的作品,点开进作品版本页。
  52. * 旧链接带 ?channel= 的 301 到该 channel 的分类视图,带 ?book= 的 301 到作品版本页。
  53. */
  54. public function category(Request $request, int $id): View|RedirectResponse
  55. {
  56. if (Str::isUuid($request->query('channel'))) {
  57. return redirect()->route('library.tipitaka.channels.category', ['channel' => $request->query('channel'), 'id' => $id], 301);
  58. }
  59. if (preg_match('/^(\d+)-(\d+)$/', (string) $request->query('book'), $m)
  60. && ($work = $this->catalog->workAt((int) $m[1], (int) $m[2]))) {
  61. return redirect()->route('library.tipitaka.work', ['sn' => $work['sn']], 301);
  62. }
  63. $currentCategory = $this->catalog->category($id);
  64. abort_unless($currentCategory, 404);
  65. $localize = $this->localizer();
  66. $currentCategory = $localize($currentCategory);
  67. $breadcrumbs = array_map($localize, $this->catalog->categoryPath($id));
  68. $works = $this->catalog->worksInCategory($id);
  69. $subCategories = array_map(fn ($c) => [
  70. ...$localize($c),
  71. 'url' => route('library.tipitaka.category', ['id' => $c['id']]),
  72. 'is_work' => false,
  73. ], $this->catalog->children($id));
  74. if (empty($subCategories)) {
  75. foreach ($works as $work) {
  76. $subCategories[] = [
  77. 'name' => $work['title'],
  78. 'url' => route('library.tipitaka.work', ['sn' => $work['sn']]),
  79. 'is_work' => true,
  80. ];
  81. }
  82. }
  83. $sideTree = $this->sideTree(0, array_column($breadcrumbs, 'id'), $id, $localize);
  84. $recentBooks = $this->recentBooks($works, 5);
  85. $activeChannels = $this->activeChannels($works, 5);
  86. return view('library.tipitaka.category', [
  87. ...$this->versionList($request, $works),
  88. ...compact('currentCategory', 'subCategories', 'breadcrumbs', 'sideTree', 'recentBooks', 'activeChannels'),
  89. ]);
  90. }
  91. /**
  92. * 作品版本页:一部作品(book_titles.sn)在所有公开 channel 里的版本。
  93. * 版式同译本列表页:按 type 分组,左栏可按语言过滤,每行带该版本的进度。
  94. */
  95. public function work(Request $request, int $sn): View
  96. {
  97. $work = $this->catalog->work($sn);
  98. abort_unless($work, 404);
  99. $all = array_map(fn ($c) => [
  100. ...$c,
  101. 'url' => route('library.tipitaka.channels.work', ['channel' => $c['uid'], 'sn' => $sn]),
  102. ], $this->catalog->workChannels($work));
  103. // 语言过滤不能叫 lang:SetLocale 中间件用 ?lang= 切换界面语言
  104. $selectedLang = $request->query('channel_lang', 'all');
  105. $versions = $selectedLang === 'all' ? $all : array_values(array_filter($all, fn ($c) => $c['lang'] === $selectedLang));
  106. $groups = $this->catalog->groupByType($versions);
  107. $languages = $this->catalog->filterOptions(collect($all)->countBy('lang')->all());
  108. $localize = $this->localizer();
  109. $breadcrumbs = $work['category_id'] === null
  110. ? []
  111. : array_map($localize, $this->catalog->categoryPath($work['category_id']));
  112. $kind = $work['kind'] ? __('library.'.$work['kind']) : null;
  113. return view('library.tipitaka.work', compact('work', 'breadcrumbs', 'groups', 'languages', 'selectedLang', 'kind'));
  114. }
  115. /**
  116. * 分类页的版本列表:过滤器选项、当前过滤、排序与分页后的书卡片。
  117. *
  118. * @param list<array<string, mixed>> $works
  119. * @return array{selected: array{type: string, lang: string, sort: string}, filterOptions: array, sortList: array, paginator: LengthAwarePaginator, categoryBooks: list<array>, totalCount: int}
  120. */
  121. private function versionList(Request $request, array $works): array
  122. {
  123. $selected = [
  124. 'type' => $request->query('type', 'all'),
  125. // 不能叫 lang:SetLocale 中间件用 ?lang= 切换界面语言
  126. 'lang' => $request->query('channel_lang', 'all'),
  127. 'sort' => $request->query('sort', 'new'),
  128. ];
  129. $base = $this->versionsQuery($works);
  130. $filterOptions = [
  131. 'types' => $this->filterOptions(clone $base, 'type', 'labels'),
  132. 'languages' => $this->filterOptions(clone $base, 'lang', 'language'),
  133. ];
  134. $query = (clone $base)->select('progress_chapters.*')->with('channel.owner')
  135. ->when($selected['type'] !== 'all', fn ($q) => $q->whereHas('channel', fn ($c) => $c->where('type', $selected['type'])))
  136. ->when($selected['lang'] !== 'all', fn ($q) => $q->whereHas('channel', fn ($c) => $c->where('lang', $selected['lang'])));
  137. $query = $selected['sort'] === 'progress'
  138. ? $query->orderByDesc('progress')
  139. : $query->orderByDesc('updated_at');
  140. $paginator = $query->paginate(self::PER_PAGE)->withQueryString();
  141. return [
  142. 'selected' => $selected,
  143. 'filterOptions' => $filterOptions,
  144. 'sortList' => [
  145. ['key' => 'new', 'label' => __('library.badge_updated')],
  146. ['key' => 'progress', 'label' => __('library.sort_progress')],
  147. ],
  148. 'paginator' => $paginator,
  149. 'categoryBooks' => $this->toCards($paginator->getCollection(), $works),
  150. 'totalCount' => $paginator->total(),
  151. ];
  152. }
  153. /**
  154. * 左栏分类树:顶级分类全列;沿当前分类的路径逐级展开,当前分类也展开它的下一级。
  155. *
  156. * @param list<int> $pathIds 从顶级到当前分类的 id
  157. * @return list<array{id: int, name: string, active: bool, open: bool, children: list<array>}>
  158. */
  159. private function sideTree(int $parentId, array $pathIds, int $currentId, \Closure $localize): array
  160. {
  161. return array_map(fn ($c) => [
  162. 'id' => $c['id'],
  163. 'name' => $localize($c)['name'],
  164. 'active' => $c['id'] === $currentId,
  165. // 在当前路径上(祖先或自身),加粗以区分层级
  166. 'open' => in_array($c['id'], $pathIds, true),
  167. 'children' => in_array($c['id'], $pathIds, true)
  168. ? $this->sideTree($c['id'], $pathIds, $currentId, $localize)
  169. : [],
  170. ], $this->catalog->children($parentId));
  171. }
  172. /**
  173. * 分类名本地化:查术语表,查不到用巴利名。
  174. *
  175. * @return \Closure(array): array
  176. */
  177. private function localizer(): \Closure
  178. {
  179. $names = $this->catalog->localNames($this->catalog->categories(), Cookie::get('language') ?? 'en');
  180. return fn (array $c) => [...$c, 'name' => $names[$c['name']] ?? $c['name']];
  181. }
  182. /**
  183. * 这些作品在公开 channel 里的全部版本(progress_chapters 的作品根节点行)。
  184. *
  185. * 两步:作品根节点 (book, para) 取自目录缓存,不查库;再和 progress_chapters 做 join。
  186. * 根节点写成 VALUES 临时表 join,而不是 whereIns 的 (book, para) in ((..),(..)…):
  187. * 没有索引时都要全表扫描,但 join 走哈希,281 个根节点时 94ms → 20ms(2026-11-06 实测)。
  188. * 需要模型的调用方要自己 select('progress_chapters.*'),否则 work_roots 的列会混进来。
  189. *
  190. * TODO: 缺 progress_chapters (book, para) 索引,仍是全表扫描,见 TipitakaCatalogService 类注释。
  191. *
  192. * @param list<array{book: int, para: int}> $works
  193. */
  194. private function versionsQuery(array $works): Builder
  195. {
  196. $query = ProgressChapter::query()->whereHas('channel', fn ($q) => $q->where('status', 30));
  197. if (empty($works)) {
  198. return $query->whereRaw('1 = 0');
  199. }
  200. // book / para 是目录里的整数,强转后直接拼进 SQL,不会注入
  201. $values = implode(',', array_map(fn ($w) => '('.(int) $w['book'].','.(int) $w['para'].')', $works));
  202. return $query->join(
  203. DB::raw("(values {$values}) as work_roots(book, para)"),
  204. fn ($join) => $join->on('work_roots.book', '=', 'progress_chapters.book')
  205. ->on('work_roots.para', '=', 'progress_chapters.para')
  206. );
  207. }
  208. /**
  209. * 过滤器选项:按 channel 的某一列聚合版本数。
  210. *
  211. * @return list<array{value: string, label: string, count: int}>
  212. */
  213. private function filterOptions(Builder $base, string $column, string $langFile): array
  214. {
  215. $rows = $base->join('channels', 'channels.uid', '=', 'progress_chapters.channel_id')
  216. ->groupBy("channels.{$column}")
  217. ->orderByRaw('count(*) desc')
  218. ->get([DB::raw("channels.{$column} as value"), DB::raw('count(*) as count')]);
  219. return [
  220. ['value' => 'all', 'label' => __('library.all'), 'count' => (int) $rows->sum('count')],
  221. ...$rows->map(fn ($row) => [
  222. 'value' => $row->value,
  223. 'label' => __("{$langFile}.{$row->value}"),
  224. 'count' => (int) $row->count,
  225. ])->all(),
  226. ];
  227. }
  228. /**
  229. * progress_chapters 行 → 书卡片。
  230. *
  231. * @param iterable<ProgressChapter> $rows
  232. * @param list<array<string, mixed>> $works
  233. * @return list<array<string, mixed>>
  234. */
  235. private function toCards(iterable $rows, array $works): array
  236. {
  237. $byRoot = collect($works)->keyBy(fn ($w) => "{$w['book']}-{$w['para']}");
  238. $cards = [];
  239. foreach ($rows as $row) {
  240. $work = $byRoot->get("{$row->book}-{$row->para}");
  241. if (! $work || ! $row->channel) {
  242. continue;
  243. }
  244. $cards[] = $this->catalog->bookCard($work, [
  245. 'uid' => (string) $row->channel_id,
  246. 'name' => $row->channel->name,
  247. 'type' => $row->channel->type,
  248. 'lang' => $row->channel->lang,
  249. 'owner' => $row->channel->owner,
  250. ], ['progress' => (float) $row->progress, 'title' => $row->title, 'summary' => $row->summary]);
  251. }
  252. return $cards;
  253. }
  254. /**
  255. * 这些作品最近更新的版本。
  256. *
  257. * @return list<array<string, mixed>>
  258. */
  259. private function recentBooks(array $works, int $limit): array
  260. {
  261. $rows = $this->versionsQuery($works)->select('progress_chapters.*')->with('channel.owner')
  262. ->orderByDesc('updated_at')
  263. ->take($limit)
  264. ->get();
  265. return $this->toCards($rows, $works);
  266. }
  267. /**
  268. * 这些作品里收录最多的公开 channel,给右栏「本类译本」用。
  269. *
  270. * @return list<array{uid: string, name: string, lang: string, count: int}>
  271. */
  272. private function activeChannels(array $works, int $limit): array
  273. {
  274. return $this->versionsQuery($works)
  275. ->join('channels', 'channels.uid', '=', 'progress_chapters.channel_id')
  276. ->groupBy('channels.uid', 'channels.name', 'channels.lang')
  277. ->orderByRaw('count(*) desc')
  278. ->take($limit)
  279. ->get([DB::raw('channels.uid as uid'), 'channels.name', 'channels.lang', DB::raw('count(*) as count')])
  280. ->map(fn ($row) => [
  281. 'uid' => (string) $row->uid,
  282. 'name' => $row->name,
  283. 'lang' => $row->lang,
  284. 'count' => (int) $row->count,
  285. ])
  286. ->all();
  287. }
  288. }