visuddhinanda 5 days ago
parent
commit
c42c56a4c7
65 changed files with 1878 additions and 440 deletions
  1. 49 3
      api-v12/app/Console/Commands/IndexTerm.php
  2. 7 7
      api-v12/app/Console/Commands/IndexTipitaka.php
  3. 3 3
      api-v12/app/Console/Commands/TestAITerm.php
  4. 81 0
      api-v12/app/Console/Commands/UpgradeAITerm.php
  5. 3 0
      api-v12/app/DTO/Search/HitItemDTO.php
  6. 25 0
      api-v12/app/Http/Api/TemplateRender.php
  7. 195 65
      api-v12/app/Http/Controllers/Library/WikiController.php
  8. 1 1
      api-v12/app/Http/Controllers/SearchPlusController.php
  9. 228 0
      api-v12/app/Services/AIAssistant/AITermService.php
  10. 0 133
      api-v12/app/Services/AITermService.php
  11. 86 30
      api-v12/app/Services/OpenSearchService.php
  12. 30 1
      api-v12/app/Services/TermService.php
  13. 254 0
      api-v12/config/taxonomy.php
  14. 105 0
      api-v12/documents/category.md
  15. 505 66
      api-v12/resources/css/modules/_wiki.css
  16. 95 0
      api-v12/resources/views/components/wiki/entry-actions.blade.php
  17. 5 14
      api-v12/resources/views/components/wiki/quality-badge.blade.php
  18. 14 0
      api-v12/resources/views/components/wiki/sub-category.blade.php
  19. 14 0
      api-v12/resources/views/components/wiki/term-link.blade.php
  20. 49 0
      api-v12/resources/views/library/wiki/index.blade.php
  21. 46 40
      api-v12/resources/views/library/wiki/layouts/app.blade.php
  22. 5 1
      api-v12/resources/views/library/wiki/show.blade.php
  23. 1 1
      dashboard-v6/src/api/Attachments.ts
  24. 1 1
      dashboard-v6/src/api/Comment.ts
  25. 1 1
      dashboard-v6/src/api/pali-text.ts
  26. 1 1
      dashboard-v6/src/api/sent-sim.ts
  27. 1 1
      dashboard-v6/src/api/sentence-history.ts
  28. 4 4
      dashboard-v6/src/api/sentence-pr.ts
  29. 1 1
      dashboard-v6/src/api/sentence.ts
  30. 1 1
      dashboard-v6/src/components/anthology/AnthologyInfoEdit.tsx
  31. 1 1
      dashboard-v6/src/components/article/ArticleCreate.tsx
  32. 2 2
      dashboard-v6/src/components/attachment/AttachmentList.tsx
  33. 2 2
      dashboard-v6/src/components/auth/Account.tsx
  34. 2 2
      dashboard-v6/src/components/discussion/DiscussionAnchor.tsx
  35. 2 2
      dashboard-v6/src/components/discussion/DiscussionCreate.tsx
  36. 1 1
      dashboard-v6/src/components/discussion/DiscussionEdit.tsx
  37. 1 1
      dashboard-v6/src/components/discussion/DiscussionListCard.tsx
  38. 3 3
      dashboard-v6/src/components/discussion/DiscussionShow.tsx
  39. 2 2
      dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx
  40. 1 1
      dashboard-v6/src/components/discussion/DiscussionTopicInfo.tsx
  41. 1 1
      dashboard-v6/src/components/discussion/InteractiveArea.tsx
  42. 1 1
      dashboard-v6/src/components/discussion/QaList.tsx
  43. 1 1
      dashboard-v6/src/components/discussion/utils.ts
  44. 1 1
      dashboard-v6/src/components/nissaya/NissayaCard.tsx
  45. 1 1
      dashboard-v6/src/components/related-para/RelatedPara.tsx
  46. 1 1
      dashboard-v6/src/components/sentence-history.tsx/SentHistory.tsx
  47. 1 1
      dashboard-v6/src/components/sentence/EditInfo.tsx
  48. 2 2
      dashboard-v6/src/components/sentence/SentCell.tsx
  49. 2 2
      dashboard-v6/src/components/sentence/SentCellEditable.tsx
  50. 1 1
      dashboard-v6/src/components/sentence/SentRead.tsx
  51. 1 1
      dashboard-v6/src/components/sentence/SentWbw.tsx
  52. 1 1
      dashboard-v6/src/components/sentence/SuggestionList.tsx
  53. 3 3
      dashboard-v6/src/components/setting/SettingAccount.tsx
  54. 3 3
      dashboard-v6/src/components/share/Collaborator.tsx
  55. 1 1
      dashboard-v6/src/components/share/CollaboratorAdd.tsx
  56. 1 1
      dashboard-v6/src/components/template/Video.tsx
  57. 1 1
      dashboard-v6/src/components/term/TermCtl.tsx
  58. 1 1
      dashboard-v6/src/components/users/UserSelect.tsx
  59. 1 1
      dashboard-v6/src/components/video/Video.tsx
  60. 6 4
      dashboard-v6/src/components/wbw/WbwDetailFactor.tsx
  61. 1 1
      dashboard-v6/src/components/wbw/WbwLookup.tsx
  62. 4 4
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  63. 9 9
      dashboard-v6/src/components/wbw/WbwWord.tsx
  64. 3 3
      dashboard-v6/src/components/webhook/WebhookEdit.tsx
  65. 2 2
      dashboard-v6/src/components/webhook/WebhookList.tsx

+ 49 - 3
api-v12/app/Console/Commands/IndexTerm.php

@@ -110,6 +110,15 @@ class IndexTerm extends Command
         $isCommunity = $this->termService->isCommunity($termData['channel_id']);
         $content     = $termData['html'] ?? $termData['meaning'];
 
+        $categories = $this->extractCategories($termData['note'] ?? '');
+        $quality = $this->extractFirstQuality($termData['note'] ?? '');
+        $tags = [];
+        foreach ($categories as $key => $category) {
+            $tags[] = "category:{$category}";
+        }
+        if (!empty($quality)) {
+            $tags[] = "quality:{$quality}";
+        }
         $document = [
             'id'            => "term_{$id}",
             'resource_id'   => $id,
@@ -125,16 +134,17 @@ class IndexTerm extends Command
                 ],
             ],
             'summary' => [
-                'text' => $termData['summary'] ?? $termData['note'] ?? '',
+                'text' => $termData['summary'] ?? '',
             ],
             'content'     => [],
             'bold_single' => [$termData['meaning'], $termData['word']],
             'related_id'  => $termData['word'],
-            'category'    => [],
-            'tags'        => $isCommunity ? ['community'] : [],
+            'category'    => null,
+            'tags'        => $tags,
             'language'    => $termData['language'],
             'updated_at'  => now()->toIso8601String(),
             'path'        => $termData['studio']['realName'] . "/{$channelName}",
+            'metadata' => ['channel' => $termData['channel_id']],
         ];
 
         // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
@@ -153,4 +163,40 @@ class IndexTerm extends Command
             $this->openSearchService->create($document['id'], $document);
         }
     }
+
+    /**
+     * 提取 Markdown 中的 {{category|...}} 分类标签
+     *
+     * @param string $content
+     * @return array
+     */
+    private function extractCategories(string $content): array
+    {
+        if (empty($content)) {
+            return [];
+        }
+        preg_match_all('/\{\{category\|([^}]+)\}\}/u', $content, $matches);
+
+        return array_values(array_filter(array_map(
+            fn($item) => trim($item),
+            $matches[1] ?? []
+        )));
+    }
+
+    /**
+     * 提取 Markdown 中第一个 {{quality|...}} 标签内的内容
+     *
+     * @param string $content
+     * @return string
+     */
+    private function extractFirstQuality(string $content): string
+    {
+        if (empty($content)) {
+            return '';
+        }
+
+        preg_match('/\{\{quality\|([^}]+)\}\}/u', $content, $matches);
+
+        return isset($matches[1]) ? trim($matches[1]) : '';
+    }
 }

+ 7 - 7
api-v12/app/Console/Commands/IndexTipitaka.php

@@ -158,7 +158,6 @@ class IndexTipitaka extends Command
             }
             $this->indexParagraph($para->toArray(), $paraContent, $commentaryId, $category);
             $this->info("{$para['book']}-[{$para['paragraph']}]-[{$commentaryId}]");
-            usleep(10000);
         }
 
         $this->info("Successfully indexed $total paragraphs for book: $book");
@@ -184,14 +183,14 @@ class IndexTipitaka extends Command
             'resource_id' => $resource_id, // Use uid from getPaliData for resource_id
             'resource_type' => 'tipitaka',
             'title' => [
-                'pali' => $title,
+                'text' => ['pali' => $title,],
             ],
             'summary' => [
                 'text' => $this->summary  ? $this->summaryService->summarize($paraContent['markdown']) : ''
             ],
             'content' => [
-                'pali' => $paraContent['text'],
-                'suggest' => $paraContent['words'],
+                'text' => ['pali' => $paraContent['text']],
+                'suggest' => ['pali' => $paraContent['words']],
             ],
             'bold_single' => implode(' ', $paraContent['bold1']),
             'bold_multi' => implode(' ', array_merge($paraContent['bold2'], $paraContent['bold3'])),
@@ -203,10 +202,10 @@ class IndexTipitaka extends Command
             'path' => $this->getPathTitle($path),
         ];
         if ($paraInfo['level'] < 8) {
-            $document['title']['suggest'] = $paraContent['words'];
+            $document['title']['suggest']['pali'] = $paraContent['words'];
         }
         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);
@@ -364,6 +363,7 @@ class IndexTipitaka extends Command
                 $this->chapterSave([
                     'book' => $book,
                     'para' => $start,
+                    'level' => $chapter->level,
                     'channel' => $channel->channel_uid,
                     'display' => implode('', $display),
                     'content' => implode('', $content),
@@ -397,7 +397,7 @@ class IndexTipitaka extends Command
             'category'    => $param['cat'],
             'language'    => $channel['lang'],
             'updated_at'  => now()->toIso8601String(),
-            'granularity' => 'chapter',
+            'granularity' => $param['level'] === 1 ? 'book' : 'chapter',
         ];
 
         // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段

+ 3 - 3
api-v12/app/Console/Commands/TestAITerm.php

@@ -3,7 +3,7 @@
 namespace App\Console\Commands;
 
 use Illuminate\Console\Command;
-use App\Services\AITermService;
+use App\Services\AIAssistant\AITermService;
 
 class TestAITerm extends Command
 {
@@ -29,10 +29,10 @@ class TestAITerm extends Command
         //
         // ===== 创建 Service =====
         $service = app(AITermService::class);
-        $service->setModel('dd81ce6c-e9ff-46b2-b1af-947728ba996e');
 
         // ===== 执行 =====
-        $result = $service->create('f3ba16e5-862d-49c4-b5b0-39ab8b8ca4f4');
+        $result = $service->setModel('dd81ce6c-e9ff-46b2-b1af-947728ba996e')
+            ->update('f3ba16e5-862d-49c4-b5b0-39ab8b8ca4f4');
 
         // ===== 调试输出(建议保留)=====
         dump($result);

+ 81 - 0
api-v12/app/Console/Commands/UpgradeAITerm.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+use App\Services\AIModelService;
+use App\Services\TermService;
+use App\Services\AIAssistant\AITermService;
+
+use App\Http\Resources\AiModelResource;
+use App\Http\Controllers\AuthController;
+
+
+class UpgradeAITerm extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:ai.term  --id=e07bf5b8-bd81-4f0a-9d2c-8e0128b954d7
+     * php artisan upgrade:ai.term
+     * @var string
+     */
+    protected $signature = 'upgrade:ai.term {--id=} {--resume} {--model=} ';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    protected AiModelResource $model;
+    protected $modelToken;
+    protected $workChannel;
+    protected $accessToken;
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected AIModelService $modelService,
+        protected TermService $termService,
+        protected AITermService $aiTermService
+    ) {
+
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (!$this->option('model')) {
+            $modelId = $this->ask('请输入 llm model id');
+        } else {
+            $modelId = $this->option('model');
+        }
+        $this->aiTermService->setModel($modelId);
+        $this->model = $this->modelService->getModelById($modelId);
+        $this->info("model:{$this->model['model']}");
+        $this->modelToken = AuthController::getUserToken($modelId);
+
+        if ($this->option('id')) {
+            $terms = [['guid' => $this->option('id'), 'word' => 'word']];
+        } else {
+            $terms = $this->termService->getCommunityGlossary('zh-Hans')['items']->toArray();
+        }
+
+        foreach ($terms as $key => $term) {
+            $this->info("[{$term['word']}] running");
+            $result = $this->aiTermService->update($term['guid']);
+            $this->info("[{$term['word']}]content " . substr($result, 0, 30));
+        }
+
+        return 0;
+    }
+}

+ 3 - 0
api-v12/app/DTO/Search/HitItemDTO.php

@@ -12,6 +12,7 @@ class HitItemDTO
         public string $title,
         public string $path,
         public array $category,
+        public array $tags,
         public string $highlight,
         public string $updated,
         public string $type,
@@ -78,6 +79,7 @@ class HitItemDTO
             content: $content,
             path: $source['path'] ?? '',
             category: $category,
+            tags: $source['tags'],
             highlight: implode('', $highlightArray),
             updated: $source['updated_at'],
             type: $source['resource_type'],
@@ -104,6 +106,7 @@ class HitItemDTO
             'content'   => $this->content,
             'path'      => $this->path,
             'category'  => $this->category,
+            'tags'  => $this->tags,
             'highlight' => $this->highlight,
             'updated'   => $this->updated,
             'type'      => $this->type,

+ 25 - 0
api-v12/app/Http/Api/TemplateRender.php

@@ -163,6 +163,9 @@ class TemplateRender
             case 'para':
                 $result = $this->render_para();
                 break;
+            case 'category':
+                $result = $this->render_category();
+                break;
             default:
                 if (mb_substr($tpl_name, 0, 4, "UTF-8") === 'Tpl:') {
                     $result = $this->render_tpl($tpl_name);
@@ -234,6 +237,28 @@ class TemplateRender
         return $output;
     }
 
