Просмотр исходного кода

Merge pull request #2364 from visuddhinanda/development

Development
visuddhinanda 2 недель назад
Родитель
Сommit
be947df199

+ 34 - 25
api-v13/app/Console/Commands/IndexTipitaka.php

@@ -297,10 +297,14 @@ class IndexTipitaka extends Command
                 ->groupBy('channel_uid')->get();
             $this->info("index chapter start={$start} end={$end}");
 
-            foreach ($channels as $key => $channel) {
+            foreach ($channels as $channel) {
                 $display = [];
                 $content = [];
                 $channelInfo = ChannelApi::getById($channel->channel_uid);
+                if (!$channelInfo) {
+                    Log::error('invalid channel', ['id' => $channel->channel_uid]);
+                    continue;
+                }
                 $this->info('channel =' . $channelInfo['name']);
                 if ($channelInfo['type'] === 'wbw') {
                     $this->info('wbw channel skip');
@@ -311,21 +315,23 @@ class IndexTipitaka extends Command
                     $start,
                     $end,
                     [$channel->channel_uid],
-                    ['mode' => 'read', 'format' => 'html', 'original' => true]
+                    ['mode' => 'read', 'format' => 'html', 'original' => false]
                 );
                 //生成html数据
 
                 $title = '';
-                foreach ($paragraphsData as $key => $paragraph) {
+                foreach ($paragraphsData as $paragraph) {
                     $translation = [];
                     $original = [];
-                    foreach ($paragraph['children'] as $key => $sent) {
+                    foreach ($paragraph['children'] as  $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;
+                            foreach ($sent['translation'] as  $tran) {
+                                if ($tran['channel']['id'] === $channel->channel_uid) {
+                                    $html = $tran['html'] ?? $tran['content'];
+                                    $translation[] = "<span class='sentence'>{$html}</span>";
+                                    if ($tran['para'] === $start && !empty($curr)) {
+                                        $title = $curr;
+                                    }
                                 }
                             }
                         }
@@ -334,30 +340,34 @@ class IndexTipitaka extends Command
                             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;
+                            foreach ($sent['origin'] as  $origin) {
+                                if ($origin['channel']['id'] === $channel->channel_uid) {
+                                    $html = $origin['html'] ?? $origin['content'];
+                                    $original[] = "<span class='sentence origin'>{$html}</span>";
+                                    if (empty($title) && $origin['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>";
+                    if ($channelInfo['type'] === 'original') {
+                        $htmlContent = $strOriginal;
                     } else {
-                        $display[] = "<div><p>{$strOriginal}</p><p>{$strTranslation}</p></div>";
+                        $htmlContent = $strTranslation;
                     }
 
-                    if ($channelInfo['type'] === 'original') {
-                        $content[] = $strOriginal;
+                    $area = $channelInfo['type'] === 'original' ? 'original' : 'translation';
+
+                    if ($level > 0) {
+                        $display[] = "<div class='{$area}' data-para='{$paragraph['para']}'><h{$level}>{$htmlContent}</h{$level}></div>";
                     } else {
-                        $content[] = $strTranslation;
+                        $display[] = "<div class='{$area}' data-para='{$paragraph['para']}'><p>{$htmlContent}</p></div>";
                     }
                 }
                 $this->chapterSave([
@@ -365,10 +375,9 @@ class IndexTipitaka extends Command
                     'para' => $start,
                     'level' => $chapter->level,
                     'channel' => $channel->channel_uid,
-                    'display' => implode('', $display),
-                    'content' => implode('', $content),
+                    'content' => implode('', $display),
                     'title' => strip_tags($title),
-                    'cat' => $category
+                    'cat' => $category ?? null
                 ]);
             }
         }
@@ -410,7 +419,7 @@ class IndexTipitaka extends Command
             $document['content']['text']['pali'] = $plainText;
             $document['title']['text']['pali'] = $title;
         }
-        $document['content']['display']    = $param['display'];             // 展示
+        $document['content']['display']    = $param['content'];             // 展示
 
         if ($this->isTest) {
             $this->info($param['content']);

+ 164 - 98
api-v13/app/Console/Commands/UpgradeProgressChapter.php

@@ -2,6 +2,10 @@
 
 namespace App\Console\Commands;
 
+use Illuminate\Support\Facades\Log;
+
+use Carbon\Carbon;
+
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Console\Command;
 use App\Models\Sentence;
@@ -13,6 +17,7 @@ use App\Models\Tag;
 use App\Models\TagMap;
 use App\Models\Channel;
 use App\Http\Api\MdRender;
+use App\Services\PaliTextService;
 
 class UpgradeProgressChapter extends Command
 {
@@ -21,7 +26,7 @@ class UpgradeProgressChapter extends Command
      * php artisan upgrade:progress.chapter --book=168 --para=915 --channel=19f53a65-81db-4b7d-8144-ac33f1217d34
      * @var string
      */
-    protected $signature = 'upgrade:progress.chapter  {--book=} {--para=} {--channel=} {--driver=str}';
+    protected $signature = 'upgrade:progress.chapter  {--book=} {--para=} {--channel=} {--driver=str} {--resume}';
 
     /**
      * The console command description.
@@ -30,6 +35,8 @@ class UpgradeProgressChapter extends Command
      */
     protected $description = '更新章节完成度,以channel为单位';
 
+    const COMPLETION_RATE = 0.9;
+
     /**
      * Create a new command instance.
      *
@@ -47,110 +54,118 @@ class UpgradeProgressChapter extends Command
      */
     public function handle()
     {
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
-		$this->info("upgrade:progresschapter start.");
-		$startTime = time();
+        $paliTextService = app(PaliTextService::class);
+
+        $this->info("upgrade:progresschapter start.");
+        $startTime = time();
         $book = $this->option('book');
         $para = $this->option('para');
         $channelId = $this->option('channel');
 
         \App\Tools\Markdown::driver($this->option('driver'));
 
-        $tagCount=0;
+        $tagCount = 0;
         #第一步 查询有多少书有译文
-        if($book){
-            $books = Sentence::where('book_id',$book)
-                            ->groupby('book_id')
-                            ->select('book_id')
-                            ->get();
-        }else{
-            $books = Sentence::where('strlen','>',0)
-                ->where('book_id','<',1000)
-                ->where('channel_uid','<>','')
+        if ($book) {
+            if ($this->option('resume')) {
+                $table = Sentence::whereBetween('book_id', [$book, 217]);
+            } else {
+                $table = Sentence::where('book_id', $book);
+            }
+            $books = $table->groupby('book_id')
+                ->select('book_id')
+                ->get();
+        } else {
+            $books = Sentence::where('strlen', '>', 0)
+                ->where('book_id', '<', 1000)
+                ->whereNotNull('channel_uid')
                 ->groupby('book_id')
                 ->select('book_id')
                 ->get();
         }
 
 
-        $bar = $this->output->createProgressBar(count($books));
+        $totalBook = count($books);
 
         foreach ($books as $book) {
-            if($para){
-                $table = PaliText::where('book',$book->book_id)
-                            ->where('paragraph','<=',$para);
-            }else{
-                $table = PaliText::where('book',$book->book_id);
+            $this->info("{$book->book_id} start total {$totalBook}");
+            if ($para) {
+                $table = PaliText::where('book', $book->book_id)
+                    ->where('paragraph', '<=', $para);
+            } else {
+                $table = PaliText::where('book', $book->book_id);
             }
-            $chapters = $table->where('level','>',0)
-                ->where('level','<',8)
-                ->select('paragraph','chapter_strlen','chapter_len')
+            $chapters = $table->where('level', '>', 0)
+                ->where('level', '<', 8)
+                ->select('paragraph', 'chapter_strlen', 'chapter_len')
                 ->get();
 
             foreach ($chapters as $key => $chapter) {
                 # code...
-                $chapter_strlen = PaliSentence::where('book',$book->book_id)
-                                    ->whereBetween('paragraph',[$chapter->paragraph,$chapter->paragraph+$chapter->chapter_len-1])
-                                    ->sum('length');
-                if($chapter_strlen == 0){
-                    $this->error('chapter_strlen is 0 book:'.$book->book_id.' paragraph:'.$chapter->paragraph.'-'.($chapter->paragraph+$chapter->chapter_len-1));
+                $chapter_strlen = PaliSentence::where('book', $book->book_id)
+                    ->whereBetween('paragraph', [$chapter->paragraph, $chapter->paragraph + $chapter->chapter_len - 1])
+                    ->sum('length');
+                if ($chapter_strlen == 0) {
+                    $this->error('chapter_strlen is 0 book:' . $book->book_id . ' paragraph:' . $chapter->paragraph . '-' . ($chapter->paragraph + $chapter->chapter_len - 1));
                     continue;
                 }
-                $table = Progress::where('book',$book->book_id)
-                                  ->whereBetween('para',[$chapter->paragraph,$chapter->paragraph+$chapter->chapter_len-1]);
-                if($channelId){
-                    $table->where('channel_id',$channelId);
+                $table = Progress::where('book', $book->book_id)
+                    ->whereBetween('para', [$chapter->paragraph, $chapter->paragraph + $chapter->chapter_len - 1]);
+                if ($channelId) {
+                    $table->where('channel_id', $channelId);
                 }
                 $strlen = $table->groupby('channel_id')
-                        ->selectRaw('channel_id, sum(all_strlen) as cp_len')
-                        ->get();
+                    ->selectRaw('channel_id, sum(all_strlen) as cp_len')
+                    ->get();
                 foreach ($strlen as $final) {
                     # code...
                     # 计算此段落完成时间
-                    $finalAt = Progress::where('book',$book->book_id)
-                                ->whereBetween('para',[$chapter->paragraph,$chapter->paragraph+$chapter->chapter_len-1])
-                                ->where('channel_id',$final->channel_id)
-                                ->max('created_at');
-                    $updateAt = Progress::where('book',$book->book_id)
-                                ->whereBetween('para',[$chapter->paragraph,$chapter->paragraph+$chapter->chapter_len-1])
-                                ->where('channel_id',$final->channel_id)
-                                ->max('updated_at');
-                    $transTexts = Sentence::where('book_id',$book->book_id)
-                                ->whereBetween('paragraph',[$chapter->paragraph+1,$chapter->paragraph+$chapter->chapter_len-1])
-                                ->where('channel_uid',$final->channel_id)
-                                ->select('content')
-                                ->orderBy('paragraph')
-                                ->orderBy('word_start')
-                                ->get();
-
-                    $mdRender = new MdRender(['format'=>'simple']);
+                    $finalAt = Progress::where('book', $book->book_id)
+                        ->whereBetween('para', [$chapter->paragraph, $chapter->paragraph + $chapter->chapter_len - 1])
+                        ->where('channel_id', $final->channel_id)
+                        ->max('created_at');
+                    $updateAt = Progress::where('book', $book->book_id)
+                        ->whereBetween('para', [$chapter->paragraph, $chapter->paragraph + $chapter->chapter_len - 1])
+                        ->where('channel_id', $final->channel_id)
+                        ->max('updated_at');
+                    $transTexts = Sentence::where('book_id', $book->book_id)
+                        ->whereBetween('paragraph', [$chapter->paragraph + 1, $chapter->paragraph + $chapter->chapter_len - 1])
+                        ->where('channel_uid', $final->channel_id)
+                        ->select('content')
+                        ->orderBy('paragraph')
+                        ->orderBy('word_start')
+                        ->get();
+
+                    $mdRender = new MdRender(['format' => 'simple']);
 
                     #查询标题
-                    $title = Sentence::where('book_id',$book->book_id)
-                          ->where('paragraph',$chapter->paragraph)
-                          ->where('channel_uid',$final->channel_id)
-                          ->value('content');
-                    $title = $mdRender->convert($title,[$final->channel_id]);
+                    $title = Sentence::where('book_id', $book->book_id)
+                        ->where('paragraph', $chapter->paragraph)
+                        ->where('channel_uid', $final->channel_id)
+                        ->value('content');
+                    $title = $mdRender->convert($title, [$final->channel_id]);
 
                     $summaryText = "";
                     foreach ($transTexts as $text) {
                         # code...
-                        $textContent = $mdRender->convert($text->content,[$final->channel_id]);
-                        $summaryText .= str_replace("\n","",$textContent);
-                        if(mb_strlen($summaryText,"UTF-8")>255){
+                        $textContent = $mdRender->convert($text->content, [$final->channel_id]);
+                        $summaryText .= str_replace("\n", "", $textContent);
+                        if (mb_strlen($summaryText, "UTF-8") > 255) {
                             break;
                         }
                     }
 
                     //查询语言
-                    $channelLang = Channel::where('uid',$final->channel_id)->value('lang');
-                    $lang = explode('-',$channelLang)[0];
+                    $channelLang = Channel::where('uid', $final->channel_id)->value('lang');
+                    $lang = explode('-', $channelLang)[0];
                     $attributes = [
-                            'book'=>$book->book_id,
-                            'para'=>$chapter->paragraph,
-                            'channel_id'=>$final->channel_id];
+                        'book' => $book->book_id,
+                        'para' => $chapter->paragraph,
+                        'channel_id' => $final->channel_id
+                    ];
 
                     $rules = array(
                         'book' => 'integer',
@@ -163,78 +178,129 @@ class UpgradeProgressChapter extends Command
                         $this->error("Validator is fails");
                         return 0;
                     }
-                    if(ProgressChapter::where($attributes)->exists()){
+                    if (ProgressChapter::where($attributes)->exists()) {
                         $chapterData = ProgressChapter::where($attributes)->first();
-                    }else{
+                    } else {
                         $chapterData = new ProgressChapter;
                         $chapterData->book = $attributes["book"];
                         $chapterData->para = $attributes["para"];
                         $chapterData->channel_id = $attributes["channel_id"];
-
                     }
+                    $progress = $final->cp_len / $chapter_strlen;
+                    $addChapter = false;
+                    if ($progress >= self::COMPLETION_RATE) {
+                        if (empty($chapterData->completed_at)) {
+                            // 添加新的章节了
+                            $chapterData->completed_at = $finalAt;
+                            $addChapter = true;
+                        }
+                    }
+
                     $chapterData->lang = $lang;
-                    $chapterData->all_trans = $final->cp_len/$chapter_strlen;
-                    $chapterData->public = $final->cp_len/$chapter_strlen;
-                    $chapterData->progress = $final->cp_len/$chapter_strlen;
-                    $chapterData->title = $title? mb_substr($title,0,255,"UTF-8"):"";
-                    $chapterData->summary = $summaryText? mb_substr($summaryText,0,255,"UTF-8"):"";
+                    $chapterData->all_trans = $progress;
+                    $chapterData->public = $progress;
+                    $chapterData->progress = $progress;
+                    $chapterData->title = $title ? mb_substr($title, 0, 255, "UTF-8") : "";
+                    $chapterData->summary = $summaryText ? mb_substr($summaryText, 0, 255, "UTF-8") : "";
                     $chapterData->created_at = $finalAt;
                     $chapterData->updated_at = $updateAt;
                     $chapterData->save();
 
+
+                    if ($addChapter) {
+                        // 添加新的章节了
+                        //修改父目录
+                        $loop = 0;
+                        $currBook = $attributes["book"];
+                        $currPara = $attributes["para"];
+                        while ($parent = $paliTextService->getParent($currBook, $currPara)) {
+                            $parentChapter = ProgressChapter::where('book', $currBook)
+                                ->where('para', $parent->paragraph)
+                                ->where('channel_id', $attributes["channel_id"])
+                                ->first();
+                            if ($parentChapter) {
+                                $currPara = $parent->paragraph;
+                                if (
+                                    is_null($parentChapter->last_chapter_completed_at) ||
+                                    Carbon::parse($finalAt)->gt(Carbon::parse($parentChapter->last_chapter_completed_at))
+                                ) {
+                                    $parentChapter->last_chapter_completed_at = $finalAt;
+                                    // 计算字章节有多少已经完成
+                                    $chapterEnd = $parent->paragraph + $parent->chapter_len - 1;
+                                    $totalCompleted = ProgressChapter::where('book', $currBook)
+                                        ->whereBetween('para', [$parent->paragraph, $chapterEnd])
+                                        ->where('channel_id', $attributes["channel_id"])
+                                        ->whereNotNull('completed_at')
+                                        ->count();
+                                    $parentChapter->completed_chapters = $totalCompleted;
+                                    $parentChapter->save();
+                                    /*
+                                    Log::info('update last_chapter_completed_at', [
+                                        'para' => $parent->paragraph,
+                                        'completed' => $totalCompleted
+                                    ]);
+                                    */
+                                }
+                            } else {
+                                break;
+                            }
+                            $loop++;
+                        }
+                        //Log::info('update parent completed loop ' . $loop);
+                    }
+
+
                     $wasCreated = $chapterData->wasRecentlyCreated;
                     $wasChanged = $chapterData->wasChanged();
                     #查询路径
                     $path = json_decode(
-                                PaliText::where('book',$book->book_id)
-                                ->where('paragraph',$chapter->paragraph)
-                                ->value('path'));
+                        PaliText::where('book', $book->book_id)
+                            ->where('paragraph', $chapter->paragraph)
+                            ->value('path')
+                    );
 
-                    if($path){
+                    if ($path) {
                         //查询标签
                         $tags = [];
                         foreach ($path as $key => $value) {
                             # code...
-                            if($value->level>0){
-                                $paliTextUuid = PaliText::where('book',$value->book)
-                                                ->where('paragraph',$value->paragraph)
-                                                ->value('uid');
-                                $tagUuids = TagMap::where('table_name','pali_texts')
-                                                ->where('anchor_id',$paliTextUuid)
-                                                ->select(['tag_id'])
-                                                ->get();
+                            if ($value->level > 0) {
+                                $paliTextUuid = PaliText::where('book', $value->book)
+                                    ->where('paragraph', $value->paragraph)
+                                    ->value('uid');
+                                $tagUuids = TagMap::where('table_name', 'pali_texts')
+                                    ->where('anchor_id', $paliTextUuid)
+                                    ->select(['tag_id'])
+                                    ->get();
                                 foreach ($tagUuids as $key => $taguuid) {
                                     # code...
-                                    $tags[$taguuid['tag_id']]=1;
+                                    $tags[$taguuid['tag_id']] = 1;
                                 }
-
                             }
                         }
 
                         //更新标签映射表
                         //删除旧的标签映射表
-                        TagMap::where('table_name' , 'progress_chapters')
-                                ->where('anchor_id' , $chapterData->uid)
-                                ->delete();
+                        TagMap::where('table_name', 'progress_chapters')
+                            ->where('anchor_id', $chapterData->uid)
+                            ->delete();
                         foreach ($tags as $key => $tag) {
                             # code...
                             $tagmap = TagMap::create([
-                                        'table_name' => 'progress_chapters',
-                                        'anchor_id' => $chapterData->uid,
-                                        'tag_id' => $key
-                                    ]);
-                            if($tagmap){
+                                'table_name' => 'progress_chapters',
+                                'anchor_id' => $chapterData->uid,
+                                'tag_id' => $key
+                            ]);
+                            if ($tagmap) {
                                 $tagCount++;
                             }
                         }
                     }
                 }
             }
-            $bar->advance();
         }
-        $bar->finish();
-		$time = time() - $startTime;
-		$this->info("upgrade:progresschapter finished in {$time}s tag count:{$tagCount}");
+        $time = time() - $startTime;
+        $this->info("upgrade:progresschapter finished in {$time}s tag count:{$tagCount}");
         return 0;
     }
 }

+ 8 - 4
api-v13/app/Http/Api/TemplateRender.php

@@ -57,11 +57,15 @@ class TemplateRender
      * string $format  'react' | 'text' | 'tex' | 'unity'
      * @return void
      */
-    public function __construct($param, $channelInfo, $mode, $format = 'react', $studioId = '', $debug = [], $lang = 'zh-Hans')
+    public function __construct(array $param, $channelInfo, string $mode, string $format = 'react', ?string $studioId = null, $debug = [], $lang = 'zh-Hans')
     {
         $this->param = $param;
-        foreach ($channelInfo as $value) {
-            $this->channel_id[] = $value->uid;
+        foreach ($channelInfo as $channel) {
+            if ($channel && isset($channel->uid)) {
+                $this->channel_id[] = $channel->uid;
+            } else {
+                Log::error('template render init error invalid channel');
+            }
         }
         $this->channelInfo = $channelInfo;
         $this->mode = $mode;
@@ -375,7 +379,7 @@ class TemplateRender
         $output = [
             "word" => $word,
             "parentChannelId" => $channelId,
-            "parentStudioId" => $channelInfo->owner_uid,
+            "parentStudioId" => $channelInfo ? $channelInfo->owner_uid : null,
         ];
         $innerString = $output["word"];
         if ($tplParam) {

+ 11 - 9
api-v13/app/Http/Controllers/Library/BookController.php

@@ -64,21 +64,22 @@ class BookController extends Controller
     }
 
 
-    public function read(Request $request, $id)
+    public function read(Request $request, string $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');
+*/
+        //$lap('start');
 
         $channelId = $request->input('channel');
         $openSearchId = "tipitaka_chapter_{$id}_{$channelId}";
 
         $chapter = HitItemDTO::fromArray($this->searchService->get($openSearchId))->toArray();
-        $lap('searchService->get + HitItemDTO');
+        //$lap('searchService->get + HitItemDTO');
 
         [$bookId, $paraId] = explode('-', $id);
 
@@ -87,7 +88,7 @@ class BookController extends Controller
         $book = [];
 
         $book['toc'] = $this->getBookToc((int)$bookId, (int)$paraId, $channelId, 2, 7);
-        $lap('getBookToc');
+        //$lap('getBookToc');
 
         $book['categories'] = $chapter['category'];
         $book['title']      = $chapter['title'];
@@ -95,23 +96,24 @@ class BookController extends Controller
         $book['tags']       = [];
 
         $book['pagination'] = $this->pagination((int)$bookId, (int)$paraId, $channelId);
-        $lap('pagination');
+        //$lap('pagination');
 
         $book['content'] = $chapter['display'];
+        Log::debug($chapter['display']);
 
         $channels = $chapterService->publicChannels((int)$bookId, (int)$paraId);
-        $lap('publicChannels');
+        //$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');
+        //$lap('view compiled — total');
 
         return $view;
     }
 
-    private function loadBook($id)
+    private function loadBook(string $id)
     {
         $book = ProgressChapter::with('channel.owner')->find($id);
         return $book;

+ 77 - 42
api-v13/app/Http/Controllers/Library/TipitakaController.php

@@ -73,7 +73,12 @@ class TipitakaController extends Controller
         $selectedType   = request('type',   'all');
         $selectedLang   = request('lang',   'all');
         $selectedAuthor = request('author', 'all');
-        $selectedSort   = request('sort',   'updated_at');
+        $selectedSort   = request('sort',   'new');
+
+        $sortList = [
+            ['key' => 'new',         'label' => '最新',],
+            ['key' => 'progress',    'label' => '完成度',],
+        ];
 
         $selected = [
             'type'   => $selectedType,
@@ -84,39 +89,73 @@ class TipitakaController extends Controller
 
         // ── 书籍列表(过滤+排序,真实实现替换此处) ──────────────
         $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],
-            ],
+            'types' => $this->filterTypes(),
+            'languages' => $this->filterLanguages(),
             'authors' => $this->getAuthorOptions($categoryBooks),
         ];
 
         // ── 右边栏:本周推荐(mock) ────────────────────────────
-        $recommended = [
+        $recommended = $this->mockRecommended();
+
+        // ── 右边栏:活跃译者(mock) ────────────────────────────
+        $activeAuthors = $this->mockActiveAuthors();
+
+        $types = $this->types();
+
+        return view('library.tipitaka.category', compact(
+            'currentCategory',
+            'subCategories',
+            'categoryBooks',
+            'breadcrumbs',
+            'types',
+            'selected',
+            'filterOptions',
+            'totalCount',
+            'recommended',
+            'activeAuthors',
+            'sortList'
+        ));
+    }
+
+    private function filterLanguages()
+    {
+        return [
+            ['value' => 'all',  'label' => '全部',   'count' => 0],
+            ['value' => 'zh-Hans',   'label' => '简体中文',   'count' => 0],
+            ['value' => 'zh-Hant',   'label' => '繁体中文',   'count' => 0],
+            ['value' => 'pi',   'label' => '巴利语', 'count' => 0],
+            ['value' => 'en',   'label' => '英语',   'count' => 0],
+        ];
+    }
+
+    private function filterTypes()
+    {
+        return [
+            ['value' => 'all',         'label' => '全部',    'count' => 0],
+            ['value' => 'original',    'label' => '原文',    'count' => 0],
+            ['value' => 'translation', 'label' => '译文',    'count' => 0],
+            ['value' => 'nissaya',     'label' => 'Nissaya', 'count' => 0],
+        ];
+    }
+
+    private function mockRecommended()
+    {
+        return [
             ['id' => 1, 'title' => '相应部·因缘篇',  'category' => '经藏'],
             ['id' => 2, 'title' => '法句经',          'category' => '经藏'],
             ['id' => 3, 'title' => '清净道论',        'category' => '注释'],
             ['id' => 4, 'title' => '律藏·波罗夷',    'category' => '律藏'],
             ['id' => 5, 'title' => '长部·梵网经',    'category' => '经藏'],
         ];
-
-        // ── 右边栏:活跃译者(mock) ────────────────────────────
-        $activeAuthors = [
+    }
+    private function mockActiveAuthors()
+    {
+        return [
             [
                 'name'    => 'Bhikkhu Bodhi',
                 'avatar'  => null,
@@ -146,21 +185,6 @@ class TipitakaController extends Controller
                 'count'   => 9,
             ],
         ];
-
-        $types = $this->types();
-
-        return view('library.tipitaka.category', compact(
-            'currentCategory',
-            'subCategories',
-            'categoryBooks',
-            'breadcrumbs',
-            'types',
-            'selected',
-            'filterOptions',
-            'totalCount',
-            'recommended',
-            'activeAuthors',
-        ));
     }
 
     // ── 辅助:从书籍列表聚合作者选项(mock,真实实现替换) ─────────
@@ -195,9 +219,8 @@ class TipitakaController extends Controller
         });
     }
 
-    private function getBooks($categories, $id, $filters)
+    private function getBooksIdInCat(array $categories, ?string $id)
     {
-
         if ($id) {
             $currentCategory = collect($categories)->firstWhere('id', $id);
             if (!$currentCategory) {
@@ -217,7 +240,14 @@ class TipitakaController extends Controller
         foreach ($booksChapter as $key => $value) {
             $chapters[] = [$value->book, $value->paragraph];
         }
-        $books = ProgressChapter::with('channel.owner')
+        return $chapters;
+    }
+    private function getBooks(array $categories, ?string $id, array $filters)
+    {
+        //根据分类获取书号
+        $chapters = $this->getBooksIdInCat($categories, $id);
+
+        $table = ProgressChapter::with('channel.owner')
             ->whereHas('channel', function ($query) use ($filters) {
                 $query->where('status', 30);
 
@@ -229,14 +259,18 @@ class TipitakaController extends Controller
                     $query->where('lang', $filters['lang']);
                 }
             })
-            ->where('progress', '>', config('mint.library.list_min_progress'))
-            ->whereIns(['book', 'para'], $chapters)
-            ->take(100)
-            ->get();
+            ->whereNotNull('last_chapter_completed_at')
+            ->whereIns(['book', 'para'], $chapters);
+        if ($filters['sort'] === 'new') {
+            $table = $table->orderBy('last_chapter_completed_at', 'desc');
+        } else if ($filters['sort'] === 'progress') {
+            $table = $table->orderBy('progress', 'desc');
+        }
+        $books = $table->take(100)->get();
         return $this->getBooksInfo($books);
     }
 
-    private function getBooksInfo($books,)
+    private function getBooksInfo(mixed $books)
     {
         $pali = PaliText::where('level', 1)->get();
         // 获取该分类下的书籍
@@ -265,6 +299,7 @@ class TipitakaController extends Controller
                 "title" => $title,
                 "author" => $book->channel->name,
                 "publisher" => $book->channel->owner,
+                'completed_chapters' => $book->completed_chapters,
                 "type" => __('labels.' . $book->channel->type),
                 "cover" => $coverUrl,
                 'cover_gradient' => $this->coverGradients[$colorIdx % count($this->coverGradients)],

+ 78 - 13
api-v13/app/Services/AIAssistant/AITermService.php

@@ -2,6 +2,8 @@
 
 namespace App\Services\AIAssistant;
 
+use Illuminate\Support\Facades\Log;
+
 use App\Services\OpenSearchService;
 use App\Services\TermService;
 use App\Services\OpenAIService;
@@ -12,7 +14,7 @@ use App\DTO\Search\SearchDataDTO;
 
 class AITermService
 {
-    protected $pageSize = 20;
+    protected $pageSize = 50;
     protected AiModelResource $model;
 
 
@@ -24,10 +26,12 @@ class AITermService
 
     搜素结果是json数组
     字段
-    - title(标题)
+    - title:(标题)
     - content:(内容)
-    - path(章节路径)
-    - link(引用链接)
+    - path:(章节路径)
+    - link:(引用链接)
+
+    link 是一个类似"{{para|id=202-1878|title=202-1878|style=reference}}" 的字符串,后面输出的时候请原样输出,不要做任何改变
 
     要求:
     1. 参考维基百科的形式和结构
@@ -39,17 +43,21 @@ class AITermService
     7. 请在文档的开头输出一个模板 {{quality|pending}}
 
     **观点引用标准格式:**
-    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接)
+    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。[link]
+
+        ### 引用标准与技术要求
+    - **数据源绑定:** 请遍历我提供的 JSON 搜索结果。对于数组中的每一项,必须使用其对应的 `link` 字段值。
+    - **硬性禁止:** 禁止在最终文档中出现 "link"、"引用链接" 或方括号占位符,必须替换为 JSON 中实际的链接文本。
 
     如果某个观点有多个出处,请分别列出巴利文引用链接。范例
-    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接1)(link引用链接2)
+    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。[link引用链接1][link引用链接2]
     示例:
     《疑惑度脱新注》在《染色学处注释》中指出:"*Kiriyākiriyanti nivāsanapārupanato, kappassa anādānato kiriyākiriyaṃ*"[9](穿着下衣、披上衣是作为,不采取如法措施是不作为,故为作为-不作为)。{{para|id=202-1878|title=202-1878|style=reference}}
 
         **引用处理规则(按优先级):**
 
     1. 【有明确论断句】直接引用该句巴利文:
-    《X》在《Y》中指出:"巴利文原文"(译文)。(link)
+    《X》在《Y》中指出:"巴利文原文"(译文)。[link]
 
     2. 【叙事段落,无单一论断句】从段落中选取最能代表该段核心意思的
     一个完整句子作为代表句引用,不得跳过巴利文:
@@ -57,7 +65,7 @@ class AITermService
     (该句译文)。(link)
 
     3. 【段落过长】从原文中截取开头或核心句,以省略号表示省略:
-    "巴利文开头...(省略)"(译文说明省略范围)。(link)
+    "巴利文开头...(省略)"(译文说明省略范围)。[link]
 
     **绝对禁止:** 任何观点陈述只有中文转述而没有对应巴利文引用。
     如确实无法提取,须注明"(原文为纯叙事,节录如下)"并仍须给出
@@ -65,7 +73,7 @@ class AITermService
 
     **输出前自检:**
     逐条检查每一个观点陈述,确认是否符合以下格式:
-    [中文陈述] + "巴利文" + (译文) + (link)
+    [中文陈述] + "巴利文" + (译文) + [link]
     若有不符合的条目,返回修改后再输出。
 
     词条结构应包括:
@@ -81,7 +89,7 @@ class AITermService
     - 使用Markdown格式
     - 标题层级清晰(#, ##, ###)
     - 直接输出百科正文,无需大标题
-    - 引用格式:《文献中文名》在《章节中文名》中 + 动词 + "巴利文"  + (巴利文的中文译文)(link引用链接)
+    - 引用格式:《文献中文名》在《章节中文名》中 + 动词 + "巴利文"  + (巴利文的中文译文)[link]
     - 引用动词可用:指出、解释、说明、定义、描述、强调、阐述、论述等
     - 巴利文使用罗马转写
     - 关键术语首次出现时提供巴利文和中文对照
@@ -207,6 +215,8 @@ class AITermService
         // 组装搜索参数
         $params = [
             'query'        => $word,
+            'resourceType' => 'tipitaka',
+            'granularity' => 'paragraph',
             'pageSize'     => $this->pageSize,
         ];
         $result = $search->search($params);
@@ -214,13 +224,16 @@ class AITermService
         $dto = SearchDataDTO::fromArray($result);
         $res = array();
         foreach ($dto->hits->items as $key => $item) {
+
             $res[] = [
                 'title' => $item->title,
                 'content' => $item->content,
                 'path' => $item->path,
+                'pid' => $item->getParaId(),
                 'link' => $item->getParaLink()
             ];
         }
+        Log::debug('query ' . count($res));
         return $res;
     }
 
@@ -230,7 +243,10 @@ class AITermService
         $term = $this->termService->getRaw($id);
         // 全文搜索
         $query = $this->query($term->word);
+
         $res = json_encode($query, JSON_UNESCAPED_UNICODE);
+        $resText = "# 搜索结果\n```json\n{$res}\n```\n";
+        $termText = "# 巴利术语\n\n{$term->word}\n\n";
         //LLM 生成
         $response = $this->openAIService->setApiUrl($this->model['url'])
             ->setModel($this->model['model'])
@@ -238,13 +254,62 @@ class AITermService
             ->setSystemPrompt($this->sysPrompt)
             ->setTemperature(0.5)
             ->setStream(false)
-            ->send(
-                "# 巴利术语\n\n{$term->word}\n\n"
-            );
+            ->send($resText . $termText);
 
         $content = $response['choices'][0]['message']['content'] ?? '';
+
+        //输出自检报告
+        Log::debug('llm response', ['strlen' => $content]);
+        $paraIds = $this->extractAllParaIds($content);
+        Log::debug('has paragraph ref ', ['total' => count($paraIds), 'id' => $paraIds]);
+        $searchPid = array_map(fn($item) => $item['pid'], $query);
+        $diff = array_values(array_diff($paraIds, $searchPid));
+        Log::debug('diff', ['total' => count($diff), 'data' => $diff]);
+
         $this->termService->update($id, ['note' => $content]);
         return $content;
     }
+
     public function create(string $word) {}
+
+    /**
+     * Extract all unique ID values from MediaWiki template parameter strings
+     *
+     * Parses a string that may contain multiple "{{para|...}}" templates
+     * and returns an array of unique 'id' parameter values found.
+     *
+     * @param string $str The input string containing zero or more {{para|...}} templates
+     * @return array<int, string> Array of unique extracted ID values (e.g., ['16-1376', 'ABC-123'])
+     *                            Returns empty array if no IDs are found
+     *
+     * @example
+     *   // Single template
+     *   extractAllParaIds('{{para|id=16-1376|title=test}}')
+     *   // returns ['16-1376']
+     *
+     *   // Multiple templates with duplicates
+     *   extractAllParaIds('{{para|id=16-1376}} and {{para|id=16-1376|style=ref}}')
+     *   // returns ['16-1376'] (duplicate removed)
+     *
+     *   // Multiple unique IDs
+     *   extractAllParaIds('{{para|id=16-1376}} {{para|id=ABC-123}} {{para|id=16-1376}}')
+     *   // returns ['16-1376', 'ABC-123']
+     */
+    public function extractAllParaIds(string $str): array
+    {
+        $ids = [];
+
+        // Find all {{para|...}} patterns
+        if (preg_match_all('/{{para\|(.*?)}}/', $str, $matches)) {
+            foreach ($matches[1] as $content) {
+                // Extract id= value from each template content
+                if (preg_match('/id=([^|&}]+)/', $content, $idMatch)) {
+                    $ids[] = $idMatch[1];
+                }
+            }
+        }
+
+        // Remove duplicates and preserve order of first occurrence
+        return array_values(array_unique($ids));
+    }
 }

+ 8 - 1
api-v13/app/Services/PaliTextService.php

@@ -8,7 +8,14 @@ use Illuminate\Support\Str;
 
 class PaliTextService
 {
-    public function getParent(int $book, int $para) {}
+    public function getParent(int $book, int $para)
+    {
+        $parent = PaliText::where('book', $book)
+            ->where('paragraph',  $para)->value('parent');
+
+        return $parent ? PaliText::where('book', $book)
+            ->where('paragraph',  $parent)->first() : null;
+    }
     public function getCurrChapter(int $book, int $para)
     {
         $paragraph = PaliText::where('book', $book)

+ 1 - 1
api-v13/app/Services/TermService.php

@@ -33,7 +33,7 @@ class TermService
         return ['items' => $result, 'total' => count($result)];
     }
 
-    public function getRaw($id)
+    public function getRaw(string $id)
     {
         $result = DhammaTerm::find($id);
         return $result;

+ 32 - 0
api-v13/database/migrations/2026_05_01_153715_add_last_chapter_completed_at_to_progress_chapters_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('progress_chapters', function (Blueprint $table) {
+            $table->timestamp('last_chapter_completed_at')
+                ->nullable()
+                ->comment('add new chapter');
+            $table->index('last_chapter_completed_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('progress_chapters', function (Blueprint $table) {
+            $table->dropIndex(['last_chapter_completed_at']);
+            $table->dropColumn('last_chapter_completed_at');
+        });
+    }
+};

+ 32 - 0
api-v13/database/migrations/2026_05_02_094555_add_completed_at_to_progress_chapters_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('progress_chapters', function (Blueprint $table) {
+            $table->timestamp('completed_at')
+                ->nullable()
+                ->comment('chapter complete time');
+            $table->index('completed_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('progress_chapters', function (Blueprint $table) {
+            $table->dropIndex(['completed_at']);
+            $table->dropColumn('completed_at');
+        });
+    }
+};

+ 31 - 0
api-v13/database/migrations/2026_05_02_110009_add_completed_chapters_to_progress_chapters_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('progress_chapters', function (Blueprint $table) {
+            $table->integer('completed_chapters')
+                ->default(0)->comment('已经完成的字章节数量');
+            $table->index('completed_chapters');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('progress_chapters', function (Blueprint $table) {
+            $table->dropIndex(['completed_chapters']);
+            $table->dropColumn('completed_chapters');
+        });
+    }
+};

+ 27 - 7
api-v13/resources/css/base/_typography.css

@@ -1,13 +1,33 @@
 /* resources/css/base/_typography.css
-   全站基础排版。
-   Noto Serif 已在 library.css 入口处 @import,此处只设置使用规则。
+   全站基础排版。无副作用,不覆盖组件样式。
+   Noto Serif 字体已在 library.css 入口 @import,此处只声明使用规则。
 */
 
-/* 正文衬线字体作用域由各 module 自行声明(如 .wiki-content-body)。
-   全站默认保持 Tabler 的 sans-serif 体系,不在此全局覆盖。 */
-
-/* 标题基础 */
-h1, h2, h3, h4, h5, h6 {
+/* ── 标题 ── */
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
     line-height: 1.3;
     font-weight: 600;
 }
+
+/* ── 链接基础 ── */
+a {
+    text-underline-offset: 0.15em;
+    text-decoration-thickness: 0.05em;
+}
+
+/* ── 行内代码 ── */
+code {
+    font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+    font-size: 0.875em;
+}
+
+/* ── 图片 ── */
+img {
+    max-width: 100%;
+    height: auto;
+}

+ 205 - 0
api-v13/resources/css/modules/_reader-content.css

@@ -0,0 +1,205 @@
+/* resources/css/modules/_reader-content.css
+   阅读器正文排版。所有规则限定在 article.reader-body 作用域内。
+   包含:基础排版、Tufte sidenote 集成、响应式断点对齐。
+*/
+
+/* ══════════════════════════════════════════
+   一、作用域根:正文必须用 <article class="reader-body"> 包裹
+   ══════════════════════════════════════════ */
+
+article.reader-body {
+    font-family: 'Noto Serif', Georgia, serif;
+    font-size: 1.05rem;
+    line-height: 1.75;
+    color: inherit;
+    counter-reset: sidenote-counter;
+}
+
+/* ── 段落 ── */
+article.reader-body p {
+    margin-bottom: 1.25rem;
+    padding-right: 0;
+    vertical-align: baseline;
+}
+
+/* ── 标题 ── */
+article.reader-body h1,
+article.reader-body h2,
+article.reader-body h3,
+article.reader-body h4 {
+    font-weight: 600;
+    line-height: 1.3;
+    margin-top: 2rem;
+    margin-bottom: 0.75rem;
+}
+
+/* ── 列表:只在正文内收缩,不影响外部 ── */
+article.reader-body ul,
+article.reader-body ol {
+    padding-left: 1.5rem;
+    margin-bottom: 1.25rem;
+}
+
+article.reader-body li {
+    line-height: 1.7;
+    margin-bottom: 0.25rem;
+}
+
+/* ── 引用块 ── */
+article.reader-body blockquote {
+    margin: 1.5rem 0 1.5rem 0;
+    padding: 0.75rem 1.25rem;
+    border-left: 3px solid var(--tblr-border-color);
+    color: var(--tblr-secondary);
+    font-style: italic;
+}
+
+/* ── 分割线 ── */
+article.reader-body hr {
+    display: block;
+    height: 1px;
+    border: 0;
+    border-top: 1px solid #ccc;
+    margin: 2em 0;
+}
+
+/* ── 代码块 ── */
+article.reader-body pre > code {
+    display: block;
+    overflow-x: auto;
+    font-size: 0.9rem;
+    line-height: 1.5;
+    padding: 1rem;
+    background: var(--tblr-bg-surface-secondary);
+    border-radius: var(--tblr-border-radius);
+}
+
+/* ══════════════════════════════════════════
+   二、Tufte Sidenote / Marginnote
+   断点与 reader 对齐:>= 992px 浮动显示,< 992px 点击展开
+   ══════════════════════════════════════════ */
+
+/* 触发标签(手机/平板用) */
+article.reader-body input.margin-toggle {
+    display: none;
+}
+
+article.reader-body label.sidenote-number {
+    display: inline-block;
+    max-height: 1.75rem;
+}
+
+/* ≥ 992px:浮动 sidenote,右侧边栏同时存在,布局一致 */
+@media (min-width: 992px) {
+    article.reader-body label.margin-toggle:not(.sidenote-number) {
+        display: none;
+    }
+
+    article.reader-body .sidenote,
+    article.reader-body .marginnote {
+        float: right;
+        clear: right;
+        margin-right: -35%; /* 收窄,适配 reader 右侧无大空白的布局 */
+        width: 28%;
+        margin-top: 0.3rem;
+        margin-bottom: 0;
+        font-size: 0.875rem;
+        line-height: 1.4;
+        vertical-align: baseline;
+        position: relative;
+        border: 1px solid wheat;
+        padding: 6px;
+        border-radius: 8px;
+        background-color: #f5deb330;
+    }
+
+    .dark-mode article.reader-body .sidenote,
+    .dark-mode article.reader-body .marginnote {
+        border-color: #6b5a3e;
+        background-color: rgba(139, 109, 56, 0.15);
+    }
+}
+
+/* < 992px:与手机一致,点击展开 */
+@media (max-width: 991px) {
+    article.reader-body label.margin-toggle:not(.sidenote-number) {
+        display: inline;
+        cursor: pointer;
+        color: var(--tblr-primary);
+    }
+
+    article.reader-body .sidenote,
+    article.reader-body .marginnote {
+        display: none;
+    }
+
+    article.reader-body .margin-toggle:checked + .sidenote,
+    article.reader-body .margin-toggle:checked + .marginnote {
+        display: block;
+        float: left;
+        left: 0;
+        clear: both;
+        width: 95%;
+        margin: 0.75rem 2.5%;
+        position: relative;
+        border: 1px solid wheat;
+        padding: 6px;
+        border-radius: 8px;
+        background-color: #f5deb330;
+    }
+}
+
+/* sidenote 编号上标 */
+article.reader-body .sidenote-number {
+    counter-increment: sidenote-counter;
+}
+
+article.reader-body .sidenote-number::after,
+article.reader-body .sidenote::before {
+    position: relative;
+    vertical-align: baseline;
+}
+
+article.reader-body .sidenote-number::after {
+    content: counter(sidenote-counter);
+    font-size: 0.75rem;
+    top: -0.5rem;
+    left: 0.1rem;
+}
+
+article.reader-body .sidenote::before {
+    content: counter(sidenote-counter) ' ';
+    font-size: 0.75rem;
+    top: -0.5rem;
+}
+
+/* ══════════════════════════════════════════
+   三、正文内 origin(原文显示)
+   ══════════════════════════════════════════ */
+
+article.reader-body .origin {
+    color: darkred;
+}
+
+.dark-mode article.reader-body .origin {
+    color: #ff8a8a;
+}
+
+/* ══════════════════════════════════════════
+   四、Epigraph(题词)
+   ══════════════════════════════════════════ */
+
+article.reader-body div.epigraph {
+    margin: 3em 0;
+}
+
+article.reader-body div.epigraph > blockquote,
+article.reader-body div.epigraph > blockquote > p {
+    font-style: italic;
+}
+
+article.reader-body div.epigraph > blockquote > footer {
+    font-style: normal;
+    text-align: right;
+    font-size: 0.9rem;
+}

+ 28 - 16
api-v13/resources/css/modules/_reader.css

@@ -9,8 +9,10 @@
    ══════════════════════════════════════════ */
 
 body {
-    font-family: "Inter", sans-serif;
-    transition: background-color 0.3s, color 0.3s;
+    font-family: 'Inter', sans-serif;
+    transition:
+        background-color 0.3s,
+        color 0.3s;
 }
 
 /* ══════════════════════════════════════════
@@ -46,13 +48,13 @@ body {
 
 /* ── 正文区(中)── */
 .content-area {
-    flex: 1;            /* 替代原 flex-grow:1,配合 min-width 防止收缩 */
-    min-width: 0;       /* 修复:内容较窄时列不收缩 */
+    flex: 1; /* 替代原 flex-grow:1,配合 min-width 防止收缩 */
+    min-width: 0; /* 修复:内容较窄时列不收缩 */
 }
 
 /* ── 右侧边栏 ── */
 .right-sidebar {
-    width: 220px;       /* 收窄,原 300px 偏宽 */
+    width: 220px; /* 收窄,原 300px 偏宽 */
     flex-shrink: 0;
     display: none;
 }
@@ -158,18 +160,30 @@ body {
     text-decoration: none;
 }
 
-.toc-sidebar ul li a:hover      { text-decoration: none; }
-.offcanvas-body ul li a:hover   { text-decoration: underline; }
+.toc-sidebar ul li a:hover {
+    text-decoration: none;
+}
+.offcanvas-body ul li a:hover {
+    text-decoration: underline;
+}
 
 .dark-mode .toc-sidebar ul li a,
 .dark-mode .offcanvas-body ul li a {
     color: #4dabf7;
 }
 
-.toc-level-1 { padding-left: 0 !important; }
-.toc-level-2 { padding-left: 16px !important; }
-.toc-level-3 { padding-left: 24px !important; }
-.toc-level-4 { padding-left: 36px !important; }
+.toc-level-1 {
+    padding-left: 0 !important;
+}
+.toc-level-2 {
+    padding-left: 16px !important;
+}
+.toc-level-3 {
+    padding-left: 24px !important;
+}
+.toc-level-4 {
+    padding-left: 36px !important;
+}
 
 .toc-disabled {
     color: #6c757d;
@@ -177,7 +191,9 @@ body {
     pointer-events: none;
 }
 
-.dark-mode .toc-disabled { color: #adb5bd; }
+.dark-mode .toc-disabled {
+    color: #adb5bd;
+}
 
 .toc_item {
     white-space: nowrap;
@@ -221,10 +237,6 @@ body {
    六、正文内容
    ══════════════════════════════════════════ */
 
-.origin {
-    color: darkred;
-}
-
 /* 术语引用 */
 .term-ref {
     cursor: pointer;

+ 2 - 1
api-v13/resources/css/reader.css

@@ -3,4 +3,5 @@
    只做 @import,不写任何样式规则。
 */
 
-@import "./modules/_reader.css";
+@import './modules/_reader.css';
+@import './modules/_reader-content.css';

+ 10 - 5
api-v13/resources/css/tufte.css

@@ -1,6 +1,7 @@
 @charset "UTF-8";
 
-/* Import ET Book styles
+/* api-v13/resources/css/tufte.css
+Import ET Book styles
    adapted from https://github.com/edwardtufte/et-book/blob/gh-pages/et-book.css */
 
 /* Tufte CSS styles */
@@ -165,6 +166,10 @@ img {
     line-height: 1.3;
     vertical-align: baseline;
     position: relative;
+    border: 1px solid wheat;
+    padding: 6px;
+    border-radius: 8px;
+    background-color: #f5deb330;
 }
 
 .sidenote-number {
@@ -186,7 +191,7 @@ img {
 }
 
 .sidenote:before {
-    content: counter(sidenote-counter) " ";
+    content: counter(sidenote-counter) ' ';
     font-size: 100%;
     top: -0.5rem;
 }
@@ -205,17 +210,17 @@ table.fullwidth {
 
 div.table-wrapper {
     overflow-x: auto;
-    font-family: "Trebuchet MS", "Gill Sans", "Gill Sans MT", sans-serif;
+    font-family: 'Trebuchet MS', 'Gill Sans', 'Gill Sans MT', sans-serif;
 }
 
 .sans {
-    font-family: "Gill Sans", "Gill Sans MT", Calibri, sans-serif;
+    font-family: 'Gill Sans', 'Gill Sans MT', Calibri, sans-serif;
     letter-spacing: 0.03em;
 }
 
 code,
 pre > code {
-    font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
+    font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
     font-size: 1rem;
     line-height: 1.42;
     -webkit-text-size-adjust: 100%; /* Prevent adjustments of font size after orientation changes in iOS. See https://github.com/edwardtufte/tufte-css/issues/81#issuecomment-261953409 */

+ 3 - 0
api-v13/resources/views/components/ui/card-book.blade.php

@@ -45,6 +45,9 @@
                 @if(!empty($book['type']))
                 <span class="card-book__badge">{{ $book['type'] }}</span>
                 @endif
+                @if(!empty($book['completed_chapters']))
+                <span class="card-book__badge">§{{ $book['completed_chapters'] }}</span>
+                @endif
             </div>
         </div>
 

+ 20 - 25
api-v13/resources/views/library/book/read.blade.php

@@ -10,7 +10,7 @@
 
 {{-- 术语抽屉(所有阅读页统一使用 wiki.term-drawer) --}}
 @push('scripts')
-@vite(['resources/css/tufte.css', 'resources/js/modules/term-tooltip.js'])
+@vite(['resources/js/modules/term-tooltip.js'])
 @endpush
 
 @section('reader-content')
@@ -65,13 +65,15 @@
                 </a>
             </div>
 
-            {{-- 版本切换:desktop 版本在右侧边栏展示,mobile 触发 offcanvas --}}
+            {{-- 版本切换:所有设备统一触发 offcanvas,放在设置右边 --}}
             @if(!empty($channels))
-            <div class="nav-item d-md-none me-2">
+            <div class="nav-item me-2">
                 <a href="#" class="nav-link"
                     data-bs-toggle="offcanvas"
                     data-bs-target="#channelDrawer">
-                    <i class="ti ti-layers"></i>
+                    <i class="ti ti-stack-2 me-1 d-none d-md-inline"></i>
+                    <span class="d-none d-md-inline">版本</span>
+                    <i class="ti ti-stack-2 d-md-none"></i>
                 </a>
             </div>
             @endif
@@ -111,11 +113,19 @@
     <div class="offcanvas-body">
         <div class="list-group list-group-flush">
             @foreach($channels as $channel)
+            @php $isActive = request('channel') == $channel['id']; @endphp
+            @if($isActive)
+            <div class="list-group-item active pe-none">
+                <div class="fw-bold">{{ $channel['name'] }}</div>
+                <small class="opacity-75">{{ __('language.' . $channel['lang']) }}</small>
+            </div>
+            @else
             <a href="{{ request()->fullUrlWithQuery(['channel' => $channel['id']]) }}"
                 class="list-group-item list-group-item-action">
                 <div class="fw-bold">{{ $channel['name'] }}</div>
                 <small class="text-muted">{{ __('language.' . $channel['lang']) }}</small>
             </a>
+            @endif
             @endforeach
         </div>
     </div>
@@ -134,7 +144,7 @@
 </div>
 
 {{-- 主内容区 --}}
-<div class="container-xl">
+<div class="container-fluid px-0">
     <div class="main-container">
 
         {{-- TOC 侧边栏(tablet+) --}}
@@ -160,13 +170,14 @@
                     @endif
                 </p>
 
-                <div class="content">
+                {{-- ↓ 正文内容用 article 包裹,隔离排版作用域 ── --}}
+                <article class="reader-body">
                     @if(isset($book['content']))
-                    {{!! $book['content'] !!}}
+                    {!! $book['content'] !!}
                     @else
                     <div>没有内容</div>
                     @endif
-                </div>
+                </article>
 
                 {{-- 上下翻页 --}}
                 <div class="mt-6 pt-6">
@@ -218,23 +229,7 @@
         {{-- 右侧边栏 --}}
         <div class="right-sidebar">
 
-            {{-- 版本卡片(desktop,wiki 侧边栏同款) --}}
-            @if(!empty($channels))
-            <div class="reader-channel-card">
-                <div class="reader-channel-title">版本</div>
-                <ul class="reader-channel-list">
-                    @foreach($channels as $channel)
-                    <li>
-                        <a href="{{ request()->fullUrlWithQuery(['channel' => $channel['id']]) }}"
-                            class="{{ request('channel') == $channel['id'] ? 'active' : '' }}">
-                            {{ $channel['name'] }}
-                            <span class="reader-channel-lang">{{ __('language.' . $channel['lang']) }}</span>
-                        </a>
-                    </li>
-                    @endforeach
-                </ul>
-            </div>
-            @endif
+
 
             {{-- 下载 --}}
             @if(!empty($book['downloads']))

+ 4 - 6
api-v13/resources/views/library/tipitaka/category.blade.php

@@ -170,12 +170,10 @@
                     <span class="tipitaka-sort-bar__label">排序</span>
                     <select class="form-select form-select-sm tipitaka-sort-select"
                         onchange="window.location=this.value">
-                        <option value="{{ request()->fullUrlWithQuery(['sort' => 'updated_at']) }}"
-                            {{ $selected['sort'] === 'updated_at' ? 'selected' : '' }}>最新</option>
-                        <option value="{{ request()->fullUrlWithQuery(['sort' => 'title']) }}"
-                            {{ $selected['sort'] === 'title' ? 'selected' : '' }}>标题</option>
-                        <option value="{{ request()->fullUrlWithQuery(['sort' => 'author']) }}"
-                            {{ $selected['sort'] === 'author' ? 'selected' : '' }}>作者</option>
+                        @foreach($sortList as $sort)
+                        <option value="{{ request()->fullUrlWithQuery(['sort' => $sort['key']]) }}"
+                            {{ $selected['sort'] === $sort['key'] ? 'selected' : '' }}>{{ $sort['label'] }}</option>
+                        @endforeach
                     </select>
                 </div>
             </div>