visuddhinanda 5 дней назад
Родитель
Сommit
e1a67aac05

+ 63 - 44
api-v12/app/Console/Commands/IndexTerm.php

@@ -8,32 +8,34 @@ use App\Services\OpenSearchService;
 use App\Services\TermService;
 use Illuminate\Support\Facades\Log;
 
-
 class IndexTerm extends Command
 {
     /**
      * The name and signature of the console command.
-     * php artisan opensearch:index-term --word=anomadassī
+     *
      * @var string
+     *
+     * @example
+     *   php artisan opensearch:index-term
+     *   php artisan opensearch:index-term --word=anomadassī
+     *   php artisan opensearch:index-term --test
      */
     protected $signature = 'opensearch:index-term
-    {--test}
-    {--word= : index word. omit to all}';
+        {--test}
+        {--word= : 指定单个词条进行索引,省略则索引全部}';
 
     /**
      * The console command description.
      *
      * @var string
      */
-    protected $description = 'Index Term data into OpenSearch ';
+    protected $description = 'Index Term data into OpenSearch';
 
-    private $isTest = false;
-    private $summary = false;
+    /** @var bool 是否为测试模式(只打印,不写入 OpenSearch) */
+    private bool $isTest = false;
 
     /**
      * Create a new command instance.
-     *
-     * @return void
      */
     public function __construct(
         protected OpenSearchService $openSearchService,
@@ -45,93 +47,110 @@ class IndexTerm extends Command
     /**
      * Execute the console command.
      *
-     * @return int
+     * 遍历所有(或指定)DhammaTerm,逐条构建文档并写入 OpenSearch。
+     * 测试模式下(--test)只打印文档内容,不执行写入。
+     *
+     * @return int  0 表示成功,1 表示失败
      */
-    public function handle()
+    public function handle(): int
     {
         $word = $this->option('word');
 
-
         if ($this->option('test')) {
             $this->isTest = true;
             $this->info('test mode');
         }
 
-
         try {
-            // Test OpenSearch connection
             [$connected, $message] = $this->openSearchService->testConnection();
             if (!$connected) {
                 $this->error($message);
                 Log::error($message);
                 return 1;
             }
-            $overallStatus = 0; // Track overall command status (0 for success, 1 for any failure)
+
             $total = DhammaTerm::count();
             $terms = DhammaTerm::select(['guid', 'word'])->orderBy('updated_at', 'asc');
+
             if ($word) {
                 $terms = $terms->where('word', $word);
             }
 
+            $overallStatus = 0;
+
             foreach ($terms->cursor() as $key => $term) {
-                $percent = (int)(($key * 100) / $total);
+                $percent = (int) (($key * 100) / $total);
                 $this->info("[{$percent}%]-{$key}  " . $term->word);
                 $this->indexTerm($term->guid);
             }
 
             return $overallStatus;
         } catch (\Exception $e) {
-            $this->error("Failed to index Pali data: " . $e->getMessage());
-            Log::error("Failed to index Term data : ", ['error' => $e]);
+            $this->error('Failed to index Term data: ' . $e->getMessage());
+            Log::error('Failed to index Term data', ['error' => $e]);
             return 1;
         }
     }
 
     /**
+     * 构建单条词条文档并写入 OpenSearch
      *
+     * 文档结构遵循新版 mapping:
+     *   title.text.pali / title.text.zh  → 全文检索
+     *   title.suggest.pali / title.suggest.zh → 自动建议
+     *   content.text.pali / content.text.zh   → 正文内容
+     *
+     * @param  string  $id  DhammaTerm 的 guid
+     * @return void
      */
-    protected function indexTerm(string $id)
+    protected function indexTerm(string $id): void
     {
-        $termData = $this->termService->find($id, 'text');
-        $channelName = $termData["channel"]['name'] ?? '';
-        $isCommunity = $this->termService->isCommunity($termData["channel_id"]);
-        $content = $termData['html'] ?? $termData['meaning'];
+        $termData    = $this->termService->find($id, 'text');
+        $channelName = $termData['channel']['name'] ?? '';
+        $isCommunity = $this->termService->isCommunity($termData['channel_id']);
+        $content     = $termData['html'] ?? $termData['meaning'];
+
         $document = [
-            'id' => "term_{$id}",
-            'resource_id' => $id, // Use uid from getPaliData for resource_id
+            'id'            => "term_{$id}",
+            'resource_id'   => $id,
             'resource_type' => 'term',
-            'title' => [
-                'pali' => $termData['word'],
-                'zh' => $termData['meaning'],
-                'suggest_pali' => [$termData['word']],
-                'suggest_zh' => [$termData['meaning']],
+            'title'         => [
+                'text' => [
+                    'pali' => $termData['word'],
+                    'zh'   => $termData['meaning'],
+                ],
+                'suggest' => [
+                    'pali' => [$termData['word']],
+                    'zh'   => [$termData['meaning']],
+                ],
             ],
             'summary' => [
-                'text' => $termData['summary'] ?? $termData['note'] ?? ''
+                'text' => $termData['summary'] ?? $termData['note'] ?? '',
             ],
-            'content' => [],
+            'content'     => [],
             'bold_single' => [$termData['meaning'], $termData['word']],
-            'related_id' => $termData['word'],
-            'category' => [],
-            'tags' => $isCommunity ? ['community'] : [],
-            'language' => $termData['language'],
-            'updated_at' => now()->toIso8601String(),
-            'path' => $termData['studio']['realName'] . "/{$channelName}",
+            'related_id'  => $termData['word'],
+            'category'    => [],
+            'tags'        => $isCommunity ? ['community'] : [],
+            'language'    => $termData['language'],
+            'updated_at'  => now()->toIso8601String(),
+            'path'        => $termData['studio']['realName'] . "/{$channelName}",
         ];
 
-        if (strpos($termData['language'], 'zh') !== false) {
-            $document['content']['zh'] = $content;
+        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
+        $plainText = strip_tags($content);
+        if (str_contains($termData['language'], 'zh')) {
+            $document['content']['text']['zh'] = $plainText;
         } else {
-            //TODO 判断语言 放在合适的字段
-            $document['content']['zh'] = $content;
+            $document['content']['text']['zh'] = $plainText;
         }
+        $document['content']['display']    = $content;             // 展示
 
         if ($this->isTest) {
-            $this->info($document['title']['pali']);
+            $this->info($document['title']['text']['pali']);
             $this->info($document['summary']['text']);
         } else {
             $this->openSearchService->create($document['id'], $document);
         }
-        return;
     }
 }

+ 172 - 20
api-v12/app/Console/Commands/IndexTipitaka.php

@@ -9,12 +9,16 @@ use App\Services\SummaryService;
 use App\Services\TagService;
 use Illuminate\Support\Facades\Log;
 use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use App\Http\Api\ChannelApi;
+use App\Models\ProgressChapter;
 
 class IndexTipitaka extends Command
 {
     /**
      * The name and signature of the console command.
-     * php artisan opensearch:index-pali 93 --para=6
+     * php artisan opensearch:index-tipitaka 93 --para=6 --granularity=chapter
      * @var string
      */
     protected $signature = 'opensearch:index-tipitaka {book : The book ID to index data for}
@@ -22,7 +26,7 @@ class IndexTipitaka extends Command
     {--para= : index paragraph No. omit to all}
     {--summary=on}
     {--resume}
-    {--granularity= : The granularity to index (paragraph, sutta, sentence; omit to index all)}';
+    {--granularity=all : The granularity to index (paragraph, sutta, sentence; omit to index all)}';
 
     /**
      * The console command description.
@@ -31,10 +35,7 @@ class IndexTipitaka extends Command
      */
     protected $description = 'Index Pali data into OpenSearch for a specified book and optional granularity (all granularities if not specified)';
 
-    protected $searchPaliDataService;
-    protected $openSearchService;
-    protected $summaryService;
-    protected $tagService;
+
     private $isTest = false;
     private $summary = false;
 
@@ -44,16 +45,12 @@ class IndexTipitaka extends Command
      * @return void
      */
     public function __construct(
-        SearchPaliDataService $searchPaliDataService,
-        OpenSearchService $openSearchService,
-        SummaryService $summaryService,
-        TagService $tagService
+        protected SearchPaliDataService $searchPaliDataService,
+        protected OpenSearchService $openSearchService,
+        protected SummaryService $summaryService,
+        protected TagService $tagService
     ) {
         parent::__construct();
-        $this->searchPaliDataService = $searchPaliDataService;
-        $this->openSearchService = $openSearchService;
-        $this->summaryService = $summaryService;
-        $this->tagService = $tagService;
     }
 
     /**
@@ -92,7 +89,18 @@ class IndexTipitaka extends Command
                 $booksId = [$book];
             }
             foreach ($booksId as $key => $bookId) {
-                $this->indexTipitakaBook($bookId, $paragraph);
+                if (
+                    $this->option('granularity') === 'chapter' ||
+                    $this->option('granularity') === 'all'
+                ) {
+                    $this->indexChapter($bookId);
+                }
+                if (
+                    $this->option('granularity') === 'paragraph' ||
+                    $this->option('granularity') === 'all'
+                ) {
+                    $this->indexTipitakaParagraph($bookId, $paragraph);
+                }
             }
 
             return $overallStatus;
@@ -109,7 +117,7 @@ class IndexTipitaka extends Command
      * @param int $book
      * @return int
      */
-    protected function indexTipitakaBook($book, $paragraph = null)
+    protected function indexTipitakaParagraph($book, $paragraph = null)
     {
         $this->info("Starting to index paragraphs for book: $book");
         $total = 0;
@@ -260,13 +268,157 @@ class IndexTipitaka extends Command
      * @param int $book
      * @return int
      */
-    protected function indexPaliSutta($book)
+    protected function indexChapter($book)
     {
-        $this->warn("Sutta indexing is not yet implemented for book: $book");
-        Log::warning("Sutta indexing not implemented for book: $book");
-        return 1;
+        $this->info("Starting to index paragraphs for book: $book");
+        $total = 0;
+        $chapters = PaliText::where('book', $book)
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')->get();
+        foreach ($chapters as $key => $chapter) {
+            if ($chapter->level === 1) {
+                $category = $this->tagService->getTagsName($chapter->uid);
+            }
+            /**
+             * 章节的起始位置算法
+             * 从章节的标题,到下一个章节的标题之间
+             */
+            $start = $chapter->paragraph;
+            if ($key === count($chapters) - 1) {
+                $end = PaliText::where('book', $book)
+                    ->orderBy('paragraph', 'desc')->first()
+                    ->value('paragraph');
+            } else {
+                $end = $chapters[$key + 1]->paragraph;
+            }
+            //获取这个段落之间的全部channel
+            $channels = Sentence::where('book_id', $book)
+                ->whereBetween('paragraph', [$start, $end])
+                ->select('channel_uid')
+                ->groupBy('channel_uid')->get();
+            $this->info("index chapter start={$start} end={$end}");
+
+            foreach ($channels as $key => $channel) {
+                $display = [];
+                $content = [];
+                $channelInfo = ChannelApi::getById($channel->channel_uid);
+                $this->info('channel =' . $channelInfo['name']);
+                if ($channelInfo['type'] === 'wbw') {
+                    $this->info('wbw channel skip');
+                    continue;
+                }
+                $paragraphsData = app(PaliContentService::class)->paragraphs(
+                    $book,
+                    $start,
+                    $end,
+                    [$channel->channel_uid],
+                    ['mode' => 'read', 'format' => 'html', 'original' => true]
+                );
+                //生成html数据
+
+                $title = '';
+                foreach ($paragraphsData as $key => $paragraph) {
+                    $translation = [];
+                    $original = [];
+                    foreach ($paragraph['children'] as $key => $sent) {
+                        if (isset($sent['translation'])) {
+                            foreach ($sent['translation'] as $key => $tran) {
+                                $curr = $tran['html'] ?? $tran['content'];
+                                $translation[] = "<span class='sentence'>{$curr}</span>";
+                                if ($tran['para'] === $start && !empty($curr)) {
+                                    $title = $curr;
+                                }
+                            }
+                        }
+                        if (
+                            isset($sent['origin']) ||
+                            is_array($sent['origin']) ||
+                            count($sent['origin']) > 0
+                        ) {
+                            $ori = $sent['origin'][0];
+                            $curr = $ori['html'] ?? $ori['content'];
+                            $original[] = "<span class='sentence origin'>{$curr}</span>";
+                            if (empty($title) && $ori['para'] === $start && !empty($curr)) {
+                                $title = $curr;
+                            }
+                        }
+                    }
+
+
+                    $level = $paragraph['para'] === $start ? $chapter->level : 0;
+                    $strOriginal = implode('', $original);
+                    $strTranslation = implode('', $translation);
+
+                    if ($level > 0) {
+                        $display[] = "<div><h{$level}>{$strOriginal}</h{$level}><h{$level}>{$strTranslation}</h{$level}></div>";
+                    } else {
+                        $display[] = "<div><p>{$strOriginal}</p><p>{$strTranslation}</p></div>";
+                    }
+
+                    if ($channelInfo['type'] === 'original') {
+                        $content[] = $strOriginal;
+                    } else {
+                        $content[] = $strTranslation;
+                    }
+                }
+                $this->chapterSave([
+                    'book' => $book,
+                    'para' => $start,
+                    'channel' => $channel->channel_uid,
+                    'display' => implode('', $display),
+                    'content' => implode('', $content),
+                    'title' => strip_tags($title),
+                    'cat' => $category
+                ]);
+            }
+        }
+
+
+        return 0;
     }
 
+    protected function chapterSave(array $param)
+    {
+        $progress = ProgressChapter::where('book', $param['book'])
+            ->where('para', $param['para'])
+            ->where('channel_id', $param['channel'])
+            ->first();
+        $channel = ChannelApi::getById($param['channel']);
+        $document = [
+            'id'            => "tipitaka_chapter_{$param['book']}-{$param['para']}_{$param['channel']}",
+            'resource_id'   => $progress ? $progress->uid : "{$param['book']}-{$param['para']}_{$param['channel']}",
+            'resource_type' => 'tipitaka',
+            'title'         => [],
+            'summary' => [
+                'text' => '',
+            ],
+            'content'     => [],
+            'related_id'  => "{$param['book']}-{$param['para']}",
+            'category'    => $param['cat'],
+            'language'    => $channel['lang'],
+            'updated_at'  => now()->toIso8601String(),
+            'granularity' => 'chapter',
+        ];
+
+        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
+        $plainText = strip_tags($param['content']);
+        $title = strip_tags($param['title']);
+        if (str_contains($channel['lang'], 'zh')) {
+            $document['content']['text']['zh'] = $plainText;
+            $document['title']['text']['zh'] = $title;
+        } else {
+            $document['content']['text']['pali'] = $plainText;
+            $document['title']['text']['pali'] = $title;
+        }
+        $document['content']['display']    = $param['display'];             // 展示
+
+        if ($this->isTest) {
+            $this->info($param['content']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+            $this->info("create index {$document['id']} size=" . strlen($param['content']));
+        }
+    }
     /**
      * Index Pali sentences for a given book (placeholder for future implementation).
      *

+ 1 - 1
api-v12/app/Console/Commands/InitSystemChannel.php

@@ -29,7 +29,7 @@ class InitSystemChannel extends Command
         ],
         [
             "name" => '_System_Wbw_VRI_',
-            'type' => 'original',
+            'type' => 'wbw',
             'lang' => 'pali',
         ],
         [

+ 88 - 30
api-v12/app/DTO/Search/HitItemDTO.php

@@ -16,49 +16,66 @@ class HitItemDTO
         public string $updated,
         public string $type,
         public string $language,
-
+        public ?string $display = null,
     ) {}
 
+    /**
+     * 从 OpenSearch hit 数组构建 DTO
+     *
+     * title 和 content 的拼接策略:
+     *   因为一条记录通常只有一种语言被赋值(pali 或 zh),
+     *   所以直接将 text.* 下所有非空值拼接输出,无需判断语言类型。
+     *
+     *   title:   取 title.text.pali + title.text.zh(跳过空值)
+     *   content: 取 content.text.pali + content.text.zh(跳过空值)
+     *   display: 取 content.display,列表页查询时因被 sourceExcludes 排除
+     *            通常为 null;详情页通过 get() 获取完整文档时才有值
+     *
+     * @param  array  $data  OpenSearch 单条 hit 原始数据
+     * @return static
+     */
     public static function fromArray(array $data): self
     {
         $source = $data['_source'];
-        $highlight = $data['highlight'] ?? null;
+
+        // ---------- highlight ----------
         $highlightArray = [];
-        if (is_array($highlight)) {
-            foreach ($highlight as $key => $value) {
-                $highlightArray = array_merge($highlightArray, $value);
+        if (!empty($data['highlight']) && is_array($data['highlight'])) {
+            foreach ($data['highlight'] as $fragments) {
+                $highlightArray = array_merge($highlightArray, $fragments);
             }
         }
 
-        $content = $source['content'];
-        $contentArray = [];
-        if (is_array($content)) {
-            foreach ($content as $key => $value) {
-                $contentArray[] = $value;
-            }
-        }
-        $titleArray = [];
-        if (is_array($source['title'])) {
-            foreach ($source['title'] as $key => $value) {
-                if (strpos($key, 'suggest') === false) {
-                    $titleArray[] = $value;
-                }
-            }
-        }
-        $title = implode('', $titleArray);
+        // ---------- title ----------
+        // 取 title.text 下所有非空语言值拼接
+        $titleText = $source['title']['text'] ?? [];
+        $title = implode('', array_filter(
+            is_array($titleText) ? array_values($titleText) : [],
+            fn($v) => is_string($v) && $v !== ''
+        ));
 
-        $category = [];
-        if (is_array($source['category'])) {
-            $category = $source['category'];
-        } else {
-            $category = [$source['category']];
-        }
+        // ---------- content ----------
+        // 取 content.text 下所有非空语言值拼接
+        $contentText = $source['content']['text'] ?? [];
+        $content = implode('', array_filter(
+            is_array($contentText) ? array_values($contentText) : [],
+            fn($v) => is_string($v) && $v !== ''
+        ));
+
+        // ---------- display ----------
+        // 列表页查询时被 sourceExcludes 排除,值为 null
+        // 详情页通过 get() 取完整文档时才有值
+        $display = $source['content']['display'] ?? null;
+
+        // ---------- category ----------
+        $rawCategory = $source['category'] ?? [];
+        $category = is_array($rawCategory) ? $rawCategory : [$rawCategory];
 
         return new self(
             id: $source['id'],
-            score: $data['_score'],
+            score: $data['_score'] ?? 0,
             title: $title,
-            content: implode('', $contentArray),
+            content: $content,
             path: $source['path'] ?? '',
             category: $category,
             highlight: implode('', $highlightArray),
@@ -66,13 +83,49 @@ class HitItemDTO
             type: $source['resource_type'],
             language: $source['language'],
             resId: $source['resource_id'],
+            display: $display,
         );
     }
 
+    /**
+     * 将 DTO 转为数组(用于 API 响应输出)
+     *
+     * display 字段仅在有值时输出,避免列表页响应体携带 null 键。
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        $data = [
+            'id'        => $this->id,
+            'res_id'    => $this->resId,
+            'score'     => $this->score,
+            'title'     => $this->title,
+            'content'   => $this->content,
+            'path'      => $this->path,
+            'category'  => $this->category,
+            'highlight' => $this->highlight,
+            'updated'   => $this->updated,
+            'type'      => $this->type,
+            'language'  => $this->language,
+            'para_id'   => $this->getParaId(),
+            'para_link' => $this->getParaLink(),
+        ];
 
+        if ($this->display !== null) {
+            $data['display'] = $this->display;
+        }
+
+        return $data;
+    }
 
     /**
-     * 提取 para 引用ID(核心逻辑🔥)
+     * 提取 para 引用 ID
+     *
+     * 匹配文档 ID 格式 "pali_para_{bookId}_{paraId}",
+     * 返回 "{bookId}-{paraId}" 格式字符串。
+     *
+     * @return string|null
      */
     public function getParaId(): ?string
     {
@@ -82,6 +135,11 @@ class HitItemDTO
         return null;
     }
 
+    /**
+     * 生成 para wiki 模板引用字符串
+     *
+     * @return string|null  例如:{{para|id=1-23|title=1-23|style=reference}}
+     */
     public function getParaLink(): ?string
     {
         $id = $this->getParaId();

+ 0 - 469
api-v12/app/Http/Controllers/CategoryController.php

@@ -1,469 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\File;
-
-use Illuminate\Support\Facades\DB;
-use App\Models\PaliText;
-use App\Models\ProgressChapter;
-use App\Models\Tag;
-use App\Models\TagMap;
-
-
-class CategoryController 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))',
-    ];
-    // -------------------------------------------------------------------------
-    // 从 uid / id 字符串中提取一个稳定的整数,用于色池取余
-    // -------------------------------------------------------------------------
-    private function colorIndex(string $uid): int
-    {
-        return hexdec(substr(str_replace('-', '', $uid), 0, 4)) % 255;
-    }
-    protected static int $nextId = 1;
-    public function home()
-    {
-        $categories = $this->loadCategories();
-
-        // 获取一级分类和对应的书籍
-        $categoryData = [];
-        foreach ($categories as $category) {
-            if ($category['level'] == 1) {
-                $children = $this->subCategories($categories, $category['id']);
-                $categoryData[] = [
-                    'category' => $category,
-                    'children' => $children,
-                ];
-            }
-        }
-        $recentBooks = $this->getRecent();
-
-        return view('library.index', compact(
-            'categoryData',
-            'categories',
-            'recentBooks'
-        ));
-    }
-
-
-    public function index()
-    {
-        $categories = $this->loadCategories();
-
-        // 获取一级分类和对应的书籍
-        $categoryData = [];
-        foreach ($categories as $category) {
-            if ($category['level'] == 1) {
-                $children = $this->subCategories($categories, $category['id']);
-                $categoryData[] = [
-                    'category' => $category,
-                    'children' => $children,
-                ];
-            }
-        }
-
-        return view('library.index', compact('categoryData', 'categories'));
-    }
-
-    // app/Http/Controllers/Library/CategoryController.php
-    // category() 方法修改版
-    // 变更:
-    //   1. $id 改为可选参数,无参数时显示顶级分类(首页复用)
-    //   2. 新增 $filters 过滤参数(type / lang / author / sort)
-    //   3. 新增右边栏数据:$recommended / $activeAuthors
-    //   4. 新增 $filterOptions(过滤器选项 + 计数)
-    //   5. 新增 $totalCount
-
-    public function category(?int $id = null)
-    {
-
-        $categories = $this->loadCategories();
-
-        // ── 当前分类 ──────────────────────────────────────────
-        if ($id) {
-            $currentCategory = collect($categories)->firstWhere('id', $id);
-            if (!$currentCategory) {
-                abort(404);
-            }
-            $breadcrumbs = $this->getBreadcrumbs($currentCategory, $categories);
-        } else {
-            // 首页:虚拟顶级分类
-            $currentCategory = ['id' => null, 'name' => '三藏'];
-            $breadcrumbs     = [];
-        }
-
-        // ── 子分类 ─────────────────────────────────────────────
-        $subCategories = array_values(array_filter(
-            $categories,
-            fn($cat) => $cat['parent_id'] == $id
-        ));
-
-        // ── 过滤参数 ────────────────────────────────────────────
-        $selectedType   = request('type',   'all');
-        $selectedLang   = request('lang',   'all');
-        $selectedAuthor = request('author', 'all');
-        $selectedSort   = request('sort',   'updated_at');
-
-        $selected = [
-            'type'   => $selectedType,
-            'lang'   => $selectedLang,
-            'author' => $selectedAuthor,
-            'sort'   => $selectedSort,
-        ];
-
-        // ── 书籍列表(过滤+排序,真实实现替换此处) ──────────────
-        $categoryBooks = $this->getBooks($categories, $id, $selected);
-        // TODO: 将 $selected 传入 getBooks() 做实际过滤
-
-        $totalCount = count($categoryBooks);
-
-        // ── 过滤器选项(mock,真实实现从书籍数据聚合) ────────────
-        $filterOptions = [
-            'types' => [
-                ['value' => 'all',         'label' => '全部',    'count' => $totalCount],
-                ['value' => 'original',    'label' => '原文',    'count' => 0],
-                ['value' => 'translation', 'label' => '译文',    'count' => 0],
-                ['value' => 'nissaya',     'label' => 'Nissaya', 'count' => 0],
-            ],
-            'languages' => [
-                ['value' => 'all',  'label' => '全部',   'count' => $totalCount],
-                ['value' => 'zh-Hans',   'label' => '简体中文',   'count' => 0],
-                ['value' => 'zh-Hant',   'label' => '繁体中文',   'count' => 0],
-                ['value' => 'pi',   'label' => '巴利语', 'count' => 0],
-                ['value' => 'en',   'label' => '英语',   'count' => 0],
-            ],
-            'authors' => $this->getAuthorOptions($categoryBooks),
-        ];
-
-        // ── 右边栏:本周推荐(mock) ────────────────────────────
-        $recommended = [
-            ['id' => 1, 'title' => '相应部·因缘篇',  'category' => '经藏'],
-            ['id' => 2, 'title' => '法句经',          'category' => '经藏'],
-            ['id' => 3, 'title' => '清净道论',        'category' => '注释'],
-            ['id' => 4, 'title' => '律藏·波罗夷',    'category' => '律藏'],
-            ['id' => 5, 'title' => '长部·梵网经',    'category' => '经藏'],
-        ];
-
-        // ── 右边栏:活跃译者(mock) ────────────────────────────
-        $activeAuthors = [
-            [
-                'name'    => 'Bhikkhu Bodhi',
-                'avatar'  => null,
-                'color'   => '#2d5a8e',
-                'initials' => 'BB',
-                'count'   => 24,
-            ],
-            [
-                'name'    => 'Bhikkhu Sujato',
-                'avatar'  => null,
-                'color'   => '#5a2d8e',
-                'initials' => 'BS',
-                'count'   => 18,
-            ],
-            [
-                'name'    => 'Buddhaghosa',
-                'avatar'  => null,
-                'color'   => '#8e5a2d',
-                'initials' => 'BG',
-                'count'   => 12,
-            ],
-            [
-                'name'    => 'Bhikkhu Brahmali',
-                'avatar'  => null,
-                'color'   => '#2d8e5a',
-                'initials' => 'BR',
-                'count'   => 9,
-            ],
-        ];
-
-        $types = $this->types();
-
-        return view('library.tipitaka.category', compact(
-            'currentCategory',
-            'subCategories',
-            'categoryBooks',
-            'breadcrumbs',
-            'types',
-            'selected',
-            'filterOptions',
-            'totalCount',
-            'recommended',
-            'activeAuthors',
-        ));
-    }
-
-    // ── 辅助:从书籍列表聚合作者选项(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],
-        ];
-    }
-
-    private function types()
-    {
-        return [
-            ['id' => '1', 'name' => 'sutta'],
-            ['id' => '48', 'name' => 'vinaya'],
-            ['id' => '66', 'name' => 'abhidhamma'],
-            ['id' => '82', 'name' => 'añña']
-        ];
-    }
-
-
-
-    private function subCategories($categories, int $id)
-    {
-        return array_filter($categories, function ($cat) use ($id) {
-            return $cat['parent_id'] == $id;
-        });
-    }
-    private function getRecent()
-    {
-        return [
-            [
-                'id'         => 'book-001',
-                'title'      => '相应部·因缘篇',
-                'author'     => 'Bhikkhu Bodhi',
-                'cover'      => null,                          // 无封面时显示渐变
-                'cover_gradient' => 'linear-gradient(135deg, #2d5a8e 0%, #1a3a5c 100%)',
-                'updated_at' => '2小时前',
-                'is_new'     => true,                          // true=新增, false=更新
-                'category'   => '经藏',
-            ],
-            [
-                'id'         => 'book-002',
-                'title'      => '长部·梵网经',
-                'author'     => 'Bhikkhu Sujato',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #5a2d8e 0%, #3a1a5c 100%)',
-                'updated_at' => '昨天',
-                'is_new'     => false,
-                'category'   => '经藏',
-            ],
-            [
-                'id'         => 'book-003',
-                'title'      => '法句经注',
-                'author'     => 'Buddhaghosa',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #8e5a2d 0%, #5c3a1a 100%)',
-                'updated_at' => '3天前',
-                'is_new'     => false,
-                'category'   => '注释',
-            ],
-            [
-                'id'         => 'book-004',
-                'title'      => '律藏·波罗夷',
-                'author'     => 'Bhikkhu Brahmali',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #2d8e5a 0%, #1a5c3a 100%)',
-                'updated_at' => '5天前',
-                'is_new'     => true,
-                'category'   => '律藏',
-            ],
-            [
-                'id'         => 'book-005',
-                'title'      => '清净道论',
-                'author'     => 'Buddhaghosa',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #8e2d2d 0%, #5c1a1a 100%)',
-                'updated_at' => '1周前',
-                'is_new'     => false,
-                'category'   => '注释',
-            ],
-            [
-                'id'         => 'book-006',
-                'title'      => '增支部·一集',
-                'author'     => 'Bhikkhu Bodhi',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #2d7a8e 0%, #1a4a5c 100%)',
-                'updated_at' => '1周前',
-                'is_new'     => false,
-                'category'   => '经藏',
-            ],
-        ];
-    }
-
-    private function getUpdateBooks()
-    {
-        $books = ProgressChapter::with('channel.owner')
-            ->leftJoin('pali_texts', function ($join) {
-                $join->on('progress_chapters.book', '=', 'pali_texts.book')
-                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
-            })
-            ->whereHas('channel', function ($query) {
-                $query->where('status', 30);
-            })
-            ->where('progress', '>', config('mint.library.list_min_progress'))
-            ->take(10)
-            ->get();
-
-        return $this->getBooksInfo($books);
-    }
-    private function getBooks($categories, $id, $filters)
-    {
-
-        if ($id) {
-            $currentCategory = collect($categories)->firstWhere('id', $id);
-            if (!$currentCategory) {
-                abort(404);
-            }
-            // 标签查章节
-            $tagNames = $currentCategory['tag'];
-            $tm = (new TagMap)->getTable();
-            $tg = (new Tag)->getTable();
-            $pt = (new PaliText)->getTable();
-            $where1 = " where co = " . count($tagNames);
-            $a = implode(",", array_fill(0, count($tagNames), '?'));
-            $in1 = "and t.name in ({$a})";
-            $param = $tagNames;
-            $where2 = "where level = 1";
-            $query = "select uid as id,book,paragraph,level,toc as title,chapter_strlen,parent,path from (
-                            select anchor_id as cid from (
-                                select tm.anchor_id , count(*) as co
-                                    from $tm as  tm
-                                    left join $tg as t on tm.tag_id = t.id
-                                    where tm.table_name  = 'pali_texts'
-                                    $in1
-                                    group by tm.anchor_id
-                            ) T
-                                $where1
-                        ) CID
-                        left join $pt as pt on CID.cid = pt.uid
-                        $where2
-                        order by book,paragraph";
-
-            $chapters = DB::select($query, $param);
-            $chaptersParam = [];
-            foreach ($chapters as $key => $chapter) {
-                $chaptersParam[] = [$chapter->book, $chapter->paragraph];
-            }
-            // 获取该分类下的章节
-            $books = ProgressChapter::with('channel.owner')
-                ->whereIns(['progress_chapters.book', 'progress_chapters.para'], $chaptersParam)
-                ->whereHas('channel', function ($query) {
-                    $query->where('status', 30);
-                })
-                ->where('progress', '>', config('mint.library.list_min_progress'))
-                ->get();
-        } else {
-            $booksChapter = PaliText::select(['book', 'paragraph'])->where('level', 1)->get();
-            $chapters = [];
-            foreach ($booksChapter as $key => $value) {
-                $chapters[] = [$value->book, $value->paragraph];
-            }
-            $books = ProgressChapter::with('channel.owner')
-                ->whereHas('channel', function ($query) use ($filters) {
-                    $filters['type'] === 'all' ? $query->where('status', 30) :
-                        $query->where('status', 30)->where('type', $filters['type']);
-                })
-                ->where('progress', '>', config('mint.library.list_min_progress'))
-                ->whereIns(['book', 'para'], $chapters)
-                ->take(100)
-                ->get();
-        }
-        return $this->getBooksInfo($books);
-    }
-
-    private function getBooksInfo($books,)
-    {
-        $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;
-            }
-            //Log::debug('getBooksInfo', ['book' => $book->book, 'paragraph' => $book->para]);
-            $pcd_book_id = $pali->first(function ($item) use ($book) {
-                return $item->book == $book->book
-                    && $item->paragraph == $book->para;
-            })?->pcd_book_id;
-
-            $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);
-
-            $categoryBooks[] = [
-                "id" => $book->uid,
-                "title" => $title,
-                "author" => $book->channel->name,
-                "publisher" => $book->channel->owner,
-                "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;
-    }
-    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
-    {
-
-        $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;
-    }
-
-    private function getBreadcrumbs($category, $categories)
-    {
-        $breadcrumbs = [];
-        $current = $category;
-
-        while ($current) {
-            array_unshift($breadcrumbs, $current);
-            $current = collect($categories)->firstWhere('id', $current['parent_id']);
-        }
-
-        return $breadcrumbs;
-    }
-}

+ 100 - 131
api-v12/app/Http/Controllers/Library/BookController.php

@@ -4,6 +4,9 @@ namespace App\Http\Controllers\Library;
 
 use App\Http\Controllers\Controller;
 
+use Illuminate\Support\Facades\Log;
+
+
 use Illuminate\Http\Request;
 use App\Models\ProgressChapter;
 use App\Models\PaliText;
@@ -11,11 +14,23 @@ use App\Models\Sentence;
 
 use App\Services\ChapterService;
 use App\Services\PaliContentService;
+use App\Services\OpenSearchService;
+
+use App\DTO\Search\HitItemDTO;
+
 
 class BookController extends Controller
 {
     protected $maxChapterLen = 50000;
     protected $minChapterLen = 100;
+
+    /**
+     * 构造函数,注入 OpenSearchService
+     *
+     * @param  \App\Services\OpenSearchService  $searchService
+     */
+    public function __construct(protected OpenSearchService $searchService) {}
+
     public function show($id)
     {
         $bookRaw = $this->loadBook($id);
@@ -51,27 +66,49 @@ class BookController extends Controller
 
     public function read(Request $request, $id)
     {
-        $chapterService = app(ChapterService::class);
-        [$book, $para] = explode('-', $id);
+        $start = microtime(true);
+        $lap = function (string $label) use ($start): void {
+            $elapsed = round((microtime(true) - $start) * 1000, 2);
+            Log::debug("[book.read] {$label}", ['elapsed_ms' => $elapsed]);
+        };
+
+        $lap('start');
+
         $channelId = $request->input('channel');
-        $chapterUid = $chapterService->getUidByChannel($book, $para, $channelId);
-        $bookRaw = $this->loadBook($chapterUid);
+        $openSearchId = "tipitaka_chapter_{$id}_{$channelId}";
 
-        if (!$bookRaw) {
-            abort(404);
-        }
-        $channelId = $bookRaw->channel_id; // 替换为具体的 channel_id 值
+        $chapter = HitItemDTO::fromArray($this->searchService->get($openSearchId))->toArray();
+        $lap('searchService->get + HitItemDTO');
 
-        $book = $this->getBookInfo($bookRaw);
-        $book['toc'] = $this->getBookToc($bookRaw->book, $bookRaw->para, $channelId, 2, 7);
-        $book['categories'] = $this->getBookCategory($bookRaw->book, $bookRaw->para);
-        $book['tags'] = [];
-        $book['pagination'] = $this->pagination($bookRaw);
-        $book['content'] = $this->getBookContent($chapterUid);
-        $channels = $chapterService->publicChannels($bookRaw->book, $bookRaw->para);
-        $editor_link = config('mint.server.dashboard_base_path') . "/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
-
-        return view('library.book.read', compact('book', 'channels', 'editor_link'));
+        [$bookId, $paraId] = explode('-', $id);
+
+        $chapterService = app(ChapterService::class);
+
+        $book = [];
+
+        $book['toc'] = $this->getBookToc((int)$bookId, (int)$paraId, $channelId, 2, 7);
+        $lap('getBookToc');
+
+        $book['categories'] = $chapter['category'];
+        $book['title']      = $chapter['title'];
+        $book['author']     = 'author'; // FIXME
+        $book['tags']       = [];
+
+        $book['pagination'] = $this->pagination((int)$bookId, (int)$paraId, $channelId);
+        $lap('pagination');
+
+        $book['content'] = $chapter['display'];
+
+        $channels = $chapterService->publicChannels((int)$bookId, (int)$paraId);
+        $lap('publicChannels');
+
+        $editor_link = config('mint.server.dashboard_base_path')
+            . "/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
+
+        $view = view('library.book.read', compact('book', 'channels', 'editor_link'));
+        $lap('view compiled — total');
+
+        return $view;
     }
 
     private function loadBook($id)
@@ -107,50 +144,53 @@ class BookController extends Controller
         ];
     }
 
-    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2)
+    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2): array
     {
-        //先找到书的起始(书名)章节
-        //一个book 里面可以有多本书
         $currBook = $this->bookStart($book, $paragraph);
+
         $start = $currBook->paragraph;
-        $end = $currBook->paragraph + $currBook->chapter_len - 1;
+        $end   = $currBook->paragraph + $currBook->chapter_len - 1;
+
         $paliTexts = PaliText::where('book', $book)
-            ->whereBetween('paragraph',  [$start, $end])
-            ->whereBetween('level', [$minLevel, $maxLevel])->orderBy('paragraph')->get();
+            ->whereBetween('paragraph', [$start, $end])
+            ->whereBetween('level', [$minLevel, $maxLevel])
+            ->orderBy('paragraph')
+            ->get();
 
         $chapters = ProgressChapter::where('book', $book)
             ->whereBetween('para', [$start, $end])
-            ->where('channel_id', $channelId)->orderBy('para')->get();
-
-        $toc = $paliTexts->map(function ($paliText) use ($chapters, $channelId) {
-            $title = $paliText->toc;
-            if (count($chapters) > 0) {
-                $found = array_filter($chapters->toArray(), function ($chapter) use ($paliText) {
-                    return $chapter['book'] == $paliText->book && $chapter['para'] == $paliText->paragraph;
-                });
-                if (count($found) > 0) {
-                    $chapter = array_shift($found);
-                    if (!empty($chapter['title'])) {
-                        $title = $chapter['title'];
-                    }
-                    if (!empty($chapter['summary'])) {
-                        $summary = $chapter['summary'];
-                    }
-                    $progress = (int)($chapter['progress'] * 100);
-                    $id = $chapter['uid'];
-                }
+            ->where('channel_id', $channelId)
+            ->orderBy('para')
+            ->get();
+
+        // keyBy 建索引,map 里 O(1) 查找,完全避免 toArray() 序列化和 array_filter O(n×m) 扫描
+        $chaptersIndexed = $chapters->keyBy('para');
+
+        return $paliTexts->map(function ($paliText) use ($chaptersIndexed, $channelId) {
+            $title    = $paliText->toc;
+            $summary  = '';
+            $progress = 0;
+            $disabled = true;
+
+            /** @var ProgressChapter|null $chapter */
+            $chapter = $chaptersIndexed->get($paliText->paragraph);
+            if ($chapter) {
+                if (!empty($chapter->title))   $title   = $chapter->title;
+                if (!empty($chapter->summary)) $summary = $chapter->summary;
+                $progress = (int)($chapter->progress * 100);
+                $disabled = false;
             }
+
             return [
-                "id" => "{$paliText->book}-{$paliText->paragraph}",
-                "channel" => $channelId,
-                "title" => $title,
-                "summary" => $summary ?? "",
-                "progress" => $progress ?? 0,
-                "level" => (int)$paliText->level,
-                "disabled" => !isset($progress),
+                'id'       => "{$paliText->book}-{$paliText->paragraph}",
+                'channel'  => $channelId,
+                'title'    => $title,
+                'summary'  => $summary,
+                'progress' => $progress,
+                'level'    => (int)$paliText->level,
+                'disabled' => $disabled,
             ];
-        })->toArray();
-        return $toc;
+        })->all();
     }
 
     public function getBookCategory($book, $paragraph)
@@ -173,91 +213,20 @@ class BookController extends Controller
             ->first();
         return $currBook;
     }