+    public function render_category()
+    {
+        $props = [];
+        $props['name'] = $this->get_param($this->param, "name", 1);
+
+        $output = [];
+        switch ($this->format) {
+            case 'react':
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'html' => $props['name'],
+                    'tag' => 'span',
+                    'tpl' => 'para',
+                ];
+                break;
+            default:
+                $output = $props['name'];
+                break;
+        }
+        return $output;
+    }
+
     public function getTermProps($word, $tag = null, $channel = null)
     {
         if ($channel && !empty($channel)) {

+ 195 - 65
api-v12/app/Http/Controllers/Library/WikiController.php

@@ -7,11 +7,14 @@ use Illuminate\Http\Request;
 use App\Helpers\WikiContentParser;
 use App\Services\TermService;
 use Illuminate\Support\Str;
+use App\Services\OpenSearchService;
+
 
 class WikiController extends Controller
 {
     public function __construct(
-        private TermService    $termService
+        private TermService    $termService,
+        private OpenSearchService    $searchService
     ) {}
 
     // ── Mock 数据 ────────────────────────────────────────────────
@@ -50,79 +53,172 @@ HTML,
         ];
     }
 
-    private function featured(array $all): array
-    {
-        return array_map(function ($item) {
-            return ['word' => $item['word'],     'zh' => $item['meaning'],   'lang' => $item['language'], 'category' => '法义术语'];
-        }, $all);
-    }
 
-    private function mockStats(): array
+
+    // ── Actions ──────────────────────────────────────────────────
+
+    public function index(Request $request, string $lang)
     {
-        return [
-            'total'        => 2847,
-            'this_month'   => 43,
-            'contributors' => 128,
+        $quality = $request->input('quality')
+            ?? $request->cookie('wiki_quality_filter')
+            ?? 'draft';
+
+        $cookie = cookie()->forever('wiki_quality_filter', $quality);
+
+        $category = $request->input('category');
+        $subs     = null;
+
+        if ($category) {
+            $taxNode = collect(config('taxonomy'))->firstWhere('id', $category);
+            $subs    = $taxNode ? $this->subEntries($taxNode['subs'], $lang, $quality) : null;
+        }
+
+        $result      = $this->termService->communityTerms($lang);
+        $fakeRequest = Request::create('', 'GET', []);
+        $terms       = $result['data']->toArray($fakeRequest);
+        $first       = $terms[0];
+
+        $today = [
+            'word'     => $first['word'],
+            'lang'     => $first['language'],
+            'slug'     => $first['word'],
+            'meaning'  => $first['meaning'],
+            'quality'  => 'featured',
+            'category' => '法义术语',
+            'content'  => $first['summary'],
         ];
+
+
+
+        return response()
+            ->view('library.wiki.index', [
+                'today'         => $request->has('category') ? null : $today,
+                'featured'      => $category ? null : $this->featured($terms),
+                'stats'         => $this->mockStats(),
+                'recentUpdates' => $this->mockRecentUpdates(),
+                'categories'    => $this->categories(),
+                'lang'          => $lang,
+                'category'      => $category,
+                'subs'          => $subs,   // null | array of subs with entries
+                'quality'   => $quality,
+                'qualities' => $this->qualities(),
+            ])->withCookie($cookie);
     }
 
-    private function mockRecentUpdates(): array
+    /**
+     * 给每个二级分类注入 词条列表
+     * 真实数据替换时:按 sub['tags'] 查询术语表,返回同结构数组即可
+     */
+    private function subEntries(array $subs, string $lang, string $quality): array
     {
-        return [
-            ['word' => 'Nibbāna',  'lang' => 'pi'],
-            ['word' => '四圣谛',   'lang' => 'zh'],
-            ['word' => '阿含经',   'lang' => 'zh'],
-            ['word' => 'Rājagaha', 'lang' => 'pi'],
-        ];
+        return array_map(function ($sub) use ($lang, $quality) {
+            $entries = $this->querySubCat($sub['tags'], $lang, $quality);
+            return array_merge($sub, ['entries' => $entries]);
+        }, $subs);
     }
 
-    // ── Actions ──────────────────────────────────────────────────
-
-    public function index(string $lang)
+    private function querySubCat(array $cats, string $lang, string $quality): array
     {
-        $result = $this->termService->communityTerms($lang);
-        $fakeRequest = Request::create('', 'GET', []);
-        $termResource = $result['data'];
-        $terms    = $termResource->toArray($fakeRequest);
+        $params = [
+            'pageSize'     => 1000,
+            'resourceType' => 'term',
+            'language'     => $lang,
+            'tags'         => array_map(fn($n) => "category:{$n}", $cats),
+        ];
 
-        $first = $terms[0];
-        $today = [
-            'word'      => $first['word'],
-            'lang'      => $first['language'],
-            'slug'      => $first['word'],
-            'meaning'        => $first['meaning'],
-            'quality'   => 'featured',   // featured | stub | review | null
-            'category'  => '法义术语',
-            'content' => $first['summary']
+        $result = $this->searchService->search($params);
+
+        // 质量等级(数值越小等级越高)
+        $qualityRank = [
+            'featured' => 1,
+            'standard' => 2,
+            'draft'    => 3,
+            'pending'  => 4,
         ];
 
+        // 当前允许的最大等级
+        $maxRank = $qualityRank[$quality] ?? 4;
 
-        return view('library.wiki.index', [
-            'today'         => $today,
-            'featured'      => $this->featured($terms),
-            'stats'         => $this->mockStats(),
-            'recentUpdates' => $this->mockRecentUpdates(),
-            'categories'    => $this->categories(),
-            'lang' => $lang
-        ]);
+        $unique = [];
+
+        foreach ($result['hits']['hits'] as $item) {
+            $text = $item['_source']['title']['text'];
+            $id   = $item['_source']['resource_id'];
+
+            if (isset($item['_source']['tags'])) {
+                $itemQuality = $this->getQuality($item['_source']['tags']) ?? 'pending';
+            } else {
+                $itemQuality = 'pending';
+            }
+
+            $itemRank = $qualityRank[$itemQuality] ?? 4;
+
+            // 按输入质量过滤
+            if ($itemRank > $maxRank) {
+                continue;
+            }
+
+            $record = [
+                'id'      => $id,
+                'word'    => $text['pali'],
+                'zh'      => $text['zh'],
+                'quality' => $itemQuality,
+            ];
+
+            // 用 pali + zh 去重
+            $key = mb_strtolower(trim($text['pali']) . '|' . trim($text['zh']));
+
+            // 如果不存在,直接保存
+            if (!isset($unique[$key])) {
+                $unique[$key] = $record;
+                continue;
+            }
+
+            // 已存在时,保留质量更高的
+            $existingQuality = $unique[$key]['quality'];
+            $existingRank    = $qualityRank[$existingQuality] ?? 4;
+
+            if ($itemRank < $existingRank) {
+                $unique[$key] = $record;
+            }
+        }
+
+        return array_values($unique);
     }
 
+    private function getQuality(array $tags)
+    {
+        $qualityTag = array_find($tags, function ($tag) {
+            return str_contains($tag, 'quality:');
+        });
+        if ($qualityTag) {
+            return mb_substr($qualityTag, 8, null, "UTF-8");
+        } else {
+            return null;
+        }
+    }
+
+
     public function show(string $lang, string $word)
     {
         if (Str::isUuid($word)) {
             $term = $this->termService->find($word, 'html');
         } else {
-            $term = $this->termService->communityTerm($word, $lang, 'html');
+            $term = $this->termService->communityWiki($word, $lang, 'html');
         }
 
-
-        $termArray    = $term;
+        $result = $this->searchService->get("term_{$term['guid']}");
+        if (isset($result['_source']['tags'])) {
+            $quality = $this->getQuality($result['_source']['tags']);
+        } else {
+            $quality = null;
+        }
         $entry = [
-            'word'      => $termArray['word'],
-            'lang'      => $termArray['language'],
-            'slug'      => $termArray['word'],
-            'meaning'        => $termArray['meaning'],
-            'quality'   => 'featured',   // featured | stub | review | null
+            'word'      => $term['word'],
+            'lang'      => $term['language'],
+            'slug'      => $term['word'],
+            'meaning'        => $term['meaning'],
+            'quality'   => $quality,   // featured | standard | draft | pending | null
             'category'  => '法义术语',
             'tags'      => [],
             'langs'     => [
@@ -131,11 +227,8 @@ HTML,
             ],
             'related' => [
                 ['word' => 'Dukkha',    'zh' => '苦',   'lang' => 'pi'],
-                ['word' => 'Anattā',    'zh' => '无我', 'lang' => 'pi'],
-                ['word' => 'Vipassanā', 'zh' => '内观', 'lang' => 'pi'],
-                ['word' => 'Ti-lakkhaṇa', 'zh' => '三相', 'lang' => 'pi'],
             ],
-            'content' => $termArray['html'] ?? ''
+            'content' => $term['html'] ?? ''
         ];
         $parsed  = WikiContentParser::parse($entry['content']);
 
@@ -143,25 +236,25 @@ HTML,
             'entry' => array_merge($entry, [
                 'content' => $parsed['content'],
                 'toc'     => $parsed['toc'],
+                'edit_url' => config('mint.server.dashboard_base_path') . "/workspace/term/{$term['guid']}/edit",
+                'zh' => '编辑'
             ]),
             'categories' => $this->categories(),
-            'lang' => $lang
+            'lang' => $lang,
+
         ]);
     }
 
+
     // ── Helpers ──────────────────────────────────────────────────
 
     private function categories(): array
     {
-        return [
-            ['slug' => 'all',      'label' => '全部'],
-            ['slug' => 'term',     'label' => '法义术语'],
-            ['slug' => 'person',   'label' => '人物传记'],
-            ['slug' => 'text',     'label' => '经典文献'],
-            ['slug' => 'school',   'label' => '宗派历史'],
-            ['slug' => 'practice', 'label' => '修行方法'],
-            ['slug' => 'place',    'label' => '佛教地理'],
-        ];
+        $cats = collect(config('taxonomy'))
+            ->map(fn($cat) => ['id' => $cat['id'], 'label' => $cat['label']])
+            ->toArray();
+
+        return $cats;
     }
 
     // 在 WikiController.php 中添加此方法
@@ -208,4 +301,41 @@ HTML,
             'dailyTerm'     => $dailyTerm,
         ]);
     }
+
+
+    private function qualities(): array
+    {
+        return [
+            ['value' => 'featured', 'label' => '典范条目', 'subtitle' => '平台推荐'],
+            ['value' => 'standard',  'label' => '规范条目', 'subtitle' => '邀请试读'],
+            ['value' => 'draft',     'label' => '草稿', 'subtitle' => '仅供参考'],
+            ['value' => 'pending',   'label' => '待定', 'subtitle' => '审稿专用'],
+        ];
+    }
+
+    private function featured(array $all): array
+    {
+        return array_map(function ($item) {
+            return ['word' => $item['word'],     'zh' => $item['meaning'],   'lang' => $item['language'], 'category' => '法义术语'];
+        }, $all);
+    }
+
+    private function mockStats(): array
+    {
+        return [
+            'total'        => 2847,
+            'this_month'   => 43,
+            'contributors' => 128,
+        ];
+    }
+
+    private function mockRecentUpdates(): array
+    {
+        return [
+            ['word' => 'Nibbāna',  'lang' => 'pi'],
+            ['word' => '四圣谛',   'lang' => 'zh'],
+            ['word' => '阿含经',   'lang' => 'zh'],
+            ['word' => 'Rājagaha', 'lang' => 'pi'],
+        ];
+    }
 }

+ 1 - 1
api-v12/app/Http/Controllers/SearchPlusController.php

@@ -54,7 +54,7 @@ class SearchPlusController extends Controller
         $resourceId = $request->input('resource_id'); // 资源类型
         $granularity  = $request->input('granularity');   // 文档颗粒度
         $language     = $request->input('language');      // 语言
-        $category     = $request->input('category');      // 分类
+        $category     = $request->has('category') ? explode(',', $request->input('category')) : null;      // 分类
         $tags         = $request->has('tags') ? explode(',', $request->input('tags')) : [];      // 标签
         $pageRefs     = $request->input('page_refs', []); // 页码标记
         $relatedId    = $request->input('related_id'); // 关联 ID

+ 228 - 0
api-v12/app/Services/AIAssistant/AITermService.php

