TipitakaController.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. <?php
  2. namespace App\Http\Controllers\Library;
  3. use App\Http\Api\ChannelApi;
  4. use App\Http\Controllers\Controller;
  5. use App\Models\PaliText;
  6. use App\Models\ProgressChapter;
  7. use App\Models\Tag;
  8. use App\Models\TagMap;
  9. use App\Services\TermService;
  10. use Illuminate\Http\Request;
  11. use Illuminate\Support\Facades\Cookie;
  12. use Illuminate\Support\Facades\File;
  13. use Illuminate\Support\Str;
  14. class TipitakaController extends Controller
  15. {
  16. // 封面渐变色池:uid 首字节取余循环,保证同一文集颜色稳定
  17. private array $coverGradients = [
  18. 'linear-gradient(160deg, #2d1020, #ae6b8b)',
  19. 'linear-gradient(160deg, #1a2d10,rgba(75, 114, 36, 0.61))',
  20. 'linear-gradient(160deg, #0d1f3c,rgb(55, 98, 150))',
  21. 'linear-gradient(160deg, #2d1020,rgb(151, 69, 94))',
  22. 'linear-gradient(160deg, #1a1a2d,rgb(76, 68, 146))',
  23. 'linear-gradient(160deg, #1a2820,rgb(55, 124, 99))',
  24. ];
  25. /**
  26. * 构造函数,注入 TermService
  27. */
  28. public function __construct(
  29. protected TermService $termService,
  30. ) {}
  31. // -------------------------------------------------------------------------
  32. // 从 uid / id 字符串中提取一个稳定的整数,用于色池取余
  33. // -------------------------------------------------------------------------
  34. private function colorIndex(string $uid): int
  35. {
  36. return hexdec(substr(str_replace('-', '', $uid), 0, 4)) % 255;
  37. }
  38. protected static int $nextId = 1;
  39. // app/Http/Controllers/Library/CategoryController.php
  40. // category() 方法修改版
  41. // 变更:
  42. // 1. $id 改为可选参数,无参数时显示顶级分类(首页复用)
  43. // 2. 新增 $filters 过滤参数(type / lang / author / sort)
  44. // 3. 新增右边栏数据:$recommended / $activeAuthors
  45. // 4. 新增 $filterOptions(过滤器选项 + 计数)
  46. // 5. 新增 $totalCount
  47. public function index(Request $request, ?int $id = null)
  48. {
  49. $locale = Cookie::get('language') ?? 'en';
  50. $categories = $this->loadCategories();
  51. // ── 当前分类 ──────────────────────────────────────────
  52. if ($id) {
  53. $currentCategory = collect($categories)->firstWhere('id', $id);
  54. if (! $currentCategory) {
  55. abort(404);
  56. }
  57. $breadcrumbs = $this->getBreadcrumbs($currentCategory, $categories);
  58. } else {
  59. // 首页:虚拟顶级分类
  60. $currentCategory = ['id' => null, 'name' => '三藏'];
  61. $breadcrumbs = [];
  62. }
  63. // ── 子分类 ─────────────────────────────────────────────
  64. $subCategories = array_values(array_filter(
  65. $categories,
  66. fn ($cat) => $cat['parent_id'] == $id
  67. ));
  68. if (count($subCategories) === 0 && ! $request->has('book')) {
  69. $paliBooks = $this->getPaliBooks($categories, $id);
  70. foreach ($paliBooks as $value) {
  71. $subCategories[] = [
  72. 'id' => $id,
  73. 'name' => $value->text,
  74. 'book' => "{$value->book}-{$value->paragraph}",
  75. ];
  76. }
  77. }
  78. $allNames = array_map(fn ($item) => $item['name'], $subCategories);
  79. // 去重
  80. $allNames = array_values(array_unique($allNames));
  81. // 查词典
  82. $terms = $this->termService->glossaryByLemma($allNames, $locale);
  83. // 构建映射
  84. $termMap = [];
  85. if ($terms) {
  86. foreach ($terms as $term) {
  87. $termMap[$term->word] = $term->meaning;
  88. }
  89. }
  90. // 回填
  91. foreach ($subCategories as $key => $cat) {
  92. $name = $cat['name'] ?? null;
  93. $subCategories[$key]['name'] = $termMap[$name] ?? $name;
  94. }
  95. // ── 过滤参数 ────────────────────────────────────────────
  96. $selectedType = request('type', 'all');
  97. $selectedLang = request('lang', 'all');
  98. $selectedAuthor = request('author', 'all');
  99. $selectedSort = request('sort', 'new');
  100. $selectedChannel = request('channel', 'all');
  101. // ── 当前频道(提供 channel 参数时) ──────────────────────
  102. $currentChannel = $selectedChannel !== 'all'
  103. ? (ChannelApi::getById($selectedChannel) ?: null)
  104. : null;
  105. $sortList = [
  106. ['key' => 'new', 'label' => __('library.badge_updated')],
  107. ['key' => 'progress', 'label' => '完成度'],
  108. ];
  109. $selected = [
  110. 'type' => $selectedType,
  111. 'lang' => $selectedLang,
  112. 'author' => $selectedAuthor,
  113. 'sort' => $selectedSort,
  114. 'channel' => $selectedChannel,
  115. ];
  116. if ($request->has('book')) {
  117. $selected['book'] = $request->input('book');
  118. }
  119. // ── 书籍列表(过滤+排序,真实实现替换此处) ──────────────
  120. $categoryBooks = $this->getBooks($categories, $id, $selected);
  121. $totalCount = count($categoryBooks);
  122. // ── 过滤器选项(mock,真实实现从书籍数据聚合) ────────────
  123. $filterOptions = [
  124. 'types' => $this->filterTypes(),
  125. 'languages' => $this->filterLanguages(),
  126. 'authors' => $this->getAuthorOptions($categoryBooks),
  127. ];
  128. // ── 右边栏:本周推荐(mock) ────────────────────────────
  129. $recommended = $this->mockRecommended();
  130. // ── 右边栏:活跃译者(mock) ────────────────────────────
  131. $activeAuthors = $this->mockActiveAuthors();
  132. $types = $this->types();
  133. return view('library.tipitaka.category', compact(
  134. 'currentCategory',
  135. 'subCategories',
  136. 'categoryBooks',
  137. 'breadcrumbs',
  138. 'types',
  139. 'selected',
  140. 'filterOptions',
  141. 'totalCount',
  142. 'recommended',
  143. 'activeAuthors',
  144. 'sortList',
  145. 'currentChannel'
  146. ));
  147. }
  148. private function filterLanguages()
  149. {
  150. return [
  151. ['value' => 'all', 'label' => '全部', 'count' => 0],
  152. ['value' => 'zh-Hans', 'label' => '简体中文', 'count' => 0],
  153. ['value' => 'zh-Hant', 'label' => '繁体中文', 'count' => 0],
  154. ['value' => 'pi', 'label' => '巴利语', 'count' => 0],
  155. ['value' => 'en', 'label' => '英语', 'count' => 0],
  156. ];
  157. }
  158. private function filterTypes()
  159. {
  160. return [
  161. ['value' => 'all', 'label' => '全部', 'count' => 0],
  162. ['value' => 'original', 'label' => '原文', 'count' => 0],
  163. ['value' => 'translation', 'label' => '译文', 'count' => 0],
  164. ['value' => 'nissaya', 'label' => 'Nissaya', 'count' => 0],
  165. ];
  166. }
  167. private function mockRecommended()
  168. {
  169. return [
  170. ['id' => 1, 'title' => '相应部·因缘篇', 'category' => '经藏'],
  171. ['id' => 2, 'title' => '法句经', 'category' => '经藏'],
  172. ['id' => 3, 'title' => '清净道论', 'category' => '注释'],
  173. ['id' => 4, 'title' => '律藏·波罗夷', 'category' => '律藏'],
  174. ['id' => 5, 'title' => '长部·梵网经', 'category' => '经藏'],
  175. ];
  176. }
  177. private function mockActiveAuthors()
  178. {
  179. return [
  180. [
  181. 'name' => 'Bhikkhu Bodhi',
  182. 'avatar' => null,
  183. 'color' => '#2d5a8e',
  184. 'initials' => 'BB',
  185. 'count' => 24,
  186. ],
  187. [
  188. 'name' => 'Bhikkhu Sujato',
  189. 'avatar' => null,
  190. 'color' => '#5a2d8e',
  191. 'initials' => 'BS',
  192. 'count' => 18,
  193. ],
  194. ];
  195. }
  196. // ── 辅助:从书籍列表聚合作者选项(mock,真实实现替换) ─────────
  197. private function getAuthorOptions(array $books): array
  198. {
  199. // TODO: 从 $books 聚合真实作者列表
  200. return [
  201. ['value' => 'all', 'label' => '全部作者', 'count' => count($books)],
  202. ['value' => 'bhikkhu-bodhi', 'label' => 'Bhikkhu Bodhi', 'count' => 0],
  203. ['value' => 'bhikkhu-sujato', 'label' => 'Bhikkhu Sujato', 'count' => 0],
  204. ['value' => 'buddhaghosa', 'label' => 'Buddhaghosa', 'count' => 0],
  205. ['value' => 'bhikkhu-brahmali', 'label' => 'Bhikkhu Brahmali', 'count' => 0],
  206. ];
  207. }
  208. private function types()
  209. {
  210. return [
  211. ['id' => '1', 'name' => 'sutta'],
  212. ['id' => '48', 'name' => 'vinaya'],
  213. ['id' => '66', 'name' => 'abhidhamma'],
  214. ['id' => '82', 'name' => 'añña'],
  215. ];
  216. }
  217. private function subCategories($categories, int $id)
  218. {
  219. return array_filter($categories, function ($cat) use ($id) {
  220. return $cat['parent_id'] == $id;
  221. });
  222. }
  223. private function getBooksIdInCat(array $categories, ?string $id)
  224. {
  225. if ($id) {
  226. $currentCategory = collect($categories)->firstWhere('id', $id);
  227. if (! $currentCategory) {
  228. abort(404);
  229. }
  230. // 标签查章节
  231. $tagNames = $currentCategory['tag'];
  232. $booksChapter = PaliText::withAllTags($tagNames)
  233. ->where('level', 1)->get();
  234. } else {
  235. $booksChapter = PaliText::select(['book', 'paragraph'])
  236. ->where('level', 1)
  237. ->get();
  238. }
  239. $chapters = [];
  240. foreach ($booksChapter as $key => $value) {
  241. $chapters[] = [$value->book, $value->paragraph];
  242. }
  243. return $chapters;
  244. }
  245. private function getPaliBooks(array $categories, string $id)
  246. {
  247. $chapters = $this->getBooksIdInCat($categories, $id);
  248. $books = PaliText::whereIns(['book', 'paragraph'], $chapters)->get();
  249. return $books;
  250. }
  251. private function getBooks(array $categories, ?string $id, array $filters)
  252. {
  253. // 根据分类获取书号
  254. if (isset($filters['book'])) {
  255. $chapters = [explode('-', $filters['book'])];
  256. } else {
  257. $chapters = $this->getBooksIdInCat($categories, $id);
  258. }
  259. $table = ProgressChapter::with('channel.owner')
  260. ->whereHas('channel', function ($query) use ($filters) {
  261. $query->where('status', 30);
  262. if ($filters['type'] !== 'all') {
  263. $query->where('type', $filters['type']);
  264. }
  265. if ($filters['lang'] !== 'all') {
  266. $query->where('lang', $filters['lang']);
  267. }
  268. if ($filters['channel'] !== 'all' && Str::isUuid($filters['channel'])) {
  269. $query->where('uid', $filters['channel']);
  270. }
  271. })
  272. ->whereNotNull('last_chapter_completed_at')
  273. ->whereIns(['book', 'para'], $chapters);
  274. if ($filters['sort'] === 'new') {
  275. $table = $table->orderBy('last_chapter_completed_at', 'desc');
  276. } elseif ($filters['sort'] === 'progress') {
  277. $table = $table->orderBy('progress', 'desc');
  278. }
  279. $books = $table->take(100)->get();
  280. return $this->getBooksInfo($books);
  281. }
  282. private function getBooksInfo(mixed $books)
  283. {
  284. $pali = PaliText::where('level', 1)->get();
  285. // 获取该分类下的书籍
  286. $categoryBooks = [];
  287. $books->each(function ($book) use (&$categoryBooks, $pali) {
  288. $title = $book->title;
  289. if (empty($title)) {
  290. $title = $pali->firstWhere('book', $book->book)->toc;
  291. }
  292. $pcd_book_id = $pali->first(function ($item) use ($book) {
  293. return $item->book == $book->book
  294. && $item->paragraph == $book->para;
  295. })?->pcd_book_id;
  296. $coverFile = "/assets/images/cover/zh-hans/1/{$pcd_book_id}.png";
  297. if (File::exists(public_path($coverFile))) {
  298. $coverUrl = $coverFile;
  299. } else {
  300. $coverUrl = null;
  301. }
  302. $colorIdx = $this->colorIndex($book->uid);
  303. $subTitle = $this->getBookType($book->book, $book->para);
  304. $categoryBooks[] = [
  305. 'id' => $book->uid,
  306. 'title' => $title,
  307. 'author' => $book->channel->name,
  308. 'subTitle' => $subTitle,
  309. 'publisher' => $book->channel->owner,
  310. 'completed_chapters' => $book->completed_chapters,
  311. 'type' => __('labels.'.$book->channel->type),
  312. 'cover' => $coverUrl,
  313. 'cover_gradient' => $this->coverGradients[$colorIdx % count($this->coverGradients)],
  314. 'description' => $book->summary ?? '比库戒律的详细说明',
  315. 'language' => __('language.'.$book->channel->lang),
  316. ];
  317. });
  318. return $categoryBooks;
  319. }
  320. private function getBookType(int $book, int $para)
  321. {
  322. $paliTextUuid = PaliText::where('book', $book)->where('paragraph', $para)->value('uid');
  323. $tagIds = TagMap::where('anchor_id', $paliTextUuid)->select('tag_id')->get();
  324. $tags = Tag::whereIn('id', $tagIds)->select('name')->get();
  325. foreach ($tags as $key => $tag) {
  326. if (in_array($tag->name, ['pāḷi', 'aṭṭhakathā', 'ṭīkā'])) {
  327. return __('library.'.$tag->name);
  328. }
  329. }
  330. return null;
  331. }
  332. private function loadCategories()
  333. {
  334. $json = file_get_contents(public_path('data/category/default.json'));
  335. $tree = json_decode($json, true);
  336. $flat = self::flattenWithIds($tree);
  337. return $flat;
  338. }
  339. public static function flattenWithIds(array $tree, int $parentId = 0, int $level = 1): array
  340. {
  341. $flat = [];
  342. foreach ($tree as $node) {
  343. $currentId = self::$nextId++;
  344. $item = [
  345. 'id' => $currentId,
  346. 'parent_id' => $parentId,
  347. 'name' => $node['name'] ?? null,
  348. 'tag' => $node['tag'] ?? [],
  349. 'description' => '佛教戒律经典',
  350. 'level' => $level,
  351. ];
  352. $flat[] = $item;
  353. if (isset($node['children']) && is_array($node['children'])) {
  354. $childrenLevel = $level + 1;
  355. $flat = array_merge($flat, self::flattenWithIds($node['children'], $currentId, $childrenLevel));
  356. }
  357. }
  358. return $flat;
  359. }
  360. private function getBreadcrumbs($category, $categories)
  361. {
  362. $breadcrumbs = [];
  363. $current = $category;
  364. while ($current) {
  365. array_unshift($breadcrumbs, $current);
  366. $current = collect($categories)->firstWhere('id', $current['parent_id']);
  367. }
  368. return $breadcrumbs;
  369. }
  370. }