-    public function getBookContent($id)
-    {
-        //查询book信息
-        $book = $this->loadBook($id);
-        $currBook = $this->bookStart($book->book, $book->para);
-        $start = $currBook->paragraph;
-        $end = $currBook->paragraph + $currBook->chapter_len - 1;
-        // 查询起始段落
-        $paragraphs = PaliText::where('book', $book->book)
-            ->whereBetween('paragraph', [$start, $end])
-            ->where('level', '<', 8)
-            ->orderBy('paragraph')
-            ->get();
-        $curr = $paragraphs->firstWhere('paragraph', $book->para);
-        $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
-        if ($curr->chapter_strlen > $this->maxChapterLen) {
-            //太大了,修改结束位置 找到下一级
-            foreach ($paragraphs as $key => $paragraph) {
-                if ($paragraph->paragraph > $curr->paragraph) {
-                    if ($paragraph->chapter_strlen <= $this->maxChapterLen) {
-                        $endParagraph = $paragraph->paragraph + $paragraph->chapter_len - 1;
-                        break;
-                    }
-                    if ($paragraph->level <= $curr->level) {
-                        //不能往下走了,就是它了
-                        $endParagraph = $paragraphs[$key - 1]->paragraph + $paragraphs[$key - 1]->chapter_len - 1;
-                        break;
-                    }
-                }
-            }
-        }
 
-        $paraStart = $curr->paragraph;
-        $paraEnd = $endParagraph;
-        $paragraphs = app(PaliContentService::class)->paragraphs(
-            $book->book,
-            $paraStart,
-            $paraEnd,
-            [$book->channel_id],
-            ['mode' => 'read', 'format' => 'html', 'original' => false]
-        );
-
-        //获取句子数据
-
-        $pali = PaliText::where('book', $book->book)
-            ->whereBetween('paragraph', [$curr->paragraph, $endParagraph])
-            ->select(['paragraph', 'level'])
-            ->orderBy('paragraph')
-            ->get();
-        $result = [];
-        foreach ($paragraphs as $key => $paragraph) {
-            $content = [];
-            foreach ($paragraph['children'] as $key => $sent) {
-                if (isset($sent['translation'])) {
-                    foreach ($sent['translation'] as $key => $translation) {
-                        $curr = $translation['html'] ?? $translation['content'];
-                        $content[] = "<span class='sentence'>{$curr}</span>";
-                    }
-                }
-            }
-            $currPaliPara = $pali->firstWhere('paragraph', $paragraph['para']);
-            $level = $currPaliPara->level;
-            $paragraph = [
-                'id' => $paragraph['para'],
-                'level' => $level,
-                'text' => [[implode('', $content)]],
-            ];
-            $result[] = $paragraph;
-        }
-
-        return $result;
-    }
 