@@ -0,0 +1,228 @@
+<?php
+
+namespace App\Services\AIAssistant;
+
+use App\Services\OpenSearchService;
+use App\Services\TermService;
+use App\Services\OpenAIService;
+use App\Services\AIModelService;
+use App\Http\Resources\AiModelResource;
+use App\Http\Controllers\AuthController;
+use App\DTO\Search\SearchDataDTO;
+
+class AITermService
+{
+    protected $pageSize = 20;
+    protected AiModelResource $model;
+
+
+    protected $modelToken;
+
+
+    private $sysPrompt = <<<md
+    请根据提供的文献搜素结果,撰写一个巴利术语的简体中文百科词条。
+
+    搜素结果是json数组
+    字段
+    - title(标题)
+    - content:(内容)
+    - path(章节路径)
+    - link(引用链接)
+
+    要求:
+    1. 参考维基百科的形式和结构
+    2. 所有观点必须标明巴利文出处,使用我提供的link
+    3. 引用巴利文原文时使用引号并斜体
+    4. 提供完整的参考文献列表
+    5. 保持学术中立性和客观性
+    6. 请引用我提供的全部内容,不要有任何遗漏
+    7. 请在文档的开头输出一个模板 {{quality|pending}}
+
+    **观点引用标准格式:**
+    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接)
+
+    如果某个观点有多个出处,请分别列出巴利文引用链接。范例
+    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(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}}
+
+    词条结构应包括:
+    - 简短定义段落(不要标题直接输出段落内容)
+    - 目录
+    - 词源与定义
+    - 其他的,文献中提及的内容分类
+    - 参考文献
+    - 相关条目
+    - 分类标签
+
+    格式要求:
+    - 使用Markdown格式
+    - 标题层级清晰(#, ##, ###)
+    - 直接输出百科正文,无需大标题
+    - 引用格式:《文献中文名》在《章节中文名》中 + 动词 + "巴利文"  + (巴利文的中文译文)(link引用链接)
+    - 引用动词可用:指出、解释、说明、定义、描述、强调、阐述、论述等
+    - 巴利文使用罗马转写
+    - 关键术语首次出现时提供巴利文和中文对照
+
+    参考文献格式:
+    [序号] 文献全称缩写, 具体章节, 标题, 段落编号
+
+    请在文档的底部输出分类标签
+
+    请根据以下分类体系,为给定的词条打上分类标签。
+
+    **分类体系:**
+
+    一、经藏教义:1.1基本教义(苦、集、灭、道、缘起、十二缘起、三法印、无常、无我、五蕴、三宝、八正道、中道、善法、不善法)/1.2业与轮回(业、善业、不善业、无记业、业报、轮回、结生、再生、三界轮转)/1.3涅槃与解脱(涅槃、有余涅槃、无余涅槃、解脱、道、果、烦恼断除、结、漏)
+
+    二、阿毗达摩:2.1心路与心类(欲界心、色界心、无色界心、出世间心、善心、不善心、无记心、心路、五门心路、意门心路、速行、有分、结生心、死心、转向心)/2.2心所法(遍一切心心所、杂心所、不善心所、美心所、贪、嗔、痴、慢、邪见、掉举、信、念、慧、悲、喜、舍)/2.3色法(四大种、地界、水界、火界、风界、净色、所造色、色聚、业生色、心生色、时节生色、食生色、真实色、非真实色)/2.4缘起与发趣法(二十四缘、因缘、所缘缘、增上缘、俱生缘、亲依止缘、前生缘、后生缘、业缘、果报缘、根缘、禅缘、道缘、相应缘、不相应缘、有缘、无有缘)
+
+    三、禅修:3.1止禅(遍禅、不净观、随念、四梵住、慈、悲、喜、舍、入出息念、四界差别、禅相、取相、似相、近行定、安止定、禅那、禅支、无色定)/3.2观禅(名色分别、观智、生灭智、坏灭智、怖畏智、厌离智、行舍智、道智、果智、毘婆舍那、三相、无常随观、苦随观、无我随观、刹那定)
+
+    四、律学:4.1戒条罪类(波罗夷、僧残、不定、舍堕、单堕、悔过、众学、灭诤、比库戒、比库尼戒、学处、犯罪、无犯、违犯条件)/4.2僧团制度与羯磨(羯磨、白羯磨、白二羯磨、白四羯磨、结界、布萨、自恣、受具足、出家、僧团、四方僧、惩罚羯磨、和合)/4.3僧侣生活与器具(三衣、钵、住处、精舍、雨安居、迦提那衣、头陀行、乞食、日用器具、净食)
+
+    五、经典与文献:5.1经藏(长部、中部、相应部、增支部、小部、经名、品名、篇名)/5.2论藏与注疏(论藏、七论、义注、复注、清净道论、摄论、史书、藏外文献)/5.3本生与偈颂(本生、长老偈、长老尼偈、佛种姓、譬喻、天宫事、饿鬼事)
+
+    六、世界观:6.1三界与诸天(欲界、色界、无色界、欲界天、梵天界、净居天、人间、有情居、天界层次)/6.2地狱与恶趣(地狱、无间地狱、寒冰地狱、孤独地狱、饿鬼界、畜生界、阿修罗界、四恶趣)/6.3神灵与非人(天神、梵天、夜叉、龙族、乾达婆、非人、护法神)
+
+    七、人物:7.1佛(佛名、过去佛、二十八佛、独觉佛、菩萨)/7.2出家弟子(上首弟子、大弟子、比库弟子、比库尼弟子、沙弥、沙弥尼、在学尼)/7.3在家人(男居士、女居士、护法者、国王、婆罗门、施主、转轮圣王、王后、大长者)/7.4外道(外道名)
+
+    八、地理:8.1国家与城镇(十六大国、国家、村落、市镇、寺院名)/8.2水系(河流、湖泊、海洋)/8.3山岳(山名、山脉)
+
+    九、动植物:9.1动物(兽类、鸟类、爬行类、鱼类、昆虫、神话动物、龙族、金翅鸟、畜养动物、野生动物)/9.2植物(树木、花卉、草药、粮食作物、果实、圣树、菩提树类)
+
+    十、巴利语言与语法:10.1语法术语(名词、动词、形容词、副词、格、数、性、时态、语式、复合词类型、前缀、后缀)/10.2语法缩写与标注(词性标注、格标注、语态标注、使役态、引用标记、出处标注)
+
+    **输出规则:**
+    - 必须从以上分类体系中选取,不得自创分类
+    - 一个词条可打多个标签,但通常不超过3个
+    - 先输出一级分类,再输出二级分类,再输出具体标签
+    - 格式严格为:`{{category|一级分类}}` `{{category|二级分类}}` `{{category|标签}}`
+    - 一级分类去除编号,例如"人物"而非"七、人物"
+    - 二级分类去除编号,例如"佛"而非"7.1佛"
+    - 只输出标签,不输出任何解释
+
+    ---
+
+    输出示例:
+
+    # 分类标签
+
+    {{category|经典与文献}} {{category|经藏}} {{category|中部}} {{category|义注}}
+    md;
+
+    private $sysPrompt1 = <<<md
+    你是一个巴利语佛教百科词条分类专家。请根据以下分类体系,为给定的词条打上分类标签。
+
+    在分类标签的前面添加一个**词条概述**
+
+    分类标签
+
+    请根据以下分类体系,为给定的词条打上分类标签。
+
+    **分类体系:**
+
+    一、经藏教义:1.1基本教义(苦、集、灭、道、缘起、十二缘起、三法印、无常、无我、五蕴、三宝、八正道、中道、善法、不善法)/1.2业与轮回(业、善业、不善业、无记业、业报、轮回、结生、再生、三界轮转)/1.3涅槃与解脱(涅槃、有余涅槃、无余涅槃、解脱、道、果、烦恼断除、结、漏)
+
+    二、阿毗达摩:2.1心路与心类(欲界心、色界心、无色界心、出世间心、善心、不善心、无记心、心路、五门心路、意门心路、速行、有分、结生心、死心、转向心)/2.2心所法(遍一切心心所、杂心所、不善心所、美心所、贪、嗔、痴、慢、邪见、掉举、信、念、慧、悲、喜、舍)/2.3色法(四大种、地界、水界、火界、风界、净色、所造色、色聚、业生色、心生色、时节生色、食生色、真实色、非真实色)/2.4缘起与发趣法(二十四缘、因缘、所缘缘、增上缘、俱生缘、亲依止缘、前生缘、后生缘、业缘、果报缘、根缘、禅缘、道缘、相应缘、不相应缘、有缘、无有缘)
+
+    三、禅修:3.1止禅(遍禅、不净观、随念、四梵住、慈、悲、喜、舍、入出息念、四界差别、禅相、取相、似相、近行定、安止定、禅那、禅支、无色定)/3.2观禅(名色分别、观智、生灭智、坏灭智、怖畏智、厌离智、行舍智、道智、果智、毘婆舍那、三相、无常随观、苦随观、无我随观、刹那定)
+
+    四、律学:4.1戒条罪类(波罗夷、僧残、不定、舍堕、单堕、悔过、众学、灭诤、比库戒、比库尼戒、学处、犯罪、无犯、违犯条件)/4.2僧团制度与羯磨(羯磨、白羯磨、白二羯磨、白四羯磨、结界、布萨、自恣、受具足、出家、僧团、四方僧、惩罚羯磨、和合)/4.3僧侣生活与器具(三衣、钵、住处、精舍、雨安居、迦提那衣、头陀行、乞食、日用器具、净食)
+
+    五、经典与文献:5.1经藏(长部、中部、相应部、增支部、小部、经名、品名、篇名)/5.2论藏与注疏(论藏、七论、义注、复注、清净道论、摄论、史书、藏外文献)/5.3本生与偈颂(本生、长老偈、长老尼偈、佛种姓、譬喻、天宫事、饿鬼事)
+
+    六、世界观:6.1三界与诸天(欲界、色界、无色界、欲界天、梵天界、净居天、人间、有情居、天界层次)/6.2地狱与恶趣(地狱、无间地狱、寒冰地狱、孤独地狱、饿鬼界、畜生界、阿修罗界、四恶趣)/6.3神灵与非人(天神、梵天、夜叉、龙族、乾达婆、非人、护法神)
+
+    七、人物:7.1佛(佛名、过去佛、二十八佛、独觉佛、菩萨)/7.2出家弟子(上首弟子、大弟子、比库弟子、比库尼弟子、沙弥、沙弥尼、在学尼)/7.3在家人(男居士、女居士、护法者、国王、婆罗门、施主、转轮圣王、王后、大长者)/7.4外道(外道名)
+
+    八、地理:8.1国家与城镇(十六大国、国家、村落、市镇、寺院名)/8.2水系(河流、湖泊、海洋)/8.3山岳(山名、山脉)
+
+    九、动植物:9.1动物(兽类、鸟类、爬行类、鱼类、昆虫、神话动物、龙族、金翅鸟、畜养动物、野生动物)/9.2植物(树木、花卉、草药、粮食作物、果实、圣树、菩提树类)
+
+    十、巴利语言与语法:10.1语法术语(名词、动词、形容词、副词、格、数、性、时态、语式、复合词类型、前缀、后缀)/10.2语法缩写与标注(词性标注、格标注、语态标注、使役态、引用标记、出处标注)
+
+    **输出规则:**
+    - 必须从以上分类体系中选取,不得自创分类
+    - 一个词条可打多个标签,但通常不超过3个
+    - 先输出一级分类,再输出二级分类,再输出具体标签
+    - 格式严格为:`{{category|一级分类}}` `{{category|二级分类}}` `{{category|标签}}`
+    - 一级分类去除编号,例如"人物"而非"七、人物"
+    - 二级分类去除编号,例如"佛"而非"7.1佛"
+    - 只输出标签,不输出任何解释
+
+    ---
+
+    输出示例:
+
+    (词条的概述)
+
+    # 分类标签
+
+    {{category|经典与文献}} {{category|经藏}} {{category|中部}} {{category|义注}}
+    md;
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected AIModelService $modelService,
+        protected OpenAIService $openAIService,
+        protected TermService $termService
+    ) {}
+    public function setModel($id)
+    {
+        $this->model = $this->modelService->getModelById($id);
+        $this->modelToken = AuthController::getUserToken($id);
+        return $this;
+    }
+
+    private function query(string $word): array
+    {
+        $search = app(OpenSearchService::class);
+        // 组装搜索参数
+        $params = [
+            'query'        => $word,
+            'pageSize'     => $this->pageSize,
+        ];
+        $result = $search->search($params);
+
+        $dto = SearchDataDTO::fromArray($result);
+        $res = array();
+        foreach ($dto->hits->items as $key => $item) {
+            $res[] = [
+                'title' => $item->title,
+                'content' => $item->content,
+                'path' => $item->path,
+                'link' => $item->getParaLink()
+            ];
+        }
+        return $res;
+    }
+
+    public function update(string $id)
+    {
+        // 获取术语
+        $term = $this->termService->getRaw($id);
+        // 全文搜索
+        $query = $this->query($term->word);
+        $res = json_encode($query, JSON_UNESCAPED_UNICODE);
+        //LLM 生成
+        $response = $this->openAIService->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($this->sysPrompt)
+            ->setTemperature(0.5)
+            ->setStream(false)
+            ->send(
+                "# 巴利术语\n\n{$term->word}\n\n"
+            );
+
+        $content = $response['choices'][0]['message']['content'] ?? '';
+        $this->termService->update($id, ['note' => $content]);
+        return $content;
+    }
+    public function create(string $word) {}
+}

+ 0 - 133
api-v12/app/Services/AITermService.php

@@ -1,133 +0,0 @@
-<?php
-
-namespace App\Services;
-
-use App\Services\OpenSearchService;
-use App\Services\TermService;
-use App\Services\OpenAIService;
-use App\Services\AIModelService;
-use App\Http\Resources\AiModelResource;
-use App\Http\Controllers\AuthController;
-use App\DTO\Search\SearchDataDTO;
-
-class AITermService
-{
-    protected $pageSize = 20;
-    protected AiModelResource $model;
-
-    protected $modelService;
-    protected $modelToken;
-    protected $openAIService;
-
-    private $sysPrompt = <<<md
-    请根据提供的文献搜素结果,撰写一个巴利术语的简体中文百科词条。
-
-    搜素结果是json数组
-    字段
-    - title(标题)
-    - content:(内容)
-    - path(章节路径)
-    - link(引用链接)
-
-    要求:
-    1. 参考维基百科的形式和结构
-    2. 所有观点必须标明巴利文出处,使用我提供的link
-    3. 引用巴利文原文时使用引号并斜体
-    4. 提供完整的参考文献列表
-    5. 保持学术中立性和客观性
-    6. 请引用我提供的全部内容,不要有任何遗漏
-
-    **观点引用标准格式:**
-    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接)
-
-    如果某个观点有多个出处,请分别列出巴利文引用链接。范例
-    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(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}}
-
-    词条结构应包括:
-    - 标题(术语的巴利语、字面含义)
-    - 简短定义段落
-    - 目录
-    - 词源与定义
-    - 其他的,文献中提及的内容分类
-    - 参考文献
-    - 相关条目
-    - 分类标签
-
-    格式要求:
-    - 使用Markdown格式
-    - 标题层级清晰(#, ##, ###)
-    - 直接输出百科正文,无需大标题
-    - 引用格式:《文献中文名》在《章节中文名》中 + 动词 + "巴利文"  + (巴利文的中文译文)(link引用链接)
-    - 引用动词可用:指出、解释、说明、定义、描述、强调、阐述、论述等
-    - 巴利文使用罗马转写
-    - 关键术语首次出现时提供巴利文和中文对照
-
-    参考文献格式:
-    [序号] 文献全称缩写, 具体章节, 标题, 段落编号
-    md;
-
-    /**
-     * Create a new command instance.
-     *
-     * @return void
-     */
-    public function __construct(
-        AIModelService $model,
-        OpenAIService $openAI,
-    ) {
-        $this->modelService = $model;
-        $this->openAIService = $openAI;
-    }
-    public function setModel($id)
-    {
-        $this->model = $this->modelService->getModelById($id);
-        $this->modelToken = AuthController::getUserToken($id);
-    }
-
-    private function query(string $word): array
-    {
-        $search = app(OpenSearchService::class);
-        // 组装搜索参数
-        $params = [
-            'query'        => $word,
-            'pageSize'     => $this->pageSize,
-        ];
-        $result = $search->search($params);
-
-        $dto = SearchDataDTO::fromArray($result);
-        $res = array();
-        foreach ($dto->hits->items as $key => $item) {
-            $res[] = [
-                'title' => $item->title,
-                'content' => $item->content,
-                'path' => $item->path,
-                'link' => $item->getParaLink()
-            ];
-        }
-        return $res;
-    }
-
-    public function create(string $id)
-    {
-        // 获取术语
-        $term = app(TermService::class)->getRaw($id);
-        // 全文搜索
-        $query = $this->query($term->word);
-        $res = json_encode($query, JSON_UNESCAPED_UNICODE);
-        //LLM 生成
-        $response = $this->openAIService->setApiUrl($this->model['url'])
-            ->setModel($this->model['model'])
-            ->setApiKey($this->model['key'])
-            ->setSystemPrompt($this->sysPrompt)
-            ->setTemperature(0.5)
-            ->setStream(false)
-            ->send("# 文献搜素结果\n\n{$res}\n\n" .
-                "# 巴利术语\n\n{$term->word}\n\n");
-
-        $content = $response['choices'][0]['message']['content'] ?? '';
-        return $content;
-    }
-    public function update() {}
-}

+ 86 - 30
api-v12/app/Services/OpenSearchService.php

@@ -574,78 +574,131 @@ class OpenSearchService
      */
     public function search(array $params): array
     {
-        $page     = $params['page']     ?? 1;
+        $page     = $params['page'] ?? 1;
         $pageSize = $params['pageSize'] ?? 20;
         $from     = ($page - 1) * $pageSize;
         $mode     = $params['searchMode'] ?? 'fuzzy';
 
+        // 排除字段
+        if (!empty($params['excludes']) && is_array($params['excludes'])) {
+            $excludes = array_merge($this->sourceExcludes, $params['excludes']);
+        } else {
+            $excludes = $this->sourceExcludes;
+        }
+
         // ---------- 过滤条件 ----------
         $filters = [];
+
         if (!empty($params['resourceType'])) {
             $filters[] = ['term' => ['resource_type' => $params['resourceType']]];
         }
+
         if (!empty($params['resourceId'])) {
             $filters[] = ['term' => ['resource_id' => $params['resourceId']]];
         }
+
         if (!empty($params['granularity'])) {
             $filters[] = ['term' => ['granularity' => $params['granularity']]];
         }
+
         if (!empty($params['language'])) {
             $filters[] = ['term' => ['language' => $params['language']]];
         }
+
         if (!empty($params['category'])) {
-            $filters[] = ['term' => ['category' => $params['category']]];
+            if (is_array($params['category'])) {
+                $categories = $params['category'];
+            } else {
+                $categories = [$params['category']];
+            }
+
+            // 必须匹配全部:为每个 category 创建一个 term 条件
+            foreach ($categories as $category) {
+                $filters[] = ['term' => ['category' => $category]];
+            }
         }
+
         if (!empty($params['tags'])) {
             $filters[] = ['terms' => ['tags' => $params['tags']]];
         }
+
         if (!empty($params['pageRefs'])) {
             $filters[] = ['terms' => ['page_refs' => $params['pageRefs']]];
         }
+
         if (!empty($params['relatedId'])) {
             $filters[] = ['term' => ['related_id' => $params['relatedId']]];
         }
+
         if (!empty($params['author'])) {
             $filters[] = ['match' => ['metadata.author' => $params['author']]];
         }
+
         if (!empty($params['channel'])) {
             $filters[] = ['term' => ['metadata.channel' => $params['channel']]];
         }
 
         // ---------- 查询部分 ----------
-        switch ($mode) {
-            case 'exact':
-                $query = $this->buildExactQuery($params['query']);
-                break;
-            case 'semantic':
-                $query = $this->buildSemanticQuery($params['query']);
-                break;
-            case 'hybrid':
-                $query = $this->buildHybridQuery($params['query']);
-                break;
-            case 'fuzzy':
-            default:
-                $query = $this->buildFuzzyQuery($params['query']);
+        $queryText = trim($params['query'] ?? '');
+
+        if ($queryText === '') {
+            $query = ['match_all' => new \stdClass()];
+        } else {
+            switch ($mode) {
+                case 'exact':
+                    $query = $this->buildExactQuery($queryText);
+                    break;
+
+                case 'semantic':
+                    $query = $this->buildSemanticQuery($queryText);
+                    break;
+
+                case 'hybrid':
+                    $query = $this->buildHybridQuery($queryText);
+                    break;
+
+                case 'fuzzy':
+                default:
+                    $query = $this->buildFuzzyQuery($queryText);
+                    break;
+            }
         }
 
-        $highlightPreTags  = $params['highlight_pre_tags']  ?? ['<mark>'];
+        $highlightPreTags  = $params['highlight_pre_tags'] ?? ['<mark>'];
         $highlightPostTags = $params['highlight_post_tags'] ?? ['</mark>'];
 
         // ---------- 最终 DSL ----------
         $dsl = [
             'from'    => $from,
             'size'    => $pageSize,
-            '_source' => ['excludes' => $this->sourceExcludes],
+            '_source' => ['excludes' => $excludes],
             'query'   => !empty($filters)
-                ? ['bool' => ['must' => [$query], 'filter' => $filters]]
+                ? [
+                    'bool' => [
+                        'must'   => [$query],
+                        'filter' => $filters,
+                    ]
+                ]
                 : $query,
             'aggs' => [
-                'resource_type' => ['terms' => ['field' => 'resource_type']],
-                'language'      => ['terms' => ['field' => 'language']],
-                'category'      => ['terms' => ['field' => 'category']],
-                'granularity'   => ['terms' => ['field' => 'granularity']],
+                'resource_type' => [
+                    'terms' => ['field' => 'resource_type']
+                ],
+                'language' => [
+                    'terms' => ['field' => 'language']
+                ],
+                'category' => [
+                    'terms' => ['field' => 'category']
+                ],
+                'granularity' => [
+                    'terms' => ['field' => 'granularity']
+                ],
             ],
-            'highlight' => [
+        ];
+
+        // 只有有搜索词时才开启高亮
+        if ($queryText !== '') {
+            $dsl['highlight'] = [
                 'fields' => [
                     'title.text.pali'   => new \stdClass(),
                     'title.text.zh'     => new \stdClass(),
@@ -653,15 +706,18 @@ class OpenSearchService
                     'content.text.pali' => new \stdClass(),
                     'content.text.zh'   => new \stdClass(),
                 ],
-                'fragmenter'         => 'sentence',
-                'fragment_size'      => 200,
+                'fragmenter'          => 'sentence',
+                'fragment_size'       => 200,
                 'number_of_fragments' => 1,
-                'pre_tags'           => $highlightPreTags,
-                'post_tags'          => $highlightPostTags,
-            ],
-        ];
+                'pre_tags'            => $highlightPreTags,
+                'post_tags'           => $highlightPostTags,
+            ];
+        }
 
-        Log::debug('OpenSearchService::search', ['dsl' => json_encode($dsl, JSON_UNESCAPED_UNICODE)]);
+        Log::debug(
+            'OpenSearchService::search',
+            ['dsl' => json_encode($dsl, JSON_UNESCAPED_UNICODE)]
+        );
 
         return $this->client->search([
             'index' => config('mint.opensearch.index'),

+ 30 - 1
api-v12/app/Services/TermService.php

@@ -16,7 +16,7 @@ class TermService
             "_community_term_" . strtolower($lang) . "_",
             "_community_term_en_"
         );
-        $result = DhammaTerm::select(['word', 'tag', 'meaning', 'other_meaning'])
+        $result = DhammaTerm::select(['guid', 'word', 'tag', 'meaning', 'other_meaning'])
             ->where('channal', $localTermChannel)
             ->get();
         return ['items' => $result, 'total' => count($result)];
@@ -73,6 +73,30 @@ class TermService
             return null;
         }
     }