-    public function pagination($book)
+    public function pagination(int $book, int $para, string $channelId)
     {
-        $currBook = $this->bookStart($book->book, $book->para);
+        $currBook = $this->bookStart($book, $para);
         $start = $currBook->paragraph;
         $end = $currBook->paragraph + $currBook->chapter_len - 1;
         // 查询起始段落
-        $paragraphs = PaliText::where('book', $book->book)
+        $paragraphs = PaliText::where('book', $book)
             ->whereBetween('paragraph', [$start, $end])
             ->where('level', '<', 8)
             ->orderBy('paragraph')
             ->get();
-        $curr = $paragraphs->firstWhere('paragraph', $book->para);
+        $curr = $paragraphs->firstWhere('paragraph', $para);
         $current = $curr; //实际显示的段落
         $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
         if ($curr->chapter_strlen > $this->maxChapterLen) {
@@ -288,7 +257,7 @@ class BookController extends Controller
             $nextTranslation = ProgressChapter::with('channel.owner')
                 ->where('book', $nextPali->book)
                 ->where('para', $nextPali->paragraph)
-                ->where('channel_id', $book->channel_id)
+                ->where('channel_id', $channelId)
                 ->first();
             if ($nextTranslation) {
                 if (!empty($nextTranslation->title)) {
@@ -296,7 +265,7 @@ class BookController extends Controller
                 } else {
                     $next['title'] = $nextPali->toc;
                 }
-                $next['id'] = $nextTranslation->uid;
+                $next['id'] = "{$nextPali->book}-{$nextPali->paragraph}";
             }
         }
 
@@ -305,7 +274,7 @@ class BookController extends Controller
             $prevTranslation = ProgressChapter::with('channel.owner')
                 ->where('book', $prevPali->book)
                 ->where('para', $prevPali->paragraph)
-                ->where('channel_id', $book->channel_id)
+                ->where('channel_id', $channelId)
                 ->first();
             if ($prevTranslation) {
                 if (!empty($prevTranslation->title)) {
@@ -313,7 +282,7 @@ class BookController extends Controller
                 } else {
                     $prev['title'] = $prevPali->toc;
                 }
-                $prev['id'] = $prevTranslation->uid;
+                $prev['id'] = "{$prevPali->book}-{$prevPali->paragraph}";
             }
         }
 