+
+    public function communityWiki(string $word, string $lang, string $format)
+    {
+        $localTermChannel = ChannelApi::getSysChannel(
+            "_community_translation_" . strtolower($lang) . "_"
+        );
+        $result = DhammaTerm::where('word', $word)
+            ->where('channal', $localTermChannel)
+            ->first();
+        if ($result) {
+            $resource = new TermResource($result);
+            $urlParam = ['format' => $format];
+            $fakeRequest = Request::create('', 'GET', $urlParam);
+            $termArray    = $resource->toArray($fakeRequest);
+            if ($result) {
+                return $termArray;
+            } else {
+                return null;
+            }
+        } else {
+            return null;
+        }
+    }
+
     public function communityTerms(string $lang)
     {
         $localTermChannel = ChannelApi::getSysChannel(
@@ -103,4 +127,9 @@ class TermService
             return null;
         }
     }
+
+    public function update(string $id, array $data)
+    {
+        DhammaTerm::where('guid', $id)->update($data);
+    }
 }

+ 254 - 0
api-v12/config/taxonomy.php

@@ -0,0 +1,254 @@
+<?php
+
+/**
+ * 佛教百科分类体系
+ * WikiPali Taxonomy Configuration
+ *
+ * 结构:一级分类 > 二级分类 > 标签列表
+ * 用法:config('taxonomy') 或 TaxonomyController 调用
+ * // 取全部分类
+$taxonomy = config('taxonomy');
+
+// 取所有一级分类标签(用于导航)
+$categories = collect(config('taxonomy'))->pluck('label', 'id');
+
+// 取某个一级分类下的二级分类
+$subs = collect(config('taxonomy'))
+    ->firstWhere('id', 'abhidhamma')['subs'];
+
+// 取所有标签(扁平化,用于搜索/自动补全)
+$allTags = collect(config('taxonomy'))
+    ->flatMap(fn($cat) => collect($cat['subs'])
+        ->flatMap(fn($sub) => $sub['tags']))
+    ->unique()
+    ->values();
+ */
+
+return [
+    [
+        'id'    => 'vinaya',
+        'label' => '律学',
+        'subs'  => [
+            [
+                'id'    => 'āpatti',
+                'label' => '戒条罪类',
+                'tags'  => ['波罗夷', '僧残', '不定', '舍堕', '单堕', '悔过', '众学', '灭诤', '比库戒', '比库尼戒', '学处', '犯罪', '无犯', '违犯条件'],
+            ],
+            [
+                'id'    => 'vinayakamma',
+                'label' => '僧羯磨',
+                'tags'  => ['羯磨',  '结界', '布萨', '自恣', '受具足', '出家', '僧团', '惩罚羯磨'],
+            ],
+            [
+                'id'    => 'monastic-life',
+                'label' => '僧侣生活与器具',
+                'tags'  => ['三衣', '钵', '住处', '精舍', '雨安居', '迦提那衣', '头陀行', '乞食', '日用器具', '净食'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'dhamma',
+        'label' => '法义',
+        'subs'  => [
+            [
+                'id'    => 'basic-doctrine',
+                'label' => '基本教义',
+                'tags'  => ['四圣谛',  '缘起', '十二缘起', '三法印', '五蕴', '三宝', '八正道',],
+            ],
+            [
+                'id'    => 'kamma-samsara',
+                'label' => '业与轮回',
+                'tags'  => ['业', '善业', '不善业', '无记业', '业报', '轮回', '结生', '再生', '三界轮转'],
+            ],
+            [
+                'id'    => 'nibbana',
+                'label' => '涅槃与解脱',
+                'tags'  => ['涅槃', '有余涅槃', '无余涅槃', '解脱', '道', '果', '烦恼断除', '结', '漏'],
+            ],
+            [
+                'id'    => 'citta',
+                'label' => '心路与心类',
+                'tags'  => ['欲界心', '色界心', '无色界心', '出世间心', '善心', '不善心', '无记心', '心路', '五门心路', '意门心路', '速行', '有分',],
+            ],
+            [
+                'id'    => 'cetasika',
+                'label' => '心所法',
+                'tags'  => ['遍一切心心所', '杂心所', '不善心所', '美心所', '贪', '嗔', '痴', '慢', '邪见', '掉举', '信', '念', '慧', '悲', '喜', '舍'],
+            ],
+            [
+                'id'    => 'rupa',
+                'label' => '色法',
+                'tags'  => ['四大种', '地界', '水界', '火界', '风界', '净色', '所造色', '色聚', '业生色', '心生色', '时节生色', '食生色', '真实色', '非真实色'],
+            ],
+            [
+                'id'    => 'paccaya',
+                'label' => '缘起与发趣法',
+                'tags'  => ['二十四缘', '因缘',],
+            ],
+        ],
+    ],
+
+
+    [
+        'id'    => 'bhavana',
+        'label' => '禅修',
+        'subs'  => [
+            [
+                'id'    => 'samatha',
+                'label' => '止禅',
+                'tags'  => ['遍禅', '不净观', '随念', '四梵住',  '入出息念', '四界差别', '禅相', '取相', '似相', '近行定', '安止定', '禅那', '禅支', '无色定'],
+            ],
+            [
+                'id'    => 'vipassana',
+                'label' => '观禅',
+                'tags'  => ['名色分别', '观智', '生灭智', '坏灭智', '怖畏智', '厌离智', '行舍智', '道智', '果智', '毘婆舍那', '三相', '无常随观', '苦随观', '无我随观', '刹那定'],
+            ],
+        ],
+    ],
+
+
+    [
+        'id'    => 'patha',
+        'label' => '典籍',
+        'tags'  => ['经名', '品名', '篇名'],
+        'subs'  => [
+            [
+                'id'    => 'suttapitaka',
+                'label' => '经藏',
+                'tags'  => ['长部', '中部', '相应部', '增支部', '小部',],
+            ],
+            [
+                'id'    => 'abhidhammapitaka',
+                'label' => '论藏',
+                'tags'  => ['法集论', '分别论', '界论', '人施设论', '论事', '双论'],
+            ],
+            [
+                'id'    => 'vinayapitaka',
+                'label' => '律藏',
+                'tags'  => ['经分别', '篇章', '附随'],
+            ],
+            [
+                'id'    => 'vanna',
+                'label' => '注释',
+                'tags'  => ['义注', '复注', '根本复注', '再复注'],
+            ],
+            [
+                'id'    => 'anna',
+                'label' => '藏外',
+                'tags'  => ['清净道论', '历史', '文法书'],
+            ],
+            [
+                'id'    => 'vatthu',
+                'label' => '故事类',
+                'tags'  => ['本生', '佛种姓', '譬喻', '天宫事', '饿鬼事'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'cosmology',
+        'label' => '世界观',
+        'subs'  => [
+            [
+                'id'    => 'akasaloka',
+                'label' => '空间世间',
+                'tags'  => ['欲界', '色界', '无色界', '欲界天', '梵天界', '净居天', '人间', '有情居', '天界层次'],
+            ],
+            [
+                'id'    => 'apaya',
+                'label' => '地狱与恶趣',
+                'tags'  => ['地狱',  '饿鬼界', '畜生界', '阿修罗界', '四恶趣'],
+            ],
+            [
+                'id'    => 'amanussa',
+                'label' => '神灵与非人',
+                'tags'  => ['天神', '梵天', '夜叉', '龙族', '乾达婆', '非人', '护法神'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'puggala',
+        'label' => '人名',
+        'subs'  => [
+            [
+                'id'    => 'buddha',
+                'label' => '佛',
+                'tags'  => ['佛名', '过去佛', '二十八佛', '独觉佛', '菩萨'],
+            ],
+            [
+                'id'    => 'pabbajita',
+                'label' => '出家弟子',
+                'tags'  => ['上首弟子', '大弟子', '比库弟子', '比库尼弟子', '沙弥', '沙弥尼', '在学尼'],
+            ],
+            [
+                'id'    => 'gahapati',
+                'label' => '在家人',
+                'tags'  => ['男居士', '女居士', '护法者', '国王', '婆罗门', '施主', '转轮圣王', '王后', '大长者'],
+            ],
+            [
+                'id'    => 'titthiya',
+                'label' => '外道',
+                'tags'  => ['外道名'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'padesa',
+        'label' => '地名',
+        'subs'  => [
+            [
+                'id'    => 'gama',
+                'label' => '人类聚落',
+                'tags'  => ['十六大国', '国家', '村落', '市镇', '寺院名'],
+            ],
+            [
+                'id'    => 'udaka',
+                'label' => '水系',
+                'tags'  => ['河流', '湖泊', '海洋'],
+            ],
+            [
+                'id'    => 'pabbata',
+                'label' => '山岳',
+                'tags'  => ['山名', '山脉'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'bhuta',
+        'label' => '动植物',
+        'subs'  => [
+            [
+                'id'    => 'tiracchana',
+                'label' => '动物',
+                'tags'  => ['兽类', '鸟类', '爬行类', '水生动物', '昆虫', '神话动物', '龙族', '金翅鸟', '畜养动物', '野生动物'],
+            ],
+            [
+                'id'    => 'bhutagama',
+                'label' => '植物',
+                'tags'  => ['树木', '花卉', '草药', '粮食作物', '果实', '菩提树类'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'saddaniti',
+        'label' => '巴利语言与语法',
+        'subs'  => [
+            [
+                'id'    => 'grammar-terms',
+                'label' => '语法术语',
+                'tags'  => ['名词', '动词', '形容词', '副词', '格', '数', '性', '时态', '语式', '复合词类型', '前缀', '后缀'],
+            ],
+            [
+                'id'    => 'grammar-abbrev',
+                'label' => '语法缩写与标注',
+                'tags'  => ['词性标注', '格标注', '语态标注', '使役态', '引用标记', '出处标注'],
+            ],
+        ],
+    ],
+
+];

+ 105 - 0
api-v12/documents/category.md

@@ -0,0 +1,105 @@
+**一、经藏教义**
+
+-   1.1 基本教义
+    -   `苦` `集` `灭` `道` `缘起` `十二缘起` `三法印` `无常` `无我` `五蕴` `三宝` `八正道` `中道` `善法` `不善法`
+-   1.2 业与轮回
+    -   `业` `善业` `不善业` `无记业` `业报` `轮回` `结生` `再生` `三界轮转`
+-   1.3 涅槃与解脱
+    -   `涅槃` `有余涅槃` `无余涅槃` `解脱` `道` `果` `烦恼断除` `结` `漏`
+
+---
+
+**二、阿毗达摩**
+
+-   2.1 心路与心类
+    -   `欲界心` `色界心` `无色界心` `出世间心` `善心` `不善心` `无记心` `心路` `五门心路` `意门心路` `速行` `有分` `结生心` `死心` `转向心`
+-   2.2 心所法
+    -   `遍一切心心所` `杂心所` `不善心所` `美心所` `贪` `嗔` `痴` `慢` `邪见` `掉举` `信` `念` `慧` `悲` `喜` `舍`
+-   2.3 色法
+    -   `四大种` `地界` `水界` `火界` `风界` `净色` `所造色` `色聚` `业生色` `心生色` `时节生色` `食生色` `真实色` `非真实色`
+-   2.4 缘起与发趣法
+    -   `二十四缘` `因缘` `所缘缘` `增上缘` `俱生缘` `亲依止缘` `前生缘` `后生缘` `业缘` `果报缘` `根缘` `禅缘` `道缘` `相应缘` `不相应缘` `有缘` `无有缘`
+
+---
+
+**三、禅修**
+
+-   3.1 止禅
+    -   `遍禅` `不净观` `随念` `四梵住` `慈` `悲` `喜` `舍` `入出息念` `四界差别` `禅相` `取相` `似相` `近行定` `安止定` `禅那` `禅支` `无色定`
+-   3.2 观禅
+    -   `名色分别` `观智` `生灭智` `坏灭智` `怖畏智` `厌离智` `行舍智` `道智` `果智` `毘婆舍那` `三相` `无常随观` `苦随观` `无我随观` `刹那定`
+
+---
+
+**四、律学**
+
+-   4.1 戒条罪类
+    -   `波罗夷` `僧残` `不定` `舍堕` `单堕` `悔过` `众学` `灭诤` `比库戒` `比库尼戒` `学处` `犯罪` `无犯` `违犯条件`
+-   4.2 僧团制度与羯磨
+    -   `羯磨` `白羯磨` `白二羯磨` `白四羯磨` `结界` `布萨` `自恣` `受具足` `出家` `僧团` `四方僧` `惩罚羯磨` `和合`
+-   4.3 僧侣生活与器具
+    -   `三衣` `钵` `住处` `精舍` `雨安居` `迦提那衣` `头陀行` `乞食` `日用器具` `净食`
+
+---
+
+**五、经典与文献**
+
+-   5.1 经藏
+    -   `长部` `中部` `相应部` `增支部` `小部` `经名` `品名` `篇名`
+-   5.2 论藏与注疏
+    -   `论藏` `七论` `义注` `复注` `清净道论` `摄论` `史书` `藏外文献`
+-   5.3 本生与偈颂
+    -   `本生` `长老偈` `长老尼偈` `佛种姓` `譬喻` `天宫事` `饿鬼事`
+
+---
+
+**六、世界观**
+
+-   6.1 三界与诸天
+    -   `欲界` `色界` `无色界` `欲界天` `梵天界` `净居天` `人间` `有情居` `天界层次`
+-   6.2 地狱与恶趣
+    -   `地狱` `无间地狱` `寒冰地狱` `孤独地狱` `饿鬼界` `畜生界` `阿修罗界` `四恶趣`
+-   6.3 神灵与非人
+    -   `天神` `梵天` `夜叉` `龙族` `乾达婆` `非人` `护法神`
+
+---
+
+**七、人物**
+
+-   7.1 佛
+    -   `佛名` `过去佛` `二十八佛` `独觉佛` `菩萨`
+-   7.2 出家弟子
+    -   `上首弟子` `大弟子` `比库弟子` `比库尼弟子` `沙弥` `沙弥尼` `在学尼`
+-   7.3 在家人
+    -   `男居士` `女居士` `护法者` `国王` `婆罗门` `施主` `转轮圣王` `王后` `大长者`
+-   7.4 外道
+    -   `外道名`
+
+---
+
+**八、地理**
+
+-   8.1 国家与城镇
+    -   `十六大国` `国家` `村落` `市镇` `寺院名`
+-   8.2 水系
+    -   `河流` `湖泊` `海洋`
+-   8.3 山岳
+    -   `山名` `山脉`
+
+---
+
+**九、动植物**
+
+-   9.1 动物
+    -   `兽类` `鸟类` `爬行类` `鱼类` `昆虫` `神话动物` `龙族` `金翅鸟` `畜养动物` `野生动物`
+-   9.2 植物
+    -   `树木` `花卉` `草药` `粮食作物` `果实` `圣树` `菩提树类`
+
+---
+
+**十、巴利语言与语法**
+
+-   10.1 语法术语
+    -   `名词` `动词` `形容词` `副词` `格` `数` `性` `时态` `语式` `复合词类型` `前缀` `后缀`
+-   10.2 语法缩写与标注
+    -   `词性标注` `格标注` `语态标注` `使役态` `引用标记` `出处标注`

+ 505 - 66
api-v12/resources/css/modules/_wiki.css

@@ -46,7 +46,9 @@
     font-size: 0.8125rem;
 }
 
-.wiki-entry-lang-label { color: var(--tblr-secondary); }
+.wiki-entry-lang-label {
+    color: var(--tblr-secondary);
+}
 
 .wiki-entry-lang-btn {
     font-size: 0.75rem;
@@ -58,7 +60,9 @@
     transition: background 0.12s;
 }
 
-.wiki-entry-lang-btn:hover { background: var(--tblr-bg-surface); }
+.wiki-entry-lang-btn:hover {
+    background: var(--tblr-bg-surface);
+}
 
 /* ══════════════════════════════════════════
    二、质量标签
@@ -82,14 +86,41 @@
     flex-shrink: 0;
 }
 
-.wiki-badge--featured { background: #eaf3de; color: #3b6d11; border: 1px solid #c0dd97; }
-.wiki-badge--featured .wiki-quality-dot { background: #639922; }
+.wiki-badge--featured {
+    background: #eaf3de;
+    color: #3b6d11;
+    border: 1px solid #c0dd97;
+}
+.wiki-badge--featured .wiki-quality-dot {
+    background: #639922;
+}
+
+.wiki-badge--standard {
+    background: rgba(var(--tblr-primary-rgb), 0.12);
+    color: var(--tblr-primary);
+    border: 1px solid rgba(var(--tblr-primary-rgb), 0.32);
+}
+.wiki-badge--standard .wiki-quality-dot {
+    background: var(--tblr-primary);
+}
 
-.wiki-badge--review   { background: #faeeda; color: #854f0b; border: 1px solid #fac775; }
-.wiki-badge--review   .wiki-quality-dot { background: #ba7517; }
+.wiki-badge--draft {
+    background: rgba(var(--tblr-orange-rgb), 0.12);
+    color: var(--tblr-orange);
+    border: 1px solid rgba(var(--tblr-orange-rgb), 0.32);
+}
+.wiki-badge--draft .wiki-quality-dot {
+    background: var(--tblr-orange);
+}
 
-.wiki-badge--stub     { background: #f1efe8; color: #5f5e5a; border: 1px solid #d3d1c7; }
-.wiki-badge--stub     .wiki-quality-dot { background: #888780; }
+.wiki-badge--pending {
+    background: rgba(var(--tblr-secondary-rgb), 0.12);
+    color: var(--tblr-secondary);
+    border: 1px solid rgba(var(--tblr-secondary-rgb), 0.32);
+}
+.wiki-badge--pending .wiki-quality-dot {
+    background: var(--tblr-secondary);
+}
 
 /* ══════════════════════════════════════════
    三、标签
@@ -198,8 +229,12 @@
     box-shadow: none;
 }
 
-.wiki-term-popover .popover-arrow { display: none; }
-.wiki-term-popover .popover-body  { padding: 0; }
+.wiki-term-popover .popover-arrow {
+    display: none;
+}
+.wiki-term-popover .popover-body {
+    padding: 0;
+}
 
 .wiki-popover-word {
     font-family: "Noto Serif", Georgia, serif;
@@ -301,13 +336,27 @@
     border-radius: 4px;
 }
 
-.wiki-term-skeleton-word { height: 18px; width: 120px; margin-bottom: 8px; }
-.wiki-term-skeleton-line { height: 13px; width: 100%; margin-bottom: 6px; }
-.wiki-term-skeleton-line.short { width: 60%; }
+.wiki-term-skeleton-word {
+    height: 18px;
+    width: 120px;
+    margin-bottom: 8px;
+}
+.wiki-term-skeleton-line {
+    height: 13px;
+    width: 100%;
+    margin-bottom: 6px;
+}
+.wiki-term-skeleton-line.short {
+    width: 60%;
+}
 
 @keyframes wiki-skeleton-shimmer {
-    0%   { background-position: 200% 0; }
-    100% { background-position: -200% 0; }
+    0% {
+        background-position: 200% 0;
+    }
+    100% {
+        background-position: -200% 0;
+    }
 }
 
 /* ══════════════════════════════════════════
@@ -323,7 +372,10 @@
     overflow-wrap: break-word;
 }
 
-.wiki-content-body p { margin-top: 0; margin-bottom: 1.125em; }
+.wiki-content-body p {
+    margin-top: 0;
+    margin-bottom: 1.125em;
+}
 
 .wiki-content-body h1,
 .wiki-content-body h2,
@@ -340,13 +392,33 @@
     scroll-margin-top: 80px;
 }
 
-.wiki-content-body h1 { font-size: 1.375rem; padding-bottom: .4em; border-bottom: 2px solid var(--tblr-border-color); }
-.wiki-content-body h2 { font-size: 1.25rem;  padding-bottom: .4em; border-bottom: 1px solid var(--tblr-border-color); }
-.wiki-content-body h3 { font-size: 1.0625rem; }
-.wiki-content-body h4 { font-size: 0.9375rem; font-weight: 600; color: var(--tblr-secondary); }
+.wiki-content-body h1 {
+    font-size: 1.375rem;
+    padding-bottom: 0.4em;
+    border-bottom: 2px solid var(--tblr-border-color);
+}
+.wiki-content-body h2 {
+    font-size: 1.25rem;
+    padding-bottom: 0.4em;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
+.wiki-content-body h3 {
+    font-size: 1.0625rem;
+}
+.wiki-content-body h4 {
+    font-size: 0.9375rem;
+    font-weight: 600;
+    color: var(--tblr-secondary);
+}
 
-.wiki-content-body strong, .wiki-content-body b { font-weight: 600; }
-.wiki-content-body em,     .wiki-content-body i { font-style: italic; }
+.wiki-content-body strong,
+.wiki-content-body b {
+    font-weight: 600;
+}
+.wiki-content-body em,
+.wiki-content-body i {
+    font-style: italic;
+}
 
 .wiki-content-body a {
     color: var(--tblr-primary);
@@ -354,7 +426,9 @@
     border-bottom: 1px solid transparent;
     transition: border-color 0.12s;
 }
-.wiki-content-body a:hover { border-bottom-color: var(--tblr-primary); }
+.wiki-content-body a:hover {
+    border-bottom-color: var(--tblr-primary);
+}
 
 .wiki-content-body blockquote {
     margin: 1.5em 0;
@@ -366,46 +440,92 @@
     line-height: 1.75;
 }
 
-.wiki-content-body blockquote p          { margin-bottom: 0.5em; }
-.wiki-content-body blockquote p:last-child { margin-bottom: 0; }
+.wiki-content-body blockquote p {
+    margin-bottom: 0.5em;
+}
+.wiki-content-body blockquote p:last-child {
+    margin-bottom: 0;
+}
 .wiki-content-body blockquote cite {
     display: block;
-    margin-top: .625rem;
-    font-size: .8125rem;
+    margin-top: 0.625rem;
+    font-size: 0.8125rem;
     font-style: normal;
     color: var(--tblr-secondary);
 }
 
 .wiki-content-body ul,
-.wiki-content-body ol { padding-left: 1.5em; margin-bottom: 1.125em; }
-.wiki-content-body li { margin-bottom: .375em; line-height: 1.75; }
+.wiki-content-body ol {
+    padding-left: 1.5em;
+    margin-bottom: 1.125em;
+}
+.wiki-content-body li {
+    margin-bottom: 0.375em;
+    line-height: 1.75;
+}
 
-.wiki-content-body table { width: 100%; border-collapse: collapse; font-size: .9rem; margin-bottom: 1.5em; }
+.wiki-content-body table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 0.9rem;
+    margin-bottom: 1.5em;
+}
 .wiki-content-body th {
     font-family: "Noto Serif", Georgia, serif;
-    font-weight: 600; font-size: .8125rem; text-align: left;
+    font-weight: 600;
+    font-size: 0.8125rem;
+    text-align: left;
     padding: 8px 12px;
     background: var(--tblr-bg-surface-secondary);
     border-bottom: 2px solid var(--tblr-border-color);
     color: var(--tblr-secondary);
-    text-transform: uppercase; letter-spacing: .04em;
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+}
+.wiki-content-body td {
+    padding: 8px 12px;
+    border-bottom: 1px solid var(--tblr-border-color);
+    vertical-align: top;
+}
+.wiki-content-body tr:last-child td {
+    border-bottom: none;
 }
-.wiki-content-body td { padding: 8px 12px; border-bottom: 1px solid var(--tblr-border-color); vertical-align: top; }
-.wiki-content-body tr:last-child td { border-bottom: none; }
 
 .wiki-content-body code {
-    font-family: var(--tblr-font-monospace, "SFMono-Regular", Consolas, monospace);
-    font-size: .875em;
+    font-family: var(
+        --tblr-font-monospace,
+        "SFMono-Regular",
+        Consolas,
+        monospace
+    );
+    font-size: 0.875em;
     background: var(--tblr-bg-surface-secondary);
     border: 1px solid var(--tblr-border-color);
     border-radius: 4px;
     padding: 1px 5px;
 }
 
-.wiki-content-body hr  { border: none; border-top: 1px solid var(--tblr-border-color); margin: 2em 0; }
-.wiki-content-body img { max-width: 100%; height: auto; border-radius: var(--tblr-border-radius); margin: .75em 0; }
-.wiki-content-body figure { margin: 1.5em 0; text-align: center; }
-.wiki-content-body figcaption { font-size: .8125rem; color: var(--tblr-secondary); margin-top: .5em; font-style: italic; }
+.wiki-content-body hr {
+    border: none;
+    border-top: 1px solid var(--tblr-border-color);
+    margin: 2em 0;
+}
+.wiki-content-body img {
+    max-width: 100%;
+    height: auto;
+    border-radius: var(--tblr-border-radius);
+    margin: 0.75em 0;
+}
+.wiki-content-body figure {
+    margin: 1.5em 0;
+    text-align: center;
+}
+.wiki-content-body figcaption {
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+    margin-top: 0.5em;
+    font-style: italic;
+}
 
 /* ══════════════════════════════════════════
    九、Wiki 首页
@@ -420,31 +540,53 @@
     padding: 3rem 1.5rem;
 }
 
-.wiki-home-wheel        { margin-bottom: 1.5rem; }
-.wiki-home-wheel-img    { width: 120px; height: auto; opacity: .85; transition: transform .3s ease; }
-.wiki-home-wheel-img:hover { transform: scale(1.05); }
+.wiki-home-wheel {
+    margin-bottom: 1.5rem;
+}
+.wiki-home-wheel-img {
+    width: 120px;
+    height: auto;
+    opacity: 0.85;
+    transition: transform 0.3s ease;
+}
+.wiki-home-wheel-img:hover {
+    transform: scale(1.05);
+}
 
-.wiki-home-title        { text-align: center; margin-bottom: 2rem; }
+.wiki-home-title {
+    text-align: center;
+    margin-bottom: 2rem;
+}
 .wiki-home-title h1 {
     font-family: "Noto Serif", Georgia, serif;
-    font-size: 2.25rem; font-weight: 600;
-    color: var(--tblr-body-color); margin-bottom: .5rem;
+    font-size: 2.25rem;
+    font-weight: 600;
+    color: var(--tblr-body-color);
+    margin-bottom: 0.5rem;
 }
 
-.wiki-home-search       { width: 100%; max-width: 640px; margin: 0 auto 1.5rem; }
+.wiki-home-search {
+    width: 100%;
+    max-width: 640px;
+    margin: 0 auto 1.5rem;
+}
 
-.wiki-home-hot-tags     { text-align: center; margin-bottom: 2.5rem; font-size: .9rem; }
+.wiki-home-hot-tags {
+    text-align: center;
+    margin-bottom: 2.5rem;
+    font-size: 0.9rem;
+}
 .wiki-hot-tag {
     display: inline-block;
-    padding: .3rem .75rem;
+    padding: 0.3rem 0.75rem;
     background: var(--tblr-bg-surface-secondary);
     border: 1px solid var(--tblr-border-color);
     border-radius: 20px;
     color: var(--tblr-secondary);
-    font-size: .8125rem;
+    font-size: 0.8125rem;
     text-decoration: none;
     margin: 2px;
-    transition: background .12s, color .12s;
+    transition: background 0.12s, color 0.12s;
 }
 .wiki-hot-tag:hover {
     background: var(--wp-brand-light);
@@ -452,26 +594,43 @@
     color: var(--wp-brand-dark);
 }
 
-.wiki-home-languages    { width: 100%; max-width: 800px; margin: 0 auto 2rem; }
+.wiki-home-languages {
+    width: 100%;
+    max-width: 800px;
+    margin: 0 auto 2rem;
+}
 .wiki-home-divider {
-    display: flex; align-items: center; text-align: center;
-    margin-bottom: 1.5rem; gap: 1.5rem;
-    color: var(--tblr-secondary); font-size: .875rem;
+    display: flex;
+    align-items: center;
+    text-align: center;
+    margin-bottom: 1.5rem;
+    gap: 1.5rem;
+    color: var(--tblr-secondary);
+    font-size: 0.875rem;
 }
 .wiki-home-divider::before,
-.wiki-home-divider::after { content: ''; flex: 1; border-bottom: 1px solid var(--tblr-border-color); }
+.wiki-home-divider::after {
+    content: "";
+    flex: 1;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
 
-.wiki-language-tags     { display: flex; flex-wrap: wrap; justify-content: center; gap: .75rem; }
+.wiki-language-tags {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    gap: 0.75rem;
+}
 .wiki-language-tag {
     display: inline-block;
-    padding: .4rem 1.125rem;
+    padding: 0.4rem 1.125rem;
     background: var(--tblr-bg-surface-secondary);
     border: 1px solid var(--tblr-border-color);
     border-radius: 30px;
     color: var(--tblr-body-color);
-    font-size: .875rem;
+    font-size: 0.875rem;
     text-decoration: none;
-    transition: background .12s, border-color .12s;
+    transition: background 0.12s, border-color 0.12s;
 }
 .wiki-language-tag:hover {
     background: var(--wp-brand-light);
@@ -489,15 +648,295 @@
 }
 
 .wiki-home-stats {
-    text-align: center; font-size: .875rem;
+    text-align: center;
+    font-size: 0.875rem;
     padding-top: 1.5rem;
     border-top: 1px solid var(--tblr-border-color);
-    width: 100%; max-width: 640px;
+    width: 100%;
+    max-width: 640px;
 }
 
 @media (max-width: 768px) {
-    .wiki-home-container    { min-height: calc(100vh - 200px); padding: 2rem 1rem; }
-    .wiki-home-wheel-img    { width: 90px; }
-    .wiki-home-title h1     { font-size: 1.75rem; }
-    .wiki-language-tag      { padding: .35rem .875rem; font-size: .8125rem; }
+    .wiki-home-container {
+        min-height: calc(100vh - 200px);
+        padding: 2rem 1rem;
+    }
+    .wiki-home-wheel-img {
+        width: 90px;
+    }
+    .wiki-home-title h1 {
+        font-size: 1.75rem;
+    }
+    .wiki-language-tag {
+        padding: 0.35rem 0.875rem;
+        font-size: 0.8125rem;
+    }
+}
+
+/* ── 追加到 resources/css/wiki.css 末尾 ── */
+
+/* ── 二级分类大块容器 ── */
+.wiki-subcat-block {
+    padding: 0;
+}
+
+.wiki-subcat-block-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 1rem 1.5rem 0.875rem;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
+
+.wiki-subcat-block-title {
+    font-size: 0.9375rem;
+    font-weight: 600;
+    color: var(--tblr-body-color);
+}
+
+.wiki-subcat-block-more {
+    font-size: 0.8125rem;
+    color: var(--tblr-primary);
+    text-decoration: none;
+}
+
+.wiki-subcat-block-more:hover {
+    text-decoration: underline;
+}
+
+/* ── 单个二级分类 ── */
+.wiki-subcat {
+    padding: 1rem 1.5rem;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
+
+.wiki-subcat:last-child {
+    border-bottom: none;
+}
+
+.wiki-subcat-header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 0.625rem;
+}
+
+.wiki-subcat-title {
+    font-size: 0.8125rem;
+    font-weight: 500;
+    color: var(--tblr-secondary);
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+}
+
+.wiki-subcat-count {
+    font-size: 0.6875rem;
+    color: var(--tblr-secondary);
+    background: var(--tblr-bg-surface-secondary);
+    border: 1px solid var(--tblr-border-color);
+    border-radius: 20px;
+    padding: 1px 7px;
+}
+
+/* ── 词条列表 (flex wrap) ── */
+.wiki-subcat-entries {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px 0;
+}
+
+/* ── 单个术语链接 ── */
+.wiki-term-link {
+    display: inline-flex;
+    align-items: baseline;
+    gap: 4px;
+    padding: 3px 8px;
+    border-radius: var(--tblr-border-radius);
+    text-decoration: none;
+    font-size: 0.875rem;
+    transition: background 0.1s;
+    color: var(--tblr-primary);
+}
+
+.wiki-term-link:hover {
+    background: var(--tblr-bg-surface-secondary);
+    text-decoration: none;
+    color: var(--tblr-primary);
+}
+
+.wiki-term-link-word {
+    font-style: italic;
+    color: var(--tblr-secondary);
+}
+
+.wiki-term-link-zh {
+    font-size: 0.8125rem;
+}
+
+/* =========================
+   Wiki Term Link States
+   典范条目 featured(蓝色 + 星标)
+   规范条目 standard(蓝色)
+   草稿 draft(橙色)
+   待定 pending(灰色 + 删除线)
+   ========================= */
+
+/* 通用链接颜色继承 */
+.wiki-term-link--featured,
+.wiki-term-link--standard,
+.wiki-term-link--draft,
+.wiki-term-link--pending {
+    position: relative;
+}
+
+.wiki-term-link--featured:hover,
+.wiki-term-link--standard:hover,
+.wiki-term-link--draft:hover,
+.wiki-term-link--pending:hover {
+    color: inherit;
+}
+
+/* =========================
+   典范条目 Featured
+   ========================= */
+.wiki-term-link--featured {
+    color: var(--tblr-green);
+}
+
+.wiki-term-link--featured::before {
+    content: "★";
+    display: inline-block;
+    margin-right: 0.2em;
+    font-size: 0.75em;
+    vertical-align: middle;
+    opacity: 0.9;
+}
+
+/* 若想改圆点可用:
+content: "●";
+*/
+
+/* =========================
+   规范条目 Standard
+   ========================= */
+.wiki-term-link--standard {
+    color: var(--tblr-primary);
+}
+
+/* =========================
+   草稿 Draft
+   ========================= */
+.wiki-term-link--draft {
+    color: var(--tblr-orange);
+}
+
+/* =========================
+   待定 Pending
+   (保持原有效果)
+   ========================= */
+.wiki-term-link--pending {
+    color: var(--tblr-secondary);
+}
+
+.wiki-term-link--pending .wiki-term-link-word {
+    text-decoration: line-through;
+    text-decoration-color: var(--tblr-border-color-dark, #adb5bd);
+}
+
+.wiki-quality-filter-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 8px;
+}
+
+.wiki-quality-filter-item span:last-child {
+    font-size: 0.75rem;
+    color: var(--tblr-secondary);
+    margin-left: auto;
+    flex-shrink: 0;
+}
+
+/* ── 条目操作按钮 ── */
+.wiki-entry-card {
+    position: relative;
+}
+
+.wiki-entry-actions {
+    position: absolute;
+    top: 1.25rem;
+    right: 1.5rem;
+    display: flex;
+    gap: 4px;
+}
+
+.wiki-action-btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 32px;
+    height: 32px;
+    border-radius: var(--tblr-border-radius);
+    border: 1px solid var(--tblr-border-color);
+    background: var(--tblr-bg-surface);
+    color: var(--tblr-secondary);
+    cursor: pointer;
+    text-decoration: none;
+    transition: background 0.12s, color 0.12s;
+}
+
+.wiki-action-btn:hover {
+    background: var(--tblr-bg-surface-secondary);
+    color: var(--tblr-body-color);
+}
+
+/* ── 微信二维码弹窗 ── */
+.wiki-wechat-modal {
+    position: fixed;
+    inset: 0;
+    z-index: 1050;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.wiki-wechat-modal-backdrop {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.45);
+}
+
+.wiki-wechat-modal-box {
+    position: relative;
+    background: var(--tblr-bg-surface);
+    border-radius: var(--tblr-border-radius-lg);
+    padding: 1.5rem;
+    text-align: center;
+    width: 220px;
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+.wiki-wechat-modal-title {
+    font-weight: 600;
+    margin-bottom: 4px;
+}
+
+.wiki-wechat-modal-desc {
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+    margin-bottom: 1rem;
+}
+
+.wiki-wechat-qr img {
+    border-radius: var(--tblr-border-radius);
+}
+
+.wiki-wechat-modal-close {
+    margin-top: 1rem;
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+    background: none;
+    border: none;
+    cursor: pointer;
+    text-decoration: underline;
 }

+ 95 - 0
api-v12/resources/views/components/wiki/entry-actions.blade.php

@@ -0,0 +1,95 @@
+{{-- resources/views/components/wiki/entry-actions.blade.php --}}
+@props(['editUrl', 'title' => ''])
+
+<div class="wiki-entry-actions">
+
+    {{-- 分享到微信 --}}
+    <button class="wiki-action-btn"
+        id="wikiShareWechat"
+        title="分享到微信"
+        data-title="{{ $title }}">
+        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
+            fill="none" stroke="currentColor" stroke-width="1.5"
+            stroke-linecap="round" stroke-linejoin="round">
+            <path d="M9.5 9a3.5 3.5 0 0 1 5 0" />
+            <path d="M5.5 11.5C4 10 3 8.1 3 6c0-3.3 3.1-6 7-6s7 2.7 7 6c0 .6-.1 1.2-.3 1.7" />
+            <path d="M12 20c-4.4 0-8-2.9-8-6.5S7.6 7 12 7s8 2.9 8 6.5c0 1.4-.5 2.7-1.4 3.8l.4 2.7-2.6-1.1A9 9 0 0 1 12 20z" />
+        </svg>
+    </button>
+
+    {{-- 编辑 --}}
+    <a class="wiki-action-btn"
+        href="{{ $editUrl }}"
+        target="_blank"
+        rel="noopener"
+        title="编辑条目">
+        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
+            fill="none" stroke="currentColor" stroke-width="1.5"
+            stroke-linecap="round" stroke-linejoin="round">
+            <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
+            <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
+        </svg>
+    </a>
+
+</div>
+
+{{-- 微信二维码弹窗 --}}
+<div class="wiki-wechat-modal" id="wikiWechatModal" style="display:none;">
+    <div class="wiki-wechat-modal-backdrop" id="wikiWechatBackdrop"></div>
+    <div class="wiki-wechat-modal-box">
+        <div class="wiki-wechat-modal-title">分享到微信</div>
+        <div class="wiki-wechat-modal-desc">使用微信扫描二维码</div>
+        <div id="wikiWechatQr" class="wiki-wechat-qr"></div>
+        <button class="wiki-wechat-modal-close" id="wikiWechatClose">关闭</button>
+    </div>
+</div>
+
+@push('scripts')
+<script>
+    (function() {
+        const btn = document.getElementById('wikiShareWechat');
+        const modal = document.getElementById('wikiWechatModal');
+        const backdrop = document.getElementById('wikiWechatBackdrop');
+        const closeBtn = document.getElementById('wikiWechatClose');
+        const qrEl = document.getElementById('wikiWechatQr');
+
+        if (!btn || !modal || !qrEl) return;
+
+        function openWechatShare() {
+            const url = encodeURIComponent(window.location.href);
+
+            // 使用更稳定的二维码服务
+            qrEl.innerHTML = `
+            <img
+                src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${url}"
+                width="180"
+                height="180"
+                alt="QR Code"
+            >
+        `;
+
+            modal.style.display = 'flex';
+        }
+
+        function closeWechatShare() {
+            modal.style.display = 'none';
+        }
+
+        btn.addEventListener('click', openWechatShare);
+
+        if (closeBtn) {
+            closeBtn.addEventListener('click', closeWechatShare);
+        }
+
+        if (backdrop) {
+            backdrop.addEventListener('click', closeWechatShare);
+        }
+
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape') {
+                closeWechatShare();
+            }
+        });
+    })();
+</script>
+@endpush

+ 5 - 14
api-v12/resources/views/components/wiki/quality-badge.blade.php

@@ -1,18 +1,9 @@
 {{-- resources/views/components/wiki/quality-badge.blade.php --}}
 @props(['quality' => null])
 
-@php
-    $map = [
-        'featured' => ['label' => '精选条目', 'class' => 'wiki-badge--featured'],
-        'review'   => ['label' => '待审核',   'class' => 'wiki-badge--review'],
-        'stub'     => ['label' => '存根',     'class' => 'wiki-badge--stub'],
-    ];
-    $config = $map[$quality] ?? null;
-@endphp
-
-@if ($config)
-    <span class="wiki-quality-badge {{ $config['class'] }}">
-        <span class="wiki-quality-dot"></span>
-        {{ $config['label'] }}
-    </span>
+@if ($quality)
+<span class="wiki-quality-badge wiki-badge--{{ $quality }}">
+    <span class="wiki-quality-dot"></span>
+    {{ $quality }}
+</span>
 @endif

+ 14 - 0
api-v12/resources/views/components/wiki/sub-category.blade.php

@@ -0,0 +1,14 @@
+{{-- resources/views/components/wiki/sub-category.blade.php --}}
+@props(['sub', 'lang'])
+
+<div class="wiki-subcat">
+    <div class="wiki-subcat-header">
+        <span class="wiki-subcat-title">{{ $sub['label'] }}</span>
+        <span class="wiki-subcat-count">{{ count($sub['entries']) }} 条</span>
+    </div>
+    <div class="wiki-subcat-entries">
+        @foreach ($sub['entries'] as $entry)
+            <x-wiki.term-link :entry="$entry" :lang="$lang" />
+        @endforeach
+    </div>
+</div>

+ 14 - 0
api-v12/resources/views/components/wiki/term-link.blade.php

@@ -0,0 +1,14 @@
+{{-- resources/views/components/wiki/term-link.blade.php --}}
+{{--
+    单个术语链接,两种状态:
+    published → 蓝色链接
+    draft     → 灰色,仍可点击
+--}}
+@props(['entry', 'lang'])
+
+<a href="{{ route('library.wiki.show', [$entry['lang'] ?? $lang, $entry['quality']==='pending'?$entry['id']:$entry['word']]) }}"
+    class="wiki-term-link wiki-term-link--{{ $entry['quality'] }}"
+    title="{{ $entry['quality'] }}">
+    <span class="wiki-term-link-zh">{{ $entry['zh'] ?? $entry['meaning'] ?? '' }}</span>
+    <span class="wiki-term-link-word">{{ $entry['word'] }}</span>
+</a>

+ 49 - 0
api-v12/resources/views/library/wiki/index.blade.php

@@ -14,6 +14,7 @@
         :hidden-fields="['resource_type' => 'term']" />
 </div>
 {{-- 今日条目 --}}
+@isset($today)
 <div class="wiki-today-banner">
     <div class="wiki-today-icon">☸</div>
     <div class="wiki-today-body">
@@ -28,8 +29,10 @@
         </a>
     </div>
 </div>
+@endisset
 
 {{-- 精选条目 --}}
+@if(isset($featured) && is_array($featured) && count($featured)>0)
 <div class="wiki-card">
     <div class="wiki-sidebar-title" style="margin-bottom: 14px;">精选条目</div>
     <div class="wiki-featured-grid">
@@ -43,6 +46,34 @@
         @endforeach
     </div>
 </div>
+@endif
+
+
+@if(isset($subs) && is_array($subs) && count($subs) > 0)
+
+{{-- 取一级分类名称作为标题 --}}
+@php
+$catLabel = collect(config('taxonomy'))->firstWhere('id', $category)['label'] ?? $category;
+@endphp
+
+<div class="wiki-card wiki-subcat-block">
+
+    <div class="wiki-subcat-block-header">
+        <span class="wiki-subcat-block-title">{{ $catLabel }}</span>
+        <a class="wiki-subcat-block-more"
+            href="{{ route('library.wiki.index', ['lang' => $lang]) }}?category={{ $category }}">
+            浏览全部
+        </a>
+    </div>
+
+    @foreach ($subs as $sub)
+    <x-wiki.sub-category :sub="$sub" :lang="$lang" />
+    @endforeach
+
+</div>
+
+@endif
+
 
 @endsection
 
@@ -66,4 +97,22 @@
     </table>
 </div>
 
+
+
+
+<div class="wiki-sidebar-section">
+    <div class="wiki-sidebar-title">质量等级</div>
+    <ul class="wiki-cat-list" id="qualityFilterList">
+        @foreach ($qualities as $q)
+        <li>
+            <a href="{{ request()->fullUrlWithQuery(['quality' => $q['value']]) }}"
+                class="wiki-quality-filter-item {{ $quality === $q['value'] ? 'active' : '' }}"
+                data-quality="{{ $q['value'] }}">
+                <span>{{ $q['label'] }}</span><span>{{ $q['subtitle'] }}</span>
+            </a>
+        </li>
+        @endforeach
+    </ul>
+</div>
+
 @endsection

+ 46 - 40
api-v12/resources/views/library/wiki/layouts/app.blade.php

@@ -6,7 +6,7 @@
 @extends('layouts.library')
 
 @push('styles')
-    @vite('resources/css/modules/_wiki.css')
+@vite('resources/css/modules/_wiki.css')
 @endpush
 
 @section('content')
@@ -15,49 +15,55 @@
 
     {{-- 左侧边栏 --}}
     @hasSection('wiki-sidebar-left')
-        <aside class="wiki-sidebar-left">
-            @yield('wiki-sidebar-left')
-        </aside>
+    <aside class="wiki-sidebar-left">
+        @yield('wiki-sidebar-left')
+    </aside>
     @else
-        @if(isset($lang))
-        <aside class="wiki-sidebar-left">
+    @if(isset($lang))
+    <aside class="wiki-sidebar-left">
 
-            @isset($categories)
-            <div class="wiki-sidebar-section">
-                <div class="wiki-sidebar-title">分类浏览</div>
-                <ul class="wiki-cat-list">
-                    @foreach ($categories as $cat)
-                    <li>
-                        <a href="{{ route('library.wiki.index', ['lang' => $lang]) }}?category={{ $cat['slug'] }}"
-                           class="{{ (request('category', 'all') === $cat['slug']) ? 'active' : '' }}">
-                            {{ $cat['label'] }}
-                            @if(isset($cat['count']))
-                                <span class="wiki-cat-count">{{ $cat['count'] }}</span>
-                            @endif
-                        </a>
-                    </li>
-                    @endforeach
-                </ul>
-            </div>
-            @endisset
+        @isset($categories)
+        <div class="wiki-sidebar-section">
+            <div class="wiki-sidebar-title">分类浏览</div>
+            <ul class="wiki-cat-list">
+                <li>
+                    <a href="{{ route('library.wiki.index', ['lang' => $lang]) }}"
+                        class="{{ (request('category', 'all') === 'all') ? 'active' : '' }}">
+                        全部
+                    </a>
+                </li>
+                @foreach ($categories as $cat)
+                <li>
+                    <a href="{{ route('library.wiki.index', ['lang' => $lang]) }}?category={{ $cat['id'] }}"
+                        class="{{ (request('category', 'all') === $cat['id']) ? 'active' : '' }}">
+                        {{ $cat['label'] }}
+                        @if(isset($cat['count']))
+                        <span class="wiki-cat-count">{{ $cat['count'] }}</span>
+                        @endif
+                    </a>
+                </li>
+                @endforeach
+            </ul>
+        </div>
+        @endisset
 
-            @isset($recentUpdates)
-            <div class="wiki-sidebar-section">
-                <div class="wiki-sidebar-title">最近更新</div>
-                <ul class="wiki-cat-list">
-                    @foreach ($recentUpdates as $item)
-                    <li>
-                        <a href="{{ route('library.wiki.show', [$item['lang'], $item['word']]) }}">
-                            {{ $item['word'] }}
-                        </a>
-                    </li>
-                    @endforeach
-                </ul>
-            </div>
-            @endisset
+        @isset($recentUpdates)
+        <div class="wiki-sidebar-section">
+            <div class="wiki-sidebar-title">最近更新</div>
+            <ul class="wiki-cat-list">
+                @foreach ($recentUpdates as $item)
+                <li>
+                    <a href="{{ route('library.wiki.show', [$item['lang'], $item['word']]) }}">
+                        {{ $item['word'] }}
+                    </a>
+                </li>
+                @endforeach
+            </ul>
+        </div>
+        @endisset
 
-        </aside>
-        @endif
+    </aside>
+    @endif
     @endif
 
     {{-- 主内容区 --}}

+ 5 - 1
api-v12/resources/views/library/wiki/show.blade.php

@@ -13,7 +13,11 @@
         size="lg"
         :hidden-fields="['resource_type' => 'term']" />
 </div>
-<article class="wiki-card">
+<article class="wiki-card" style="position: relative;">
+
+    <x-wiki.entry-actions
+        :editUrl="$entry['edit_url']"
+        :title="$entry['zh']" />
 
     {{-- 条目头部 --}}
     <x-wiki.entry-header :entry="$entry" />

+ 1 - 1
dashboard-v6/src/api/Attachments.ts

@@ -42,7 +42,7 @@ export interface IResAttachmentListResponse {
 }
 
 export const deleteRes = (id: string) => {
-  const url = `/v2/attachment/${id}`;
+  const url = `/api/v2/attachment/${id}`;
   console.info("attachment delete url", url);
   delete_<IDeleteResponse>(url)
     .then((json) => {

+ 1 - 1
dashboard-v6/src/api/Comment.ts

@@ -100,6 +100,6 @@ export async function fetchCommentList(
   params: IFetchCommentListParams = {}
 ): Promise<ICommentListResponse> {
   const { limit = 5, offset = 0, status = "active" } = params;
-  const url = `/v2/discussion?type=discussion&res_type=task&view=question&id=${taskId}&limit=${limit}&offset=${offset}&status=${status}`;
+  const url = `/api/v2/discussion?type=discussion&res_type=task&view=question&id=${taskId}&limit=${limit}&offset=${offset}&status=${status}`;
   return get<ICommentListResponse>(url);
 }

+ 1 - 1
dashboard-v6/src/api/pali-text.ts

@@ -199,6 +199,6 @@ export const fetchChapterToc = (
 ): Promise<IChapterTocListResponse> => {
   const { book, para } = params;
   return get<IChapterTocListResponse>(
-    `/v2/chapter?view=toc&book=${book}&para=${para}`
+    `/api/v2/chapter?view=toc&book=${book}&para=${para}`
   );
 };

+ 1 - 1
dashboard-v6/src/api/sent-sim.ts

@@ -33,7 +33,7 @@ export async function fetchSentSim(
   const { book, para, wordStart, wordEnd, limit, offset, sim, channelsId } =
     params;
 
-  let url = `/v2/sent-sim?view=sentence&book=${book}&paragraph=${para}&start=${wordStart}&end=${wordEnd}&mode=edit`;
+  let url = `/api/v2/sent-sim?view=sentence&book=${book}&paragraph=${para}&start=${wordStart}&end=${wordEnd}&mode=edit`;
   url += `&limit=${limit}`;
   url += `&offset=${offset}`;
   url += `&sim=${sim}`;

+ 1 - 1
dashboard-v6/src/api/sentence-history.ts

@@ -48,7 +48,7 @@ export async function fetchSentenceHistory(
   view: THistoryView = "sentence",
   fork = false
 ): Promise<ISentHistoryData[]> {
-  let url = `/v2/sent_history?view=${view}&id=${sentId}`;
+  let url = `/api/v2/sent_history?view=${view}&id=${sentId}`;
   if (fork) {
     url += `&fork=1`;
   }

+ 4 - 4
dashboard-v6/src/api/sentence-pr.ts

@@ -69,7 +69,7 @@ export async function fetchSentencePrList(
   wordEnd: number,
   channelId: string
 ): Promise<ISentencePrData[]> {
-  const url = `/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channelId}`;
+  const url = `/api/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channelId}`;
 
   const json = await get<ISentencePrListResponse>(url);
 
@@ -88,7 +88,7 @@ export async function createSentencePr(
   content: string
 ): Promise<void> {
   const json = await post<ISentencePrRequest, ISentencePrResponse>(
-    `/v2/sentpr`,
+    `/api/v2/sentpr`,
     {
       book: sentence.book,
       para: sentence.para,
@@ -112,7 +112,7 @@ export async function updateSentencePr(
   content: string
 ): Promise<void> {
   const json = await put<Pick<ISentencePrRequest, "text">, ISentencePrResponse>(
-    `/v2/sentpr/${prId}`,
+    `/api/v2/sentpr/${prId}`,
     { text: content }
   );
 
@@ -125,7 +125,7 @@ export async function updateSentencePr(
  * 删除 PR
  */
 export async function deleteSentencePr(prId: string): Promise<void> {
-  const json = await delete_<IDeleteResponse>(`/v2/sentpr/${prId}`);
+  const json = await delete_<IDeleteResponse>(`/api/v2/sentpr/${prId}`);
 
   if (!json.ok) {
     throw new Error(json.message ?? "删除失败");

+ 1 - 1
dashboard-v6/src/api/sentence.ts

@@ -262,7 +262,7 @@ export async function fetchSnowflakeIds(count: number): Promise<string[]> {
     ok: boolean;
     message?: string;
     data: { rows: string[]; count: number };
-  }>(`/v2/snowflake?count=${count}`);
+  }>(`/api/v2/snowflake?count=${count}`);
 
   if (!json.ok) {
     throw new Error(json.message ?? "获取 ID 失败");

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologyInfoEdit.tsx

@@ -167,7 +167,7 @@ const AnthologyInfoEditWidget = ({
             if (typeof keyWords === "undefined") {
               return currChannel ? [currChannel] : [];
             }
-            const url = `/v2/channel?view=studio&name=${studioName}`;
+            const url = `/api/v2/channel?view=studio&name=${studioName}`;
             console.log("url", url);
             const json = await get<IApiResponseChannelList>(url);
             const textbookList = json.data.rows.map((item) => {

+ 1 - 1
dashboard-v6/src/components/article/ArticleCreate.tsx

@@ -117,7 +117,7 @@ const ArticleCreateWidget = ({
             debounceTime={300}
             request={async ({ keyWords }) => {
               console.log("keyWord", keyWords);
-              let url = `/v2/anthology?view=studio&view2=my&name=${studio}`;
+              let url = `/api/v2/anthology?view=studio&view2=my&name=${studio}`;
               url += keyWords ? "&search=" + keyWords : "";
               const res = await get<IAnthologyListResponse>(url);
               const result = res.data.rows.map((item) => {

+ 2 - 2
dashboard-v6/src/components/attachment/AttachmentList.tsx

@@ -107,7 +107,7 @@ const AttachmentWidget = ({
       onOk() {
         console.log("delete", id);
         return delete_2<IUserDictDeleteRequest, IDeleteResponse>(
-          `/v2/userdict/${id}`,
+          `/api/v2/userdict/${id}`,
           {
             id: JSON.stringify(id),
           }
@@ -134,7 +134,7 @@ const AttachmentWidget = ({
         editable={{
           onSave: async (key, record, originRow) => {
             console.log(key, record, originRow);
-            const url = `/v2/attachment/${key}`;
+            const url = `/api/v2/attachment/${key}`;
             const res = await put<IAttachmentUpdate, IAttachmentResponse>(url, {
               title: record.title,
             });

+ 2 - 2
dashboard-v6/src/components/auth/Account.tsx

@@ -23,7 +23,7 @@ const AccountWidget = ({ userId, onLoad }: IWidget) => {
       onFinish={async (values: IUserApiData) => {
         console.log(values);
 
-        const url = `/v2/user/${userId}`;
+        const url = `/api/v2/user/${userId}`;
         const postData = {
           roles: values.role,
         };
@@ -38,7 +38,7 @@ const AccountWidget = ({ userId, onLoad }: IWidget) => {
       }}
       params={{}}
       request={async () => {
-        const url = `/v2/user/${userId}`;
+        const url = `/api/v2/user/${userId}`;
         console.log("url", url);
         const res = await get<IUserResponse>(url);
         if (res.ok) {

+ 2 - 2
dashboard-v6/src/components/discussion/DiscussionAnchor.tsx

@@ -34,7 +34,7 @@ const DiscussionAnchorWidget = ({
 
   useEffect(() => {
     if (typeof topicId === "string") {
-      const url = `/v2/discussion-anchor/${topicId}`;
+      const url = `/api/v2/discussion-anchor/${topicId}`;
       console.info("api request", url);
       get<ICommentAnchorResponse>(url).then((json) => {
         console.debug("api response", json);
@@ -58,7 +58,7 @@ const DiscussionAnchorWidget = ({
             if (json.ok) {
               const id = `${json.data.book}-${json.data.paragraph}-${json.data.word_start}-${json.data.word_end}`;
               const channel = json.data.channel.id;
-              const url = `/v2/corpus-sent/${id}?mode=edit&channels=${channel}`;
+              const url = `/api/v2/corpus-sent/${id}?mode=edit&channels=${channel}`;
               console.log("url", url);
               get<IArticleResponse>(url).then((json) => {
                 if (json.ok) {

+ 2 - 2
dashboard-v6/src/components/discussion/DiscussionCreate.tsx

@@ -78,7 +78,7 @@ const DiscussionCreateWidget = ({
                     content_type: "markdown",
                     type: topic.type,
                   };
-                  const url = `/v2/discussion`;
+                  const url = `/api/v2/discussion`;
                   console.log("create topic api request", url, topicData);
                   const newTopic = await post<
                     ICommentRequest,
@@ -97,7 +97,7 @@ const DiscussionCreateWidget = ({
                   }
                 }
               }
-              const url = `/v2/discussion`;
+              const url = `/api/v2/discussion`;
               const data: ICommentRequest = {
                 res_id: resId,
                 res_type: resType,

+ 1 - 1
dashboard-v6/src/components/discussion/DiscussionEdit.tsx

@@ -48,7 +48,7 @@ const DiscussionEditWidget = ({ data, onUpdated, onClose }: IWidget) => {
           },
         }}
         onFinish={async (values) => {
-          const url = `/v2/discussion/${data.id}`;
+          const url = `/api/v2/discussion/${data.id}`;
           const newData: ICommentRequest = {
             title: values.title,
             content: values.content,

+ 1 - 1
dashboard-v6/src/components/discussion/DiscussionListCard.tsx

@@ -169,7 +169,7 @@ const DiscussionListCardWidget = ({
           },
         }}
         request={async (params = {}, _sorter, _filter) => {
-          let url: string = `/v2/discussion?type=${type}&res_type=${resType}&`;
+          let url: string = `/api/v2/discussion?type=${type}&res_type=${resType}&`;
           if (typeof topicId !== "undefined") {
             url += `view=question-by-topic&id=${topicId}`;
           } else if (typeof resId !== "undefined") {

+ 3 - 3
dashboard-v6/src/components/discussion/DiscussionShow.tsx

@@ -79,7 +79,7 @@ const DiscussionShowWidget = ({
         id: "buttons.no",
       }),
       onOk() {
-        const url = `/v2/discussion/${id}`;
+        const url = `/api/v2/discussion/${id}`;
         console.info("Discussion delete api request", url);
         return delete_<IDeleteResponse>(url)
           .then((json) => {
@@ -100,7 +100,7 @@ const DiscussionShowWidget = ({
   };
 
   const close = (value: boolean) => {
-    const url = `/v2/discussion/${data.id}`;
+    const url = `/api/v2/discussion/${data.id}`;
     const newData: ICommentRequest = {
       title: data.title,
       content: data.content,
@@ -122,7 +122,7 @@ const DiscussionShowWidget = ({
   };
 
   const convert = (newType: TDiscussionType) => {
-    const url = `/v2/discussion/${data.id}`;
+    const url = `/api/v2/discussion/${data.id}`;
     const newData: ICommentRequest = {
       title: data.title,
       content: data.content,

+ 2 - 2
dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx

@@ -135,7 +135,7 @@ const DiscussionTopicChildrenWidget = ({
 
   useEffect(() => {
     if (resType === "sentence" && resId) {
-      const url = `/v2/sent_history?view=sentence&id=${resId}&order=created_at&dir=asc`;
+      const url = `/api/v2/sent_history?view=sentence&id=${resId}&order=created_at&dir=asc`;
       setLoading(true);
       get<ISentHistoryListResponse>(url)
         .then((res) => {
@@ -153,7 +153,7 @@ const DiscussionTopicChildrenWidget = ({
       return;
     }
     setLoading(true);
-    const url = `/v2/discussion?view=answer&id=${topicId}`;
+    const url = `/api/v2/discussion?view=answer&id=${topicId}`;
     console.info("api request", url);
     get<ICommentListResponse>(url)
       .then((json) => {

+ 1 - 1
dashboard-v6/src/components/discussion/DiscussionTopicInfo.tsx

@@ -44,7 +44,7 @@ const DiscussionTopicInfoWidget = ({
     if (typeof topicId === "undefined") {
       return;
     }
-    const url = `/v2/discussion/${topicId}`;
+    const url = `/api/v2/discussion/${topicId}`;
     console.info("discussion api request", url);
     get<ICommentResponse>(url)
       .then((json) => {

+ 1 - 1
dashboard-v6/src/components/discussion/InteractiveArea.tsx

@@ -35,7 +35,7 @@ const InteractiveAreaWidget = ({ resId, resType }: IWidget) => {
   const [showDiscussion, setShowDiscussion] = useState(false);
 
   useEffect(() => {
-    get<IInteractive>(`/v2/interactive/${resId}?res_type=${resType}`).then(
+    get<IInteractive>(`/api/v2/interactive/${resId}?res_type=${resType}`).then(
       (json) => {
         if (json.ok) {
           console.debug("interactive", json);

+ 1 - 1
dashboard-v6/src/components/discussion/QaList.tsx

@@ -20,7 +20,7 @@ const QaListWidget = ({ resId, resType, onSelect }: IWidget) => {
     if (!resType || !resType) {
       return;
     }
-    let url: string = `/v2/discussion?res_type=${resType}&view=res_id&id=${resId}`;
+    let url: string = `/api/v2/discussion?res_type=${resType}&view=res_id&id=${resId}`;
     url += "&dir=asc&type=qa&status=active,close";
     console.info("api request", url);
     get<ICommentListResponse>(url).then((json) => {

+ 1 - 1
dashboard-v6/src/components/discussion/utils.ts

@@ -29,7 +29,7 @@ export const discussionCountUpgrade = (resId?: string) => {
   if (typeof resId === "undefined") {
     return;
   }
-  const url = `/v2/discussion-count/${resId}`;
+  const url = `/api/v2/discussion-count/${resId}`;
   console.info("discussion-count api request", url);
   get<IDiscussionCountResponse>(url).then((json) => {
     console.debug("discussion-count api response", json);

+ 1 - 1
dashboard-v6/src/components/nissaya/NissayaCard.tsx

@@ -112,7 +112,7 @@ const NissayaCardWidget = ({
     }
 
     // ── Network request ─────────────────────────────────────────────────────
-    const url = `/v2/nissaya-card/${text}?lang=${uiLang}&content_type=json`;
+    const url = `/api/v2/nissaya-card/${text}?lang=${uiLang}&content_type=json`;
     console.debug("api request", url);
 
     let cancelled = false;

+ 1 - 1
dashboard-v6/src/components/related-para/RelatedPara.tsx

@@ -58,7 +58,7 @@ const RelatedParaWidget = ({ book, para, trigger }: IWidget) => {
         setLoad(true);
         try {
           const json = await get<IRelatedParaResponse>(
-            `/v2/related-paragraph?book=${book}&para=${para}`
+            `/api/v2/related-paragraph?book=${book}&para=${para}`
           );
           console.log("import", json);
           if (json.ok) {

+ 1 - 1
dashboard-v6/src/components/sentence-history.tsx/SentHistory.tsx

@@ -30,7 +30,7 @@ const SentHistoryWidget = ({ sentId }: IWidget) => {
         }
         console.log(params, sorter, filter);
 
-        let url = `/v2/sent_history?view=sentence&id=${sentId}`;
+        let url = `/api/v2/sent_history?view=sentence&id=${sentId}`;
         const offset =
           ((params.current ? params.current : 1) - 1) *
           (params.pageSize ? params.pageSize : 20);

+ 1 - 1
dashboard-v6/src/components/sentence/EditInfo.tsx

@@ -26,7 +26,7 @@ const Fork = ({ sentId, highlight = false }: IFork) => {
 
   useEffect(() => {
     if (sentId) {
-      const url = `/v2/sent_history?view=sentence&id=${sentId}&fork=1`;
+      const url = `/api/v2/sent_history?view=sentence&id=${sentId}&fork=1`;
       get<ISentHistoryListResponse>(url).then((json) => {
         if (json.ok) {
           setData(json.data.rows);

+ 2 - 2
dashboard-v6/src/components/sentence/SentCell.tsx

@@ -154,7 +154,7 @@ const SentCellWidget = ({
   }, [acceptPr, sentData, isPr, uuid, changedSent, sid]);
 
   const deletePr = (id: string) => {
-    delete_<IDeleteResponse>(`/v2/sentpr/${id}`)
+    delete_<IDeleteResponse>(`/api/v2/sentpr/${id}`)
       .then((json) => {
         if (json.ok) {
           AntdMessage.success("删除成功");
@@ -323,7 +323,7 @@ const SentCellWidget = ({
                 : [];
               if (wbw.length > 0) {
                 const snowflake = await get<ISnowFlakeResponse>(
-                  `/v2/snowflake?count=${wbw.length}`
+                  `/api/v2/snowflake?count=${wbw.length}`
                 );
                 wbw.forEach((_value: IWbw, index: number, array: IWbw[]) => {
                   array[index].uid = snowflake.data.rows[index];

+ 2 - 2
dashboard-v6/src/components/sentence/SentCellEditable.tsx

@@ -84,7 +84,7 @@ const SentCellEditable = ({
       channel: data.channel.id,
       text: value ?? "",
     };
-    post<ISentencePrRequest, ISentencePrResponse>(`/v2/sentpr`, newData)
+    post<ISentencePrRequest, ISentencePrResponse>(`/api/v2/sentpr`, newData)
       .then((json) => {
         if (json.ok) {
           message.success(intl.formatMessage({ id: "flashes.success" }));
@@ -109,7 +109,7 @@ const SentCellEditable = ({
       return;
     }
     setSaving(true);
-    const url = `/v2/sentpr/${data.id}`;
+    const url = `/api/v2/sentpr/${data.id}`;
     console.log("url", url);
     put<ISentencePrRequest, ISentencePrResponse>(url, {
       text: value,

+ 1 - 1
dashboard-v6/src/components/sentence/SentRead.tsx

@@ -83,7 +83,7 @@ const SentReadFrame = ({
 
         try {
           const json = await get<IEditableSentence>(
-            `/v2/editable-sentence/${item.id}`
+            `/api/v2/editable-sentence/${item.id}`
           );
           if (json.ok) {
             setSentData(json.data);

+ 1 - 1
dashboard-v6/src/components/sentence/SentWbw.tsx

@@ -47,7 +47,7 @@ const SentWbwWidget = ({
   }
 
   const load = useCallback(async () => {
-    let url = `/v2/wbw-sentence?view=sent-can-read`;
+    let url = `/api/v2/wbw-sentence?view=sent-can-read`;
     url += `&book=${book}&para=${para}&wordStart=${wordStart}&wordEnd=${wordEnd}`;
 
     if (myCourse && course) {

+ 1 - 1
dashboard-v6/src/components/sentence/SuggestionList.tsx

@@ -45,7 +45,7 @@ const SuggestionListWidget = ({
     const controller = new AbortController();
 
     const fetchData = async () => {
-      const url = `/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channel.id}`;
+      const url = `/api/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channel.id}`;
       console.log("url", url);
       setLoading(true);
       try {

+ 3 - 3
dashboard-v6/src/components/setting/SettingAccount.tsx

@@ -44,7 +44,7 @@ const SettingAccountWidget = () => {
           console.debug("upload ", values.avatar[0].response);
           _avatar = values.avatar[0].response.data.name;
         }
-        const url = `/v2/user/${user?.id}`;
+        const url = `/api/v2/user/${user?.id}`;
         const postData = {
           nickName: values.nickName,
           avatar: _avatar,
@@ -61,7 +61,7 @@ const SettingAccountWidget = () => {
       }}
       params={{}}
       request={async () => {
-        const url = `/v2/user/${user?.id}`;
+        const url = `/api/v2/user/${user?.id}`;
         console.log("url", url);
         const res = await get<IUserResponse>(url);
         if (!res.ok) {
@@ -98,7 +98,7 @@ const SettingAccountWidget = () => {
           },
           onRemove: (file: UploadFile<unknown>): boolean => {
             console.log("remove", file);
-            const url = `/v2/attachment/1?name=${file.uid}`;
+            const url = `/api/v2/attachment/1?name=${file.uid}`;
             console.info("avatar delete url", url);
             delete_<IDeleteResponse>(url)
               .then((json) => {

+ 3 - 3
dashboard-v6/src/components/share/Collaborator.tsx

@@ -62,7 +62,7 @@ const CollaboratorWidget = ({ resId, load = false, onReload }: IWidget) => {
       request={async (params = {}, sorter, filter) => {
         console.log(params, sorter, filter);
 
-        let url = `/v2/share?view=res&id=${resId}`;
+        let url = `/api/v2/share?view=res&id=${resId}`;
         const offset =
           ((params.current ? params.current : 1) - 1) *
           (params.pageSize ? params.pageSize : 20);
@@ -163,7 +163,7 @@ const CollaboratorWidget = ({ resId, load = false, onReload }: IWidget) => {
                   }),
                   onClick: (e) => {
                     put<IShareUpdateRequest, IShareResponse>(
-                      `/v2/share/${row.id}`,
+                      `/api/v2/share/${row.id}`,
                       {
                         role: e.key as TRole,
                       }
@@ -192,7 +192,7 @@ const CollaboratorWidget = ({ resId, load = false, onReload }: IWidget) => {
                 })}
                 onConfirm={() => {
                   console.log("delete", row.id);
-                  delete_<IShareDeleteResponse>("/v2/share/" + row.id)
+                  delete_<IShareDeleteResponse>("/api/v2/share/" + row.id)
                     .then((json) => {
                       if (json.ok) {
                         message.success("delete ok");

+ 1 - 1
dashboard-v6/src/components/share/CollaboratorAdd.tsx

@@ -44,7 +44,7 @@ const CollaboratorAddWidget = ({ resId, resType, onSuccess }: IWidget) => {
             res_id: resId,
             res_type: resType,
           };
-          const url = "/v2/share";
+          const url = "/api/v2/share";
           console.info("share api request", url, postData);
           post<IShareRequest, IShareResponse>(url, postData).then((json) => {
             console.debug("share api response", json);

+ 1 - 1
dashboard-v6/src/components/template/Video.tsx

@@ -12,7 +12,7 @@ import type { TDisplayStyle } from "../../types/template";
 const { Text } = Typography;
 
 const getUrl = async (fileId: string) => {
-  const url = `/v2/attachment/${fileId}`;
+  const url = `/api/v2/attachment/${fileId}`;
   const res = await get<IAttachmentResponse>(url);
   return res.ok ? res.data.url : "";
 };

+ 1 - 1
dashboard-v6/src/components/term/TermCtl.tsx

@@ -175,7 +175,7 @@ export const TermCtl = ({
         dispatch({ type: "SET_TERM_DATA", payload: dataMap(term) });
         return;
       } else {
-        const url = `/v2/terms/${id}?community_summary=1`;
+        const url = `/api/v2/terms/${id}?community_summary=1`;
         console.info("api request", url);
         setLoading(true);
         get<ITermResponse>(url)

+ 1 - 1
dashboard-v6/src/components/users/UserSelect.tsx

@@ -50,7 +50,7 @@ const UserSelectWidget = ({
 
         if (typeof keyWords === "string") {
           const json = await get<IUserListResponse>(
-            `/v2/user?view=key&key=${keyWords}`
+            `/api/v2/user?view=key&key=${keyWords}`
           );
           console.info("api response user select", json);
           const userList: RequestOptionsType[] = json.data.rows.map((item) => {

+ 1 - 1
dashboard-v6/src/components/video/Video.tsx

@@ -17,7 +17,7 @@ const VideoWidget = ({ fileId, src, type }: IWidget) => {
 
   useEffect(() => {
     if (fileId) {
-      const url = `/v2/attachment/${fileId}`;
+      const url = `/api/v2/attachment/${fileId}`;
       console.info("VideoWidget api request", url);
       get<IAttachmentResponse>(url).then((json) => {
         console.debug("VideoWidget api response", json);

+ 6 - 4
dashboard-v6/src/components/wbw/WbwDetailFactor.tsx

@@ -40,10 +40,12 @@ const WbwDetailFactorWidget = ({
 
       if (search.length === 0) return;
 
-      get<IApiResponseDictList>(`/v2/wbwlookup?base=${search}`).then((json) => {
-        console.log("lookup ok", json.data.count);
-        store.dispatch(add(json.data.rows));
-      });
+      get<IApiResponseDictList>(`/api/v2/wbwlookup?base=${search}`).then(
+        (json) => {
+          console.log("lookup ok", json.data.count);
+          store.dispatch(add(json.data.rows));
+        }
+      );
     },
     [inlineWordIndex]
   );

+ 1 - 1
dashboard-v6/src/components/wbw/WbwLookup.tsx

@@ -45,7 +45,7 @@ const WbwLookup = ({ words, run = false, delay = 300 }: IWidget) => {
     if (searchWord.length === 0) {
       return;
     }
-    const url = `/v2/wbwlookup?word=${searchWord.join()}`;
+    const url = `/api/v2/wbwlookup?word=${searchWord.join()}`;
     console.info("api request", url);
     get<IApiResponseDictList>(url).then((json) => {
       console.debug("api response", json);

+ 4 - 4
dashboard-v6/src/components/wbw/WbwSentCtl.tsx

@@ -282,7 +282,7 @@ const WbwSentCtl = memo(
     );
 
     const postWord = useCallback((postParam: IWbwRequest) => {
-      const url = `/v2/wbw`;
+      const url = `/api/v2/wbw`;
       console.info("wbw api request", url, postParam);
       post<IWbwRequest, IWbwUpdateResponse>(url, postParam).then((json) => {
         console.info("wbw api response", json);
@@ -351,7 +351,7 @@ const WbwSentCtl = memo(
 
     const magicDictLookup = useCallback(() => {
       const _lang = GetUserSetting("setting.dict.lang", settings);
-      const url = `/v2/wbwlookup`;
+      const url = `/api/v2/wbwlookup`;
 
       post<IMagicDictRequest, IMagicDictResponse>(url, {
         book: book,
@@ -487,7 +487,7 @@ const WbwSentCtl = memo(
     }, [wordData, update, saveWbwAll]);
 
     const deleteWbw = useCallback(() => {
-      const url = `/v2/wbw-sentence/${sentId}?channel=${channelId}`;
+      const url = `/api/v2/wbw-sentence/${sentId}?channel=${channelId}`;
       console.info("api request", url);
       setLoading(true);
       delete_<IDeleteResponse>(url)
@@ -513,7 +513,7 @@ const WbwSentCtl = memo(
         return;
       }
 
-      let url = `/v2/wbw-sentence?view=course-answer`;
+      let url = `/api/v2/wbw-sentence?view=course-answer`;
       url += `&book=${book}&para=${para}&wordStart=${wordStart}&wordEnd=${wordEnd}`;
       url += `&course=${course.courseId}`;
 

+ 9 - 9
dashboard-v6/src/components/wbw/WbwWord.tsx

@@ -114,15 +114,15 @@ const WbwWordWidget = ({
     if (searchWord.length === 0) {
       return;
     }
-    get<IApiResponseDictList>(`/v2/wbwlookup?word=${searchWord.join()}`).then(
-      (json) => {
-        console.log("lookup ok", json.data.count);
-        console.log("time", json.data.time);
-        //存储到redux
-        store.dispatch(add(json.data.rows));
-        store.dispatch(updateIndex(searchWord));
-      }
-    );
+    get<IApiResponseDictList>(
+      `/api/v2/wbwlookup?word=${searchWord.join()}`
+    ).then((json) => {
+      console.log("lookup ok", json.data.count);
+      console.log("time", json.data.time);
+      //存储到redux
+      store.dispatch(add(json.data.rows));
+      store.dispatch(updateIndex(searchWord));
+    });
 
     console.log("lookup", searchWord);
   };

+ 3 - 3
dashboard-v6/src/components/webhook/WebhookEdit.tsx

@@ -63,12 +63,12 @@ const WebhookEditWidget = ({
           let res: IWebhookResponse;
           if (typeof id === "undefined") {
             res = await post<IWebhookRequest, IWebhookResponse>(
-              `/v2/webhook`,
+              `/api/v2/webhook`,
               data
             );
           } else {
             res = await put<IWebhookRequest, IWebhookResponse>(
-              `/v2/webhook/${id}`,
+              `/api/v2/webhook/${id}`,
               data
             );
           }
@@ -87,7 +87,7 @@ const WebhookEditWidget = ({
         request={
           id
             ? async () => {
-                const url = `/v2/webhook/${id}`;
+                const url = `/api/v2/webhook/${id}`;
                 const res: IWebhookResponse = await get<IWebhookResponse>(url);
                 console.log("get", res);
                 if (res.ok) {

+ 2 - 2
dashboard-v6/src/components/webhook/WebhookList.tsx

@@ -49,7 +49,7 @@ const WebhookListWidget = ({ channelId, studioName }: IWidget) => {
       }),
       onOk() {
         console.log("delete", id);
-        return delete_<IDeleteResponse>(`/v2/webhook/${id}`)
+        return delete_<IDeleteResponse>(`/api/v2/webhook/${id}`)
           .then((json) => {
             if (json.ok) {
               message.success("删除成功");
@@ -188,7 +188,7 @@ const WebhookListWidget = ({ channelId, studioName }: IWidget) => {
         ]}
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);
-          let url = `/v2/webhook?view=channel&id=${channelId}`;
+          let url = `/api/v2/webhook?view=channel&id=${channelId}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);