+ 6 - 1
api-v12/app/Http/Controllers/Library/SearchController.php

@@ -33,7 +33,7 @@ class SearchController extends Controller
         $dto = SearchDataDTO::fromArray($result);
         $results = [];
         foreach ($dto->hits->items as $key => $item) {
-            $results[] = [
+            $data = [
                 'id'     => $item->resId,
                 'title'       => $item->title,
                 'type'     => $item->type,
@@ -43,6 +43,11 @@ class SearchController extends Controller
                 'snippet'  => !empty($item->highlight) ? $item->highlight : $item->content,
                 'updated'  => Carbon::parse($item->updated)->format('Y-m-d'),
             ];
+            if ($item->type === 'tipitaka') {
+                $arrId = explode('_', $item->id);
+                $data['chapter'] = ['id' => $arrId['2'], 'channel' => $arrId['3']];
+            }
+            $results[] = $data;
         }
 
         $aggregations = $dto->aggregations->toArray();

+ 3 - 6
api-v12/app/Http/Controllers/SearchPlusController.php

@@ -4,20 +4,16 @@ namespace App\Http\Controllers;
 
 use App\Services\OpenSearchService;
 use Illuminate\Http\Request;
+use App\DTO\Search\HitItemDTO;
 
 class SearchPlusController extends Controller
 {
-    protected $searchService;
-
     /**
      * 构造函数,注入 OpenSearchService
      *
      * @param  \App\Services\OpenSearchService  $searchService
      */
-    public function __construct(OpenSearchService $searchService)
-    {
-        $this->searchService = $searchService;
-    }
+    public function __construct(protected OpenSearchService $searchService) {}
 
     /**
      * Display a listing of the resource.
@@ -133,6 +129,7 @@ class SearchPlusController extends Controller
     public function show($id)
     {
         //
+        return $this->ok(HitItemDTO::fromArray($this->searchService->get($id)));
     }
 
     /**

+ 30 - 0
api-v12/app/Models/PaliText.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Builder;
 
 class PaliText extends Model
 {
@@ -21,4 +22,33 @@ class PaliText extends Model
     {
         return $this->hasMany(TagMap::class, 'anchor_id', 'uid');
     }
+
+    public function scopeWithAllTags(Builder $query, array $tagNames): Builder
+    {
+        foreach ($tagNames as $name) {
+            $query->whereHas('tagMaps.tags', function ($q) use ($name) {
+                $q->where('name', $name);
+            });
+        }
+        return $query;
+    }
+
+    /**
+     * 标签多的优化版
+     *     public function scopeWithAllTags(Builder $query, array $tagNames): Builder
+    {
+        $count = count($tagNames);
+
+        $query->whereIn('uid', function ($sub) use ($tagNames, $count) {
+            $sub->select('tm.anchor_id')
+                ->from('tag_maps as tm')
+                ->join('tags as t', 't.id', '=', 'tm.tag_id')
+                ->whereIn('t.name', $tagNames)
+                ->groupBy('tm.anchor_id')
+                ->havingRaw('COUNT(DISTINCT t.name) = ?', [$count]);
+        });
+
+        return $query;
+    }
+     */
 }

+ 1 - 1
api-v12/app/Models/TagMap.php

@@ -22,6 +22,6 @@ class TagMap extends Model
 
     public function tags()
     {
-        return $this->hasOne('App\Models\Tag', 'id', 'tag_id');
+        return $this->belongsTo(Tag::class, 'tag_id', 'id');
     }
 }

Разница между файлами не показана из-за своего большого размера
+ 439 - 302
api-v12/app/Services/OpenSearchService.php


+ 1 - 1
api-v12/config/mint.php

@@ -151,7 +151,7 @@ return [
         ],
     ],
     'opensearch' => [
-        'index' => 'wikipali_20260423',
+        'index' => 'wikipali_20260424',
         'config' => [
             'scheme' => env('OPENSEARCH_SCHEME', 'http'),
             'host' => env('OPENSEARCH_HOST', '127.0.0.1'),

+ 42 - 0
api-v12/resources/css/modules/_tipitaka.css

@@ -353,3 +353,45 @@
         width: 100%;
     }
 }
+
+/* 目录项布局 */
+.toc-item {
+    display: flex;
+    flex-direction: column; /* 默认:手机上下 */
+    gap: 0.4rem;
+}
+
+/* 标题 */
+.toc-title {
+    font-size: 0.95rem;
+}
+
+/* 进度区 */
+.toc-progress {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+/* 百分比 */
+.toc-progress-text {
+    font-size: 0.7rem;
+    color: var(--tblr-secondary);
+    white-space: nowrap;
+}
+
+/* ≥ md(平板/桌面):左右分布 */
+@media (min-width: 768px) {
+    .toc-item {
+        flex-direction: row;
+        align-items: center;
+    }
+
+    .toc-title {
+        flex: 1;
+    }
+
+    .toc-progress {
+        width: 140px;
+    }
+}

+ 3 - 0
api-v12/resources/views/components/wiki/search-result-card.blade.php

@@ -9,6 +9,9 @@
             @if($result['type']==='term')
             {{ route('library.wiki.show', [$result['lang'], $result['id']]) }}
             @endif
+            @if($result['type']==='tipitaka')
+            {{ route('library.tipitaka.read', ['id' => $result['chapter']['id'], 'channel' => $result['chapter']['channel']]) }}
+            @endif
             ">
             {{ $result['title'] }}
             <span class="wiki-search-card-word">{{ $result['type'] }}</span>

+ 2 - 18
api-v12/resources/views/library/book/read.blade.php

@@ -161,24 +161,8 @@
                 </p>
 
                 <div class="content">
-                    @if(isset($book['content']) && count($book['content']) > 0)
-                    @foreach ($book['content'] as $paragraph)
-                    <div id="para-{{ $paragraph['id'] }}">
-                        @foreach ($paragraph['text'] as $rows)
-                        <div style="display:flex;">
-                            @foreach ($rows as $col)
-                            <div style="flex:1;">
-                                @if($paragraph['level'] < 8)
-                                    <h{{ $paragraph['level'] }}>{!! $col !!}</h{{ $paragraph['level'] }}>
-                                    @else
-                                    <p>{!! $col !!}</p>
-                                    @endif
-                            </div>
-                            @endforeach
-                        </div>
-                        @endforeach
-                    </div>
-                    @endforeach
+                    @if(isset($book['content']))
+                    {{!! $book['content'] !!}}
                     @else
                     <div>没有内容</div>
                     @endif

+ 21 - 7
api-v12/resources/views/library/tipitaka/show.blade.php

@@ -95,13 +95,27 @@
                 <ul class="wiki-toc-list">
                     @foreach($book['contents'] as $chapter)
                     <li>
-                        <a href="{{ route('library.tipitaka.read', $chapter['id']) }}?channel={{ $chapter['channel'] }}">
-                            <div style="display: flex;">
-                                <div>{{ $chapter['title'] }}</div>
-                                <div>
-                                    <span style="margin-left: auto; font-size: 0.75rem; color: var(--tblr-secondary);">
-                                        {{ $chapter['progress'] }}%
-                                    </span>
+                        <a href="{{ route('library.tipitaka.read', $chapter['id']) }}?channel={{ $chapter['channel'] }}" target="_blank">
+                            <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>
                             @if(isset($chapter['summary']))

+ 5 - 4
api-v12/routes/web.php

@@ -6,13 +6,14 @@ use App\Http\Controllers\WbwAnalysisController;
 use App\Http\Controllers\PageIndexController;
 use App\Http\Controllers\AssetsController;
 use App\Http\Controllers\BlogController;
-use App\Http\Controllers\CategoryController;
 use App\Http\Controllers\DownloadController;
 use App\Http\Controllers\Library\AnthologyController;
 use App\Http\Controllers\Library\AnthologyReadController;
 use App\Http\Controllers\Library\BookController;
 use App\Http\Controllers\Library\WikiController;
 use App\Http\Controllers\Library\SearchController;
+use App\Http\Controllers\Library\HomeController;
+use App\Http\Controllers\Library\TipitakaController;
 
 /*
 |--------------------------------------------------------------------------
@@ -64,10 +65,10 @@ Route::post('/logout', function () {
 })->name('logout');
 
 Route::prefix('library')->name('library.')->group(function () {
-    Route::get('/', [CategoryController::class, 'home'])->name('home');
+    Route::get('/', [HomeController::class, 'index'])->name('home');
 
-    Route::get('/tipitaka', [CategoryController::class, 'category'])->name('tipitaka.index');
-    Route::get('/tipitaka/category/{id}', [CategoryController::class, 'category'])->name('tipitaka.category');
+    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');
 

Некоторые файлы не были показаны из-за большого количества измененных файлов