2
0
Эх сурвалжийг харах

Merge branch 'development' of github.com:iapt-platform/mint into development

Jeremy Zheng 1 сар өмнө
parent
commit
bbf33fb687
100 өөрчлөгдсөн 12646 нэмэгдсэн , 120 устгасан
  1. 19 5
      api-v12/app/Console/Commands/UpgradeAITranslation.php
  2. 13 9
      api-v12/app/Http/Controllers/CorpusController.php
  3. 3 0
      api-v12/app/Providers/AppServiceProvider.php
  4. 314 0
      api-v12/app/Services/AIAssistant/NissayaTranslateService.php
  5. 13 0
      api-v12/app/Services/AIModelService.php
  6. 122 0
      api-v12/app/Services/NissayaParser.php
  7. 307 0
      api-v12/app/Services/PacketService.php
  8. 738 0
      api-v12/app/Services/RomanizeService.php
  9. 5 2
      api-v12/app/Services/SentenceService.php
  10. 49 48
      api-v12/database/migrations/2021_12_30_053602_add_func_to_fts_texts_table.php
  11. 149 13
      api-v12/resources/views/components/language-switcher.blade.php
  12. 247 11
      api-v12/resources/views/library/layouts/app.blade.php
  13. 2 0
      api-v12/routes/web.php
  14. 244 0
      api-v12/tests/Unit/Services/NissayaParserTest.php
  15. 488 0
      api-v12/tests/Unit/Services/PacketServiceTest.php
  16. 253 0
      api-v12/tests/Unit/Services/PaliTransliterationServiceTest.php
  17. 343 0
      dashboard-v4/dashboard/src/components/pro-table/ProTable.tsx
  18. 151 0
      dashboard-v4/dashboard/src/components/pro-table/ProTable.types.ts
  19. 319 0
      dashboard-v4/dashboard/src/components/pro-table/ProTableTest.tsx
  20. 243 0
      dashboard-v4/dashboard/src/components/pro-table/README.md
  21. 72 0
      dashboard-v4/dashboard/src/components/pro-table/usage-example.tsx
  22. 8 4
      dashboard-v4/dashboard/src/components/template/Nissaya.tsx
  23. 50 0
      dashboard-v4/dashboard/src/components/template/Nissaya/NissayaSent.tsx
  24. 3 0
      dashboard-v4/dashboard/src/components/template/SentEdit/SentCell.tsx
  25. 33 28
      dashboard-v4/dashboard/src/components/template/SentEdit/SentContent.tsx
  26. 12 0
      dashboard-v6/backup/components/README.md
  27. 59 0
      dashboard-v6/backup/components/admin/HeadBar.tsx
  28. 94 0
      dashboard-v6/backup/components/admin/LeftSider.tsx
  29. 64 0
      dashboard-v6/backup/components/admin/api/ApiDelayHour.tsx
  30. 83 0
      dashboard-v6/backup/components/admin/api/ApiGauge.tsx
  31. 42 0
      dashboard-v6/backup/components/admin/relation/CaseSelect.tsx
  32. 100 0
      dashboard-v6/backup/components/admin/relation/DataImport.tsx
  33. 84 0
      dashboard-v6/backup/components/admin/relation/GrammarSelect.tsx
  34. 128 0
      dashboard-v6/backup/components/admin/relation/NissayaEndingEdit.tsx
  35. 200 0
      dashboard-v6/backup/components/admin/relation/RelationEdit.tsx
  36. 80 0
      dashboard-v6/backup/components/ai/AiAssistantSelect.tsx
  37. 69 0
      dashboard-v6/backup/components/ai/AiModelCreate.tsx
  38. 114 0
      dashboard-v6/backup/components/ai/AiModelEdit.tsx
  39. 150 0
      dashboard-v6/backup/components/ai/AiModelList.tsx
  40. 154 0
      dashboard-v6/backup/components/ai/AiModelLogList.tsx
  41. 78 0
      dashboard-v6/backup/components/ai/AiTranslate.tsx
  42. 145 0
      dashboard-v6/backup/components/ai/ModelSelector.tsx
  43. 83 0
      dashboard-v6/backup/components/anthology/AnthologyCreate.tsx
  44. 396 0
      dashboard-v6/backup/components/anthology/AnthologyList.tsx
  45. 67 0
      dashboard-v6/backup/components/anthology/AnthologyModal.tsx
  46. 52 0
      dashboard-v6/backup/components/anthology/AnthologySelect.tsx
  47. 87 0
      dashboard-v6/backup/components/anthology/AnthologyTocTree.tsx
  48. 215 0
      dashboard-v6/backup/components/anthology/EditableTocTree.tsx
  49. 42 0
      dashboard-v6/backup/components/anthology/TextBookToc.tsx
  50. 246 0
      dashboard-v6/backup/components/api/Article.ts
  51. 38 0
      dashboard-v6/backup/components/api/Attachments.ts
  52. 124 0
      dashboard-v6/backup/components/api/Auth.ts
  53. 74 0
      dashboard-v6/backup/components/api/Channel.ts
  54. 88 0
      dashboard-v6/backup/components/api/Comment.ts
  55. 326 0
      dashboard-v6/backup/components/api/Corpus.ts
  56. 227 0
      dashboard-v6/backup/components/api/Course.ts
  57. 141 0
      dashboard-v6/backup/components/api/Dict.ts
  58. 25 0
      dashboard-v6/backup/components/api/Exp.ts
  59. 80 0
      dashboard-v6/backup/components/api/Group.ts
  60. 5 0
      dashboard-v6/backup/components/api/Guide.ts
  61. 46 0
      dashboard-v6/backup/components/api/Share.ts
  62. 30 0
      dashboard-v6/backup/components/api/Suggestion.ts
  63. 84 0
      dashboard-v6/backup/components/api/Tag.ts
  64. 83 0
      dashboard-v6/backup/components/api/Term.ts
  65. 45 0
      dashboard-v6/backup/components/api/Transfer.ts
  66. 105 0
      dashboard-v6/backup/components/api/ai.ts
  67. 56 0
      dashboard-v6/backup/components/api/like.ts
  68. 43 0
      dashboard-v6/backup/components/api/notification.ts
  69. 260 0
      dashboard-v6/backup/components/api/task.ts
  70. 26 0
      dashboard-v6/backup/components/api/token.ts
  71. 51 0
      dashboard-v6/backup/components/api/view.ts
  72. 45 0
      dashboard-v6/backup/components/api/webhook.ts
  73. 82 0
      dashboard-v6/backup/components/article/AddToAnthology.tsx
  74. 91 0
      dashboard-v6/backup/components/article/AnchorNav.tsx
  75. 72 0
      dashboard-v6/backup/components/article/AnthologiesAtArticle.tsx
  76. 68 0
      dashboard-v6/backup/components/article/AnthologyCard.tsx
  77. 126 0
      dashboard-v6/backup/components/article/AnthologyDetail.tsx
  78. 198 0
      dashboard-v6/backup/components/article/AnthologyInfoEdit.tsx
  79. 87 0
      dashboard-v6/backup/components/article/AnthologyList.tsx
  80. 56 0
      dashboard-v6/backup/components/article/AnthologyStudioList.tsx
  81. 226 0
      dashboard-v6/backup/components/article/Article.tsx
  82. 90 0
      dashboard-v6/backup/components/article/ArticleCard.tsx
  83. 78 0
      dashboard-v6/backup/components/article/ArticleCardMainMenu.tsx
  84. 139 0
      dashboard-v6/backup/components/article/ArticleCreate.tsx
  85. 122 0
      dashboard-v6/backup/components/article/ArticleDrawer.tsx
  86. 302 0
      dashboard-v6/backup/components/article/ArticleEdit.tsx
  87. 74 0
      dashboard-v6/backup/components/article/ArticleEditDrawer.tsx
  88. 59 0
      dashboard-v6/backup/components/article/ArticleEditTools.tsx
  89. 553 0
      dashboard-v6/backup/components/article/ArticleList.tsx
  90. 58 0
      dashboard-v6/backup/components/article/ArticleListModal.tsx
  91. 109 0
      dashboard-v6/backup/components/article/ArticleListPublic.tsx
  92. 90 0
      dashboard-v6/backup/components/article/ArticlePrevDrawer.tsx
  93. 13 0
      dashboard-v6/backup/components/article/ArticleSkeleton.tsx
  94. 151 0
      dashboard-v6/backup/components/article/ArticleView.tsx
  95. 73 0
      dashboard-v6/backup/components/article/ChapterToc.tsx
  96. 463 0
      dashboard-v6/backup/components/article/EditableTree.tsx
  97. 72 0
      dashboard-v6/backup/components/article/EditableTreeNode.tsx
  98. 83 0
      dashboard-v6/backup/components/article/ExerciseList.tsx
  99. 56 0
      dashboard-v6/backup/components/article/Find.tsx
  100. 21 0
      dashboard-v6/backup/components/article/MainMenu.tsx

+ 19 - 5
api-v12/app/Console/Commands/UpgradeAITranslation.php

@@ -4,12 +4,13 @@ namespace App\Console\Commands;
 
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Http;
 
 use App\Services\OpenAIService;
 use App\Services\AIModelService;
 use App\Services\SentenceService;
 use App\Services\SearchPaliDataService;
+use App\Services\AIAssistant\NissayaTranslateService;
+use App\Http\Resources\AiModelResource;
 use App\Http\Controllers\AuthController;
 
 use App\Models\PaliText;
@@ -26,6 +27,7 @@ class UpgradeAITranslation extends Command
     /**
      * The name and signature of the console command.
      * php artisan upgrade:ai.translation translation --book=141 --para=535
+     * php artisan upgrade:ai.translation nissaya --book=207 --para=1247
      * @var string
      */
     protected $signature = 'upgrade:ai.translation {type} {--book=} {--para=} {--resume} {--model=} ';
@@ -39,7 +41,8 @@ class UpgradeAITranslation extends Command
     protected $sentenceService;
     protected $modelService;
     protected $openAIService;
-    protected $model;
+    protected $nissayaTranslateService;
+    protected AiModelResource $model;
     protected $modelToken;
     protected $workChannel;
     protected $accessToken;
@@ -51,11 +54,13 @@ class UpgradeAITranslation extends Command
     public function __construct(
         AIModelService $model,
         SentenceService $sent,
-        OpenAIService $openAI
+        OpenAIService $openAI,
+        NissayaTranslateService $nissayaTranslate
     ) {
         $this->modelService = $model;
         $this->sentenceService = $sent;
         $this->openAIService = $openAI;
+        $this->nissayaTranslateService = $nissayaTranslate;
         parent::__construct();
     }
 
@@ -94,8 +99,10 @@ class UpgradeAITranslation extends Command
                         break;
                     case 'nissaya':
                         $data = $this->aiNissayaTranslate($book, $paragraph);
+                        break;
                     case 'wbw':
                         $data = $this->aiWBW($book, $paragraph);
+                        break;
                     default:
                         # code...
                         break;
@@ -269,6 +276,8 @@ class UpgradeAITranslation extends Command
             ->get();
         $result = [];
         foreach ($sentences as $key => $sentence) {
+            $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
+            /*
             $nissaya = [];
             $rows = explode("\n", $sentence->content);
             foreach ($rows as $key => $row) {
@@ -292,10 +301,15 @@ class UpgradeAITranslation extends Command
             $complete = time() - $startAt;
             $content = $response['choices'][0]['message']['content'] ?? '';
             Log::debug("ai response in {$complete}s content=" . $content);
-            $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
+            */
+            $aiNissaya = $this->nissayaTranslateService
+                ->setModel($this->model)
+                ->translate($sentence->content, false);
+            Log::debug("ai response ", ['content' => $aiNissaya['data']]);
             $result[] = [
                 'id' => $id,
-                'content' => $content,
+                'content' => json_encode($aiNissaya['data'] ?? [], JSON_UNESCAPED_UNICODE),
+                'content_type' => 'json'
             ];
         }
         return $result;

+ 13 - 9
api-v12/app/Http/Controllers/CorpusController.php

@@ -747,15 +747,19 @@ class CorpusController extends Controller
                                 "/sent/{$channelId}/{$currSentId}/{$format}",
                                 config('mint.cache.expire'),
                                 function () use ($row, $mode, $format) {
-                                    return MdRender::render(
-                                        $row->content,
-                                        [$row->channel_uid],
-                                        null,
-                                        $mode,
-                                        "nissaya",
-                                        $row->content_type,
-                                        $format
-                                    );
+                                    if ($row->content_type === 'markdown') {
+                                        return MdRender::render(
+                                            $row->content,
+                                            [$row->channel_uid],
+                                            null,
+                                            $mode,
+                                            "nissaya",
+                                            $row->content_type,
+                                            $format
+                                        );
+                                    } else {
+                                        return null;
+                                    }
                                 }
                             );
                             break;

+ 3 - 0
api-v12/app/Providers/AppServiceProvider.php

@@ -7,6 +7,7 @@ use Godruoyi\Snowflake\Snowflake;
 use Godruoyi\Snowflake\LaravelSequenceResolver;
 use App\Tools\QueryBuilderMacro;
 use Illuminate\Database\Query\Builder as QueryBuilder;
+use App\Services\RomanizeService;
 
 
 class AppServiceProvider extends ServiceProvider
@@ -30,6 +31,8 @@ class AppServiceProvider extends ServiceProvider
                     )
                 );
         });
+
+        $this->app->singleton(RomanizeService::class);
     }
 
     /**

+ 314 - 0
api-v12/app/Services/AIAssistant/NissayaTranslateService.php

@@ -0,0 +1,314 @@
+<?php
+
+namespace App\Services\AIAssistant;
+
+use App\Services\NissayaParser;
+use App\Services\OpenAIService;
+use App\Services\RomanizeService;
+use Illuminate\Support\Facades\Log;
+use App\Http\Resources\AiModelResource;
+
+
+class NissayaTranslateService
+{
+    protected OpenAIService $openAIService;
+    protected NissayaParser $nissayaParser;
+    protected RomanizeService $romanizeService;
+    protected AiModelResource $model;
+    protected bool $romanize;
+
+    /**
+     * 翻译提示词模板
+     */
+    protected string $translatePrompt = <<<PROMPT
+你是一个专业的缅甸语翻译专家。你的任务是将缅文逐词解析(Nissaya)翻译成中文。
+
+输入格式:
+- 每行包含三个部分: original(巴利文), translation(缅文译文), note(缅文注释,可能没有)
+- 输入为JSON Lines格式
+
+输出要求:
+1. 保持巴利文(original)原样输出,不做任何修改
+2. 将巴利文(original)直接翻译成中文
+2. 将缅文译文(translation)翻译成中文
+3. 将缅文注释(note)翻译成中文
+4. 输出必须是严格的JSON Lines格式,每行一个有效的JSON对象
+5. 不要添加任何解释、说明或markdown代码块标记
+6. 保持原有的数据结构和字段名称
+7. 输出三个字段
+    1. original:原巴利文
+    2. translation:巴利文的中文译文>缅文的中文译文
+    3. note:缅文注释的中文译文
+    3. confidence:两个译文的语义相似度(0-100)
+
+示例输入:
+{"original":"buddha","translation":"ဗုဒ္ဓ","note":"အဘိဓာန်"}
+
+示例输出:
+{"original":"buddha","translation":"佛>佛陀","note":"词汇表","confidence":100}
+
+请翻译以下内容:
+PROMPT;
+
+    public function __construct(
+        OpenAIService $openAIService,
+        NissayaParser $nissayaParser,
+        RomanizeService $romanizeService
+    ) {
+        $this->openAIService = $openAIService;
+        $this->nissayaParser = $nissayaParser;
+        $this->romanizeService = $romanizeService;
+        $this->romanize = true;
+    }
+
+    /**
+     * 设置模型配置
+     *
+     * @param \App\Http\Resources\AiModelResource $model
+     * @return self
+     */
+    public function setModel(AiModelResource $model): self
+    {
+        $this->model = $model;
+        return $this;
+    }
+
+    /**
+     * 设置翻译提示词
+     *
+     * @param string $prompt
+     * @return self
+     */
+    public function setTranslatePrompt(string $prompt): self
+    {
+        $this->translatePrompt = $prompt;
+        return $this;
+    }
+
+    /**
+     * 设置翻译提示词
+     *
+     * @param string $prompt
+     * @return self
+     */
+    public function setRomanize(bool $romanize): self
+    {
+        $this->romanize = $romanize;
+        return $this;
+    }
+
+    /**
+     * 翻译缅文版逐词解析
+     *
+     * @param string $text 格式: 巴利文=缅文
+     * @param bool $stream 是否流式输出
+     * @return array
+     * @throws \Exception
+     */
+    public function translate(string $text, bool $stream = false): array
+    {
+        $startAt = time();
+
+        try {
+            // 1. 解析nissaya文本为数组
+            $parsedData = $this->nissayaParser->parse($text);
+
+            if (empty($parsedData)) {
+                throw new \Exception('解析nissaya文本失败,返回空数组');
+            }
+
+            $parsedData = $this->romanize($parsedData);
+
+            foreach ($parsedData as $key => $value) {
+                if (isset($value['notes']) && is_array($value['notes'])) {
+                    $parsedData[$key]['note'] = implode("\n\n----\n\n", $value['notes']);
+                    $parsedData[$key]['note'] = str_replace("\n**", "\n\n-----\n\n", $parsedData[$key]['note']);
+                    unset($parsedData[$key]['notes']);
+                }
+            }
+
+            // 2. 将解析后的数组转换为JSONL格式
+            $jsonlInput = $this->arrayToJsonl($parsedData);
+
+            Log::info('NissayaTranslate: 准备翻译', [
+                'items_count' => count($parsedData),
+                'input_length' => strlen($jsonlInput),
+            ]);
+
+            // 3. 调用LLM进行翻译
+            $response = $this->openAIService
+                ->setApiUrl($this->model['url'])
+                ->setModel($this->model['model'])
+                ->setApiKey($this->model['key'])
+                ->setSystemPrompt($this->translatePrompt)
+                ->setTemperature(0.3)
+                ->setStream($stream)
+                ->send($jsonlInput);
+
+            $complete = time() - $startAt;
+            $content = $response['choices'][0]['message']['content'] ?? '';
+
+            if (empty($content)) {
+                throw new \Exception('LLM返回内容为空');
+            }
+
+            // 4. 解析JSONL格式的翻译结果
+            $translatedData = $this->jsonlToArray($content);
+
+            Log::info('NissayaTranslate: 翻译完成', [
+                'duration' => $complete,
+                'output_items' => count($translatedData),
+                'input_tokens' => $response['usage']['prompt_tokens'] ?? 0,
+                'output_tokens' => $response['usage']['completion_tokens'] ?? 0,
+            ]);
+
+            return [
+                'success' => true,
+                'data' => $translatedData,
+                'meta' => [
+                    'duration' => $complete,
+                    'items_count' => count($translatedData),
+                    'usage' => $response['usage'] ?? [],
+                ],
+            ];
+        } catch (\Exception $e) {
+            Log::error('NissayaTranslate: 翻译失败', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => $e->getMessage(),
+                'data' => [],
+            ];
+        }
+    }
+
+    protected function romanize(array $data): array
+    {
+        if ($this->romanize) {
+            foreach ($data as $key => $value) {
+                $data[$key]['original'] = $this->romanizeService->myanmarToRoman($value['original']);
+            }
+        }
+        return $data;
+    }
+
+    /**
+     * 将数组转换为JSONL格式
+     *
+     * @param array $data
+     * @return string
+     */
+    protected function arrayToJsonl(array $data): string
+    {
+        $lines = [];
+        foreach ($data as $item) {
+            $lines[] = json_encode($item, JSON_UNESCAPED_UNICODE);
+        }
+        return implode("\n", $lines);
+    }
+
+    /**
+     * 将JSONL格式转换为数组
+     *
+     * @param string $jsonl
+     * @return array
+     */
+    protected function jsonlToArray(string $jsonl): array
+    {
+        // 清理可能的markdown代码块标记
+        $jsonl = preg_replace('/```json\s*|\s*```/', '', $jsonl);
+        $jsonl = trim($jsonl);
+
+        $lines = explode("\n", $jsonl);
+        $result = [];
+
+        foreach ($lines as $line) {
+            $line = trim($line);
+            if (empty($line)) {
+                continue;
+            }
+
+            $decoded = json_decode($line, true);
+            if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+                $result[] = $decoded;
+            } else {
+                Log::warning('NissayaTranslate: 无法解析JSON行', [
+                    'line' => $line,
+                    'error' => json_last_error_msg(),
+                ]);
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * 批量翻译(将大文本分批处理)
+     *
+     * @param string $text
+     * @param int $batchSize 每批处理的条目数
+     * @return array
+     */
+    public function translateInBatches(string $text, int $batchSize = 50): array
+    {
+        try {
+            $parsedData = $this->nissayaParser->parse($text);
+            $batches = array_chunk($parsedData, $batchSize);
+            $allResults = [];
+            $totalDuration = 0;
+            $totalUsage = [
+                'prompt_tokens' => 0,
+                'completion_tokens' => 0,
+                'total_tokens' => 0,
+            ];
+
+            foreach ($batches as $index => $batch) {
+                Log::info("NissayaTranslate: 处理批次 " . ($index + 1) . "/" . count($batches));
+
+                $jsonlInput = $this->arrayToJsonl($batch);
+                $response = $this->openAIService
+                    ->setApiUrl($this->model['url'])
+                    ->setModel($this->model['model'])
+                    ->setApiKey($this->model['key'])
+                    ->setSystemPrompt($this->translatePrompt)
+                    ->setTemperature(0.7)
+                    ->setStream(false)
+                    ->send($jsonlInput);
+
+                $content = $response['choices'][0]['message']['content'] ?? '';
+                $translatedBatch = $this->jsonlToArray($content);
+                $allResults = array_merge($allResults, $translatedBatch);
+
+                // 累计使用统计
+                if (isset($response['usage'])) {
+                    $totalUsage['prompt_tokens'] += $response['usage']['prompt_tokens'] ?? 0;
+                    $totalUsage['completion_tokens'] += $response['usage']['completion_tokens'] ?? 0;
+                    $totalUsage['total_tokens'] += $response['usage']['total_tokens'] ?? 0;
+                }
+            }
+
+            return [
+                'success' => true,
+                'data' => $allResults,
+                'meta' => [
+                    'batches' => count($batches),
+                    'items_count' => count($allResults),
+                    'usage' => $totalUsage,
+                ],
+            ];
+        } catch (\Exception $e) {
+            Log::error('NissayaTranslate: 批量翻译失败', [
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => $e->getMessage(),
+                'data' => [],
+            ];
+        }
+    }
+}

+ 13 - 0
api-v12/app/Services/AIModelService.php

@@ -11,12 +11,21 @@ class AIModelService
 
     public function getModelsById($id)
     {
+        // 添加表存在检查
+        if (!\Illuminate\Support\Facades\Schema::hasTable('ai_models')) {
+            return [];
+        }
+
         $table = AiModel::whereIn('uid', $id);
         $result = $table->get();
         return AiModelResource::collection(resource: $result);
     }
     public function getModelById($id)
     {
+        // 添加表存在检查
+        if (!\Illuminate\Support\Facades\Schema::hasTable('ai_models')) {
+            return [];
+        }
         $result = AiModel::where('uid', $id)
             ->first();
         return new AiModelResource(resource: $result);
@@ -24,6 +33,10 @@ class AIModelService
 
     public function getSysModels($type = null)
     {
+        // 添加表存在检查
+        if (!\Illuminate\Support\Facades\Schema::hasTable('ai_models')) {
+            return [];
+        }
         if (empty($type)) {
             $types = ['wbw', 'chat', 'summarize'];
         } else {

+ 122 - 0
api-v12/app/Services/NissayaParser.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace App\Services;
+
+class NissayaParser
+{
+    /**
+     * 解析nissaya巴利文-缅文文本
+     *
+     * @param string $content
+     * @return array
+     */
+    public function parse(string $content): array
+    {
+        $lines = explode("\n", $content);
+        $records = [];
+        $currentRecord = null;
+        $pendingNotes = [];
+        $inCodeBlock = false;
+        $codeBlockContent = '';
+        $codeBlockDelimiter = '';
+
+        for ($i = 0; $i < count($lines); $i++) {
+            $line = $lines[$i];
+            $trimmedLine = trim($line);
+
+            // 检测代码块开始/结束 (支持 ``` 和 ``)
+            if (preg_match('/^(```|``)$/', $trimmedLine, $matches)) {
+                if (!$inCodeBlock) {
+                    // 开始代码块
+                    $inCodeBlock = true;
+                    $codeBlockDelimiter = $matches[1];
+                    $codeBlockContent = '';
+                } elseif ($matches[1] === $codeBlockDelimiter) {
+                    // 结束代码块
+                    $inCodeBlock = false;
+                    $pendingNotes[] = trim($codeBlockContent);
+                    $codeBlockContent = '';
+                    $codeBlockDelimiter = '';
+                }
+                continue;
+            }
+
+            // 在代码块内
+            if ($inCodeBlock) {
+                $codeBlockContent .= $line . "\n";
+                continue;
+            }
+
+            // 空行跳过
+            if (empty($trimmedLine)) {
+                continue;
+            }
+
+            // 检查是否包含等号
+            if (strpos($line, '=') !== false) {
+                // 检查是否是以等号开头(补充上一条记录的翻译)
+                if (strpos(ltrim($line), '=') === 0) {
+                    // 这是对上一条记录的翻译补充
+                    if ($currentRecord !== null && empty($currentRecord['translation'])) {
+                        $currentRecord['translation'] = trim(substr(ltrim($line), 1));
+                    }
+                } else {
+                    // 保存之前的记录
+                    if ($currentRecord !== null) {
+                        $currentRecord['notes'] = $pendingNotes;
+                        $records[] = $currentRecord;
+                        $pendingNotes = [];
+                    }
+
+                    // 解析新记录
+                    list($original, $translation) = explode('=', $line, 2);
+                    $currentRecord = [
+                        'original' => trim($original),
+                        'translation' => trim($translation),
+                        'notes' => []
+                    ];
+                }
+            } else {
+                // 没有等号的行
+                if ($currentRecord !== null && empty($currentRecord['translation'])) {
+                    // 情况1: 上一行只有巴利文(等号后为空),当前行是缅文翻译
+                    $currentRecord['translation'] = trim($line);
+                } elseif ($currentRecord === null) {
+                    // 情况2: 第一行没有等号,可能是不完整的巴利文
+                    $currentRecord = [
+                        'original' => trim($line),
+                        'translation' => '',
+                        'notes' => []
+                    ];
+                } else {
+                    // 其他情况视为注释内容
+                    $pendingNotes[] = trim($line);
+                }
+            }
+        }
+
+        // 保存最后一条记录
+        if ($currentRecord !== null) {
+            $currentRecord['notes'] = $pendingNotes;
+            $records[] = $currentRecord;
+        }
+
+        return $records;
+    }
+
+    /**
+     * 解析文件
+     *
+     * @param string $filePath
+     * @return array
+     */
+    public function parseFile(string $filePath): array
+    {
+        if (!file_exists($filePath)) {
+            throw new \InvalidArgumentException("文件不存在: {$filePath}");
+        }
+
+        $content = file_get_contents($filePath);
+        return $this->parse($content);
+    }
+}

+ 307 - 0
api-v12/app/Services/PacketService.php

@@ -0,0 +1,307 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Channel;
+use App\Models\Sentence;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use ZipArchive;
+use App\Http\Api\ChannelApi;
+
+/**
+ * PacketService
+ *
+ * 用于导出句子数据为训练数据包的服务类
+ * 将指定版本的译文与巴利原文配对导出为JSONL格式,并打包为ZIP文件
+ */
+class PacketService
+{
+    /**
+     * 每批处理的记录数
+     */
+    private const CHUNK_SIZE = 1000;
+
+    /**
+     * 临时文件存储路径
+     */
+    private const TEMP_DIR = 'temp/packet';
+
+    /**
+     * 巴利原文的channel_uid
+     */
+    private string $paliChannelUid;
+
+    /**
+     * 译文版本的channel_uid数组
+     */
+    private array $translationChannelUids;
+
+    /**
+     * 临时文件路径集合
+     */
+    private array $tempFiles = [];
+
+    /**
+     * 构造函数
+     *
+     * @param string $paliChannelUid 巴利原文的channel_uid
+     * @param array $translationChannelUids 译文版本的channel_uid数组
+     */
+    public function __construct(array $translationChannelUids)
+    {
+        $this->paliChannelUid = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        $this->translationChannelUids = $translationChannelUids;
+    }
+
+    /**
+     * 执行导出并打包
+     *
+     * @return string 返回生成的ZIP文件路径
+     * @throws \Exception
+     */
+    public function export(): string
+    {
+        try {
+            // 创建临时目录
+            $this->createTempDirectory();
+
+            // 导出所有译文文件
+            foreach ($this->translationChannelUids as $channelUid) {
+                $this->exportTranslation($channelUid);
+            }
+
+            // 打包ZIP文件
+            $zipPath = $this->createZipArchive();
+
+            // 清理临时文件
+            $this->cleanupTempFiles();
+
+            return $zipPath;
+        } catch (\Exception $e) {
+            // 发生错误时也要清理临时文件
+            $this->cleanupTempFiles();
+            throw $e;
+        }
+    }
+
+    /**
+     * 创建临时目录
+     *
+     * @return void
+     */
+    private function createTempDirectory(): void
+    {
+        $tempPath = storage_path('app/' . self::TEMP_DIR);
+
+        if (!is_dir($tempPath)) {
+            mkdir($tempPath, 0755, true);
+        }
+
+        // 创建translations子目录
+        $translationsPath = $tempPath . '/translations';
+        if (!is_dir($translationsPath)) {
+            mkdir($translationsPath, 0755, true);
+        }
+    }
+
+    /**
+     * 导出指定译文版本的数据
+     *
+     * @param string $channelUid 译文版本的channel_uid
+     * @return void
+     */
+    private function exportTranslation(string $channelUid): void
+    {
+        // 获取channel名称
+        $channelName = $this->getChannelName($channelUid);
+
+        // 创建JSONL文件
+        $filename = $channelName . '.jsonl';
+        $filepath = storage_path('app/' . self::TEMP_DIR . '/translations/' . $filename);
+
+        // 记录临时文件路径
+        $this->tempFiles[] = $filepath;
+
+        // 打开文件准备写入
+        $handle = fopen($filepath, 'w');
+
+        if ($handle === false) {
+            throw new \RuntimeException("无法创建文件: {$filepath}");
+        }
+
+        try {
+            // 分批查询并写入数据
+            $this->writeTranslationData($handle, $channelUid);
+        } finally {
+            fclose($handle);
+        }
+    }
+
+    /**
+     * 查询并写入译文数据
+     *
+     * @param resource $handle 文件句柄
+     * @param string $channelUid 译文版本的channel_uid
+     * @return void
+     */
+    private function writeTranslationData($handle, string $channelUid): void
+    {
+        // 构建查询,联表获取译文和巴利文
+        DB::table('sentences as s1')
+            ->select([
+                's1.book_id',
+                's1.paragraph',
+                's1.word_start',
+                's1.word_end',
+                's1.content as translation',
+                's2.content as pali'
+            ])
+            ->join('sentences as s2', function ($join) {
+                $join->on('s1.book_id', '=', 's2.book_id')
+                    ->on('s1.paragraph', '=', 's2.paragraph')
+                    ->on('s1.word_start', '=', 's2.word_start')
+                    ->on('s1.word_end', '=', 's2.word_end')
+                    ->where('s2.channel_uid', '=', $this->paliChannelUid);
+            })
+            ->where('s1.channel_uid', '=', $channelUid)
+            ->whereNotNull('s1.content')
+            ->where('s1.content', '!=', '')
+            ->orderBy('s1.book_id')
+            ->orderBy('s1.paragraph')
+            ->orderBy('s1.word_start')
+            ->orderBy('s1.word_end')
+            ->chunk(self::CHUNK_SIZE, function ($sentences) use ($handle) {
+                foreach ($sentences as $sentence) {
+                    // 如果没有译文,跳过
+                    if (empty($sentence->translation)) {
+                        continue;
+                    }
+
+                    // 构建ID
+                    $id = sprintf(
+                        '%s-%s-%s-%s',
+                        $sentence->book_id,
+                        $sentence->paragraph,
+                        $sentence->word_start,
+                        $sentence->word_end
+                    );
+
+                    // 构建JSON对象
+                    $data = [
+                        'id' => $id,
+                        'pali' => $sentence->pali ?? '',
+                        'translation' => $sentence->translation
+                    ];
+
+                    // 写入JSONL格式(每行一个JSON对象)
+                    fwrite($handle, json_encode($data, JSON_UNESCAPED_UNICODE) . "\n");
+                }
+            });
+    }
+
+    /**
+     * 获取channel名称
+     *
+     * @param string $channelUid channel的uuid
+     * @return string channel名称,如果找不到则返回uuid
+     */
+    private function getChannelName(string $channelUid): string
+    {
+        $channel = Channel::where('uid', $channelUid)->first();
+
+        return $channel?->name ?? $channelUid;
+    }
+
+    /**
+     * 创建ZIP压缩包
+     *
+     * @return string 返回ZIP文件在Storage中的路径
+     * @throws \RuntimeException
+     */
+    private function createZipArchive(): string
+    {
+        $timestamp = now()->format('YmdHis');
+        $zipFilename = "training_data_{$timestamp}.zip";
+        $zipPath = storage_path('app/packet/' . $zipFilename);
+
+        // 确保packet目录存在
+        $packetDir = storage_path('app/packet');
+        if (!is_dir($packetDir)) {
+            mkdir($packetDir, 0755, true);
+        }
+
+        $zip = new ZipArchive();
+
+        if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+            throw new \RuntimeException("无法创建ZIP文件: {$zipPath}");
+        }
+
+        try {
+            // 添加所有JSONL文件到ZIP
+            $translationsDir = storage_path('app/' . self::TEMP_DIR . '/translations');
+
+            if (is_dir($translationsDir)) {
+                $files = scandir($translationsDir);
+
+                foreach ($files as $file) {
+                    if ($file === '.' || $file === '..') {
+                        continue;
+                    }
+
+                    $filePath = $translationsDir . '/' . $file;
+
+                    if (is_file($filePath)) {
+                        // 添加到ZIP的translations目录下
+                        $zip->addFile($filePath, 'translations/' . $file);
+                    }
+                }
+            }
+
+            $zip->close();
+        } catch (\Exception $e) {
+            $zip->close();
+            throw $e;
+        }
+
+        // 返回相对于Storage的路径
+        return 'packet/' . $zipFilename;
+    }
+
+    /**
+     * 清理临时文件和目录
+     *
+     * @return void
+     */
+    private function cleanupTempFiles(): void
+    {
+        $tempPath = storage_path('app/' . self::TEMP_DIR);
+
+        if (is_dir($tempPath)) {
+            $this->deleteDirectory($tempPath);
+        }
+    }
+
+    /**
+     * 递归删除目录
+     *
+     * @param string $dir 目录路径
+     * @return void
+     */
+    private function deleteDirectory(string $dir): void
+    {
+        if (!is_dir($dir)) {
+            return;
+        }
+
+        $files = array_diff(scandir($dir), ['.', '..']);
+
+        foreach ($files as $file) {
+            $path = $dir . '/' . $file;
+
+            is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
+        }
+
+        rmdir($dir);
+    }
+}

+ 738 - 0
api-v12/app/Services/RomanizeService.php

@@ -0,0 +1,738 @@
+<?php
+
+namespace App\Services;
+
+/**
+ * 控制器中
+public function convert(PaliTransliterationService $pali)
+{
+    $result = $pali->myanmarToRoman('သမ္မာ');
+}
+
+// 或者使用 app() 助手函数
+$pali = app(PaliTransliterationService::class);
+$result = $pali->thaiToRoman('สมฺมา');
+ */
+class RomanizeService
+{
+    /**
+     * 缅文字母映射表
+     */
+    private const MYANMAR_CHARS = [
+        "ႁႏၵ",
+        "ခ္",
+        "ဃ္",
+        "ဆ္",
+        "ဈ္",
+        "ည္",
+        "ဌ္",
+        "ဎ္",
+        "ထ္",
+        "ဓ္",
+        "ဖ္",
+        "ဘ္",
+        "က္",
+        "ဂ္",
+        "စ္",
+        "ဇ္",
+        "ဉ္",
+        "ဠ္",
+        "ဋ္",
+        "ဍ္",
+        "ဏ္",
+        "တ္",
+        "ဒ္",
+        "န္",
+        "ဟ္",
+        "ပ္",
+        "ဗ္",
+        "မ္",
+        "ယ္",
+        "ရ္",
+        "လ္",
+        "ဝ္",
+        "သ္",
+        "င္",
+        "င်္",
+        "ဿ",
+        "ခ",
+        "ဃ",
+        "ဆ",
+        "ဈ",
+        "စျ",
+        "ည",
+        "ဌ",
+        "ဎ",
+        "ထ",
+        "ဓ",
+        "ဖ",
+        "ဘ",
+        "က",
+        "ဂ",
+        "စ",
+        "ဇ",
+        "ဉ",
+        "ဠ",
+        "ဋ",
+        "ဍ",
+        "ဏ",
+        "တ",
+        "ဒ",
+        "န",
+        "ဟ",
+        "ပ",
+        "ဗ",
+        "မ",
+        "ယ",
+        "ရ",
+        "႐",
+        "လ",
+        "ဝ",
+        "သ",
+        "aျ္",
+        "aွ္",
+        "aြ္",
+        "aြ",
+        "ၱ",
+        "ၳ",
+        "ၵ",
+        "ၶ",
+        "ၬ",
+        "ၭ",
+        "ၠ",
+        "ၡ",
+        "ၢ",
+        "ၣ",
+        "ၸ",
+        "ၹ",
+        "ၺ",
+        "႓",
+        "ၥ",
+        "ၧ",
+        "ၨ",
+        "ၩ",
+        "်",
+        "ျ",
+        "ႅ",
+        "ၼ",
+        "ွ",
+        "ႇ",
+        "ႆ",
+        "ၷ",
+        "ၲ",
+        "႒",
+        "႗",
+        "ၯ",
+        "ၮ",
+        "႑",
+        "kaၤ",
+        "gaၤ",
+        "khaၤ",
+        "ghaၤ",
+        "aှ",
+        "aိံ",
+        "aုံ",
+        "aော",
+        "aေါ",
+        "aအံ",
+        "aဣံ",
+        "aဥံ",
+        "aံ",
+        "aာ",
+        "aါ",
+        "aိ",
+        "aီ",
+        "aု",
+        "aဳ",
+        "aူ",
+        "aေ",
+        "အါ",
+        "အာ",
+        "အ",
+        "ဣ",
+        "ဤ",
+        "ဥ",
+        "ဦ",
+        "ဧ",
+        "ဩ",
+        "ႏ",
+        "ၪ",
+        "a္",
+        "္",
+        "aံ",
+        "ေss",
+        "ေkh",
+        "ေgh",
+        "ေch",
+        "ေjh",
+        "ေññ",
+        "ေṭh",
+        "ေḍh",
+        "ေth",
+        "ေdh",
+        "ေph",
+        "ေbh",
+        "ေk",
+        "ေg",
+        "ေc",
+        "ေj",
+        "ေñ",
+        "ေḷ",
+        "ေṭ",
+        "ေḍ",
+        "ေṇ",
+        "ေt",
+        "ေd",
+        "ေn",
+        "ေh",
+        "ေp",
+        "ေb",
+        "ေm",
+        "ေy",
+        "ေr",
+        "ေl",
+        "ေv",
+        "ေs",
+        "ေy",
+        "ေv",
+        "ေr",
+        "ea",
+        "eā",
+        "၁",
+        "၂",
+        "၃",
+        "၄",
+        "၅",
+        "၆",
+        "၇",
+        "၈",
+        "၉",
+        "၀",
+        "း",
+        "့",
+        "။",
+        "၊"
+    ];
+
+    /**
+     * 罗马巴利字母映射表
+     */
+    private const ROMAN_CHARS = [
+        "ndra",
+        "kh",
+        "gh",
+        "ch",
+        "jh",
+        "ññ",
+        "ṭh",
+        "ḍh",
+        "th",
+        "dh",
+        "ph",
+        "bh",
+        "k",
+        "g",
+        "c",
+        "j",
+        "ñ",
+        "ḷ",
+        "ṭ",
+        "ḍ",
+        "ṇ",
+        "t",
+        "d",
+        "n",
+        "h",
+        "p",
+        "b",
+        "m",
+        "y",
+        "r",
+        "l",
+        "v",
+        "s",
+        "ṅ",
+        "ṅ",
+        "ssa",
+        "kha",
+        "gha",
+        "cha",
+        "jha",
+        "jha",
+        "ñña",
+        "ṭha",
+        "ḍha",
+        "tha",
+        "dha",
+        "pha",
+        "bha",
+        "ka",
+        "ga",
+        "ca",
+        "ja",
+        "ña",
+        "ḷa",
+        "ṭa",
+        "ḍa",
+        "ṇa",
+        "ta",
+        "da",
+        "na",
+        "ha",
+        "pa",
+        "ba",
+        "ma",
+        "ya",
+        "ra",
+        "ra",
+        "la",
+        "va",
+        "sa",
+        "ya",
+        "va",
+        "ra",
+        "ra",
+        "្ta",
+        "្tha",
+        "្da",
+        "្dha",
+        "្ṭa",
+        "្ṭha",
+        "្ka",
+        "្kha",
+        "្ga",
+        "្gha",
+        "្pa",
+        "្pha",
+        "្ba",
+        "្bha",
+        "្ca",
+        "្cha",
+        "្ja",
+        "្jha",
+        "្a",
+        "្ya",
+        "្la",
+        "្ma",
+        "្va",
+        "្ha",
+        "ssa",
+        "na",
+        "ta",
+        "ṭṭha",
+        "ṭṭa",
+        "ḍḍha",
+        "ḍḍa",
+        "ṇḍa",
+        "ṅka",
+        "ṅga",
+        "ṅkha",
+        "ṅgha",
+        "ha",
+        "iṃ",
+        "uṃ",
+        "o",
+        "o",
+        "aṃ",
+        "iṃ",
+        "uṃ",
+        "aṃ",
+        "ā",
+        "ā",
+        "i",
+        "ī",
+        "u",
+        "u",
+        "ū",
+        "e",
+        "ā",
+        "ā",
+        "a",
+        "i",
+        "ī",
+        "u",
+        "ū",
+        "e",
+        "o",
+        "n",
+        "ñ",
+        "",
+        "",
+        "aṃ",
+        "sse",
+        "khe",
+        "ghe",
+        "che",
+        "jhe",
+        "ññe",
+        "ṭhe",
+        "ḍhe",
+        "the",
+        "dhe",
+        "phe",
+        "bhe",
+        "ke",
+        "ge",
+        "ce",
+        "je",
+        "ñe",
+        "ḷe",
+        "ṭe",
+        "ḍe",
+        "ṇe",
+        "te",
+        "de",
+        "ne",
+        "he",
+        "pe",
+        "be",
+        "me",
+        "ye",
+        "re",
+        "le",
+        "ve",
+        "se",
+        "ye",
+        "ve",
+        "re",
+        "e",
+        "o",
+        "1",
+        "2",
+        "3",
+        "4",
+        "5",
+        "6",
+        "7",
+        "8",
+        "9",
+        "0",
+        "\"",
+        "'",
+        ".",
+        ","
+    ];
+
+    /**
+     * 泰文字母映射表
+     */
+    private const THAI_CHARS = [
+        "นฺทฺร",
+        "ขฺ",
+        "ฆฺ",
+        "ฉฺ",
+        "ฌฺ",
+        "ญฺ",
+        "ฐฺ",
+        "ฑฺ",
+        "ถฺ",
+        "ธฺ",
+        "ผฺ",
+        "ภฺ",
+        "กฺ",
+        "คฺ",
+        "จฺ",
+        "ชฺ",
+        "ญฺ",
+        "ฬฺ",
+        "ฏฺ",
+        "ฑฺ",
+        "ณฺ",
+        "ตฺ",
+        "ทฺ",
+        "นฺ",
+        "หฺ",
+        "ปฺ",
+        "พฺ",
+        "มฺ",
+        "ยฺ",
+        "รฺ",
+        "ลฺ",
+        "วฺ",
+        "สฺ",
+        "งฺ",
+        "งฺ",
+        "สฺส",
+        "ข",
+        "ฆ",
+        "ฉ",
+        "ฌ",
+        "ฌ",
+        "ญฺญ",
+        "ฐ",
+        "ฑ",
+        "ถ",
+        "ธ",
+        "ผ",
+        "ภ",
+        "ก",
+        "ค",
+        "จ",
+        "ช",
+        "ญ",
+        "ฬ",
+        "ฏ",
+        "ฑ",
+        "ณ",
+        "ต",
+        "ท",
+        "น",
+        "ห",
+        "ป",
+        "พ",
+        "ม",
+        "ย",
+        "ร",
+        "ร",
+        "ล",
+        "ว",
+        "ส",
+        "ฺย",
+        "ฺว",
+        "ฺร",
+        "ร",
+        "ตฺต",
+        "ตฺถ",
+        "ทฺท",
+        "ทฺธ",
+        "ฏฺฏ",
+        "ฏฺฐ",
+        "กฺก",
+        "ขฺข",
+        "คฺค",
+        "ฆฺฆ",
+        "ปฺป",
+        "ผฺผ",
+        "พฺพ",
+        "ภฺภ",
+        "จฺจ",
+        "ฉฺฉ",
+        "ชฺช",
+        "ฌฺฌ",
+        "ฺ",
+        "ฺย",
+        "ฺล",
+        "ฺม",
+        "ฺว",
+        "ฺห",
+        "สฺส",
+        "น",
+        "ต",
+        "ฏฺฐ",
+        "ฏฺฏ",
+        "ฑฺฒ",
+        "ฑฺฑ",
+        "ณฺฑ",
+        "งฺก",
+        "งฺค",
+        "งฺข",
+        "งฺฆ",
+        "ห",
+        "ิํ",
+        "ุํ",
+        "โอ",
+        "โอ",
+        "อํ",
+        "อิํ",
+        "อุํ",
+        "ํ",
+        "า",
+        "า",
+        "ิ",
+        "ี",
+        "ุ",
+        "ุ",
+        "ู",
+        "เ",
+        "อา",
+        "อา",
+        "อ",
+        "อิ",
+        "อี",
+        "อุ",
+        "อู",
+        "เอ",
+        "โอ",
+        "น",
+        "ญ",
+        "",
+        "ฺ",
+        "ํ",
+        "เสฺส",
+        "เข",
+        "เฆ",
+        "เฉ",
+        "เฌ",
+        "เญฺญ",
+        "เฐ",
+        "เฑ",
+        "เถ",
+        "เธ",
+        "เผ",
+        "เภ",
+        "เก",
+        "เค",
+        "เจ",
+        "เช",
+        "เญ",
+        "เฬ",
+        "เฏ",
+        "เฑ",
+        "เณ",
+        "เต",
+        "เท",
+        "เน",
+        "เห",
+        "เป",
+        "เพ",
+        "เม",
+        "เย",
+        "เร",
+        "เล",
+        "เว",
+        "เส",
+        "เย",
+        "เว",
+        "เร",
+        "เอ",
+        "โอ",
+        "๑",
+        "๒",
+        "๓",
+        "๔",
+        "๕",
+        "๖",
+        "๗",
+        "๘",
+        "๙",
+        "๐",
+        "ํ",
+        "ฺ",
+        "ฯ",
+        "ฯลฯ"
+    ];
+
+    /**
+     * 缅文转罗马巴利
+     *
+     * @param string $input
+     * @return string
+     */
+    public function myanmarToRoman(string $input): string
+    {
+        return str_replace(self::MYANMAR_CHARS, self::ROMAN_CHARS, $input);
+    }
+
+    /**
+     * 罗马巴利转缅文
+     *
+     * @param string $input
+     * @return string
+     */
+    public function romanToMyanmar(string $input): string
+    {
+        // 手动构建映射数组,遇到重复的键时保留第一个
+        $mapping = [];
+        foreach (self::ROMAN_CHARS as $index => $roman) {
+            if (!isset($mapping[$roman])) {
+                $mapping[$roman] = self::MYANMAR_CHARS[$index];
+            }
+        }
+
+        // 按键长度降序排序,优先匹配较长的字符串
+        uksort($mapping, function ($a, $b) {
+            $lenDiff = strlen($b) - strlen($a);
+            if ($lenDiff !== 0) {
+                return $lenDiff;
+            }
+            return strcmp($a, $b);
+        });
+
+        return str_replace(array_keys($mapping), array_values($mapping), $input);
+    }
+
+    /**
+     * 泰文转罗马巴利
+     *
+     * @param string $input
+     * @return string
+     */
+    public function thaiToRoman(string $input): string
+    {
+        return str_replace(self::THAI_CHARS, self::ROMAN_CHARS, $input);
+    }
+
+    /**
+     * 罗马巴利转泰文
+     *
+     * @param string $input
+     * @return string
+     */
+    public function romanToThai(string $input): string
+    {
+        // 手动构建映射数组,遇到重复的键时保留第一个
+        $mapping = [];
+        foreach (self::ROMAN_CHARS as $index => $roman) {
+            if (!isset($mapping[$roman])) {
+                $mapping[$roman] = self::THAI_CHARS[$index];
+            }
+        }
+
+        // 按键长度降序排序,优先匹配较长的字符串
+        uksort($mapping, function ($a, $b) {
+            $lenDiff = strlen($b) - strlen($a);
+            if ($lenDiff !== 0) {
+                return $lenDiff;
+            }
+            return strcmp($a, $b);
+        });
+
+        return str_replace(array_keys($mapping), array_values($mapping), $input);
+    }
+
+    /**
+     * 缅文转泰文
+     *
+     * @param string $input
+     * @return string
+     */
+    public function myanmarToThai(string $input): string
+    {
+        $roman = $this->myanmarToRoman($input);
+        return $this->romanToThai($roman);
+    }
+
+    /**
+     * 泰文转缅文
+     *
+     * @param string $input
+     * @return string
+     */
+    public function thaiToMyanmar(string $input): string
+    {
+        $roman = $this->thaiToRoman($input);
+        return $this->romanToMyanmar($roman);
+    }
+
+    /**
+     * 自动检测并转换为罗马巴利
+     *
+     * @param string $input
+     * @return string
+     */
+    public function toRoman(string $input): string
+    {
+        // 检测是否包含缅文字符
+        if (preg_match('/[\x{1000}-\x{109F}]/u', $input)) {
+            return $this->myanmarToRoman($input);
+        }
+
+        // 检测是否包含泰文字符
+        if (preg_match('/[\x{0E00}-\x{0E7F}]/u', $input)) {
+            return $this->thaiToRoman($input);
+        }
+
+        // 默认返回原文
+        return $input;
+    }
+}

+ 5 - 2
api-v12/app/Services/SentenceService.php

@@ -4,6 +4,8 @@ namespace App\Services;
 
 use App\Models\Sentence;
 use App\Models\SentHistory;
+use App\Models\Channel;
+
 use Illuminate\Support\Str;
 
 class SentenceService
@@ -25,8 +27,9 @@ class SentenceService
             $row->content_type = $data['content_type'];
         }
         $row->strlen = mb_strlen($data['content'], "UTF-8");
-        $row->language = $data['lang'];
-        $row->status = $data['status'];
+        $lang = Channel::where('uid', $data['channel_uid'])->value('lang');
+        $row->language = $lang;
+        $row->status = $data['status'] ?? 10;
         if (isset($data['copy'])) {
             //复制句子,保留原作者信息
             $row->editor_uid = $data["editor_uid"];

+ 49 - 48
api-v12/database/migrations/2021_12_30_053602_add_func_to_fts_texts_table.php

@@ -4,6 +4,7 @@ use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
 
 return new class extends Migration
 {
@@ -14,48 +15,45 @@ return new class extends Migration
      */
     public function up(): void
     {
-        Schema::table('fts_texts', function (Blueprint $table) {
-            //
-        });
+        try {
+            $sql = 'CREATE TEXT SEARCH CONFIGURATION pali ( parser = pg_catalog.default );';
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = 'CREATE TEXT SEARCH CONFIGURATION pali ( parser = pg_catalog.default );';
-        DB::connection()->getPdo()->exec($sql);
+            $sql = 'CREATE TEXT SEARCH CONFIGURATION pali_unaccent ( parser = pg_catalog.default );';
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = 'CREATE TEXT SEARCH CONFIGURATION pali_unaccent ( parser = pg_catalog.default );';
-        DB::connection()->getPdo()->exec($sql);
+            $sql = 'CREATE TEXT SEARCH DICTIONARY pali_stem ( TEMPLATE = synonym, SYNONYMS = pali );';
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = 'CREATE TEXT SEARCH DICTIONARY pali_stem ( TEMPLATE = synonym, SYNONYMS = pali );';
-        DB::connection()->getPdo()->exec($sql);
+            $sql = ' CREATE TEXT SEARCH DICTIONARY pali_stopwords ( TEMPLATE = pg_catalog.simple, STOPWORDS = pali, ACCEPT = true);';
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = ' CREATE TEXT SEARCH DICTIONARY pali_stopwords ( TEMPLATE = pg_catalog.simple, STOPWORDS = pali, ACCEPT = true);';
-        DB::connection()->getPdo()->exec($sql);
-
-        $sql = '
+            $sql = '
 ALTER TEXT SEARCH CONFIGURATION pali
 ADD MAPPING FOR asciiword, word, hword_part, hword_asciipart
 WITH pali_stem, pali_stopwords;';
-        DB::connection()->getPdo()->exec($sql);
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = '
+            $sql = '
 CREATE EXTENSION IF NOT EXISTS "unaccent";
 ALTER TEXT SEARCH CONFIGURATION pali_unaccent
 ADD MAPPING FOR asciiword, word, hword_part, hword_asciipart
 WITH unaccent, pali_stem, pali_stopwords;';
-        DB::connection()->getPdo()->exec($sql);
+            DB::connection()->getPdo()->exec($sql);
 
 
-        $sql = "ALTER TABLE fts_texts
+            $sql = "ALTER TABLE fts_texts
 ADD COLUMN full_text_search_weighted TSVECTOR
 GENERATED ALWAYS AS (
-   setweight(to_tsvector('pali', coalesce(content,'')), 'A')  || ' ' ||
-   setweight(to_tsvector('pali', coalesce(bold_single,'')), 'B') || ' '  ||
-   setweight(to_tsvector('pali', coalesce(bold_double,'')), 'C') || ' ' ||
-   setweight(to_tsvector('pali', coalesce(bold_multiple,'')), 'D')
+setweight(to_tsvector('pali', coalesce(content,'')), 'A')  || ' ' ||
+setweight(to_tsvector('pali', coalesce(bold_single,'')), 'B') || ' '  ||
+setweight(to_tsvector('pali', coalesce(bold_double,'')), 'C') || ' ' ||
+setweight(to_tsvector('pali', coalesce(bold_multiple,'')), 'D')
 ) STORED;
 ";
-        DB::connection()->getPdo()->exec($sql);
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = "
+            $sql = "
 ALTER TABLE fts_texts
 ADD COLUMN full_text_search_weighted_unaccent TSVECTOR
 GENERATED ALWAYS AS (
@@ -64,13 +62,16 @@ setweight(to_tsvector('pali_unaccent', coalesce(bold_single,'')), 'B') || ' '  |
 setweight(to_tsvector('pali_unaccent', coalesce(bold_double,'')), 'C') || ' ' ||
 setweight(to_tsvector('pali_unaccent', coalesce(bold_multiple,'')), 'D')
 ) STORED;";
-        DB::connection()->getPdo()->exec($sql);
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = "CREATE INDEX full_text_search_weighted_idx ON fts_texts USING GIN (full_text_search_weighted);";
-        DB::connection()->getPdo()->exec($sql);
+            $sql = "CREATE INDEX full_text_search_weighted_idx ON fts_texts USING GIN (full_text_search_weighted);";
+            DB::connection()->getPdo()->exec($sql);
 
-        $sql = "CREATE INDEX full_text_search_weighted__unaccent_idx ON fts_texts USING GIN (full_text_search_weighted_unaccent);";
-        DB::connection()->getPdo()->exec($sql);
+            $sql = "CREATE INDEX full_text_search_weighted__unaccent_idx ON fts_texts USING GIN (full_text_search_weighted_unaccent);";
+            DB::connection()->getPdo()->exec($sql);
+        } catch (\Throwable $th) {
+            Log::error('fts migrate', ['data' => $th->getMessage()]);
+        }
     }
 
     /**
@@ -80,25 +81,25 @@ setweight(to_tsvector('pali_unaccent', coalesce(bold_multiple,'')), 'D')
      */
     public function down(): void
     {
-        Schema::table('fts_texts', function (Blueprint $table) {
-            //
-        });
-
-        # 删除全文检索配置 pali
-        $sql = 'DROP TEXT SEARCH CONFIGURATION  pali ;';
-        DB::connection()->getPdo()->exec($sql);
-
-        # 删除全文检索配置 pali_unaccent 无标音符号版
-        $sql = 'DROP TEXT SEARCH CONFIGURATION pali_unaccent ;';
-        DB::connection()->getPdo()->exec($sql);
-
-
-        # 删除巴利语词形转换字典
-        $sql = 'DROP TEXT SEARCH DICTIONARY pali_stem ;';
-        DB::connection()->getPdo()->exec($sql);
-
-        # 删除巴利语停用词字典
-        $sql = 'DROP TEXT SEARCH DICTIONARY pali_stopwords ;';
-        DB::connection()->getPdo()->exec($sql);
+        try {
+            # 删除全文检索配置 pali
+            $sql = 'DROP TEXT SEARCH CONFIGURATION  pali ;';
+            DB::connection()->getPdo()->exec($sql);
+
+            # 删除全文检索配置 pali_unaccent 无标音符号版
+            $sql = 'DROP TEXT SEARCH CONFIGURATION pali_unaccent ;';
+            DB::connection()->getPdo()->exec($sql);
+
+
+            # 删除巴利语词形转换字典
+            $sql = 'DROP TEXT SEARCH DICTIONARY pali_stem ;';
+            DB::connection()->getPdo()->exec($sql);
+
+            # 删除巴利语停用词字典
+            $sql = 'DROP TEXT SEARCH DICTIONARY pali_stopwords ;';
+            DB::connection()->getPdo()->exec($sql);
+        } catch (\Throwable $th) {
+            Log::error('fts migrate', ['data' => $th->getMessage()]);
+        }
     }
 };

+ 149 - 13
api-v12/resources/views/components/language-switcher.blade.php

@@ -1,29 +1,165 @@
 @php
 $currentLocale = app()->getLocale();
 $languages = config('mint.languages');
-$currentLanguage = $languages[$currentLocale] ?? 'English';
+$currentLanguage = $languages[$currentLocale] ?? 'en';
 @endphp
 
-<div class="dropdown">
-    <a class="btn btn-ghost d-flex align-items-center" type="button" data-bs-toggle="dropdown" aria-expanded="false">
-        <svg t="1749033276791" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4471" width="200" height="200" fill="currentColor">
-            <path d="M512 929.959184c-230.4 0-417.959184-187.559184-417.959184-417.959184s187.559184-417.959184 417.959184-417.959184 417.959184 187.559184 417.959184 417.959184-187.559184 417.959184-417.959184 417.959184z m0-794.122449c-207.412245 0-376.163265 168.75102-376.163265 376.163265s168.75102 376.163265 376.163265 376.163265 376.163265-168.75102 376.163265-376.163265-168.75102-376.163265-376.163265-376.163265z" fill="#333333" p-id="4472"></path>
-            <path d="M512 929.959184c-11.493878 0-20.897959-9.404082-20.897959-20.89796V114.938776c0-11.493878 9.404082-20.897959 20.897959-20.89796s20.897959 9.404082 20.897959 20.89796v794.122448c0 11.493878-9.404082 20.897959-20.897959 20.89796z" fill="#333333" p-id="4473"></path>
-            <path d="M909.061224 532.897959H114.938776c-11.493878 0-20.897959-9.404082-20.89796-20.897959s9.404082-20.897959 20.89796-20.897959h794.122448c11.493878 0 20.897959 9.404082 20.89796 20.897959s-9.404082 20.897959-20.89796 20.897959z" fill="#333333" p-id="4474"></path>
-            <path d="M227.787755 809.795918c-5.22449 0-10.971429-2.089796-15.15102-6.269387C136.359184 725.159184 94.040816 621.191837 94.040816 512s42.318367-213.159184 118.595919-291.526531c7.836735-8.359184 21.420408-8.359184 29.779592-0.522449 8.359184 7.836735 8.359184 21.420408 0.522449 29.779592C173.97551 320.261224 135.836735 413.257143 135.836735 512s38.138776 191.738776 106.579592 262.269388c7.836735 8.359184 7.836735 21.420408-0.522449 29.779592-3.657143 3.657143-8.881633 5.746939-14.106123 5.746938z" fill="#333333" p-id="4475"></path>
-            <path d="M504.163265 929.959184c-0.522449 0-0.522449 0 0 0-110.759184-2.089796-214.204082-47.020408-291.52653-126.432653-7.836735-8.359184-7.836735-20.897959 0-29.257143 39.183673-40.228571 84.636735-71.57551 135.836734-92.995919 5.22449-2.089796 10.971429-2.089796 16.195919 0s9.404082 6.269388 11.493877 11.493878c29.779592 76.8 78.889796 146.285714 141.583674 200.097959 6.791837 5.746939 8.881633 15.15102 5.746939 23.510204-3.134694 7.836735-10.971429 13.583673-19.330613 13.583674z m-246.595918-141.061225c53.289796 49.110204 118.073469 80.979592 188.604082 93.518368-42.318367-44.930612-76.277551-97.17551-100.832653-153.6-31.869388 15.673469-61.64898 35.526531-87.771429 60.081632zM356.310204 344.293878c-2.612245 0-5.746939-0.522449-8.359184-1.567347-51.2-21.942857-96.653061-53.289796-135.836734-92.995919-7.836735-8.359184-7.836735-20.897959 0-29.257143C289.959184 141.061224 392.881633 96.653061 503.640816 94.040816c8.881633-0.522449 16.718367 5.22449 19.853062 13.583674s0.522449 17.763265-5.746939 23.510204C454.530612 184.946939 405.420408 253.910204 375.640816 331.232653c-2.089796 5.22449-6.269388 9.404082-11.493877 11.493878-2.089796 1.044898-5.22449 1.567347-7.836735 1.567347zM257.567347 235.102041c26.644898 24.555102 55.902041 44.408163 87.771429 60.604081 24.555102-56.42449 59.036735-108.669388 100.832653-153.6-70.530612 12.016327-135.314286 43.885714-188.604082 92.995919zM796.212245 809.795918c-5.22449 0-10.44898-2.089796-14.628572-5.746938-8.359184-7.836735-8.359184-21.420408-0.522449-29.779592C850.02449 703.738776 888.163265 610.742857 888.163265 512s-38.138776-191.738776-106.579592-262.269388c-7.836735-8.359184-7.836735-21.420408 0.522449-29.779592 8.359184-7.836735 21.420408-7.836735 29.779592 0.522449C887.640816 298.840816 929.959184 402.808163 929.959184 512s-42.318367 213.159184-118.595919 291.526531c-4.179592 4.179592-9.404082 6.269388-15.15102 6.269387z" fill="#333333" p-id="4476"></path>
-            <path d="M514.612245 929.959184c-8.881633 0-16.718367-5.22449-19.330612-13.583674-3.134694-8.359184-0.522449-17.240816 5.746938-22.987755 63.738776-54.334694 112.84898-124.342857 142.628572-202.187755 2.089796-5.22449 6.269388-9.404082 11.493877-11.493878 5.22449-2.089796 10.971429-2.089796 16.195919 0 52.767347 21.942857 100.310204 53.812245 140.538775 95.085715 7.836735 8.359184 7.836735 20.897959 0 29.257143-78.889796 80.457143-184.42449 124.865306-297.273469 125.910204z m159.869388-203.755102c-25.077551 57.991837-59.559184 111.281633-102.922449 157.257142 72.620408-11.493878 140.016327-43.885714 194.873469-94.563265-27.689796-25.6-58.514286-46.497959-91.95102-62.693877zM662.987755 346.383673c-2.612245 0-5.746939-0.522449-8.359184-1.567346-5.22449-2.089796-9.404082-6.269388-11.493877-11.493878-29.779592-77.844898-78.889796-147.853061-142.628572-202.187755-6.791837-5.746939-8.881633-15.15102-5.746938-22.987755 3.134694-8.359184 10.971429-13.583673 19.330612-13.583674 112.84898 0.522449 217.861224 45.453061 296.75102 126.432653 7.836735 8.359184 7.836735 20.897959 0 29.257143-40.228571 41.273469-87.24898 73.142857-140.538775 95.085715-1.567347 0.522449-4.702041 1.044898-7.314286 1.044897z m-91.428571-205.844897c42.840816 45.97551 77.844898 99.265306 102.922449 157.257142 33.436735-16.195918 64.783673-37.093878 91.95102-62.693877-54.857143-50.677551-122.253061-83.069388-194.873469-94.563265z" fill="#333333" p-id="4477"></path>
-            <path d="M356.310204 721.502041c-2.612245 0-5.746939-0.522449-8.359184-1.567347-5.22449-2.089796-9.404082-6.269388-11.493877-11.493878-24.032653-62.693878-36.571429-128.522449-36.571429-195.918367s12.016327-133.22449 36.571429-195.918367c2.089796-5.22449 6.269388-9.404082 11.493877-11.493878s10.971429-2.089796 16.195919 0c47.020408 19.330612 96.653061 29.257143 147.853061 29.257143 49.632653 0 97.697959-9.404082 143.15102-28.212245 5.22449-2.089796 10.971429-2.089796 16.195919 0s9.404082 6.269388 11.493877 11.493878c23.510204 62.171429 35.526531 127.477551 35.526531 193.828571 0 66.873469-12.016327 132.179592-35.526531 193.828571-2.089796 5.22449-6.269388 9.404082-11.493877 11.493878-5.22449 2.089796-10.971429 2.089796-16.195919 0-45.453061-18.808163-93.518367-28.212245-143.15102-28.212245-51.2 0-100.832653 9.926531-147.330612 29.779592-2.612245 2.612245-5.746939 3.134694-8.359184 3.134694z m12.538776-370.416327c-17.763265 51.722449-26.644898 106.057143-26.644898 160.914286s8.881633 109.191837 26.644898 160.914286c45.97551-16.718367 94.040816-25.077551 143.15102-25.077551 47.542857 0 94.040816 7.836735 138.44898 23.510204 17.240816-51.2 26.122449-105.012245 26.122449-159.346939 0-54.857143-8.881633-108.146939-26.122449-159.346939-44.408163 15.673469-90.906122 23.510204-138.44898 23.510204-49.632653 0-97.697959-8.359184-143.15102-25.077551z" fill="#333333" p-id="4478"></path>
+<div class="language-switcher">
+    <a href="#" class="language-link">
+        <svg class="language-icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
+            <path d="M512 929.959184c-230.4 0-417.959184-187.559184-417.959184-417.959184s187.559184-417.959184 417.959184-417.959184 417.959184 187.559184 417.959184 417.959184-187.559184 417.959184-417.959184 417.959184z m0-794.122449c-207.412245 0-376.163265 168.75102-376.163265 376.163265s168.75102 376.163265 376.163265 376.163265 376.163265-168.75102 376.163265-376.163265-168.75102-376.163265-376.163265-376.163265z" p-id="4472"></path>
+            <path d="M512 929.959184c-11.493878 0-20.897959-9.404082-20.897959-20.89796V114.938776c0-11.493878 9.404082-20.897959 20.897959-20.89796s20.897959 9.404082 20.897959 20.89796v794.122448c0 11.493878-9.404082 20.897959-20.897959 20.89796z" p-id="4473"></path>
+            <path d="M909.061224 532.897959H114.938776c-11.493878 0-20.897959-9.404082-20.89796-20.897959s9.404082-20.897959 20.89796-20.897959h794.122448c11.493878 0 20.897959 9.404082 20.89796 20.897959s-9.404082 20.897959-20.89796 20.897959z" p-id="4474"></path>
+            <path d="M227.787755 809.795918c-5.22449 0-10.971429-2.089796-15.15102-6.269387C136.359184 725.159184 94.040816 621.191837 94.040816 512s42.318367-213.159184 118.595919-291.526531c7.836735-8.359184 21.420408-8.359184 29.779592-0.522449 8.359184 7.836735 8.359184 21.420408 0.522449 29.779592C173.97551 320.261224 135.836735 413.257143 135.836735 512s38.138776 191.738776 106.579592 262.269388c7.836735 8.359184 7.836735 21.420408-0.522449 29.779592-3.657143 3.657143-8.881633 5.746939-14.106123 5.746938z" p-id="4475"></path>
+            <path d="M504.163265 929.959184c-0.522449 0-0.522449 0 0 0-110.759184-2.089796-214.204082-47.020408-291.52653-126.432653-7.836735-8.359184-7.836735-20.897959 0-29.257143 39.183673-40.228571 84.636735-71.57551 135.836734-92.995919 5.22449-2.089796 10.971429-2.089796 16.195919 0s9.404082 6.269388 11.493877 11.493878c29.779592 76.8 78.889796 146.285714 141.583674 200.097959 6.791837 5.746939 8.881633 15.15102 5.746939 23.510204-3.134694 7.836735-10.971429 13.583673-19.330613 13.583674z m-246.595918-141.061225c53.289796 49.110204 118.073469 80.979592 188.604082 93.518368-42.318367-44.930612-76.277551-97.17551-100.832653-153.6-31.869388 15.673469-61.64898 35.526531-87.771429 60.081632zM356.310204 344.293878c-2.612245 0-5.746939-0.522449-8.359184-1.567347-51.2-21.942857-96.653061-53.289796-135.836734-92.995919-7.836735-8.359184-7.836735-20.897959 0-29.257143C289.959184 141.061224 392.881633 96.653061 503.640816 94.040816c8.881633-0.522449 16.718367 5.22449 19.853062 13.583674s0.522449 17.763265-5.746939 23.510204C454.530612 184.946939 405.420408 253.910204 375.640816 331.232653c-2.089796 5.22449-6.269388 9.404082-11.493877 11.493878-2.089796 1.044898-5.22449 1.567347-7.836735 1.567347zM257.567347 235.102041c26.644898 24.555102 55.902041 44.408163 87.771429 60.604081 24.555102-56.42449 59.036735-108.669388 100.832653-153.6-70.530612 12.016327-135.314286 43.885714-188.604082 92.995919zM796.212245 809.795918c-5.22449 0-10.44898-2.089796-14.628572-5.746938-8.359184-7.836735-8.359184-21.420408-0.522449-29.779592C850.02449 703.738776 888.163265 610.742857 888.163265 512s-38.138776-191.738776-106.579592-262.269388c-7.836735-8.359184-7.836735-21.420408 0.522449-29.779592 8.359184-7.836735 21.420408-7.836735 29.779592 0.522449C887.640816 298.840816 929.959184 402.808163 929.959184 512s-42.318367 213.159184-118.595919 291.526531c-4.179592 4.179592-9.404082 6.269388-15.15102 6.269387z" p-id="4476"></path>
+            <path d="M514.612245 929.959184c-8.881633 0-16.718367-5.22449-19.330612-13.583674-3.134694-8.359184-0.522449-17.240816 5.746938-22.987755 63.738776-54.334694 112.84898-124.342857 142.628572-202.187755 2.089796-5.22449 6.269388-9.404082 11.493877-11.493878 5.22449-2.089796 10.971429-2.089796 16.195919 0 52.767347 21.942857 100.310204 53.812245 140.538775 95.085715 7.836735 8.359184 7.836735 20.897959 0 29.257143-78.889796 80.457143-184.42449 124.865306-297.273469 125.910204z m159.869388-203.755102c-25.077551 57.991837-59.559184 111.281633-102.922449 157.257142 72.620408-11.493878 140.016327-43.885714 194.873469-94.563265-27.689796-25.6-58.514286-46.497959-91.95102-62.693877zM662.987755 346.383673c-2.612245 0-5.746939-0.522449-8.359184-1.567346-5.22449-2.089796-9.404082-6.269388-11.493877-11.493878-29.779592-77.844898-78.889796-147.853061-142.628572-202.187755-6.791837-5.746939-8.881633-15.15102-5.746938-22.987755 3.134694-8.359184 10.971429-13.583673 19.330612-13.583674 112.84898 0.522449 217.861224 45.453061 296.75102 126.432653 7.836735 8.359184 7.836735 20.897959 0 29.257143-40.228571 41.273469-87.24898 73.142857-140.538775 95.085715-1.567347 0.522449-4.702041 1.044898-7.314286 1.044897z m-91.428571-205.844897c42.840816 45.97551 77.844898 99.265306 102.922449 157.257142 33.436735-16.195918 64.783673-37.093878 91.95102-62.693877-54.857143-50.677551-122.253061-83.069388-194.873469-94.563265z" p-id="4477"></path>
+            <path d="M356.310204 721.502041c-2.612245 0-5.746939-0.522449-8.359184-1.567347-5.22449-2.089796-9.404082-6.269388-11.493877-11.493878-24.032653-62.693878-36.571429-128.522449-36.571429-195.918367s12.016327-133.22449 36.571429-195.918367c2.089796-5.22449 6.269388-9.404082 11.493877-11.493878s10.971429-2.089796 16.195919 0c47.020408 19.330612 96.653061 29.257143 147.853061 29.257143 49.632653 0 97.697959-9.404082 143.15102-28.212245 5.22449-2.089796 10.971429-2.089796 16.195919 0s9.404082 6.269388 11.493877 11.493878c23.510204 62.171429 35.526531 127.477551 35.526531 193.828571 0 66.873469-12.016327 132.179592-35.526531 193.828571-2.089796 5.22449-6.269388 9.404082-11.493877 11.493878-5.22449 2.089796-10.971429 2.089796-16.195919 0-45.453061-18.808163-93.518367-28.212245-143.15102-28.212245-51.2 0-100.832653 9.926531-147.330612 29.779592-2.612245 2.612245-5.746939 3.134694-8.359184 3.134694z m12.538776-370.416327c-17.763265 51.722449-26.644898 106.057143-26.644898 160.914286s8.881633 109.191837 26.644898 160.914286c45.97551-16.718367 94.040816-25.077551 143.15102-25.077551 47.542857 0 94.040816 7.836735 138.44898 23.510204 17.240816-51.2 26.122449-105.012245 26.122449-159.346939 0-54.857143-8.881633-108.146939-26.122449-159.346939-44.408163 15.673469-90.906122 23.510204-138.44898 23.510204-49.632653 0-97.697959-8.359184-143.15102-25.077551z" p-id="4478"></path>
         </svg>
         {{ $currentLanguage }}
     </a>
-    <ul class="dropdown-menu">
+    <ul class="language-dropdown">
         @foreach ($languages as $locale => $language)
         <li>
-            <a class="dropdown-item" href="{{ route(Route::currentRouteName(), array_merge(request()->route()->parameters(), ['lang' => $locale])) }}">
+            <a href="{{ route(Route::currentRouteName(), array_merge(request()->route()->parameters(), ['lang' => $locale])) }}"
+                class="language-dropdown-item {{ $locale === $currentLocale ? 'active' : '' }}">
                 {{ $language }}
             </a>
         </li>
         @endforeach
     </ul>
 </div>
+
+<style>
+    .language-switcher {
+        position: relative;
+        display: inline-block;
+    }
+
+    .language-link {
+        display: flex;
+        align-items: center;
+        gap: 0.4rem;
+        color: white;
+        text-decoration: none;
+        font-size: 0.95rem;
+        font-weight: 500;
+        transition: opacity 0.2s;
+        white-space: nowrap;
+        padding: 0.25rem 0;
+    }
+
+    .language-link:hover {
+        opacity: 0.8;
+        color: white;
+    }
+
+    .language-icon {
+        width: 18px;
+        height: 18px;
+        fill: currentColor;
+    }
+
+    .language-dropdown {
+        position: absolute;
+        top: 100%;
+        right: 0;
+        /* 背景颜色和透明度 - 调整 rgba 的最后一个值(0.85)来改变透明度,范围 0-1 */
+        background: rgba(0, 0, 0, 0.85);
+        /* 毛玻璃效果 - 调整数值(10px)来改变模糊程度,数值越大越模糊 */
+        backdrop-filter: blur(10px);
+        /* Safari 兼容性 */
+        -webkit-backdrop-filter: blur(10px);
+        /* 边框(可选) - 添加微妙的边框增强层次感 */
+        border: 1px solid rgba(255, 255, 255, 0.1);
+        border-radius: 0.375rem;
+        /* 阴影效果 */
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+        list-style: none;
+        margin: 0.5rem 0 0 0;
+        padding: 0.5rem 0;
+        min-width: 150px;
+        opacity: 0;
+        visibility: hidden;
+        transform: translateY(-10px);
+        transition: all 0.2s ease;
+        z-index: 1000;
+    }
+
+    .language-switcher:hover .language-dropdown {
+        opacity: 1;
+        visibility: visible;
+        transform: translateY(0);
+    }
+
+    .language-dropdown li {
+        margin: 0;
+        padding: 0;
+    }
+
+    .language-dropdown-item {
+        display: block;
+        padding: 0.5rem 1rem;
+        /* 下拉菜单文字颜色 */
+        color: #fff;
+        text-decoration: none;
+        font-size: 0.9rem;
+        transition: background 0.2s;
+        white-space: nowrap;
+    }
+
+    .language-dropdown-item:hover {
+        /* 悬停背景 - 调整 rgba 的最后一个值(0.2)来改变悬停时的高亮程度 */
+        background: rgba(255, 255, 255, 0.2);
+        color: #fff;
+    }
+
+    .language-dropdown-item.active {
+        /* 当前选中项的背景 - 调整 rgba 的最后一个值(0.3)来改变高亮程度 */
+        background: rgba(255, 255, 255, 0.3);
+        font-weight: 600;
+        color: #fff;
+    }
+
+    /* 移动端样式 */
+    @media (max-width: 768px) {
+        .language-switcher {
+            width: 100%;
+        }
+
+        .language-link {
+            justify-content: flex-start;
+            padding: 0;
+        }
+
+        .language-dropdown {
+            position: static;
+            opacity: 1;
+            visibility: visible;
+            transform: none;
+            box-shadow: none;
+            /* 移动端菜单背景 - 完全透明,融入侧边栏 */
+            background: transparent;
+            backdrop-filter: none;
+            -webkit-backdrop-filter: none;
+            border: none;
+            margin-top: 0.5rem;
+            padding: 0;
+        }
+
+        .language-dropdown-item {
+            color: white;
+            padding: 0.75rem 1rem;
+            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+        }
+
+        .language-dropdown-item:hover {
+            background: rgba(255, 255, 255, 0.1);
+            color: white;
+        }
+
+        .language-dropdown-item.active {
+            background: rgba(255, 255, 255, 0.15);
+            color: white;
+        }
+    }
+</style>

+ 247 - 11
api-v12/resources/views/library/layouts/app.blade.php

@@ -8,10 +8,8 @@
     <title>@yield('title', '巴利书库')</title>
     @stack('styles')
     <link href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet" />
-    <link href="https://cdn.jsdelivr.net/npm/@tabler/icons@latest/icons-sprite.svg" rel="stylesheet" />
-    <script
-        src="https://cdn.jsdelivr.net/npm/@tabler/core@1.3.2/dist/js/tabler.min.js">
-    </script>
+    <link href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css" rel="stylesheet" />
+    <script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.3.2/dist/js/tabler.min.js"></script>
 
     <style>
         .book-card {
@@ -118,6 +116,147 @@
             margin-top: 0.5rem;
         }
 
+        /* Navigation Styles */
+        .top-nav {
+            height: 50px;
+            width: 100%;
+            display: flex;
+            justify-content: flex-end;
+            align-items: center;
+            padding: 0 2rem;
+            position: relative;
+            z-index: 10;
+        }
+
+        .nav-menu {
+            display: flex;
+            align-items: center;
+            gap: 1.5rem;
+            list-style: none;
+            margin: 0;
+            padding: 0;
+        }
+
+        .nav-item a {
+            color: white;
+            text-decoration: none;
+            font-size: 0.95rem;
+            font-weight: 500;
+            transition: opacity 0.2s;
+            white-space: nowrap;
+        }
+
+        .nav-item a:hover {
+            opacity: 0.8;
+        }
+
+        /* Hamburger Menu */
+        .hamburger-btn {
+            display: none;
+            background: rgba(255, 255, 255, 0.2);
+            border: 2px solid white;
+            border-radius: 0.375rem;
+            color: white;
+            font-size: 1.5rem;
+            cursor: pointer;
+            padding: 0.5rem 0.75rem;
+            z-index: 1001;
+            transition: background 0.2s;
+            width: 44px;
+            height: 44px;
+            align-items: center;
+            justify-content: center;
+        }
+
+        .hamburger-btn:hover {
+            background: rgba(255, 255, 255, 0.3);
+        }
+
+        /* CSS Hamburger Icon */
+        .hamburger-icon {
+            display: flex;
+            flex-direction: column;
+            gap: 4px;
+            width: 24px;
+        }
+
+        .hamburger-icon span {
+            display: block;
+            height: 2px;
+            background: white;
+            border-radius: 2px;
+            transition: all 0.3s;
+        }
+
+        .hamburger-btn.active .hamburger-icon span:nth-child(1) {
+            transform: translateY(6px) rotate(45deg);
+        }
+
+        .hamburger-btn.active .hamburger-icon span:nth-child(2) {
+            opacity: 0;
+        }
+
+        .hamburger-btn.active .hamburger-icon span:nth-child(3) {
+            transform: translateY(-6px) rotate(-45deg);
+        }
+
+        .mobile-menu {
+            display: none;
+            position: fixed;
+            top: 0;
+            right: -100%;
+            width: 280px;
+            height: 100vh;
+            background: rgba(0, 0, 0, 0.95);
+            backdrop-filter: blur(10px);
+            transition: right 0.3s ease;
+            z-index: 1000;
+            padding-top: 60px;
+        }
+
+        .mobile-menu.active {
+            right: 0;
+        }
+
+        .mobile-nav-menu {
+            list-style: none;
+            padding: 0;
+            margin: 0;
+        }
+
+        .mobile-nav-item {
+            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+        }
+
+        .mobile-nav-item a {
+            display: block;
+            color: white;
+            text-decoration: none;
+            padding: 1rem 2rem;
+            font-size: 1rem;
+            transition: background 0.2s;
+        }
+
+        .mobile-nav-item a:hover {
+            background: rgba(255, 255, 255, 0.1);
+        }
+
+        .mobile-overlay {
+            display: none;
+            position: fixed;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            background: rgba(0, 0, 0, 0.5);
+            z-index: 999;
+        }
+
+        .mobile-overlay.active {
+            display: block;
+        }
+
+        /* Responsive */
         @media (max-width: 768px) {
             .hero-title {
                 font-size: 2rem;
@@ -134,6 +273,22 @@
             .stat-number {
                 font-size: 2rem;
             }
+
+            .top-nav {
+                padding: 0 1rem;
+            }
+
+            .nav-menu {
+                display: none;
+            }
+
+            .hamburger-btn {
+                display: flex;
+            }
+
+            .mobile-menu {
+                display: block;
+            }
         }
 
         @media (max-width: 576px) {
@@ -144,26 +299,83 @@
             .hero-subtitle {
                 font-size: 0.9rem;
             }
+
+            .top-nav {
+                padding: 0 0.5rem;
+            }
         }
     </style>
 </head>
 
 <body>
     <div class="page">
+        <!-- Mobile Overlay -->
+        <div class="mobile-overlay" id="mobileOverlay"></div>
+
+        <!-- Mobile Menu -->
+        <div class="mobile-menu" id="mobileMenu">
+            <ul class="mobile-nav-menu">
+                <li class="mobile-nav-item">
+                    <a href="{{ route('library.home') }}">首页</a>
+                </li>
+                <li class="mobile-nav-item">
+                    <a href="{{ route('library.wiki') }}">百科</a>
+                </li>
+                <li class="mobile-nav-item">
+                    <a href="{{ route('library.download') }}">下载</a>
+                </li>
+                <li class="mobile-nav-item">
+                    @auth
+                    <a href="#">我的账户</a>
+                    @else
+                    <a href="#">注册/登录</a>
+                    @endauth
+                </li>
+                <li class="mobile-nav-item" style="padding: 1rem 2rem;">
+                    <x-language-switcher />
+                </li>
+            </ul>
+        </div>
+
         <!-- Hero Section -->
         <section class="hero-section">
             <div class="hero-overlay">
-                <div style="height:30px;width:100%;display: flex;justify-content: center;">
-                    <div style="color:white;flex:1;">
-                    </div>
-                    <div style="color:white;">
-                        <x-language-switcher />
-                    </div>
+                <div class="top-nav">
+                    <!-- Desktop Menu -->
+                    <ul class="nav-menu">
+                        <li class="nav-item">
+                            <a href="{{ route('library.home') }}">首页</a>
+                        </li>
+                        <li class="nav-item">
+                            <a href="{{ route('library.wiki') }}">百科</a>
+                        </li>
+                        <li class="nav-item">
+                            <a href="{{ route('library.download') }}">下载</a>
+                        </li>
+                        <li class="nav-item">
+                            @auth
+                            <a href="#">我的账户</a>
+                            @else
+                            <a href="#">注册/登录</a>
+                            @endauth
+                        </li>
+                        <li class="nav-item">
+                            <x-language-switcher />
+                        </li>
+                    </ul>
+
+                    <!-- Hamburger Button -->
+                    <button class="hamburger-btn" id="hamburgerBtn">
+                        <div class="hamburger-icon">
+                            <span></span>
+                            <span></span>
+                            <span></span>
+                        </div>
+                    </button>
                 </div>
             </div>
 
             <div class="hero-content">
-
                 <h1 class="hero-title">巴利书库</h1>
                 <p class="hero-subtitle">探索wikipali,开启智慧之门</p>
 
@@ -186,6 +398,30 @@
     <!-- Tabler JS and Bootstrap -->
     <script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta21/dist/js/tabler.min.js"></script>
     <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+
+    <script>
+        // Hamburger Menu Toggle
+        const hamburgerBtn = document.getElementById('hamburgerBtn');
+        const mobileMenu = document.getElementById('mobileMenu');
+        const mobileOverlay = document.getElementById('mobileOverlay');
+
+        function toggleMenu() {
+            mobileMenu.classList.toggle('active');
+            mobileOverlay.classList.toggle('active');
+            hamburgerBtn.classList.toggle('active');
+        }
+
+        hamburgerBtn.addEventListener('click', toggleMenu);
+        mobileOverlay.addEventListener('click', toggleMenu);
+
+        // Close menu when clicking on a link
+        document.querySelectorAll('.mobile-nav-item a').forEach(link => {
+            link.addEventListener('click', () => {
+                toggleMenu();
+            });
+        });
+    </script>
+
     @stack('scripts')
 </body>
 

+ 2 - 0
api-v12/routes/web.php

@@ -63,6 +63,8 @@ Route::prefix('library')->group(function () {
     Route::get('/category/{id}', [CategoryController::class, 'show'])->name('library.category.show');
     Route::get('/book/{id}', [BookController::class, 'show'])->name('library.book.show');
     Route::get('/book/{id}/read', [BookController::class, 'read'])->name('library.book.read');
+    Route::get('/wiki', [BookController::class, 'read'])->name('library.wiki');
+    Route::get('/download', [BookController::class, 'read'])->name('library.download');
 });
 // 博客路由
 Route::prefix('blog')->group(function () {

+ 244 - 0
api-v12/tests/Unit/Services/NissayaParserTest.php

@@ -0,0 +1,244 @@
+<?php
+
+namespace Tests\Unit\Services;
+
+use App\Services\NissayaParser;
+use Tests\TestCase;
+
+/**
+ * php artisan test --filter NissayaParserTest
+ */
+class NissayaParserTest extends TestCase
+{
+    private NissayaParser $parser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->parser = new NissayaParser();
+    }
+
+    /**
+     * 测试标准格式解析
+     */
+    public function test_parse_standard_format(): void
+    {
+        $content = "pañcamassa=ပဉ္စမဝဂ်၏\npaṭhame=ပထမသုတ်၌";
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(2, $result);
+        $this->assertEquals('pañcamassa', $result[0]['original']);
+        $this->assertEquals('ပဉ္စမဝဂ်၏', $result[0]['translation']);
+        $this->assertEmpty($result[0]['notes']);
+
+        $this->assertEquals('paṭhame', $result[1]['original']);
+        $this->assertEquals('ပထမသုတ်၌', $result[1]['translation']);
+    }
+
+    /**
+     * 测试带单个注释块的格式
+     */
+    public function test_parse_with_single_note(): void
+    {
+        $content = "uttānāti=ဥတ္တာနာ-ဟူသည်ကား\n```\nထင်ရှားသော\n```\nappaṭicchannā=ဖုံးကွယ်ခြင်းမရှိသော";
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(2, $result);
+        $this->assertEquals('uttānāti', $result[0]['original']);
+        $this->assertEquals('ဥတ္တာနာ-ဟူသည်ကား', $result[0]['translation']);
+        $this->assertCount(1, $result[0]['notes']);
+        $this->assertEquals('ထင်ရှားသော', $result[0]['notes'][0]);
+    }
+
+    /**
+     * 测试特殊情况1: 巴利文和翻译分离,中间有注释
+     */
+    public function test_parse_separated_pali_and_translation_with_note(): void
+    {
+        $content = "uttānāti\n```\nထင်ရှားသော\n```\n=ဥတ္တာနာ-ဟူသည်ကား";
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(1, $result);
+        $this->assertEquals('uttānāti', $result[0]['original']);
+        $this->assertContains('ထင်ရှားသော', $result[0]['notes']);
+    }
+
+    /**
+     * 测试特殊情况2: 等号在上一行
+     */
+    public function test_parse_with_equal_sign_on_previous_line(): void
+    {
+        $content = "uttānāti=\n```\nထင်ရှားသော\n```\nဥတ္တာနာ-ဟူသည်ကား";
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(1, $result);
+        $this->assertEquals('uttānāti', $result[0]['original']);
+        $this->assertEquals('ဥတ္တာနာ-ဟူသည်ကား', $result[0]['translation']);
+        $this->assertCount(1, $result[0]['notes']);
+        $this->assertEquals('ထင်ရှားသော', $result[0]['notes'][0]);
+    }
+
+    /**
+     * 测试多个注释块
+     */
+    public function test_parse_with_multiple_notes(): void
+    {
+        $content = "uttānāti=ဥတ္တာနာ-ဟူသည်ကား\n```\nထင်ရှားသော\n```\n```\n第二个注释\n```";
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(1, $result);
+        $this->assertEquals('uttānāti', $result[0]['original']);
+        $this->assertCount(2, $result[0]['notes']);
+        $this->assertEquals('ထင်ရှားသော', $result[0]['notes'][0]);
+        $this->assertEquals('第二个注释', $result[0]['notes'][1]);
+    }
+
+    /**
+     * 测试使用``包裹的注释
+     */
+    public function test_parse_with_double_backtick_notes(): void
+    {
+        $content = "uttānāti=ဥတ္တာနာ-ဟူသည်ကား\n``\n注释内容\n``";
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(1, $result);
+        $this->assertCount(1, $result[0]['notes']);
+        $this->assertEquals('注释内容', $result[0]['notes'][0]);
+    }
+
+    /**
+     * 测试复杂的混合格式
+     */
+    public function test_parse_complex_mixed_format(): void
+    {
+        $content = <<<TEXT
+pañcamassa=ပဉ္စမဝဂ်၏
+paṭhame=ပထမသုတ်၌
+uttānāti=ဥတ္တာနာ-ဟူသည်ကား
+```
+ထင်ရှားသော
+```
+appaṭicchannā=ဖုံးကွယ်ခြင်းမရှိသော
+``
+另一种注释格式
+``
+dhammā=ဓမ္မာ-ဟူသည်ကား
+```
+第一个注释
+```
+```
+第二个注释
+```
+TEXT;
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(5, $result);
+
+        // 第一条记录
+        $this->assertEquals('pañcamassa', $result[0]['original']);
+        $this->assertEmpty($result[0]['notes']);
+
+        // 第二条记录
+        $this->assertEquals('paṭhame', $result[1]['original']);
+
+        // 第三条记录 - 有单个注释
+        $this->assertEquals('uttānāti', $result[2]['original']);
+        $this->assertCount(1, $result[2]['notes']);
+
+        // 第四条记录 - 有``格式注释
+        $this->assertEquals('appaṭicchannā', $result[3]['original']);
+        $this->assertCount(1, $result[3]['notes']);
+        $this->assertEquals('另一种注释格式', $result[3]['notes'][0]);
+
+        // 第五条记录 - 有两个注释
+        $this->assertEquals('dhammā', $result[4]['original']);
+        $this->assertCount(2, $result[4]['notes']);
+        $this->assertEquals('第一个注释', $result[4]['notes'][0]);
+        $this->assertEquals('第二个注释', $result[4]['notes'][1]);
+    }
+
+    /**
+     * 测试空内容
+     */
+    public function test_parse_empty_content(): void
+    {
+        $result = $this->parser->parse('');
+
+        $this->assertEmpty($result);
+    }
+
+    /**
+     * 测试只有空行的内容
+     */
+    public function test_parse_only_blank_lines(): void
+    {
+        $result = $this->parser->parse("\n\n\n");
+
+        $this->assertEmpty($result);
+    }
+
+    /**
+     * 测试文件解析 - Mock Storage
+     */
+    public function test_parse_file(): void
+    {
+        // 创建临时测试文件
+        $testContent = "pañcamassa=ပဉ္စမဝဂ်၏\npaṭhame=ပထမသုတ်၌";
+        $tempFile = tempnam(sys_get_temp_dir(), 'pali_test_');
+        file_put_contents($tempFile, $testContent);
+
+        try {
+            $result = $this->parser->parseFile($tempFile);
+
+            $this->assertCount(2, $result);
+            $this->assertEquals('pañcamassa', $result[0]['original']);
+            $this->assertEquals('paṭhame', $result[1]['original']);
+        } finally {
+            // 清理临时文件
+            if (file_exists($tempFile)) {
+                unlink($tempFile);
+            }
+        }
+    }
+
+    /**
+     * 测试文件不存在的情况
+     */
+    public function test_parse_file_not_found(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('文件不存在');
+
+        $this->parser->parseFile('/path/to/nonexistent/file.txt');
+    }
+
+    /**
+     * 测试带有多行注释内容的代码块
+     */
+    public function test_parse_multiline_note_content(): void
+    {
+        $content = <<<TEXT
+uttānāti=ဥတ္တာနာ-ဟူသည်ကား
+```
+第一行注释
+第二行注释
+第三行注释
+```
+TEXT;
+
+        $result = $this->parser->parse($content);
+
+        $this->assertCount(1, $result);
+        $this->assertCount(1, $result[0]['notes']);
+        $this->assertStringContainsString('第一行注释', $result[0]['notes'][0]);
+        $this->assertStringContainsString('第二行注释', $result[0]['notes'][0]);
+        $this->assertStringContainsString('第三行注释', $result[0]['notes'][0]);
+    }
+}

+ 488 - 0
api-v12/tests/Unit/Services/PacketServiceTest.php

@@ -0,0 +1,488 @@
+<?php
+
+namespace Tests\Feature\Services;
+
+use App\Models\Channel;
+use App\Services\PacketService;
+use App\Services\SentenceService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Tests\TestCase;
+use ZipArchive;
+use App\Http\Api\ChannelApi;
+
+
+/**
+ * PacketService单元测试
+ *
+ * 测试PacketService的数据导出和打包功能
+ */
+class PacketServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    /**
+     * 巴利文channel
+     */
+    private Channel $paliChannel;
+
+    /**
+     * 译文channel
+     */
+    private Channel $translationChannel;
+
+    /**
+     * 测试用的editor_uid
+     */
+    private string $editorUid;
+
+    /**
+     * SentenceService实例
+     */
+    private SentenceService $sentenceService;
+
+    /**
+     * 设置测试环境
+     *
+     * @return void
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        // 调试:检查数据库连接
+        dump(config('database.default')); // 应该是 pgsql
+        dump(config('database.connections.pgsql.database')); // 应该是 mint_test
+
+        // 生成测试用的editor_uid
+        $this->editorUid = Str::uuid()->toString();
+
+        // 初始化SentenceService
+        $this->sentenceService = app(SentenceService::class);
+
+        // 创建测试用的channels
+        $orgChannelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        $this->paliChannel = Channel::find($orgChannelId);
+
+        $this->translationChannel = Channel::find('00ae2c48-c204-4082-ae79-79ba2740d506');
+    }
+
+    /**
+     * 清理测试环境
+     *
+     * @return void
+     */
+    protected function tearDown(): void
+    {
+        // 清理生成的文件
+        Storage::deleteDirectory('packet');
+        Storage::deleteDirectory('temp');
+
+        parent::tearDown();
+    }
+
+    /**
+     * 测试基本的导出功能
+     *
+     * @return void
+     */
+    public function test_export_creates_zip_file(): void
+    {
+        // 创建测试数据
+        $this->createTestSentences();
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [$this->translationChannel->uid]
+        );
+
+        $zipPath = $service->export();
+
+        // 断言ZIP文件已创建
+        $this->assertTrue(Storage::exists($zipPath));
+        $this->assertStringStartsWith('packet/training_data_', $zipPath);
+        $this->assertStringEndsWith('.zip', $zipPath);
+    }
+
+    /**
+     * 测试ZIP文件内容结构
+     *
+     * @return void
+     */
+    public function test_zip_contains_correct_structure(): void
+    {
+        // 创建测试数据
+        $this->createTestSentences();
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [$this->translationChannel->uid]
+        );
+
+        $zipPath = $service->export();
+        $fullPath = Storage::path($zipPath);
+
+        // 打开ZIP文件检查内容
+        $zip = new ZipArchive();
+        $this->assertTrue($zip->open($fullPath));
+
+        // 检查是否包含translations目录
+        $expectedFile = 'translations/Chinese Translation.jsonl';
+        $this->assertNotFalse($zip->locateName($expectedFile));
+
+        $zip->close();
+    }
+
+    /**
+     * 测试JSONL文件内容格式
+     *
+     * @return void
+     */
+    public function test_jsonl_file_format(): void
+    {
+        // 创建测试数据
+        $this->createTestSentences();
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [$this->translationChannel->uid]
+        );
+
+        $zipPath = $service->export();
+        $fullPath = Storage::path($zipPath);
+
+        // 解压并读取JSONL文件
+        $zip = new ZipArchive();
+        $zip->open($fullPath);
+
+        $jsonlContent = $zip->getFromName('translations/Chinese Translation.jsonl');
+        $zip->close();
+
+        // 解析JSONL内容
+        $lines = explode("\n", trim($jsonlContent));
+
+        $this->assertGreaterThan(0, count($lines));
+
+        // 检查第一行的格式
+        $firstLine = json_decode($lines[0], true);
+
+        $this->assertArrayHasKey('id', $firstLine);
+        $this->assertArrayHasKey('pali', $firstLine);
+        $this->assertArrayHasKey('translation', $firstLine);
+
+        // 检查ID格式
+        $this->assertMatchesRegularExpression('/^\d+-\d+-\d+-\d+$/', $firstLine['id']);
+    }
+
+    /**
+     * 测试数据排序
+     *
+     * @return void
+     */
+    public function test_data_is_sorted_correctly(): void
+    {
+        // 创建乱序的测试数据
+        $this->sentenceService->save([
+            'book_id' => 2,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Pali text 2',
+            'channel_uid' => $this->paliChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 2,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Translation 2',
+            'channel_uid' => $this->translationChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Pali text 1',
+            'channel_uid' => $this->paliChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Translation 1',
+            'channel_uid' => $this->translationChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [$this->translationChannel->uid]
+        );
+
+        $zipPath = $service->export();
+        $fullPath = Storage::path($zipPath);
+
+        // 读取JSONL内容
+        $zip = new ZipArchive();
+        $zip->open($fullPath);
+        $jsonlContent = $zip->getFromName('translations/Chinese Translation.jsonl');
+        $zip->close();
+
+        $lines = explode("\n", trim($jsonlContent));
+        $firstLine = json_decode($lines[0], true);
+        $secondLine = json_decode($lines[1], true);
+
+        // 第一条应该是book_id=1的记录
+        $this->assertEquals('1-1-1-5', $firstLine['id']);
+        $this->assertEquals('2-1-1-5', $secondLine['id']);
+    }
+
+    /**
+     * 测试跳过空译文
+     *
+     * @return void
+     */
+    public function test_skips_empty_translations(): void
+    {
+        // 创建有空译文的测试数据
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Pali text',
+            'channel_uid' => $this->paliChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => '', // 空译文
+            'channel_uid' => $this->translationChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 2,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Pali text 2',
+            'channel_uid' => $this->paliChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 2,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Valid translation',
+            'channel_uid' => $this->translationChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [$this->translationChannel->uid]
+        );
+
+        $zipPath = $service->export();
+        $fullPath = Storage::path($zipPath);
+
+        // 读取JSONL内容
+        $zip = new ZipArchive();
+        $zip->open($fullPath);
+        $jsonlContent = $zip->getFromName('translations/Chinese Translation.jsonl');
+        $zip->close();
+
+        $lines = array_filter(explode("\n", trim($jsonlContent)));
+
+        // 应该只有一条记录(跳过了空译文)
+        $this->assertCount(1, $lines);
+    }
+
+    /**
+     * 测试多个译文版本
+     *
+     * @return void
+     */
+    public function test_multiple_translation_channels(): void
+    {
+        // 创建第二个译文channel
+        $secondTranslation = Channel::create([
+            'uid' => 'translation-2-test-uid',
+            'name' => 'English Translation',
+            'lang' => 'en',
+        ]);
+
+        // 创建测试数据
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Pali text',
+            'channel_uid' => $this->paliChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Chinese translation',
+            'channel_uid' => $this->translationChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'English translation',
+            'channel_uid' => $secondTranslation->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [
+                $this->translationChannel->uid,
+                $secondTranslation->uid
+            ]
+        );
+
+        $zipPath = $service->export();
+        $fullPath = Storage::path($zipPath);
+
+        // 检查ZIP包含两个文件
+        $zip = new ZipArchive();
+        $zip->open($fullPath);
+
+        $this->assertNotFalse($zip->locateName('translations/Chinese Translation.jsonl'));
+        $this->assertNotFalse($zip->locateName('translations/English Translation.jsonl'));
+
+        $zip->close();
+    }
+
+    /**
+     * 测试channel不存在时使用uid作为文件名
+     *
+     * @return void
+     */
+    public function test_uses_uid_when_channel_not_found(): void
+    {
+        // 创建测试数据,使用不存在的channel_uid
+        $nonExistentUid = 'non-existent-uid';
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Pali text',
+            'channel_uid' => $this->paliChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Translation',
+            'channel_uid' => $nonExistentUid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [$nonExistentUid]
+        );
+
+        $zipPath = $service->export();
+        $fullPath = Storage::path($zipPath);
+
+        // 检查使用uid作为文件名
+        $zip = new ZipArchive();
+        $zip->open($fullPath);
+
+        $expectedFile = "translations/{$nonExistentUid}.jsonl";
+        $this->assertNotFalse($zip->locateName($expectedFile));
+
+        $zip->close();
+    }
+
+    /**
+     * 测试临时文件清理
+     *
+     * @return void
+     */
+    public function test_cleanup_temp_files(): void
+    {
+        // 创建测试数据
+        $this->createTestSentences();
+
+        // 执行导出
+        $service = new PacketService(
+            $this->paliChannel->uid,
+            [$this->translationChannel->uid]
+        );
+
+        $service->export();
+
+        // 检查临时目录已被清理
+        $tempPath = storage_path('app/temp/packet');
+        $this->assertDirectoryDoesNotExist($tempPath);
+    }
+
+    /**
+     * 创建基础测试数据
+     *
+     * @return void
+     */
+    private function createTestSentences(): void
+    {
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Pali sentence content',
+            'channel_uid' => $this->paliChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+
+        $this->sentenceService->save([
+            'book_id' => 1,
+            'paragraph' => 1,
+            'word_start' => 1,
+            'word_end' => 5,
+            'content' => 'Chinese translation content',
+            'channel_uid' => $this->translationChannel->uid,
+            'editor_uid' => $this->editorUid,
+        ]);
+    }
+}

+ 253 - 0
api-v12/tests/Unit/Services/PaliTransliterationServiceTest.php

@@ -0,0 +1,253 @@
+<?php
+
+namespace Tests\Unit\Services;
+
+use App\Services\PaliTransliterationService;
+use PHPUnit\Framework\TestCase;
+
+class PaliTransliterationServiceTest extends TestCase
+{
+    private PaliTransliterationService $service;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->service = new PaliTransliterationService();
+    }
+
+    /**
+     * 测试缅文转罗马巴利
+     */
+    public function test_myanmar_to_roman_conversion(): void
+    {
+        // 测试基本辅音
+        $this->assertEquals('ka', $this->service->myanmarToRoman('က'));
+        $this->assertEquals('kha', $this->service->myanmarToRoman('ခ'));
+        $this->assertEquals('ga', $this->service->myanmarToRoman('ဂ'));
+
+        // 测试复杂组合
+        $this->assertEquals('ssa', $this->service->myanmarToRoman('ဿ'));
+        $this->assertEquals('ndra', $this->service->myanmarToRoman('ႁႏၵ'));
+
+        // 测试元音标记
+        $this->assertEquals('ā', $this->service->myanmarToRoman('aာ'));
+        $this->assertEquals('i', $this->service->myanmarToRoman('aိ'));
+        $this->assertEquals('ī', $this->service->myanmarToRoman('aီ'));
+
+        // 测试数字
+        $this->assertEquals('1', $this->service->myanmarToRoman('၁'));
+        $this->assertEquals('0', $this->service->myanmarToRoman('၀'));
+
+        // 测试标点符号
+        $this->assertEquals('.', $this->service->myanmarToRoman('။'));
+        $this->assertEquals(',', $this->service->myanmarToRoman('၊'));
+    }
+
+    /**
+     * 测试罗马巴利转缅文
+     */
+    public function test_roman_to_myanmar_conversion(): void
+    {
+        $this->assertEquals('က', $this->service->romanToMyanmar('ka'));
+        $this->assertEquals('ခ', $this->service->romanToMyanmar('kha'));
+        $this->assertEquals('ဿ', $this->service->romanToMyanmar('ssa'));
+        $this->assertEquals('၁', $this->service->romanToMyanmar('1'));
+    }
+
+    /**
+     * 测试泰文转罗马巴利
+     */
+    public function test_thai_to_roman_conversion(): void
+    {
+        // 测试基本辅音
+        $this->assertEquals('ka', $this->service->thaiToRoman('ก'));
+        $this->assertEquals('kha', $this->service->thaiToRoman('ข'));
+        $this->assertEquals('ga', $this->service->thaiToRoman('ค'));
+
+        // 测试复杂组合
+        $this->assertEquals('ssa', $this->service->thaiToRoman('สฺส'));
+        $this->assertEquals('ndra', $this->service->thaiToRoman('นฺทฺร'));
+
+        // 测试元音
+        $this->assertEquals('ā', $this->service->thaiToRoman('า'));
+        $this->assertEquals('i', $this->service->thaiToRoman('ิ'));
+
+        // 测试数字
+        $this->assertEquals('1', $this->service->thaiToRoman('๑'));
+        $this->assertEquals('0', $this->service->thaiToRoman('๐'));
+
+        // 测试标点符号 - 泰文 ฯ 转换为罗马巴利 .
+        $this->assertEquals('.', $this->service->thaiToRoman('ฯ'));
+    }
+
+    /**
+     * 测试罗马巴利转泰文
+     */
+    public function test_roman_to_thai_conversion(): void
+    {
+        $this->assertEquals('ก', $this->service->romanToThai('ka'));
+        $this->assertEquals('ข', $this->service->romanToThai('kha'));
+        $this->assertEquals('สฺส', $this->service->romanToThai('ssa'));
+        $this->assertEquals('๑', $this->service->romanToThai('1'));
+    }
+
+    /**
+     * 测试缅文转泰文
+     */
+    public function test_myanmar_to_thai_conversion(): void
+    {
+        $this->assertEquals('ก', $this->service->myanmarToThai('က'));
+        $this->assertEquals('ข', $this->service->myanmarToThai('ခ'));
+        $this->assertEquals('สฺส', $this->service->myanmarToThai('ဿ'));
+    }
+
+    /**
+     * 测试泰文转缅文
+     */
+    public function test_thai_to_myanmar_conversion(): void
+    {
+        $this->assertEquals('က', $this->service->thaiToMyanmar('ก'));
+        $this->assertEquals('ခ', $this->service->thaiToMyanmar('ข'));
+        $this->assertEquals('ဿ', $this->service->thaiToMyanmar('สฺส'));
+    }
+
+    /**
+     * 测试自动检测 - 缅文输入
+     */
+    public function test_auto_detect_myanmar_input(): void
+    {
+        $input = 'သမ္မာ'; // 缅文
+        $result = $this->service->toRoman($input);
+
+        // 验证结果包含罗马字母和可能的变音符号
+        $this->assertMatchesRegularExpression('/^[a-zA-Zāīūṅñṭḍṇḷṃ]+$/', $result);
+    }
+
+    /**
+     * 测试自动检测 - 泰文输入
+     */
+    public function test_auto_detect_thai_input(): void
+    {
+        $input = 'สมฺมา'; // 泰文
+        $result = $this->service->toRoman($input);
+
+        // 验证结果包含罗马字母和可能的变音符号
+        $this->assertMatchesRegularExpression('/^[a-zA-Zāīūṅñṭḍṇḷṃ]+$/', $result);
+    }
+
+    /**
+     * 测试自动检测 - 罗马文输入(无需转换)
+     */
+    public function test_auto_detect_roman_input(): void
+    {
+        $input = 'sammā';
+        $result = $this->service->toRoman($input);
+
+        // 应该返回原文
+        $this->assertEquals($input, $result);
+    }
+
+    /**
+     * 测试空字符串
+     */
+    public function test_empty_string_conversion(): void
+    {
+        $this->assertEquals('', $this->service->myanmarToRoman(''));
+        $this->assertEquals('', $this->service->thaiToRoman(''));
+        $this->assertEquals('', $this->service->toRoman(''));
+    }
+
+    /**
+     * 测试完整单词转换 - 缅文
+     */
+    public function test_full_word_myanmar_conversion(): void
+    {
+        // 测试"法"这个词
+        $myanmar = 'ဓမ္မ';
+        $expected = 'dhamma';
+
+        $this->assertEquals($expected, $this->service->myanmarToRoman($myanmar));
+    }
+
+    /**
+     * 测试完整单词转换 - 泰文
+     */
+    public function test_full_word_thai_conversion(): void
+    {
+        // 测试"法"这个词
+        $thai = 'ธมฺม';
+        $expected = 'dhamma';
+
+        $this->assertEquals($expected, $this->service->thaiToRoman($thai));
+    }
+
+    /**
+     * 测试往返转换一致性 - 缅文
+     * 注意:由于映射表的特性,往返转换可能不完全一致
+     * 这里测试的是转换后再转回能得到有效的缅文
+     */
+    public function test_myanmar_round_trip_conversion(): void
+    {
+        $original = 'က';
+        $roman = $this->service->myanmarToRoman($original);
+
+        // 验证转换为罗马字母成功
+        $this->assertEquals('ka', $roman);
+
+        // 验证可以转回缅文(可能不完全相同)
+        $backToMyanmar = $this->service->romanToMyanmar($roman);
+        $this->assertNotEmpty($backToMyanmar);
+
+        // 验证再次转换为罗马字母时结果一致
+        $this->assertEquals($roman, $this->service->myanmarToRoman($backToMyanmar));
+    }
+
+    /**
+     * 测试往返转换一致性 - 泰文
+     * 注意:由于映射表的特性,往返转换可能不完全一致
+     * 这里测试的是转换后再转回能得到有效的泰文
+     */
+    public function test_thai_round_trip_conversion(): void
+    {
+        $original = 'ก';
+        $roman = $this->service->thaiToRoman($original);
+
+        // 验证转换为罗马字母成功
+        $this->assertEquals('ka', $roman);
+
+        // 验证可以转回泰文(可能不完全相同)
+        $backToThai = $this->service->romanToThai($roman);
+        $this->assertNotEmpty($backToThai);
+
+        // 验证再次转换为罗马字母时结果一致
+        $this->assertEquals($roman, $this->service->thaiToRoman($backToThai));
+    }
+
+    /**
+     * 测试混合内容(包含未映射字符)
+     */
+    public function test_mixed_content_with_unmapped_characters(): void
+    {
+        $input = 'က test ခ';
+        $result = $this->service->myanmarToRoman($input);
+
+        // 验证缅文被转换,英文保持不变
+        $this->assertStringContainsString('test', $result);
+        $this->assertStringContainsString('ka', $result);
+        $this->assertStringContainsString('kha', $result);
+    }
+
+    /**
+     * 测试特殊组合字符
+     */
+    public function test_special_combined_characters(): void
+    {
+        // 测试鼻音组合
+        $this->assertEquals('ṅka', $this->service->myanmarToRoman('kaၤ'));
+        $this->assertEquals('ṅga', $this->service->myanmarToRoman('gaၤ'));
+
+        // 测试双辅音
+        $this->assertEquals('ṭṭha', $this->service->myanmarToRoman('႒'));
+        $this->assertEquals('ṭṭa', $this->service->myanmarToRoman('႗'));
+    }
+}

+ 343 - 0
dashboard-v4/dashboard/src/components/pro-table/ProTable.tsx

@@ -0,0 +1,343 @@
+import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
+import { Table, Input, Space, Button } from 'antd';
+import { SearchOutlined } from '@ant-design/icons';
+import type { TableProps, TablePaginationConfig } from 'antd/es/table';
+import type { SorterResult, FilterValue, ColumnType } from 'antd/es/table/interface';
+
+// 类型定义
+export interface ActionType {
+  reload: (resetPageIndex?: boolean) => void;
+  reset: () => void;
+  clearSelected?: () => void;
+}
+
+export interface ProColumns<T = any> extends Omit<ColumnType<T>, 'render' | 'filters' | 'onFilter'> {
+  title?: React.ReactNode;
+  dataIndex?: string | string[];
+  key?: string;
+  width?: number | string;
+  search?: boolean | SearchConfig;
+  hideInTable?: boolean;
+  tooltip?: string;
+  ellipsis?: boolean;
+  valueType?: 'text' | 'date' | 'dateTime' | 'option' | 'money' | 'index';
+  valueEnum?: Record<string, { text: React.ReactNode; status?: string }>;
+  render?: (
+    dom: any,
+    entity: T,
+    index: number,
+    action: ActionType,
+    schema?: ProColumns<T>
+  ) => React.ReactNode;
+  filters?: boolean;
+  onFilter?: boolean | ((value: any, record: T) => boolean);
+  sorter?: boolean | ((a: T, b: T) => number);
+}
+
+interface SearchConfig {
+  transform?: (value: any) => any;
+}
+
+export interface RequestData<T> {
+  data: T[];
+  success?: boolean;
+  total?: number;
+}
+
+export interface ProTableProps<T = any> {
+  columns: ProColumns<T>[];
+  request?: (
+    params: Record<string, any>,
+    sorter: Record<string, any>,
+    filter: Record<string, any>
+  ) => Promise<RequestData<T>>;
+  actionRef?: React.MutableRefObject<ActionType | undefined>;
+  rowKey?: string | ((record: T) => string);
+  bordered?: boolean;
+  pagination?: false | TablePaginationConfig;
+  search?: false | { labelWidth?: number | 'auto' };
+  options?: {
+    search?: boolean;
+    reload?: boolean;
+    density?: boolean;
+    setting?: boolean;
+  };
+  toolBarRender?: () => React.ReactNode[];
+  toolbar?: {
+    menu?: {
+      activeKey?: React.Key;
+      items?: Array<{
+        key: string;
+        label: React.ReactNode;
+      }>;
+      onChange?: (key: React.Key) => void;
+    };
+  };
+  headerTitle?: React.ReactNode;
+  params?: Record<string, any>;
+}
+
+const ProTable = <T extends Record<string, any>>({
+  columns,
+  request,
+  actionRef,
+  rowKey = 'id',
+  bordered = false,
+  pagination = {},
+  search = false,
+  options = {},
+  toolBarRender,
+  toolbar,
+  headerTitle,
+  params: externalParams,
+  ...restProps
+}: ProTableProps<T>) => {
+  const [loading, setLoading] = useState(false);
+  const [dataSource, setDataSource] = useState<T[]>([]);
+  const [total, setTotal] = useState(0);
+  const [currentPage, setCurrentPage] = useState(1);
+  const [pageSize, setPageSize] = useState(
+    typeof pagination === 'object' ? pagination.defaultPageSize || 20 : 20
+  );
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [sorter, setSorter] = useState<Record<string, any>>({});
+  const [filters, setFilters] = useState<Record<string, any>>({});
+
+  // 创建内部 ref
+  const internalActionRef = useRef<ActionType>({
+    reload: async (resetPageIndex = false) => {
+      if (resetPageIndex) {
+        setCurrentPage(1);
+      }
+      await fetchData(resetPageIndex ? 1 : currentPage);
+    },
+    reset: () => {
+      setSearchKeyword('');
+      setCurrentPage(1);
+      setSorter({});
+      setFilters({});
+    },
+  });
+
+  // 暴露 actionRef
+  useImperativeHandle(actionRef, () => internalActionRef.current);
+
+  const fetchData = async (page = currentPage) => {
+    if (!request) return;
+
+    setLoading(true);
+    try {
+      const params = {
+        current: page,
+        pageSize,
+        keyword: searchKeyword,
+        ...externalParams,
+      };
+
+      const result = await request(params, sorter, filters);
+      
+      setDataSource(result.data || []);
+      setTotal(result.total || 0);
+    } catch (error) {
+      console.error('ProTable fetch error:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 监听参数变化
+  useEffect(() => {
+    fetchData(1);
+    setCurrentPage(1);
+  }, [searchKeyword, sorter, filters, JSON.stringify(externalParams)]);
+
+  // 处理表格变化
+  const handleTableChange = (
+    newPagination: TablePaginationConfig,
+    newFilters: Record<string, FilterValue | null>,
+    newSorter: SorterResult<T> | SorterResult<T>[]
+  ) => {
+    // 处理分页
+    if (newPagination.current !== currentPage) {
+      setCurrentPage(newPagination.current || 1);
+      fetchData(newPagination.current || 1);
+    }
+    if (newPagination.pageSize !== pageSize) {
+      setPageSize(newPagination.pageSize || 20);
+      setCurrentPage(1);
+    }
+
+    // 处理排序
+    const sorterResult = Array.isArray(newSorter) ? newSorter[0] : newSorter;
+    if (sorterResult && sorterResult.field) {
+      setSorter({
+        [sorterResult.field as string]: sorterResult.order,
+      });
+    } else {
+      setSorter({});
+    }
+
+    // 处理过滤
+    const validFilters: Record<string, any> = {};
+    Object.entries(newFilters).forEach(([key, value]) => {
+      if (value && value.length > 0) {
+        validFilters[key] = value;
+      }
+    });
+    setFilters(validFilters);
+  };
+
+  // 转换列配置
+  const processedColumns = columns
+    .filter((col) => !col.hideInTable)
+    .map((col) => {
+      const processed: any = { ...col };
+
+      // 处理 valueEnum 为 filters
+      if (col.valueEnum && col.filters) {
+        processed.filters = Object.entries(col.valueEnum).map(([key, value]) => ({
+          text: value.text,
+          value: key,
+        }));
+        if (col.onFilter) {
+          processed.onFilter = (value: any, record: T) => {
+            const dataValue = col.dataIndex
+              ? record[col.dataIndex as string]
+              : undefined;
+            return dataValue === value;
+          };
+        }
+      }
+
+      // 处理 valueType
+      if (col.valueType === 'date' || col.valueType === 'dateTime') {
+        const originalRender = processed.render;
+        processed.render = (text: any, record: T, index: number) => {
+          if (originalRender) {
+            return originalRender(text, record, index, internalActionRef.current, col);
+          }
+          if (!text) return '-';
+          const date = new Date(text);
+          if (col.valueType === 'date') {
+            return date.toLocaleDateString();
+          }
+          return date.toLocaleString();
+        };
+      }
+
+      // 处理自定义 render
+      if (col.render && processed.render !== col.render) {
+        const customRender = col.render;
+        processed.render = (text: any, record: T, index: number) => {
+          return customRender(text, record, index, internalActionRef.current, col);
+        };
+      }
+
+      // 处理 ellipsis 和 tooltip
+      if (col.ellipsis) {
+        processed.ellipsis = {
+          showTitle: col.tooltip !== undefined,
+        };
+      }
+
+      return processed;
+    });
+
+  // 构建工具栏
+  const renderToolbar = () => {
+    const menuItems = toolbar?.menu?.items || [];
+    const activeKey = toolbar?.menu?.activeKey;
+    const onChange = toolbar?.menu?.onChange;
+
+    return (
+      <div
+        style={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          marginBottom: 16,
+          padding: '16px 0',
+        }}
+      >
+        <div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
+          {headerTitle}
+          {menuItems.length > 0 && (
+            <Space>
+              {menuItems.map((item) => (
+                <Button
+                  key={item.key}
+                  type={activeKey === item.key ? 'primary' : 'default'}
+                  onClick={() => onChange?.(item.key)}
+                >
+                  {item.label}
+                </Button>
+              ))}
+            </Space>
+          )}
+        </div>
+        <Space>{toolBarRender?.()}</Space>
+      </div>
+    );
+  };
+
+  // 构建搜索栏
+  const renderSearch = () => {
+    if (!options.search) return null;
+
+    return (
+      <div style={{ marginBottom: 16 }}>
+        <Input.Search
+          placeholder="请输入关键词搜索"
+          allowClear
+          enterButton={<SearchOutlined />}
+          value={searchKeyword}
+          onChange={(e) => setSearchKeyword(e.target.value)}
+          onSearch={(value) => {
+            setSearchKeyword(value);
+            setCurrentPage(1);
+          }}
+          style={{ maxWidth: 400 }}
+        />
+      </div>
+    );
+  };
+
+  const paginationConfig: TablePaginationConfig | false =
+    pagination === false
+      ? false
+      : {
+          current: currentPage,
+          pageSize,
+          total,
+          showSizeChanger: true,
+          showQuickJumper: true,
+          showTotal: (total) => `共 ${total} 条`,
+          onChange: (page, newPageSize) => {
+            setCurrentPage(page);
+            if (newPageSize !== pageSize) {
+              setPageSize(newPageSize);
+              setCurrentPage(1);
+            }
+          },
+          ...pagination,
+        };
+
+  return (
+    <div className="pro-table">
+      {renderToolbar()}
+      {renderSearch()}
+      <Table<T>
+        {...restProps}
+        columns={processedColumns}
+        dataSource={dataSource}
+        loading={loading}
+        rowKey={rowKey}
+        bordered={bordered}
+        pagination={paginationConfig}
+        onChange={handleTableChange}
+      />
+    </div>
+  );
+};
+
+export default ProTable;

+ 151 - 0
dashboard-v4/dashboard/src/components/pro-table/ProTable.types.ts

@@ -0,0 +1,151 @@
+// ProTable 类型定义文件
+import type { TableProps, TablePaginationConfig } from 'antd/es/table';
+import type { SorterResult, ColumnType } from 'antd/es/table/interface';
+
+/**
+ * ActionType - 表格操作接口
+ */
+export interface ActionType {
+  /** 刷新表格 */
+  reload: (resetPageIndex?: boolean) => void;
+  /** 重置表格状态 */
+  reset: () => void;
+  /** 清空选中项 */
+  clearSelected?: () => void;
+}
+
+/**
+ * 搜索配置
+ */
+export interface SearchConfig {
+  /** 转换搜索值 */
+  transform?: (value: any) => any;
+}
+
+/**
+ * 列配置
+ */
+export interface ProColumns<T = any> extends Omit<ColumnType<T>, 'render' | 'filters' | 'onFilter'> {
+  /** 列标题 */
+  title?: React.ReactNode;
+  /** 数据索引 */
+  dataIndex?: string | string[];
+  /** 唯一 key */
+  key?: string;
+  /** 列宽 */
+  width?: number | string;
+  /** 是否可搜索 */
+  search?: boolean | SearchConfig;
+  /** 是否在表格中隐藏 */
+  hideInTable?: boolean;
+  /** 提示信息 */
+  tooltip?: string;
+  /** 是否自动缩略 */
+  ellipsis?: boolean;
+  /** 值类型 */
+  valueType?: 'text' | 'date' | 'dateTime' | 'option' | 'money' | 'index';
+  /** 枚举值 */
+  valueEnum?: Record<
+    string,
+    {
+      text: React.ReactNode;
+      status?: string;
+    }
+  >;
+  /** 自定义渲染 */
+  render?: (
+    dom: any,
+    entity: T,
+    index: number,
+    action: ActionType,
+    schema?: ProColumns<T>
+  ) => React.ReactNode;
+  /** 是否支持过滤 */
+  filters?: boolean;
+  /** 过滤函数 */
+  onFilter?: boolean | ((value: any, record: T) => boolean);
+  /** 排序 */
+  sorter?: boolean | ((a: T, b: T) => number);
+}
+
+/**
+ * 请求返回数据格式
+ */
+export interface RequestData<T> {
+  /** 数据列表 */
+  data: T[];
+  /** 是否成功 */
+  success?: boolean;
+  /** 总数 */
+  total?: number;
+}
+
+/**
+ * 工具栏菜单项
+ */
+export interface ToolbarMenuItem {
+  key: string;
+  label: React.ReactNode;
+}
+
+/**
+ * 工具栏配置
+ */
+export interface ToolbarConfig {
+  menu?: {
+    /** 当前激活的 key */
+    activeKey?: React.Key;
+    /** 菜单项 */
+    items?: ToolbarMenuItem[];
+    /** 切换回调 */
+    onChange?: (key: React.Key) => void;
+  };
+}
+
+/**
+ * 选项配置
+ */
+export interface OptionsConfig {
+  /** 是否显示搜索 */
+  search?: boolean;
+  /** 是否显示刷新 */
+  reload?: boolean;
+  /** 是否显示密度 */
+  density?: boolean;
+  /** 是否显示设置 */
+  setting?: boolean;
+}
+
+/**
+ * ProTable 组件属性
+ */
+export interface ProTableProps<T = any> {
+  /** 列配置 */
+  columns: ProColumns<T>[];
+  /** 请求数据的函数 */
+  request?: (
+    params: Record<string, any>,
+    sorter: Record<string, any>,
+    filter: Record<string, any>
+  ) => Promise<RequestData<T>>;
+  /** 表格操作引用 */
+  actionRef?: React.MutableRefObject<ActionType | undefined>;
+  /** 行唯一键 */
+  rowKey?: string | ((record: T) => string);
+  /** 是否显示边框 */
+  bordered?: boolean;
+  /** 分页配置 */
+  pagination?: false | TablePaginationConfig;
+  /** 搜索配置 */
+  search?: false | { labelWidth?: number | 'auto' };
+  /** 选项配置 */
+  options?: OptionsConfig;
+  /** 工具栏渲染 */
+  toolBarRender?: () => React.ReactNode[];
+  /** 工具栏配置 */
+  toolbar?: ToolbarConfig;
+  /** 标题 */
+  headerTitle?: React.ReactNode;
+  /** 额外参数 */
+  params?: Record<string, any>;
+}

+ 319 - 0
dashboard-v4/dashboard/src/components/pro-table/ProTableTest.tsx

@@ -0,0 +1,319 @@
+import React, { useRef, useState } from 'react';
+import { Button, Badge, message } from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import ProTable, { ActionType } from './ProTable';
+
+// 模拟数据类型
+interface IChannelItem {
+  id: number;
+  uid: string;
+  title: string;
+  summary: string;
+  type: 'translation' | 'nissaya' | 'commentary' | 'original';
+  role?: 'owner' | 'manager' | 'editor' | 'member';
+  publicity: number;
+  created_at: string;
+}
+
+// 模拟 API 响应
+const mockApiResponse = (page: number, pageSize: number, keyword: string) => {
+  const mockData: IChannelItem[] = Array.from({ length: 50 }, (_, i) => ({
+    id: i + 1,
+    uid: `channel-${i + 1}`,
+    title: `Channel ${i + 1} ${keyword ? `(含关键词: ${keyword})` : ''}`,
+    summary: `这是第 ${i + 1} 个频道的简介`,
+    type: ['translation', 'nissaya', 'commentary', 'original'][i % 4] as any,
+    role: ['owner', 'manager', 'editor', 'member'][i % 4] as any,
+    publicity: i % 3,
+    created_at: new Date(2024, 0, i + 1).toISOString(),
+  }));
+
+  const start = (page - 1) * pageSize;
+  const end = start + pageSize;
+  
+  // 模拟关键词搜索
+  let filtered = mockData;
+  if (keyword) {
+    filtered = mockData.filter(item => 
+      item.title.toLowerCase().includes(keyword.toLowerCase())
+    );
+  }
+
+  return {
+    data: {
+      rows: filtered.slice(start, end),
+      count: filtered.length,
+    },
+  };
+};
+
+// 测试组件
+const ProTableTest: React.FC = () => {
+  const ref = useRef<ActionType>();
+  const [activeKey, setActiveKey] = useState<React.Key>('my');
+  const [myNumber] = useState(25);
+  const [collaborationNumber] = useState(15);
+
+  const renderBadge = (count: number, active = false) => {
+    return (
+      <Badge
+        count={count}
+        style={{
+          marginBlockStart: -2,
+          marginInlineStart: 4,
+          color: active ? '#1890FF' : '#999',
+          backgroundColor: active ? '#E6F7FF' : '#eee',
+        }}
+      />
+    );
+  };
+
+  return (
+    <div style={{ padding: 24 }}>
+      <h1>ProTable 测试示例</h1>
+      
+      <ProTable<IChannelItem>
+        actionRef={ref}
+        columns={[
+          {
+            title: '序号',
+            dataIndex: 'id',
+            key: 'id',
+            width: 50,
+            search: false,
+          },
+          {
+            title: '标题',
+            dataIndex: 'title',
+            width: 250,
+            key: 'title',
+            tooltip: '过长会自动收缩',
+            ellipsis: true,
+            render: (text, row, index, action) => {
+              return (
+                <Button
+                  type="link"
+                  onClick={() => {
+                    message.info(`点击了: ${row.title}`);
+                    console.log('选中的频道:', row);
+                  }}
+                >
+                  {row.title}
+                </Button>
+              );
+            },
+          },
+          {
+            title: '简介',
+            dataIndex: 'summary',
+            key: 'summary',
+            tooltip: '过长会自动收缩',
+            ellipsis: true,
+          },
+          {
+            title: '角色',
+            dataIndex: 'role',
+            key: 'role',
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: { text: '全部', status: 'Default' },
+              owner: { text: '所有者' },
+              manager: { text: '管理员' },
+              editor: { text: '编辑' },
+              member: { text: '成员' },
+            },
+          },
+          {
+            title: '类型',
+            dataIndex: 'type',
+            key: 'type',
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: { text: '全部', status: 'Default' },
+              translation: { text: '翻译', status: 'Success' },
+              nissaya: { text: 'Nissaya', status: 'Processing' },
+              commentary: { text: '注释', status: 'Default' },
+              original: { text: '原创', status: 'Default' },
+            },
+          },
+          {
+            title: '公开性',
+            dataIndex: 'publicity',
+            key: 'publicity',
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              0: { text: '私有' },
+              1: { text: '组内' },
+              2: { text: '公开' },
+            },
+          },
+          {
+            title: '创建时间',
+            key: 'created_at',
+            width: 120,
+            search: false,
+            dataIndex: 'created_at',
+            valueType: 'date',
+            sorter: true,
+          },
+          {
+            title: '操作',
+            key: 'option',
+            width: 120,
+            valueType: 'option',
+            hideInTable: activeKey !== 'my',
+            render: (text, row, index, action) => {
+              return [
+                <Button
+                  key="edit"
+                  type="link"
+                  onClick={() => message.info(`编辑: ${row.title}`)}
+                >
+                  编辑
+                </Button>,
+                <Button
+                  key="delete"
+                  type="link"
+                  danger
+                  onClick={() => {
+                    message.success(`删除成功: ${row.title}`);
+                    action.reload();
+                  }}
+                >
+                  删除
+                </Button>,
+              ];
+            },
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log('请求参数:', { params, sorter, filter });
+          
+          // 模拟网络延迟
+          await new Promise(resolve => setTimeout(resolve, 500));
+          
+          const page = params.current || 1;
+          const pageSize = params.pageSize || 20;
+          const keyword = params.keyword || '';
+          
+          const res = mockApiResponse(page, pageSize, keyword);
+          
+          return {
+            total: res.data.count,
+            success: true,
+            data: res.data.rows.map((item, id) => ({
+              ...item,
+              id: (page - 1) * pageSize + id + 1,
+            })),
+          };
+        }}
+        rowKey="uid"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          defaultPageSize: 10,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Button
+            key="refresh"
+            onClick={() => {
+              ref.current?.reload();
+              message.success('刷新成功');
+            }}
+          >
+            刷新
+          </Button>,
+          <Button
+            key="reset"
+            onClick={() => {
+              ref.current?.reset();
+              message.success('重置成功');
+            }}
+          >
+            重置
+          </Button>,
+          <Button
+            key="create"
+            icon={<PlusOutlined />}
+            type="primary"
+            onClick={() => message.info('创建新频道')}
+          >
+            创建
+          </Button>,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: 'my',
+                label: (
+                  <span>
+                    我的工作室
+                    {renderBadge(myNumber, activeKey === 'my')}
+                  </span>
+                ),
+              },
+              {
+                key: 'collaboration',
+                label: (
+                  <span>
+                    协作
+                    {renderBadge(collaborationNumber, activeKey === 'collaboration')}
+                  </span>
+                ),
+              },
+              {
+                key: 'community',
+                label: (
+                  <span>
+                    社区
+                    {renderBadge(10, activeKey === 'community')}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log('切换标签:', key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+        headerTitle="频道列表"
+      />
+      
+      <div style={{ marginTop: 24, padding: 16, background: '#f5f5f5' }}>
+        <h3>测试功能:</h3>
+        <ul>
+          <li>✅ 切换标签页(我的工作室/协作/社区)</li>
+          <li>✅ 搜索功能(输入关键词搜索)</li>
+          <li>✅ 分页(切换页码、调整每页数量)</li>
+          <li>✅ 排序(点击"创建时间"列头排序)</li>
+          <li>✅ 过滤(点击"角色"、"类型"、"公开性"列头过滤)</li>
+          <li>✅ 刷新按钮(手动刷新表格)</li>
+          <li>✅ 重置按钮(重置所有过滤和排序)</li>
+          <li>✅ 操作列(仅在"我的工作室"显示)</li>
+          <li>✅ 文本省略(标题和简介过长自动省略)</li>
+          <li>✅ 日期格式化(创建时间自动格式化)</li>
+        </ul>
+      </div>
+    </div>
+  );
+};
+
+export default ProTableTest;

+ 243 - 0
dashboard-v4/dashboard/src/components/pro-table/README.md

@@ -0,0 +1,243 @@
+# ProTable 组件
+
+自定义实现的 ProTable 组件,用于替代 `@ant-design/pro-components` 的 ProTable。
+
+## 功能特性
+
+✅ 完整支持你当前代码中使用的所有功能:
+
+### 核心功能
+- **actionRef** - 表格操作引用,支持 `reload()` 和 `reset()` 方法
+- **columns** - 列配置,支持所有你使用的属性
+- **request** - 异步数据请求,自动处理分页、排序、过滤
+- **rowKey** - 行唯一标识
+- **pagination** - 分页配置
+
+### 列配置 (ProColumns)
+- `title` - 列标题
+- `dataIndex` - 数据字段名
+- `key` - 唯一标识
+- `width` - 列宽
+- `search` - 搜索配置
+- `hideInTable` - 隐藏列
+- `tooltip` - 提示信息
+- `ellipsis` - 文本省略
+- `valueType` - 值类型(date, dateTime, option 等)
+- `valueEnum` - 枚举值(自动转换为过滤器)
+- `render` - 自定义渲染函数
+- `filters` - 过滤配置
+- `onFilter` - 过滤函数
+- `sorter` - 排序配置
+
+### 工具栏
+- `toolBarRender` - 自定义工具栏按钮
+- `toolbar.menu` - 标签页切换(如 my/collaboration/community)
+- `options.search` - 关键词搜索功能
+
+## 安装使用
+
+### 1. 复制文件
+将 `ProTable.tsx` 复制到你的项目中。
+
+### 2. 替换导入
+```tsx
+// 原来
+import { ActionType, ProTable } from "@ant-design/pro-components";
+
+// 现在
+import ProTable, { ActionType } from './components/ProTable';
+```
+
+### 3. 使用方式
+```tsx
+import { useRef } from 'react';
+import ProTable, { ActionType } from './components/ProTable';
+
+const MyComponent = () => {
+  const ref = useRef<ActionType>();
+
+  return (
+    <ProTable
+      actionRef={ref}
+      columns={[
+        {
+          title: '序号',
+          dataIndex: 'id',
+          key: 'id',
+          width: 50,
+          search: false,
+        },
+        {
+          title: '标题',
+          dataIndex: 'title',
+          key: 'title',
+          ellipsis: true,
+          render: (text, row, index, action) => {
+            return <Button onClick={() => action.reload()}>{text}</Button>;
+          },
+        },
+        {
+          title: '类型',
+          dataIndex: 'type',
+          key: 'type',
+          filters: true,
+          onFilter: true,
+          valueEnum: {
+            all: { text: '全部' },
+            translation: { text: '翻译' },
+            original: { text: '原创' },
+          },
+        },
+        {
+          title: '创建时间',
+          dataIndex: 'created_at',
+          key: 'created_at',
+          valueType: 'date',
+          sorter: true,
+        },
+      ]}
+      request={async (params, sorter, filter) => {
+        // 构建 API URL
+        const url = `/api/data?page=${params.current}&size=${params.pageSize}`;
+        
+        // 发起请求
+        const res = await fetch(url);
+        const json = await res.json();
+        
+        return {
+          data: json.rows,
+          total: json.count,
+          success: true,
+        };
+      }}
+      rowKey="id"
+      bordered
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      options={{
+        search: true,
+      }}
+      toolBarRender={() => [
+        <Button key="create" type="primary">新建</Button>,
+      ]}
+      toolbar={{
+        menu: {
+          activeKey: 'my',
+          items: [
+            { key: 'my', label: '我的' },
+            { key: 'all', label: '全部' },
+          ],
+          onChange: (key) => {
+            console.log('切换到', key);
+          },
+        },
+      }}
+    />
+  );
+};
+```
+
+## API 说明
+
+### ProTable Props
+
+| 属性 | 说明 | 类型 | 默认值 |
+|------|------|------|--------|
+| columns | 列配置 | `ProColumns[]` | - |
+| request | 数据请求函数 | `(params, sorter, filter) => Promise<RequestData>` | - |
+| actionRef | 表格操作引用 | `React.MutableRefObject<ActionType>` | - |
+| rowKey | 行唯一标识 | `string \| (record) => string` | `'id'` |
+| bordered | 是否显示边框 | `boolean` | `false` |
+| pagination | 分页配置 | `false \| PaginationConfig` | - |
+| search | 搜索配置 | `false \| SearchConfig` | `false` |
+| options | 选项配置 | `OptionsConfig` | `{}` |
+| toolBarRender | 工具栏渲染 | `() => ReactNode[]` | - |
+| toolbar | 工具栏配置 | `ToolbarConfig` | - |
+| headerTitle | 表格标题 | `ReactNode` | - |
+| params | 额外请求参数 | `Record<string, any>` | - |
+
+### ActionType
+
+```typescript
+interface ActionType {
+  reload: (resetPageIndex?: boolean) => void;  // 刷新表格
+  reset: () => void;                           // 重置表格
+}
+```
+
+### Request 函数参数
+
+```typescript
+async (params, sorter, filter) => {
+  // params: { current: 1, pageSize: 20, keyword: '搜索词', ...自定义参数 }
+  // sorter: { field_name: 'ascend' | 'descend' }
+  // filter: { field_name: ['value1', 'value2'] }
+  
+  return {
+    data: [],      // 数据列表
+    total: 0,      // 总数
+    success: true, // 是否成功
+  };
+}
+```
+
+## 与原 ProTable 的差异
+
+### 保持一致的功能
+✅ 所有你代码中使用的功能都已实现
+✅ API 接口完全兼容
+✅ TypeScript 类型支持
+
+### 简化的部分
+- 移除了未使用的高级功能(如拖拽排序、可编辑表格等)
+- 简化了搜索表单(仅保留关键词搜索)
+- 移除了列设置、密度调整等辅助功能
+
+这些简化不影响你当前代码的运行。
+
+## 迁移检查清单
+
+- [ ] 复制 `ProTable.tsx` 到项目
+- [ ] 更新导入语句
+- [ ] 验证表格渲染正常
+- [ ] 测试分页功能
+- [ ] 测试搜索功能
+- [ ] 测试过滤功能
+- [ ] 测试排序功能
+- [ ] 测试 actionRef.reload()
+- [ ] 测试工具栏切换
+
+## 注意事项
+
+1. **依赖要求**:需要 `antd` 4.24+
+2. **样式**:基于 Ant Design 原生样式,无需额外 CSS
+3. **性能**:避免频繁调用 `reload()`,使用防抖优化
+4. **类型安全**:使用 TypeScript 泛型确保类型安全
+
+## 扩展
+
+如需添加更多功能,可以在 `ProTable.tsx` 中扩展:
+
+```tsx
+// 添加批量操作
+const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+
+<Table
+  rowSelection={{
+    selectedRowKeys,
+    onChange: setSelectedRowKeys,
+  }}
+  // ...
+/>
+
+// 添加导出功能
+const handleExport = () => {
+  // 导出逻辑
+};
+```
+
+## 许可
+
+MIT

+ 72 - 0
dashboard-v4/dashboard/src/components/pro-table/usage-example.tsx

@@ -0,0 +1,72 @@
+// 使用示例:如何替换你的 ChannelTableWidget 组件
+
+// 1. 替换导入语句
+// 原来:
+// import { ActionType, ProTable } from "@ant-design/pro-components";
+
+// 现在:
+import ProTable, { ActionType } from "./ProTable";
+
+// 2. 其他代码保持不变,ProTable 组件会自动适配你的用法
+
+// 主要实现的功能:
+// ✅ actionRef - 通过 ref.current?.reload() 刷新表格
+// ✅ columns 配置 - 支持所有你使用的列配置
+// ✅ request 异步请求 - 支持分页、排序、过滤参数
+// ✅ rowKey - 行唯一标识
+// ✅ bordered - 边框样式
+// ✅ pagination - 分页配置(showQuickJumper, showSizeChanger)
+// ✅ search - 搜索功能(options.search)
+// ✅ toolBarRender - 工具栏渲染
+// ✅ toolbar.menu - 标签页切换(my/collaboration/community)
+// ✅ valueEnum - 枚举值过滤
+// ✅ valueType - 日期格式化
+// ✅ sorter - 排序支持
+// ✅ filters/onFilter - 过滤支持
+// ✅ ellipsis - 文本溢出省略
+// ✅ hideInTable - 隐藏列
+
+// 完整替换示例:
+/*
+import ProTable, { ActionType } from './ProTable';
+import { FormattedMessage, useIntl } from "react-intl";
+// ... 其他导入保持不变
+
+const ChannelTableWidget = ({ ... }) => {
+  const ref = useRef<ActionType>();
+  
+  return (
+    <ProTable<IChannelItem>
+      actionRef={ref}
+      columns={[ ... ]} // 你的列配置保持不变
+      request={async (params, sorter, filter) => {
+        // 你的请求逻辑保持不变
+        // ...
+        return {
+          total: res.data.count,
+          success: true,
+          data: items,
+        };
+      }}
+      rowKey="id"
+      bordered
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      search={false}
+      options={{
+        search: true,
+      }}
+      toolBarRender={() => [ ... ]} // 保持不变
+      toolbar={{
+        menu: {
+          activeKey,
+          items: [ ... ],
+          onChange(key) { ... }
+        }
+      }}
+    />
+  );
+};
+*/

+ 8 - 4
dashboard-v4/dashboard/src/components/template/Nissaya.tsx

@@ -7,18 +7,22 @@ import PaliText from "./Wbw/PaliText";
 import { MoreIcon } from "../../assets/icon";
 
 interface IWidgetNissayaCtl {
+  original?: string;
   pali?: string;
   meaning?: string[];
   lang?: string;
+  note?: string;
   children?: React.ReactNode | React.ReactNode[];
 }
-const NissayaCtl = ({ pali, meaning, lang, children }: IWidgetNissayaCtl) => {
+export const NissayaCtl = ({
+  pali,
+  meaning,
+  lang,
+  children,
+}: IWidgetNissayaCtl) => {
   const settings = useAppSelector(settingInfo);
   const layout = GetUserSetting("setting.nissaya.layout.read", settings);
   console.debug("NissayaCtl layout", layout);
-  const isArray = Array.isArray(children);
-  const meaning2 = isArray ? children[1] : "";
-  const show = -1;
   const ect = meaning
     ?.slice(0, -1)
     .map((item, id) => <NissayaMeaning key={id} text={item} />);

+ 50 - 0
dashboard-v4/dashboard/src/components/template/Nissaya/NissayaSent.tsx

@@ -0,0 +1,50 @@
+import { Popover, Tag } from "antd";
+import { NissayaCtl } from "../Nissaya";
+import Marked from "../../general/Marked";
+
+export interface INissaya {
+  original?: string;
+  translation?: string;
+  note?: string;
+  confidence?: number;
+}
+interface IWidget {
+  data?: INissaya[];
+}
+const NissayaSent = ({ data }: IWidget) => {
+  if (!data) {
+    return <></>;
+  }
+
+  return (
+    <>
+      {data.map((item, id) => {
+        return (
+          <>
+            <NissayaCtl
+              pali={item.original}
+              meaning={item.translation?.split(">")}
+            />
+            <>
+              {item.confidence && item.confidence < 90 ? (
+                <Tag color="red">{item.confidence}</Tag>
+              ) : undefined}
+            </>
+            <>
+              {item.note && (
+                <Popover
+                  overlayInnerStyle={{ width: 600 }}
+                  placement="bottom"
+                  content={<Marked text={item.note} />}
+                >
+                  [nt]
+                </Popover>
+              )}
+            </>
+          </>
+        );
+      })}
+    </>
+  );
+};
+export default NissayaSent;

+ 3 - 0
dashboard-v4/dashboard/src/components/template/SentEdit/SentCell.tsx

@@ -31,6 +31,7 @@ import User from "../../auth/User";
 import { ISentenceListResponse } from "../../api/Corpus";
 import { toISentence } from "./SentCanRead";
 import SentAttachment from "./SentAttachment";
+import NissayaSent from "../Nissaya/NissayaSent";
 
 interface ISnowFlakeResponse {
   ok: boolean;
@@ -430,6 +431,8 @@ const SentCellWidget = ({
                   content={sentData.content}
                   oldContent={diffText}
                 />
+              ) : sentData.channel.type === "nissaya" ? (
+                <NissayaSent data={JSON.parse(sentData.content ?? "[])")} />
               ) : (
                 <MdView
                   className="sentence"

+ 33 - 28
dashboard-v4/dashboard/src/components/template/SentEdit/SentContent.tsx

@@ -11,6 +11,7 @@ import { ArticleMode } from "../../article/Article";
 import SuggestionFocus from "./SuggestionFocus";
 import store from "../../../store";
 import { push } from "../../../reducers/sentence";
+import NissayaSent from "../Nissaya/NissayaSent";
 
 interface ILayoutFlex {
   left: number;
@@ -152,34 +153,38 @@ const SentContentWidget = ({
       <div style={{ flex: layoutFlex.left, color: "#9f3a01" }}>
         {origin?.map((item, id) => {
           if (item.contentType === "json") {
-            return (
-              <WbwSentCtl
-                key={id}
-                book={book}
-                para={para}
-                wordStart={wordStart}
-                wordEnd={wordEnd}
-                studio={item.studio}
-                channelId={item.channel.id}
-                channelType={item.channel.type}
-                channelLang={item.channel.lang}
-                data={JSON.parse(item.content ?? "")}
-                answer={answer ? JSON.parse(answer.content ?? "") : undefined}
-                mode={mode}
-                wbwProgress={wbwProgress}
-                readonly={readonly}
-                onChange={(data: IWbw[]) => {
-                  if (typeof onWbwChange !== "undefined") {
-                    onWbwChange(data);
-                  }
-                }}
-                onMagicDictDone={() => {
-                  if (typeof onMagicDictDone !== "undefined") {
-                    onMagicDictDone();
-                  }
-                }}
-              />
-            );
+            if (item.channel.type === "nissaya") {
+              return <NissayaSent data={JSON.parse(item.content ?? "[])")} />;
+            } else {
+              return (
+                <WbwSentCtl
+                  key={id}
+                  book={book}
+                  para={para}
+                  wordStart={wordStart}
+                  wordEnd={wordEnd}
+                  studio={item.studio}
+                  channelId={item.channel.id}
+                  channelType={item.channel.type}
+                  channelLang={item.channel.lang}
+                  data={JSON.parse(item.content ?? "")}
+                  answer={answer ? JSON.parse(answer.content ?? "") : undefined}
+                  mode={mode}
+                  wbwProgress={wbwProgress}
+                  readonly={readonly}
+                  onChange={(data: IWbw[]) => {
+                    if (typeof onWbwChange !== "undefined") {
+                      onWbwChange(data);
+                    }
+                  }}
+                  onMagicDictDone={() => {
+                    if (typeof onMagicDictDone !== "undefined") {
+                      onMagicDictDone();
+                    }
+                  }}
+                />
+              );
+            }
           } else {
             return <SentCell key={id} initValue={item} wordWidget={true} />;
           }

+ 12 - 0
dashboard-v6/backup/components/README.md

@@ -0,0 +1,12 @@
+# 组件
+
+## 目录
+
+某栏目的专用组件放在以该栏目命名的目录下
+
+- `libray` 前台(阅读)的栏目
+  - `栏目名称` 某栏目的专用组件
+  - `组件1.tsx` libray 下面的栏目的公共组件
+- `studio` 用户后台(课程发布等)
+
+nut目录为练习用途。里面的内容可能会被删除。**线上不要使用**。

+ 59 - 0
dashboard-v6/backup/components/admin/HeadBar.tsx

@@ -0,0 +1,59 @@
+import { Link } from "react-router";
+import { Input, Layout, Space } from "antd";
+
+import img_banner from "../../assets/studio/images/wikipali_banner.svg";
+import UiLangSelect from "../general/UiLangSelect";
+import SignInAvatar from "../auth/SignInAvatar";
+import ToLibrary from "../auth/ToLibrary";
+import ThemeSelect from "../general/ThemeSelect";
+
+const { Search } = Input;
+const { Header } = Layout;
+
+const onSearch = (value: string) => console.log(value);
+
+const HeadBarWidget = () => {
+  return (
+    <Header
+      className="header"
+      style={{
+        lineHeight: "44px",
+        height: 44,
+        paddingLeft: 10,
+        paddingRight: 10,
+      }}
+    >
+      <div
+        style={{
+          display: "flex",
+          width: "100%",
+          justifyContent: "space-between",
+        }}
+      >
+        <div style={{ width: 80 }}>
+          <Link to="/">
+            <img alt="code" style={{ height: 36 }} src={img_banner} />
+          </Link>
+        </div>
+        <div style={{ width: 500, lineHeight: 44 }}>
+          <Search
+            disabled
+            placeholder="input search text"
+            onSearch={onSearch}
+            style={{ width: "100%" }}
+          />
+        </div>
+        <div>
+          <Space>
+            <ToLibrary />
+            <SignInAvatar />
+            <UiLangSelect />
+            <ThemeSelect />
+          </Space>
+        </div>
+      </div>
+    </Header>
+  );
+};
+
+export default HeadBarWidget;

+ 94 - 0
dashboard-v6/backup/components/admin/LeftSider.tsx

@@ -0,0 +1,94 @@
+import { Link } from "react-router";
+import type { MenuProps } from "antd";
+import { Affix, Layout } from "antd";
+import { Menu } from "antd";
+import { AppstoreOutlined, HomeOutlined } from "@ant-design/icons";
+
+const { Sider } = Layout;
+
+const onClick: MenuProps["onClick"] = (e) => {
+  console.log("click ", e);
+};
+
+type IWidgetHeadBar = {
+  selectedKeys?: string;
+};
+const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
+  const items: MenuProps["items"] = [
+    {
+      label: "API",
+      key: "api",
+      icon: <HomeOutlined />,
+      children: [
+        {
+          label: <Link to="/admin/api/dashboard">dashboard</Link>,
+          key: "dashboard",
+        },
+      ],
+    },
+    {
+      label: "管理",
+      key: "manage",
+      icon: <HomeOutlined />,
+      children: [
+        {
+          label: <Link to="/admin/relation/list">Relation</Link>,
+          key: "relation",
+        },
+        {
+          label: <Link to="/admin/nissaya-ending/list">Nissaya Ending</Link>,
+          key: "nissaya-ending",
+        },
+        {
+          label: <Link to="/admin/ai/list">AI</Link>,
+          key: "ai",
+        },
+        {
+          label: "Dictionary",
+          key: "dict",
+          children: [
+            {
+              label: <Link to="/admin/dictionary/list">List</Link>,
+              key: "list",
+            },
+            {
+              label: <Link to="/admin/dictionary/preference">Preference</Link>,
+              key: "preference",
+            },
+          ],
+        },
+        {
+          label: <Link to="/admin/users/list">users</Link>,
+          key: "users",
+        },
+        {
+          label: <Link to="/admin/invite/list">invite</Link>,
+          key: "invite",
+        },
+      ],
+    },
+    {
+      label: "统计",
+      key: "advance",
+      icon: <AppstoreOutlined />,
+      children: [],
+    },
+  ];
+
+  return (
+    <Affix offsetTop={0}>
+      <Sider width={200} breakpoint="lg" className="site-layout-background">
+        <Menu
+          theme="light"
+          onClick={onClick}
+          defaultSelectedKeys={[selectedKeys]}
+          defaultOpenKeys={["basic", "advance", "collaboration"]}
+          mode="inline"
+          items={items}
+        />
+      </Sider>
+    </Affix>
+  );
+};
+
+export default LeftSiderWidget;

+ 64 - 0
dashboard-v6/backup/components/admin/api/ApiDelayHour.tsx

@@ -0,0 +1,64 @@
+import React, { useEffect, useState } from "react";
+import { Column } from "@ant-design/plots";
+import { put } from "../../../request";
+import { StatisticCard } from "@ant-design/pro-components";
+
+interface IApiDelay {
+  date: string;
+  value: number;
+}
+interface IApiDelayResponse {
+  ok: boolean;
+  message: string;
+  data: IApiDelay[];
+}
+interface IApiRequest {
+  api: string;
+  item: string;
+}
+
+interface IWidget {
+  title?: React.ReactNode;
+  type: "average" | "count" | "delay";
+  api?: string;
+}
+
+const ApiDelayHourWidget = ({ title, type, api = "all" }: IWidget) => {
+  const [delayData, setDelayData] = useState<IApiDelay[]>([]);
+
+  useEffect(() => {
+    put<IApiRequest, IApiDelayResponse>("/v2/api/10", {
+      api: api,
+      item: type,
+    }).then((json) => {
+      console.log("data", json.data);
+      setDelayData(json.data);
+    });
+  }, []);
+
+  const config = {
+    data: delayData,
+    xField: "date",
+    yField: "value",
+    seriesField: "",
+    xAxis: {
+      label: {
+        autoHide: true,
+        autoRotate: false,
+      },
+    },
+  };
+
+  return (
+    <StatisticCard
+      statistic={{
+        title: title,
+        value: "",
+        suffix: "/ ms",
+      }}
+      chart={<Column {...config} height={300} />}
+    />
+  );
+};
+
+export default ApiDelayHourWidget;

+ 83 - 0
dashboard-v6/backup/components/admin/api/ApiGauge.tsx

@@ -0,0 +1,83 @@
+import { useEffect, useRef, useState } from "react";
+import { Gauge } from "@ant-design/plots";
+import { get } from "../../../request";
+import { StatisticCard } from "@ant-design/pro-components";
+
+interface IApiResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+const ApiGaugeWidget = () => {
+  const min = 0;
+  const max = 1;
+  const [percent, setPercent] = useState<number>(0);
+  const [delay, setDelay] = useState<number>(0);
+  const maxAxis = 5000; //最大量程-毫秒
+
+  useEffect(() => {
+    const timer = setInterval(() => {
+      get<IApiResponse>("/v2/api/10?item=average").then((json) => {
+        setPercent(json.data / maxAxis);
+        setDelay(json.data);
+      });
+    }, 1000 * 5);
+    return () => {
+      clearInterval(timer);
+    };
+  }, []);
+
+  const graphRef: any = useRef(null);
+
+  const config = {
+    percent: percent,
+    range: {
+      ticks: [min, max],
+      color: ["l(0) 0:#30BF78 0.5:#FAAD14 1:#F4664A"],
+    },
+    indicator: {
+      pointer: {
+        style: {
+          stroke: "#D0D0D0",
+        },
+      },
+      pin: {
+        style: {
+          stroke: "#D0D0D0",
+        },
+      },
+    },
+    axis: {
+      label: {
+        formatter(v: any) {
+          return Number(v) * maxAxis;
+        },
+      },
+      subTickLine: {
+        count: 3,
+      },
+    },
+  };
+
+  return (
+    <StatisticCard
+      style={{ width: 400 }}
+      statistic={{
+        title: "平均相应时间",
+        value: delay,
+        suffix: "/ ms",
+      }}
+      chart={
+        <Gauge
+          ref={graphRef}
+          {...config}
+          onReady={(chart) => {
+            graphRef.current = chart;
+          }}
+        />
+      }
+    />
+  );
+};
+
+export default ApiGaugeWidget;

+ 42 - 0
dashboard-v6/backup/components/admin/relation/CaseSelect.tsx

@@ -0,0 +1,42 @@
+import { ProFormSelect } from "@ant-design/pro-components";
+
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  name?: string;
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+}
+const CaseSelectWidget = ({ name = "case", width = "md" }: IWidget) => {
+  const intl = useIntl();
+  const _case = [
+    "nom",
+    "acc",
+    "gen",
+    "dat",
+    "inst",
+    "abl",
+    "loc",
+    "ger",
+    "adv",
+  ];
+  const caseOptions = _case.map((item) => {
+    return {
+      value: item,
+      label: intl.formatMessage({
+        id: `dict.fields.type.${item}.label`,
+        defaultMessage: item,
+      }),
+    };
+  });
+
+  return (
+    <ProFormSelect
+      options={caseOptions}
+      width={width}
+      name={name}
+      label={intl.formatMessage({ id: "forms.fields.case.label" })}
+    />
+  );
+};
+
+export default CaseSelectWidget;

+ 100 - 0
dashboard-v6/backup/components/admin/relation/DataImport.tsx

@@ -0,0 +1,100 @@
+import { ModalForm, ProFormUploadDragger } from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { API_HOST, get } from "../../../request";
+import type { UploadFile } from "antd/es/upload/interface";
+import type { IAttachmentResponse } from "../../../api/Attachments";
+import modal from "antd/lib/modal";
+import { useIntl } from "react-intl";
+
+interface INissayaEndingUpload {
+  filename: UploadFile<IAttachmentResponse>[];
+}
+export interface INissayaEndingImportResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    success: number;
+    fail: number;
+  };
+}
+
+interface IWidget {
+  title?: string;
+  url: string;
+  urlExtra?: string;
+  trigger?: JSX.Element;
+  onSuccess?: Function;
+}
+const DataImportWidget = ({
+  title,
+  url,
+  urlExtra,
+  trigger = <>{"trigger"}</>,
+  onSuccess,
+}: IWidget) => {
+  const intl = useIntl();
+  const [form] = Form.useForm<INissayaEndingUpload>();
+  const formTitle = title ? title : intl.formatMessage({ id: "labels.upload" });
+  return (
+    <ModalForm<INissayaEndingUpload>
+      title={formTitle}
+      trigger={trigger}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        console.log("values", values);
+        let _filename: string = "";
+
+        if (
+          typeof values.filename === "undefined" ||
+          values.filename.length === 0
+        ) {
+          _filename = "";
+        } else if (typeof values.filename[0].response === "undefined") {
+          _filename = values.filename[0].uid;
+        } else {
+          _filename = values.filename[0].response.data.filename;
+        }
+
+        const queryUrl = `${url}?filename=${_filename}&${urlExtra}`;
+        const res = await get<INissayaEndingImportResponse>(queryUrl);
+        if (res.ok) {
+          if (res.data.fail > 0) {
+            modal.info({
+              title: "error",
+              content: `成功${res.data.success}-失败${res.data.fail}\n${res.message}`,
+            });
+          } else {
+            message.success(`成功导入${res.data.success}`);
+          }
+
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+    >
+      <ProFormUploadDragger
+        max={1}
+        label="请确保您的xlsx文件是用导出功能导出的。word为空可以删除该词条。使用其他studio导出的数据,请将channel_id设置为空。否则该术语将被忽略。"
+        name="filename"
+        fieldProps={{
+          name: "file",
+        }}
+        action={`${API_HOST}/api/v2/attachments?is_tmp=true`}
+      />
+    </ModalForm>
+  );
+};
+
+export default DataImportWidget;

+ 84 - 0
dashboard-v6/backup/components/admin/relation/GrammarSelect.tsx

@@ -0,0 +1,84 @@
+import { ProFormSelect } from "@ant-design/pro-components";
+import type { JSX } from "react";
+
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  name: string;
+  trigger?: JSX.Element;
+  id?: string;
+  hidden?: boolean;
+  onSuccess?: () => void;
+}
+const GrammarSelectWidget = ({ name, hidden = false }: IWidget) => {
+  const intl = useIntl();
+  const _verb = [
+    "n",
+    "ti",
+    "v",
+    "v:ind",
+    "ind",
+    "sg",
+    "pl",
+    "nom",
+    "acc",
+    "gen",
+    "dat",
+    "inst",
+    "voc",
+    "abl",
+    "loc",
+    "base",
+    "imp",
+    "opt",
+    "pres",
+    "aor",
+    "fut",
+    "1p",
+    "2p",
+    "3p",
+    "prp",
+    "pp",
+    "grd",
+    "fpp",
+    "vdn",
+    "ger",
+    "inf",
+    "adj",
+    "pron",
+    "caus",
+    "num",
+    "adv",
+    "conj",
+    "pre",
+    "suf",
+    "ti:base",
+    "n:base",
+    "v:base",
+    "vdn",
+  ];
+  const verbOptions = _verb.map((item) => {
+    return {
+      value: item,
+      label: intl.formatMessage({
+        id: `dict.fields.type.${item}.label`,
+        defaultMessage: item,
+      }),
+    };
+  });
+  return (
+    <ProFormSelect
+      hidden={hidden}
+      options={verbOptions}
+      fieldProps={{
+        mode: "tags",
+      }}
+      width="md"
+      name={name}
+      allowClear={false}
+      label={intl.formatMessage({ id: "forms.fields.case.label" })}
+    />
+  );
+};
+
+export default GrammarSelectWidget;

+ 128 - 0
dashboard-v6/backup/components/admin/relation/NissayaEndingEdit.tsx

@@ -0,0 +1,128 @@
+import { ModalForm, ProForm, ProFormText } from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import type {
+  INissayaEnding,
+  INissayaEndingRequest,
+  INissayaEndingResponse,
+} from "../../../pages/admin/nissaya-ending/list";
+import { get, post, put } from "../../../request";
+import LangSelect from "../../general/LangSelect";
+import GrammarSelect from "./GrammarSelect";
+
+interface IWidget {
+  trigger?: JSX.Element;
+  id?: string;
+  onSuccess?: Function;
+}
+const NissayaEndingWidget = ({
+  trigger = <>{"trigger"}</>,
+  id,
+  onSuccess,
+}: IWidget) => {
+  const [title, setTitle] = useState<string | undefined>(id ? "" : "新建");
+  const [form] = Form.useForm<INissayaEnding>();
+  const intl = useIntl();
+  return (
+    <ModalForm<INissayaEnding>
+      title={title}
+      trigger={trigger}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        const data = values;
+        data.from = {
+          spell: values.fromSpell,
+          case: values.fromCase ? values.fromCase : undefined,
+        };
+        let res: INissayaEndingResponse;
+        if (typeof id === "undefined") {
+          res = await post<INissayaEndingRequest, INissayaEndingResponse>(
+            `/v2/nissaya-ending`,
+            data
+          );
+        } else {
+          res = await put<INissayaEndingRequest, INissayaEndingResponse>(
+            `/v2/nissaya-ending/${id}`,
+            data
+          );
+        }
+        console.log(res);
+        if (res.ok) {
+          message.success("提交成功");
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+      request={
+        id
+          ? async () => {
+              const res = await get<INissayaEndingResponse>(
+                `/v2/nissaya-ending/${id}`
+              );
+              console.log("nissaya-ending get", res);
+              if (res.ok) {
+                setTitle(res.data.ending);
+
+                return {
+                  id: id,
+                  ending: res.data.ending,
+                  relation: res.data.relation,
+                  from: res.data.from,
+                  fromCase: res.data.from?.case,
+                  fromSpell: res.data.from?.spell,
+                  lang: res.data.lang,
+                };
+              } else {
+                return {
+                  id: undefined,
+                  ending: "",
+                  relation: "",
+                  lang: "",
+                };
+              }
+            }
+          : undefined
+      }
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="ending"
+          label={intl.formatMessage({ id: "forms.fields.ending.label" })}
+          tooltip="最长为 24 位"
+        />
+        <LangSelect width="md" />
+      </ProForm.Group>
+      <ProForm.Group>
+        <GrammarSelect name="fromCase" />
+        <ProFormText
+          width="md"
+          name="fromSpell"
+          label={intl.formatMessage({ id: "buttons.spell" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="relation"
+          label={intl.formatMessage({ id: "forms.fields.relation.label" })}
+        />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default NissayaEndingWidget;

+ 200 - 0
dashboard-v6/backup/components/admin/relation/RelationEdit.tsx

@@ -0,0 +1,200 @@
+import {
+  ModalForm,
+  ProForm,
+  type ProFormInstance,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import type {
+  IRelation,
+  IRelationRequest,
+  IRelationResponse,
+} from "../../../pages/admin/relation/list";
+import { get, post, put } from "../../../request";
+import GrammarSelect from "./GrammarSelect";
+
+export const _verb = [
+  "n",
+  "ti",
+  "v",
+  "v:ind",
+  "ind",
+  "sg",
+  "pl",
+  "nom",
+  "acc",
+  "gen",
+  "dat",
+  "inst",
+  "voc",
+  "abl",
+  "loc",
+  "base",
+  "imp",
+  "opt",
+  "pres",
+  "aor",
+  "fut",
+  "1p",
+  "2p",
+  "3p",
+  "prp",
+  "pp",
+  "grd",
+  "fpp",
+  "vdn",
+  "ger",
+  "inf",
+  "adj",
+  "pron",
+  "caus",
+  "num",
+  "adv",
+  "conj",
+  "pre",
+  "suf",
+  "ti:base",
+  "n:base",
+  "v:base",
+  "vdn",
+];
+interface IWidget {
+  trigger?: JSX.Element;
+  id?: string;
+  onSuccess?: Function;
+}
+const RelationEditWidget = ({
+  trigger = <>{"trigger"}</>,
+  id,
+  onSuccess,
+}: IWidget) => {
+  const [title, setTitle] = useState<string | undefined>(id ? "" : "新建");
+  const [form] = Form.useForm<IRelation>();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  const intl = useIntl();
+
+  return (
+    <ModalForm<IRelation>
+      title={title}
+      trigger={trigger}
+      formRef={formRef}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("onCancel"),
+      }}
+      submitTimeout={3000}
+      onFinish={async (values) => {
+        const data = values;
+        data.from = { spell: values.fromSpell, case: values.fromCase };
+        data.to = { spell: values.toSpell, case: values.toCase };
+        let res: IRelationResponse;
+        if (typeof id === "undefined") {
+          res = await post<IRelationRequest, IRelationResponse>(
+            `/v2/relation`,
+            data
+          );
+        } else {
+          res = await put<IRelationRequest, IRelationResponse>(
+            `/v2/relation/${id}`,
+            data
+          );
+        }
+        console.log(res);
+        if (res.ok) {
+          message.success("提交成功");
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+      request={
+        id
+          ? async () => {
+              const res = await get<IRelationResponse>(`/v2/relation/${id}`);
+              console.log("relation get", res);
+              if (res.ok) {
+                setTitle(res.data.name + "dd");
+
+                return {
+                  id: id,
+                  name: res.data.name,
+                  case: res.data.case,
+                  from: res.data.from,
+                  fromCase: res.data.from?.case,
+                  fromSpell: res.data.from?.spell,
+                  to: res.data.to,
+                  toCase: res.data.to?.case,
+                  toSpell: res.data.to?.spell,
+                  match: res.data.match ? res.data.match : undefined,
+                  category: res.data.category,
+                };
+              } else {
+                return {
+                  id: undefined,
+                  name: "",
+                };
+              }
+            }
+          : undefined
+      }
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group title="从">
+        <GrammarSelect name="fromCase" />
+        <ProFormText
+          width="md"
+          name="fromSpell"
+          label={intl.formatMessage({ id: "buttons.spell" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group title="连接到">
+        <GrammarSelect name="toCase" />
+        <ProFormText
+          width="md"
+          name="toSpell"
+          label={intl.formatMessage({ id: "buttons.spell" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          options={["gender", "number", "case"].map((item) => {
+            return {
+              value: item,
+              label: item,
+            };
+          })}
+          fieldProps={{
+            mode: "tags",
+          }}
+          width="md"
+          name="match"
+          allowClear={false}
+          label={intl.formatMessage({ id: "forms.fields.match.label" })}
+        />
+        <ProFormText
+          width="md"
+          name="category"
+          label={intl.formatMessage({ id: "forms.fields.category.label" })}
+        />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default RelationEditWidget;

+ 80 - 0
dashboard-v6/backup/components/ai/AiAssistantSelect.tsx

@@ -0,0 +1,80 @@
+import {
+  ProFormSelect,
+  type RequestOptionsType,
+} from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import type { IUserListResponse } from "../../api/Auth";
+
+interface IWidget {
+  name?: string;
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  multiple?: boolean;
+  hidden?: boolean;
+  hiddenTitle?: boolean;
+  required?: boolean;
+  initialValue?: string | string[] | null;
+  options?: RequestOptionsType[];
+}
+const UserSelectWidget = ({
+  name = "user",
+  multiple = false,
+  width = "md",
+  hidden = false,
+  hiddenTitle = false,
+  required = true,
+  options = [],
+  initialValue,
+}: IWidget) => {
+  const intl = useIntl();
+  console.log("UserSelect options", options);
+  return (
+    <ProFormSelect
+      name={name}
+      label={
+        hiddenTitle
+          ? undefined
+          : intl.formatMessage({ id: "labels.ai-assistant" })
+      }
+      hidden={hidden}
+      width={width}
+      initialValue={initialValue}
+      showSearch
+      debounceTime={300}
+      fieldProps={{
+        mode: multiple ? "tags" : undefined,
+      }}
+      request={async ({ keyWords }) => {
+        console.log("keyWord", keyWords);
+
+        if (typeof keyWords === "string") {
+          const url = `/v2/ai-assistant?keyword=${keyWords}`;
+          console.info("ai assistant api request", url);
+          const json = await get<IUserListResponse>(url);
+          console.info("ai assistant api response ", json);
+          const userList: RequestOptionsType[] = json.data.rows.map((item) => {
+            return {
+              value: item.id,
+              label: `${item.nickName}`,
+            };
+          });
+          console.log("json", userList);
+          return userList;
+        } else {
+          const defaultOptions: RequestOptionsType[] = options.map((item) => {
+            return { label: item.label, value: item.value?.toString() };
+          });
+          return defaultOptions;
+        }
+      }}
+      rules={[
+        {
+          required: required,
+        },
+      ]}
+    />
+  );
+};
+
+export default UserSelectWidget;

+ 69 - 0
dashboard-v6/backup/components/ai/AiModelCreate.tsx

@@ -0,0 +1,69 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { post } from "../../request";
+import { useRef } from "react";
+import type { IAiModelRequest, IAiModelResponse } from "../../api/ai";
+
+interface IWidget {
+  studioName?: string;
+  onCreate?: Function;
+}
+const AiModelCreate = ({ studioName, onCreate }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IAiModelRequest>
+      formRef={formRef}
+      onFinish={async (values: IAiModelRequest) => {
+        if (typeof studioName === "undefined") {
+          return;
+        }
+        const url = `/v2/ai-model`;
+        console.info("api request", url, values);
+        const res = await post<IAiModelRequest, IAiModelResponse>(url, values);
+        console.info("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+            formRef.current?.resetFields();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="studio_name"
+          initialValue={studioName}
+          hidden
+          required
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AiModelCreate;

+ 114 - 0
dashboard-v6/backup/components/ai/AiModelEdit.tsx

@@ -0,0 +1,114 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { get, put } from "../../request";
+import { useRef } from "react";
+import type { IAiModelRequest, IAiModelResponse } from "../../api/ai";
+import Publicity from "../studio/Publicity";
+
+interface IWidget {
+  studioName?: string;
+  modelId?: string;
+  onChange?: Function;
+}
+const AiModelEdit = ({ studioName, modelId, onChange }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IAiModelRequest>
+      formRef={formRef}
+      onFinish={async (values: IAiModelRequest) => {
+        if (typeof studioName === "undefined") {
+          return;
+        }
+        const url = `/v2/ai-model/${modelId}`;
+        console.info("api request", url, values);
+        const res = await put<IAiModelRequest, IAiModelResponse>(url, values);
+        console.info("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          onChange && onChange();
+        } else {
+          message.error(res.message);
+        }
+      }}
+      request={async () => {
+        const url = `/v2/ai-model/${modelId}`;
+        console.info("api request", url);
+        const res = await get<IAiModelResponse>(url);
+        console.info("api response", res);
+        return res.data;
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="studio_name"
+          initialValue={studioName}
+          hidden
+          required
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="url"
+          label={intl.formatMessage({ id: "forms.fields.url.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="model"
+          label={intl.formatMessage({ id: "forms.fields.model.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="key"
+          label={intl.formatMessage({ id: "forms.fields.key.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <Publicity name="privacy" disable={["public_no_list", "blocked"]} />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({ id: "forms.fields.description.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="system_prompt"
+          label={"system_prompt"}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AiModelEdit;

+ 150 - 0
dashboard-v6/backup/components/ai/AiModelList.tsx

@@ -0,0 +1,150 @@
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+import { Button, Popover, Tag, Space } from "antd";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { PlusOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+
+import { useRef, useState } from "react";
+
+import { getSorterUrl } from "../../utils";
+import type { IAiModel, IAiModelListResponse } from "../../api/ai";
+import AiModelCreate from "./AiModelCreate";
+import PublicityIcon from "../studio/PublicityIcon";
+import ShareModal from "../share/ShareModal";
+import { EResType } from "../share/Share";
+import User from "../auth/User";
+
+interface IWidget {
+  studioName?: string;
+}
+const AiModelList = ({ studioName }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProList<IAiModel>
+        actionRef={ref}
+        onRow={(_record) => ({
+          onClick: () => {},
+        })}
+        metas={{
+          title: {
+            dataIndex: "name",
+            render(_dom, entity, _index, _action, _schema) {
+              return (
+                <Space>
+                  <PublicityIcon value={entity.privacy} />
+                  <Link
+                    to={`/studio/${studioName}/ai/models/${entity.uid}/edit`}
+                  >
+                    {entity.name}
+                  </Link>
+                </Space>
+              );
+            },
+          },
+          description: {
+            dataIndex: "url",
+          },
+          subTitle: {
+            render(_dom, entity, _index, _action, _schema) {
+              return <Tag>{entity.model}</Tag>;
+            },
+          },
+          content: {
+            render(_dom, entity, _index, _action, _schema) {
+              return entity.description;
+            },
+          },
+          avatar: {
+            render(_dom, entity, _index, _action, _schema) {
+              return <User {...entity.user} showName={false} />;
+            },
+          },
+          actions: {
+            render(_dom, entity, _index, _action, _schema) {
+              return (
+                <Space>
+                  <Link
+                    to={`/studio/${studioName}/ai/models/${entity.uid}/logs`}
+                  >
+                    logs
+                  </Link>
+                  <ShareModal
+                    trigger={
+                      <Button type="link" size="small">
+                        {intl.formatMessage({
+                          id: "buttons.share",
+                        })}
+                      </Button>
+                    }
+                    resId={entity.uid}
+                    resType={EResType.modal}
+                  />
+                </Space>
+              );
+            },
+          },
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/v2/ai-model?view=studio&name=${studioName}`;
+          const offset = ((params.current ?? 1) - 1) * (params.pageSize ?? 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += getSorterUrl(sorter);
+
+          console.info("api request", url);
+          const res = await get<IAiModelListResponse>(url);
+          console.info("api response", res);
+          return {
+            total: res.data.total,
+            succcess: res.ok,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Popover
+            content={
+              <AiModelCreate
+                studioName={studioName}
+                onCreate={() => {
+                  setOpenCreate(false);
+                  ref.current?.reload();
+                }}
+              />
+            }
+            placement="bottomRight"
+            trigger="click"
+            open={openCreate}
+            onOpenChange={(open: boolean) => {
+              setOpenCreate(open);
+            }}
+          >
+            <Button key="button" icon={<PlusOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.create" })}
+            </Button>
+          </Popover>,
+        ]}
+      />
+    </>
+  );
+};
+
+export default AiModelList;

+ 154 - 0
dashboard-v6/backup/components/ai/AiModelLogList.tsx

@@ -0,0 +1,154 @@
+import { ProList } from "@ant-design/pro-components";
+import { Space, Tabs, Tag, Typography } from "antd";
+import type { Key } from "react";
+import { useState } from "react";
+
+import { CheckOutlined, WarningOutlined } from "@ant-design/icons";
+
+import type { IAiModelLogData, IAiModelLogListResponse } from "../../api/ai";
+import { get } from "../../request";
+import moment from "moment";
+
+const { Text } = Typography;
+
+interface IWidget {
+  modelId?: string;
+}
+const AiModelLogList = ({ modelId }: IWidget) => {
+  const [expandedRowKeys, setExpandedRowKeys] = useState<readonly Key[]>([]);
+
+  return (
+    <ProList<IAiModelLogData>
+      rowKey="title"
+      headerTitle="logs"
+      expandable={{ expandedRowKeys, onExpandedRowsChange: setExpandedRowKeys }}
+      metas={{
+        title: {},
+        subTitle: {
+          render: (_dom, entity, _index, _action, _schema) => {
+            return (
+              <Space size={0}>
+                <Tag color="blue">{entity.status}</Tag>
+              </Space>
+            );
+          },
+        },
+        description: {
+          render: (_dom, entity, _index, _action, _schema) => {
+            const jsonView = (text?: string | null) => {
+              return (
+                <div>
+                  <pre>
+                    {text ? JSON.stringify(JSON.parse(text), null, 2) : ""}
+                  </pre>
+                </div>
+              );
+            };
+            const info = (headers: string, data: string) => {
+              return (
+                <div>
+                  <Text strong>Headers</Text>
+                  <div
+                    style={{
+                      backgroundColor: "rgb(246, 248, 250)",
+                      border: "1px solid gray",
+                      padding: 6,
+                    }}
+                  >
+                    {jsonView(headers)}
+                  </div>
+                  <Text strong>Payload</Text>
+                  <div
+                    style={{
+                      backgroundColor: "rgb(246, 248, 250)",
+                      border: "1px solid gray",
+                      padding: 6,
+                    }}
+                  >
+                    {jsonView(data)}
+                  </div>
+                </div>
+              );
+            };
+            return (
+              <>
+                <Tabs
+                  items={[
+                    {
+                      label: "request",
+                      key: "request",
+                      children: (
+                        <div>
+                          {info(entity.request_headers, entity.request_data)}
+                        </div>
+                      ),
+                    },
+                    {
+                      label: "response",
+                      key: "response",
+                      children: (
+                        <div>
+                          {info(
+                            entity.response_headers ?? "",
+                            entity.response_data ?? ""
+                          )}
+                        </div>
+                      ),
+                    },
+                  ]}
+                />
+              </>
+            );
+          },
+        },
+        avatar: {
+          render(_dom, entity, _index, _action, _schema) {
+            return (
+              <>
+                {entity.success ? (
+                  <CheckOutlined style={{ color: "green" }} />
+                ) : (
+                  <WarningOutlined color="error" />
+                )}
+              </>
+            );
+          },
+        },
+        actions: {
+          render: (_dom, entity, _index, _action, _schema) => {
+            const date = moment(entity.created_at).toLocaleString();
+            return <Text type="secondary">{date}</Text>;
+          },
+        },
+      }}
+      bordered
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+        pageSize: 20,
+      }}
+      search={false}
+      options={{
+        search: true,
+      }}
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/model-log?view=model&id=${modelId}`;
+        const offset = ((params.current ?? 1) - 1) * (params.pageSize ?? 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+
+        console.info("ai model log api request", url);
+        const res = await get<IAiModelLogListResponse>(url);
+        console.info("ai model log api response", res);
+        return {
+          total: res.data.total,
+          succcess: res.ok,
+          data: res.data.rows,
+        };
+      }}
+    />
+  );
+};
+
+export default AiModelLogList;

+ 78 - 0
dashboard-v6/backup/components/ai/AiTranslate.tsx

@@ -0,0 +1,78 @@
+import { Button, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { LoadingOutlined } from "@ant-design/icons";
+
+import Marked from "../general/Marked";
+import { get } from "../../request";
+import type { IAiTranslateResponse } from "../../api/ai";
+
+const { Text } = Typography;
+
+interface IAiTranslateWidget {
+  origin?: string;
+  paragraph?: string;
+  autoLoad?: boolean;
+  trigger?: boolean;
+}
+
+const AiTranslate = ({
+  paragraph,
+  autoLoad = false,
+  trigger = false,
+}: IAiTranslateWidget) => {
+  const [loading, setLoading] = useState(false);
+  const [translation, setTranslation] = useState<string>();
+  const [error, setError] = useState<string>();
+  const url = "/v2/ai-translate";
+
+  useEffect(() => {
+    if (typeof paragraph === "undefined") {
+      return;
+    }
+    if (!autoLoad) {
+      return;
+    }
+    //onTranslatePara();
+  }, [paragraph, autoLoad]);
+
+  const onTranslatePara = () => {
+    const _url = `${url}/${paragraph}`;
+    console.info("api request", _url);
+    setLoading(true);
+    get<IAiTranslateResponse>(_url)
+      .then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          setTranslation(json.data.choices[0].message.content);
+        } else {
+          setError(json.message);
+        }
+      })
+      .finally(() => setLoading(false));
+  };
+
+  if (translation) {
+    return <Marked text={translation} />;
+  } else if (loading) {
+    return <LoadingOutlined />;
+  } else if (error) {
+    return (
+      <div>
+        <Text type="danger">{error}</Text>
+        <Button type="link" onClick={() => onTranslatePara()}>
+          再试一次
+        </Button>
+      </div>
+    );
+  } else if (trigger) {
+    return (
+      <Button type="link" onClick={() => onTranslatePara()}>
+        AI 翻译
+      </Button>
+    );
+  } else {
+    return <></>;
+  }
+};
+
+export default AiTranslate;

+ 145 - 0
dashboard-v6/backup/components/ai/ModelSelector.tsx

@@ -0,0 +1,145 @@
+import { Button, Dropdown, Typography, Tag } from "antd";
+import {
+  DownOutlined,
+  ReloadOutlined,
+  GlobalOutlined,
+} from "@ant-design/icons";
+import type { MenuProps } from "antd";
+
+const { Text } = Typography;
+
+const ModelSelector = () => {
+  const modelItems: MenuProps["items"] = [
+    {
+      key: "auto",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">Auto</div>
+        </div>
+      ),
+    },
+    {
+      key: "gpt-4o",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">
+            GPT-4o<Tag>翻译</Tag>
+          </div>
+          <Text type="secondary">适用于大多数任务</Text>
+        </div>
+      ),
+    },
+    {
+      key: "o4-mini",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">o4-mini</div>
+          <Text type="secondary">快速进行高级推理</Text>
+        </div>
+      ),
+    },
+    {
+      key: "gpt-4.1-mini",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">GPT-4.1-mini</div>
+          <Text type="secondary">适合处理日常任务</Text>
+        </div>
+      ),
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "refresh",
+      label: (
+        <div className="py-2 flex items-center space-x-2 text-gray-700">
+          <ReloadOutlined />
+          <span>重试</span>
+          <div className="ml-auto">
+            <Text type="secondary">GPT-4o</Text>
+          </div>
+        </div>
+      ),
+    },
+    {
+      key: "search",
+      label: (
+        <div className="py-2 flex items-center space-x-2 text-gray-700">
+          <GlobalOutlined />
+          <span>搜索网页</span>
+        </div>
+      ),
+    },
+  ];
+
+  const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
+    if (key === "refresh") {
+      console.log("重试操作");
+      return;
+    }
+    if (key === "search") {
+      console.log("搜索网页");
+      return;
+    }
+  };
+
+  return (
+    <div className="bg-gray-50 min-h-screen">
+      <div className="max-w-md mx-auto">
+        <Dropdown
+          menu={{
+            items: modelItems,
+            onClick: handleMenuClick,
+            className: "w-64",
+          }}
+          trigger={["click"]}
+          placement="bottomLeft"
+          overlayClassName="model-selector-dropdown"
+        >
+          <Button
+            className="flex items-center justify-between w-48 h-12 px-4 border border-gray-300 rounded-lg bg-white hover:bg-gray-50 shadow-sm"
+            type="text"
+          >
+            <div className="flex items-center space-x-2">
+              <span className="font-medium text-gray-900 model_name">
+                {"AI"}
+              </span>
+              <DownOutlined className="text-gray-500 text-sm" />
+            </div>
+          </Button>
+        </Dropdown>
+      </div>
+
+      <style>{`
+        .model-selector-dropdown .ant-dropdown-menu {
+          padding: 8px;
+          border-radius: 12px;
+          box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+          border: 1px solid #e5e7eb;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-item {
+          padding: 0;
+          margin: 2px 0;
+          border-radius: 8px;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-item:hover {
+          background-color: #f3f4f6;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-item-selected {
+          background-color: #eff6ff;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-divider {
+          margin: 8px 0;
+          background-color: #e5e7eb;
+        }
+      `}</style>
+    </div>
+  );
+};
+
+export default ModelSelector;

+ 83 - 0
dashboard-v6/backup/components/anthology/AnthologyCreate.tsx

@@ -0,0 +1,83 @@
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { message } from "antd";
+
+import LangSelect from "../general/LangSelect";
+import type {
+  IAnthologyCreateRequest,
+  IAnthologyResponse,
+} from "../../api/Article";
+import { post } from "../../request";
+import { useRef } from "react";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidget {
+  studio?: string;
+  onSuccess?: Function;
+}
+const AnthologyCreateWidget = ({ studio, onSuccess }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        if (typeof studio === "undefined") {
+          return;
+        }
+        values.studio = studio;
+        const url = `/v2/anthology`;
+        console.info("api request", url, values);
+        const res = await post<IAnthologyCreateRequest, IAnthologyResponse>(
+          url,
+          values
+        );
+        console.debug("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+            formRef.current?.resetFields(["title"]);
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.title.required",
+              }),
+              max: 255,
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <LangSelect />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AnthologyCreateWidget;

+ 396 - 0
dashboard-v6/backup/components/anthology/AnthologyList.tsx

@@ -0,0 +1,396 @@
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+import { message, Modal, Typography } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+import { Button, Dropdown, Popover } from "antd";
+import {
+  ExclamationCircleOutlined,
+  TeamOutlined,
+  DeleteOutlined,
+  EyeOutlined,
+} from "@ant-design/icons";
+
+import AnthologyCreate from "./AnthologyCreate";
+import type {
+  IAnthologyListResponse,
+  IDeleteResponse,
+} from "../../api/Article";
+import { delete_, get } from "../../request";
+import { PublicityValueEnum } from "../studio/table";
+import { useEffect, useRef, useState } from "react";
+import Share, { EResType } from "../share/Share";
+
+import StudioName, { type IStudio } from "../auth/Studio";
+import { type IResNumberResponse, renderBadge } from "../channel/ChannelTable";
+import { fullUrl, getSorterUrl } from "../../utils";
+
+const { Text } = Typography;
+
+interface IItem {
+  sn: number;
+  id: string;
+  title: string;
+  subtitle: string;
+  publicity: number;
+  articles: number;
+  studio?: IStudio;
+  updated_at: string;
+}
+interface IWidget {
+  title?: string;
+  studioName?: string;
+  showCol?: string[];
+  showCreate?: boolean;
+  showOption?: boolean;
+  onTitleClick?: (id: string) => void;
+}
+const AnthologyListWidget = ({
+  title,
+  studioName,
+  showCreate = true,
+  showOption = true,
+  onTitleClick,
+}: IWidget) => {
+  const intl = useIntl();
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("my");
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+
+  useEffect(() => {
+    /**
+     * 获取各种课程的数量
+     */
+    const url = `/v2/anthology-my-number?studio=${studioName}`;
+    console.log("url", url);
+    get<IResNumberResponse>(url).then((json) => {
+      if (json.ok) {
+        setMyNumber(json.data.my);
+        setCollaborationNumber(json.data.collaboration);
+      }
+    });
+  }, [studioName]);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/v2/anthology/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [shareResId, setShareResId] = useState<string>("");
+  const [shareResType, setShareResType] = useState<EResType>(
+    EResType.collection
+  );
+  const showShareModal = (resId: string, resType: EResType) => {
+    setShareResId(resId);
+    setShareResType(resType);
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  const ref = useRef<ActionType | null>(null);
+  return (
+    <>
+      <ProTable<IItem>
+        headerTitle={title}
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (_text, row, index) => {
+              return (
+                <div key={index}>
+                  <div>
+                    <Typography.Link
+                      onClick={() => {
+                        if (typeof onTitleClick !== "undefined") {
+                          onTitleClick(row.id);
+                        }
+                      }}
+                    >
+                      {row.title}
+                    </Typography.Link>
+                  </div>
+                  <Text type="secondary">{row.subtitle}</Text>
+                </div>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.owner.label",
+            }),
+            dataIndex: "studio",
+            key: "studio",
+            render: (_text, row) => {
+              return <StudioName data={row.studio} />;
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.publicity.label",
+            }),
+            dataIndex: "publicity",
+            key: "publicity",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: PublicityValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "article.fields.article.count.label",
+            }),
+            dataIndex: "articles",
+            key: "articles",
+            width: 100,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated_at",
+            width: 100,
+            search: false,
+            dataIndex: "updated_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            hideInTable: !showOption,
+            valueType: "option",
+            render: (_text, row, index) => [
+              <Dropdown.Button
+                key={index}
+                type="link"
+                trigger={["click", "contextMenu"]}
+                menu={{
+                  items: [
+                    {
+                      key: "open",
+                      label: (
+                        <Link to={`/anthology/${row.id}`}>
+                          {intl.formatMessage({
+                            id: "buttons.open.in.library",
+                          })}
+                        </Link>
+                      ),
+                      icon: <EyeOutlined />,
+                    },
+                    {
+                      key: "share",
+                      label: intl.formatMessage({
+                        id: "buttons.share",
+                      }),
+                      icon: <TeamOutlined />,
+                    },
+                    {
+                      key: "remove",
+                      label: intl.formatMessage({
+                        id: "buttons.delete",
+                      }),
+                      icon: <DeleteOutlined />,
+                      danger: true,
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "open":
+                        window.open(fullUrl(`/anthology/${row.id}`), "_blank");
+                        break;
+                      case "share":
+                        console.log("share");
+                        showShareModal(row.id, EResType.collection);
+                        break;
+                      case "remove":
+                        showDeleteConfirm(row.id, row.title);
+                        break;
+                    }
+                  },
+                }}
+              >
+                <Link to={`/anthology/${row.id}`} target="_blank">
+                  {intl.formatMessage({
+                    id: "buttons.view",
+                  })}
+                </Link>
+              </Dropdown.Button>,
+            ],
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/v2/anthology?view=studio&view2=${activeKey}&name=${studioName}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+
+          url += getSorterUrl(sorter);
+
+          const res = await get<IAnthologyListResponse>(url);
+          const items: IItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + offset + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              publicity: item.status,
+              articles: item.childrenNumber,
+              studio: item.studio,
+              updated_at: item.updated_at,
+            };
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          pageSize: 10,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          showCreate ? (
+            <Popover
+              content={
+                <AnthologyCreate
+                  studio={studioName}
+                  onSuccess={() => {
+                    setOpenCreate(false);
+                    ref.current?.reload();
+                  }}
+                />
+              }
+              placement="bottomRight"
+              trigger="click"
+              open={openCreate}
+              onOpenChange={(open: boolean) => {
+                setOpenCreate(open);
+              }}
+            >
+              <Button key="button" icon={<PlusOutlined />} type="primary">
+                {intl.formatMessage({ id: "buttons.create" })}
+              </Button>
+            </Popover>
+          ) : undefined,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "my",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.this-studio" })}
+                    {renderBadge(myNumber, activeKey === "my")}
+                  </span>
+                ),
+              },
+              {
+                key: "collaboration",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.collaboration" })}
+                    {renderBadge(
+                      collaborationNumber,
+                      activeKey === "collaboration"
+                    )}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+
+      <Modal
+        destroyOnHidden={true}
+        width={700}
+        title={intl.formatMessage({ id: "labels.collaboration" })}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Share resId={shareResId} resType={shareResType} />
+      </Modal>
+    </>
+  );
+};
+
+export default AnthologyListWidget;

+ 67 - 0
dashboard-v6/backup/components/anthology/AnthologyModal.tsx

@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import AnthologyList from "./AnthologyList";
+
+interface IWidget {
+  studioName?: string;
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: (closed: boolean) => void;
+  onSelect?: (selected: string) => void;
+  onCancel?: () => void;
+}
+const AnthologyModalWidget = ({
+  studioName,
+  trigger,
+  open,
+  onClose,
+  onSelect,
+}: IWidget) => {
+  const [innerOpen, setInnerOpen] = useState(false);
+
+  const isModalOpen = open ?? innerOpen;
+
+  const openModal = () => {
+    if (open === undefined) {
+      setInnerOpen(true);
+    } else {
+      onClose?.(true);
+    }
+  };
+
+  const closeModal = () => {
+    onClose?.(false);
+    if (open === undefined) {
+      setInnerOpen(false);
+    }
+  };
+
+  return (
+    <>
+      <span role="button" tabIndex={0} onClick={openModal}>
+        {trigger}
+      </span>
+
+      <Modal
+        width="80%"
+        title="加入文集"
+        open={isModalOpen}
+        onOk={closeModal}
+        onCancel={closeModal}
+      >
+        <AnthologyList
+          title="选择文集"
+          studioName={studioName}
+          showCreate={false}
+          showOption={false}
+          onTitleClick={(id) => {
+            onSelect?.(id);
+            closeModal();
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default AnthologyModalWidget;

+ 52 - 0
dashboard-v6/backup/components/anthology/AnthologySelect.tsx

@@ -0,0 +1,52 @@
+import { Select } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import type { IAnthologyListResponse } from "../../api/Article";
+
+interface IOptions {
+  value: string;
+  label: string;
+}
+interface IWidget {
+  studioName?: string;
+  onSelect?: Function;
+}
+const AnthologyTocTreeWidget = ({ studioName, onSelect }: IWidget) => {
+  const [anthology, setAnthology] = useState<IOptions[]>([
+    { value: "all", label: "全部" },
+    { value: "none", label: "没有加入文集的" },
+  ]);
+  useEffect(() => {
+    const url = `/v2/anthology?view=studio&name=${studioName}`;
+    get<IAnthologyListResponse>(url).then((json) => {
+      if (json.ok) {
+        const data = json.data.rows.map((item) => {
+          return {
+            value: item.uid,
+            label: item.title,
+          };
+        });
+        setAnthology([
+          { value: "all", label: "全部" },
+          { value: "none", label: "没有加入文集的" },
+          ...data,
+        ]);
+      }
+    });
+  }, [studioName]);
+  return (
+    <Select
+      defaultValue="all"
+      style={{ width: 180 }}
+      onChange={(value: string) => {
+        console.log(`selected ${value}`);
+        if (typeof onSelect !== "undefined") {
+          onSelect(value);
+        }
+      }}
+      options={anthology}
+    />
+  );
+};
+
+export default AnthologyTocTreeWidget;

+ 87 - 0
dashboard-v6/backup/components/anthology/AnthologyTocTree.tsx

@@ -0,0 +1,87 @@
+import { useEffect, useState } from "react";
+
+import { get } from "../../request";
+import type { IArticleMapListResponse } from "../../api/Article";
+import type { ListNodeData } from "../article/EditableTree";
+import TocTree from "../article/TocTree";
+
+interface IWidget {
+  anthologyId?: string;
+  channels?: string[];
+  onClick?: (
+    anthologyId: string,
+    id: string,
+    target: "_blank" | "self"
+  ) => void;
+  onArticleSelect?: (anthologyId: string, keys: string[]) => void;
+}
+const AnthologyTocTreeWidget = ({
+  anthologyId,
+  channels,
+  onClick,
+  onArticleSelect,
+}: IWidget) => {
+  const [tocData, setTocData] = useState<ListNodeData[]>([]);
+  const [expandedKeys, setExpandedKeys] = useState<string[]>();
+
+  useEffect(() => {
+    if (typeof anthologyId === "undefined") {
+      return;
+    }
+    let url = `/v2/article-map?view=anthology&id=${anthologyId}&lazy=1`;
+    url += channels && channels.length > 0 ? "&channel=" + channels[0] : "";
+    console.log("url", url);
+    get<IArticleMapListResponse>(url).then((json) => {
+      if (json.ok) {
+        const toc: ListNodeData[] = json.data.rows.map((item) => {
+          return {
+            key: item.article_id ? item.article_id : item.title,
+            title: item.title_text ? item.title_text : item.title,
+            level: item.level,
+            children: item.children,
+            status: item.status,
+            deletedAt: item.deleted_at,
+          };
+        });
+        setTocData(toc);
+        if (json.data.rows.length === json.data.count) {
+          setExpandedKeys(
+            json.data.rows
+              .filter((value) => value.level === 1)
+              .map((item) => (item.article_id ? item.article_id : item.title))
+          );
+        } else {
+          setExpandedKeys(undefined);
+        }
+      }
+    });
+  }, [anthologyId, channels]);
+  return (
+    <TocTree
+      treeData={tocData}
+      expandedKeys={expandedKeys}
+      onSelect={(keys: string[]) => {
+        if (
+          typeof onArticleSelect !== "undefined" &&
+          typeof anthologyId !== "undefined"
+        ) {
+          onArticleSelect(anthologyId, keys);
+        }
+      }}
+      onClick={(
+        id: string,
+        e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+      ) => {
+        const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+        if (
+          typeof onClick !== "undefined" &&
+          typeof anthologyId !== "undefined"
+        ) {
+          onClick(anthologyId, id, target);
+        }
+      }}
+    />
+  );
+};
+
+export default AnthologyTocTreeWidget;

+ 215 - 0
dashboard-v6/backup/components/anthology/EditableTocTree.tsx

@@ -0,0 +1,215 @@
+import { Button, message } from "antd";
+import { useEffect, useState } from "react";
+import { FolderOpenOutlined } from "@ant-design/icons";
+
+import { get as getUiLang } from "../../locales";
+
+import { get, post, put } from "../../request";
+import type {
+  IAnthologyDataResponse,
+  IArticleCreateRequest,
+  IArticleDataResponse,
+  IArticleMapAddResponse,
+  IArticleMapListResponse,
+  IArticleMapRequest,
+  IArticleMapUpdateRequest,
+  IArticleResponse,
+} from "../../api/Article";
+import ArticleListModal from "../article/ArticleListModal";
+import EditableTree, {
+  type ListNodeData,
+  type TreeNodeData,
+} from "../article/EditableTree";
+import ArticleDrawer from "../article/ArticleDrawer";
+import { fullUrl, randomString } from "../../utils";
+
+interface IWidget {
+  anthologyId?: string;
+  studioName?: string;
+  myStudioName?: string;
+  anthology?: IAnthologyDataResponse;
+}
+const EditableTocTreeWidget = ({
+  anthologyId,
+  anthology,
+  studioName,
+  myStudioName,
+}: IWidget) => {
+  const [tocData, setTocData] = useState<ListNodeData[]>([]);
+  const [addArticle, setAddArticle] = useState<TreeNodeData>();
+  const [updatedArticle, setUpdatedArticle] = useState<TreeNodeData>();
+  const [openViewer, setOpenViewer] = useState(false);
+  const [viewArticle, setViewArticle] = useState<TreeNodeData>();
+
+  const save = (data?: ListNodeData[]) => {
+    console.debug("onSave", data);
+    if (typeof data === "undefined") {
+      console.warn("data === undefined");
+      return;
+    }
+    const url = `/v2/article-map/${anthologyId}`;
+    console.info("url", url);
+    const newData: IArticleMapRequest[] = data.map((item) => {
+      let title = "";
+      if (typeof item.title === "string") {
+        title = item.title;
+      }
+      //TODO 整一个string title
+      return {
+        article_id: item.key,
+        level: item.level,
+        title: title,
+        children: item.children,
+        status: item.status,
+        deleted_at: item.deletedAt,
+      };
+    });
+
+    put<IArticleMapUpdateRequest, IArticleMapAddResponse>(url, {
+      data: newData,
+      operation: "anthology",
+    })
+      .finally(() => {})
+      .then((json) => {
+        if (json.ok) {
+          message.success(json.data);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => message.error(e));
+  };
+
+  useEffect(() => {
+    get<IArticleMapListResponse>(
+      `/v2/article-map?view=anthology&id=${anthologyId}`
+    ).then((json) => {
+      if (json.ok) {
+        const toc: ListNodeData[] = json.data.rows.map((item) => {
+          return {
+            key: item.article_id ? item.article_id : item.title,
+            title: item.title,
+            title_text: item.title_text ? item.title_text : item.title,
+            level: item.level,
+            status: item.status,
+            deletedAt: item.deleted_at,
+          };
+        });
+        setTocData(toc);
+      }
+    });
+  }, [anthologyId]);
+
+  return (
+    <div>
+      <EditableTree
+        treeData={tocData}
+        addOnArticle={addArticle}
+        addFileButton={
+          <ArticleListModal
+            studioName={myStudioName}
+            trigger={<Button icon={<FolderOpenOutlined />}>添加</Button>}
+            multiple={false}
+            onSelect={(id: string, title: string) => {
+              console.log("add article", id);
+              const newNode: TreeNodeData = {
+                key: randomString(),
+                id: id,
+                title: title,
+                title_text: title,
+                children: [],
+                level: 1,
+              };
+              setAddArticle(newNode);
+            }}
+          />
+        }
+        updatedNode={updatedArticle}
+        onChange={(data: ListNodeData[]) => {
+          save(data);
+        }}
+        onSave={(data: ListNodeData[]) => {
+          save(data);
+        }}
+        onAppend={async (
+          node: TreeNodeData
+        ): Promise<TreeNodeData | undefined> => {
+          /**
+           * 在某节点下append新的节点
+           */
+          if (typeof studioName === "undefined") {
+            console.log("studio", studioName);
+            return;
+          }
+          const res = await post<IArticleCreateRequest, IArticleResponse>(
+            `/v2/article`,
+            {
+              title: "new article",
+              lang: anthology?.lang ?? getUiLang(),
+              studio: studioName,
+              anthologyId: anthologyId,
+              status: anthology?.status ?? undefined,
+            }
+          );
+
+          console.log(res);
+          if (res.ok) {
+            return {
+              key: randomString(),
+              id: res.data.uid,
+              title: res.data.title,
+              title_text: res.data.title,
+              children: [],
+              level: node.level + 1,
+            };
+          } else {
+            return;
+          }
+        }}
+        onTitleClick={(
+          e: React.MouseEvent<HTMLElement, MouseEvent>,
+          node: TreeNodeData
+        ) => {
+          if (e.ctrlKey || e.metaKey) {
+            window.open(fullUrl(`/article/article/${node.id}`), "_blank");
+          } else {
+            setViewArticle(node);
+            setOpenViewer(true);
+          }
+        }}
+      />
+      <ArticleDrawer
+        articleId={viewArticle?.id}
+        anthologyId={anthologyId}
+        type="article"
+        open={openViewer}
+        title={viewArticle?.title_text}
+        onClose={() => setOpenViewer(false)}
+        onArticleEdit={(value: IArticleDataResponse) => {
+          setUpdatedArticle({
+            key: randomString(),
+            id: value.uid,
+            title: value.title,
+            title_text: value.title_text,
+            level: 0,
+            children: [],
+          });
+        }}
+        onTitleChange={(value: string) => {
+          if (viewArticle?.id) {
+            setUpdatedArticle({
+              key: randomString(),
+              id: viewArticle?.id,
+              title: value,
+              title_text: value,
+              level: 0,
+              children: [],
+            });
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default EditableTocTreeWidget;

+ 42 - 0
dashboard-v6/backup/components/anthology/TextBookToc.tsx

@@ -0,0 +1,42 @@
+import { useEffect, useState } from "react";
+import AnthologyTocTree from "./AnthologyTocTree";
+import { get } from "../../request";
+import type { ICourseResponse } from "../../api/Course";
+
+interface IWidget {
+  courseId?: string | null;
+  channels?: string[];
+  onClick?: (article: string, target: string) => void;
+}
+const TextBookTocWidget = ({ courseId, channels, onClick }: IWidget) => {
+  const [anthologyId, setAnthologyId] = useState<string>();
+
+  useEffect(() => {
+    if (!courseId) {
+      return;
+    }
+    const url = `/v2/course/${courseId}`;
+    console.debug("course url", url);
+    get<ICourseResponse>(url).then((json) => {
+      console.debug("course data", json.data);
+      if (json.ok) {
+        setAnthologyId(json.data.anthology_id);
+      }
+    });
+  }, [courseId]);
+
+  return (
+    <AnthologyTocTree
+      anthologyId={anthologyId}
+      channels={channels}
+      onClick={(_anthology: string, article: string, target: string) => {
+        console.debug("AnthologyTocTree onClick", article);
+        if (typeof onClick !== "undefined") {
+          onClick(article, target);
+        }
+      }}
+    />
+  );
+};
+
+export default TextBookTocWidget;

+ 246 - 0
dashboard-v6/backup/components/api/Article.ts

@@ -0,0 +1,246 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { IChannel } from "../channel/Channel"
+import type { ITocPathNode } from "../corpus/TocPath"
+import type { IStudioApiResponse, TRole } from "./Auth";
+
+export interface IArticleListApiResponse {
+  article: string;
+  title: string;
+  level: string;
+  children: number;
+}
+export interface IAnthologyDataRequest {
+  title: string;
+  subtitle: string;
+  summary?: string;
+  article_list?: IArticleListApiResponse[];
+  lang: string;
+  status: number;
+  default_channel?: string | null;
+}
+export interface IAnthologyDataResponse {
+  uid: string;
+  title: string;
+  subtitle: string;
+  summary: string;
+  article_list: IArticleListApiResponse[];
+  studio: IStudio;
+  default_channel?: IChannel;
+  lang: string;
+  status: number;
+  childrenNumber: number;
+  created_at: string;
+  updated_at: string;
+}
+export interface IAnthologyResponse {
+  ok: boolean;
+  message: string;
+  data: IAnthologyDataResponse;
+}
+export interface IAnthologyListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IAnthologyDataResponse[];
+    count: number;
+  };
+}
+
+export interface IAnthologyStudioListApiResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    count: number;
+    rows: IAnthologyStudioListDataApiResponse[];
+  };
+}
+export interface IAnthologyStudioListDataApiResponse {
+  count: number;
+  studio: IStudioApiResponse;
+}
+
+export interface IArticleDataRequest {
+  uid: string;
+  title: string;
+  subtitle: string;
+  summary?: string | null;
+  content?: string;
+  content_type?: string;
+  status: number;
+  lang: string;
+  to_tpl?: boolean;
+  anthology_id?: string;
+}
+export interface IChapterToc {
+  key?: string;
+  book: number;
+  paragraph: number;
+  level: number;
+  pali_title: string /**巴利文标题 */;
+  title?: string /**译文文标题 */;
+  progress?: number[];
+}
+export interface IArticleDataResponse {
+  uid: string;
+  title: string;
+  title_text?: string;
+  subtitle: string;
+  summary: string | null;
+  _summary?: string;
+  content?: string;
+  content_type?: string;
+  toc?: IChapterToc[];
+  html?: string;
+  path?: ITocPathNode[];
+  status: number;
+  lang: string;
+  anthology_count?: number;
+  anthology_first?: { uid: string; title: string };
+  role?: TRole;
+  studio?: IStudio;
+  editor?: IUser;
+  created_at: string;
+  updated_at: string;
+  from?: number;
+  to?: number;
+  mode?: string;
+  paraId?: string;
+  parent_uid?: string;
+  channels?: string;
+}
+export interface IArticleResponse {
+  ok: boolean;
+  message: string;
+  data: IArticleDataResponse;
+}
+export interface IArticleListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IArticleDataResponse[];
+    count: number;
+  };
+}
+
+export interface IArticleCreateRequest {
+  title: string;
+  lang: string;
+  studio: string;
+  anthologyId?: string;
+  parentId?: string;
+  status?: number;
+}
+
+export interface IAnthologyCreateRequest {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+export interface IArticleMapRequest {
+  id?: string;
+  collect_id?: string;
+  collection?: { id: string; title: string };
+  article_id?: string;
+  level: number;
+  title: string;
+  title_text?: string;
+  editor?: IUser;
+  children?: number;
+  status?: number;
+  deleted_at?: string | null;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface IArticleMapListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IArticleMapRequest[];
+    count: number;
+  };
+}
+export interface IArticleMapAddRequest {
+  anthology_id: string;
+  article_id: string[];
+  operation: string;
+}
+export interface IArticleMapUpdateRequest {
+  data: IArticleMapRequest[];
+  operation: string;
+}
+export interface IArticleMapAddResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+export interface IDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+export interface IArticleNavResponse {
+  ok: boolean;
+  data: IArticleNavData;
+  message: string;
+}
+
+export interface IArticleNavData {
+  curr?: IArticleMapRequest;
+  prev?: IArticleMapRequest;
+  next?: IArticleMapRequest;
+}
+
+export interface IPageNavResponse {
+  ok: boolean;
+  data: IPageNavData;
+  message: string;
+}
+
+export interface IPageNavData {
+  curr: IPageNavItem;
+  prev: IPageNavItem;
+  next: IPageNavItem;
+}
+
+export interface IPageNavItem {
+  id: number;
+  type: string;
+  volume: number;
+  page: number;
+  book: number;
+  paragraph: number;
+  wid: number;
+  pcd_book_id: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ICSParaNavResponse {
+  ok: boolean;
+  data: ICSParaNavData;
+  message: string;
+}
+
+export interface ICSParaNavData {
+  curr: ICSParaNavItem;
+  prev?: ICSParaNavItem;
+  next?: ICSParaNavItem;
+  end: number;
+}
+
+export interface ICSParaNavItem {
+  book: number;
+  start: number;
+  content: string;
+}
+
+export interface IArticleFtsListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IArticleDataResponse[];
+    page: { size: number; current: number; total: number };
+  };
+}

+ 38 - 0
dashboard-v6/backup/components/api/Attachments.ts

@@ -0,0 +1,38 @@
+export interface IAttachmentRequest {
+  id: string;
+  name: string;
+  filename: string;
+  title: string;
+  size: number;
+  content_type: string;
+  url: string;
+  thumbnail?: { small: string; middle: string };
+  created_at?: string;
+  updated_at?: string;
+}
+export interface IAttachmentUpdate {
+  title: string;
+}
+export interface IAttachmentResponse {
+  ok: boolean;
+  message: string;
+  data: IAttachmentRequest;
+}
+
+export interface IAttachmentListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IAttachmentRequest[]; count: number };
+}
+
+export interface IResAttachmentData {
+  uid: string;
+  sentence_id: string;
+  attachment_id: string;
+  attachment: IAttachmentRequest;
+}
+export interface IResAttachmentListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IResAttachmentData[]; count: number };
+}

+ 124 - 0
dashboard-v6/backup/components/api/Auth.ts

@@ -0,0 +1,124 @@
+import type { IUser } from "../auth/User"
+
+export type TRole =
+  | "owner"
+  | "manager"
+  | "editor"
+  | "member"
+  | "reader"
+  | "student"
+  | "assistant"
+  | "unknown";
+
+export interface ISignUpRequest {
+  token: string;
+  username: string;
+  nickname: string;
+  email: string;
+  password: string;
+  lang: string;
+}
+export interface ISignUpVerifyResponse {
+  ok: boolean;
+  message: string | { email: boolean; username: boolean };
+  data: string;
+}
+export interface ISignInResponse {
+  ok: boolean;
+  message: string;
+  data: string;
+}
+export interface IUserRequest {
+  id?: string;
+  userName?: string;
+  nickName?: string;
+  email?: string;
+  avatar?: string;
+  roles?: string[];
+}
+
+export interface IUserListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IUser[];
+    count: number;
+  };
+}
+export interface IUserListResponse2 {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IUserApiData[];
+    count: number;
+  };
+}
+export interface IUserResponse {
+  ok: boolean;
+  message: string;
+  data: IUserApiData;
+}
+
+export interface IUserApiData {
+  id: string;
+  userName: string;
+  nickName: string;
+  email: string;
+  avatar?: string;
+  avatarName?: string;
+  role: string[];
+  created_at?: string;
+  updated_at?: string;
+}
+
+export interface IStudioApiResponse {
+  id: string;
+  nickName: string;
+  studioName?: string;
+  realName: string;
+  avatar?: string;
+  owner: IUser;
+}
+
+export interface IInviteRequest {
+  email: string;
+  lang: string;
+  studio: string;
+  subject?: string;
+  dashboard?: string;
+}
+export interface IInviteResponse {
+  ok: boolean;
+  message: string;
+  data: IInviteData;
+}
+
+export interface IInviteData {
+  id: string;
+  user_uid: string;
+  email: string;
+  status: string;
+  created_at: string;
+  updated_at: string;
+}
+export interface IInviteListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IInviteData[];
+    count: number;
+  };
+}
+export interface IInviteResponse {
+  ok: boolean;
+  message: string;
+  data: IInviteData;
+}
+
+export interface IEmailCertificationResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+
+export type TSoftwareEdition = "basic" | "pro";

+ 74 - 0
dashboard-v6/backup/components/api/Channel.ts

@@ -0,0 +1,74 @@
+import type { IStudio } from "../auth/Studio"
+import type { TRole } from "./Auth"
+export type TChannelType =
+  | "translation"
+  | "nissaya"
+  | "original"
+  | "wbw"
+  | "commentary"
+  | "similar";
+export interface IChannelApiData {
+  id: string;
+  name: string;
+  type?: TChannelType;
+}
+
+export interface ChannelInfoProps {
+  channel: IChannelApiData;
+  studio: IStudio;
+  count?: number;
+}
+/**
+ * 句子完成情况
+ * [句子字符数,是否完成]
+ *
+ */
+export type IFinal = [number, boolean];
+export interface IApiResponseChannelData {
+  uid: string;
+  name: string;
+  summary: string;
+  type: TChannelType;
+  studio: IStudio;
+  lang: string;
+  status: number;
+  is_system: boolean;
+  progress?: number;
+  created_at: string;
+  updated_at: string;
+  role?: TRole;
+  final?: IFinal[];
+  content_created_at: string;
+  content_updated_at: string;
+}
+export interface IApiResponseChannel {
+  ok: boolean;
+  message: string;
+  data: IApiResponseChannelData;
+}
+export interface IApiResponseChannelList {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IApiResponseChannelData[];
+    count: number;
+  };
+}
+
+export interface ISentInChapterListResponse {
+  ok: boolean;
+  data: ISentInChapterListData;
+  message: string;
+}
+
+export interface ISentInChapterListData {
+  rows: ISentInChapterListDataRow[];
+  count: number;
+}
+
+export interface ISentInChapterListDataRow {
+  book: number;
+  paragraph: number;
+  word_begin: number;
+  word_end: number;
+}

+ 88 - 0
dashboard-v6/backup/components/api/Comment.ts

@@ -0,0 +1,88 @@
+import type { IUser } from "../auth/User"
+import type { TDiscussionType } from "../discussion/Discussion"
+import type { TContentType } from "../discussion/DiscussionCreate"
+import type { TResType } from "../discussion/DiscussionListCard"
+import type { ITagMapData } from "./Tag"
+
+export interface ICommentRequest {
+  id?: string;
+  res_id?: string;
+  res_type?: string;
+  type?: TDiscussionType;
+  title?: string;
+  content?: string;
+  content_type?: TContentType;
+  parent?: string;
+  topicId?: string;
+  tpl_id?: string;
+  status?: "active" | "close";
+  editor?: IUser;
+  created_at?: string;
+  updated_at?: string;
+}
+
+export interface ICommentApiData {
+  id: string;
+  res_id: string;
+  res_type: TResType;
+  type: TDiscussionType;
+  title?: string;
+  content?: string;
+  content_type?: TContentType;
+  html?: string;
+  summary?: string;
+  parent?: string;
+  tpl_id?: string;
+  status?: "active" | "close";
+  children_count?: number;
+  editor: IUser;
+  created_at?: string;
+  updated_at?: string;
+}
+
+export interface ICommentResponse {
+  ok: boolean;
+  message: string;
+  data: ICommentApiData;
+}
+
+export interface ICommentListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ICommentApiData[];
+    count: number;
+    active: number;
+    close: number;
+    can_create: boolean;
+    can_reply: boolean;
+  };
+}
+export interface ICommentAnchorResponse {
+  ok: boolean;
+  message: string;
+  data: string;
+}
+
+export interface IDiscussionCountRequest {
+  course_id?: string | null;
+  sentences: string[][];
+}
+
+export interface IDiscussionCountWbw {
+  book_id: number;
+  paragraph: number;
+  wid: number;
+}
+export interface IDiscussionCountData {
+  id: string;
+  res_id: string;
+  type: string;
+  editor_uid: string;
+  wbw?: IDiscussionCountWbw;
+}
+export interface IDiscussionCountResponse {
+  ok: boolean;
+  message: string;
+  data: { discussions: IDiscussionCountData[]; tags: ITagMapData[] };
+}

+ 326 - 0
dashboard-v6/backup/components/api/Corpus.ts

@@ -0,0 +1,326 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { IChannel } from "../channel/Channel"
+import type { TContentType } from "../discussion/DiscussionCreate"
+import type { ISuggestionCount, IWidgetSentEditInner } from "../template/SentEdit"
+import type { TChannelType } from "./Channel"
+import type { TagNode } from "./Tag"
+
+export interface IApiPaliChapterList {
+  id: string;
+  book: number;
+  paragraph: number;
+  level: number;
+  toc: string;
+  title: string;
+  lenght: number;
+  chapter_len: number;
+  next_chapter: number;
+  prev_chapter: number;
+  parent: number;
+  chapter_strlen: number;
+  path: string;
+  progress_line?: number[];
+}
+
+export interface IPaliChapterListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IApiPaliChapterList[]; count: number };
+}
+export interface IApiResponsePaliChapter {
+  ok: boolean;
+  message: string;
+  data: IApiPaliChapterList;
+}
+
+export interface IPaliPara {
+  book: number;
+  paragraph: number;
+  level: number;
+  class: string;
+  toc: string;
+  text: string;
+  html: string;
+  lenght: number;
+  chapter_len: number;
+  next_chapter: number;
+  prev_chapter: number;
+  parent: number;
+  chapter_strlen: number;
+  path: string;
+  uid: string;
+}
+
+export interface IPaliParagraphResponse {
+  ok: boolean;
+  message: string;
+  data: IPaliPara;
+}
+export interface IPaliListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IPaliPara[]; count: number };
+}
+
+/**
+ * progress?view=chapter_channels&book=98&par=22
+ */
+export interface IChapterChannelData {
+  book: number;
+  para: number;
+  uid: string;
+  channel_id: string;
+  progress: number;
+  progress_line?: number[];
+  updated_at: string;
+  views: number;
+  likes: number[];
+  channel: {
+    type: TChannelType;
+    owner_uid: string;
+    editor_id: number;
+    name: string;
+    summary: string;
+    lang: string;
+    status: number;
+    created_at: string;
+    updated_at: string;
+    uid: string;
+  };
+  studio: IStudio;
+}
+
+export interface IChapterChannelListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IChapterChannelData[]; count: number };
+}
+
+export interface IApiChapterTag {
+  id: string;
+  name: string;
+  count: number;
+}
+export interface IApiResponseChapterTagList {
+  ok: boolean;
+  message: string;
+  data: { rows: IApiChapterTag[]; count: number };
+}
+
+export interface IApiResponseChannelListData {
+  channel_id: string;
+  count: number;
+  channel: {
+    id: number;
+    type: TChannelType;
+    owner_uid: string;
+    editor_id: number;
+    name: string;
+    summary: string;
+    lang: string;
+    status: number;
+    setting: string;
+    created_at: string;
+    updated_at: string;
+    uid: string;
+  };
+  studio: IStudio;
+}
+export interface IApiResponseChannelList {
+  ok: boolean;
+  message: string;
+  data: { rows: IApiResponseChannelListData[]; count: number };
+}
+
+export interface ISentenceDiffRequest {
+  sentences: string[];
+  channels: string[];
+}
+export interface ISentenceDiffData {
+  book_id: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel_uid: string;
+  content: string | null;
+  content_type: string;
+  editor_uid: string;
+  updated_at: string;
+}
+export interface ISentenceDiffResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentenceDiffData[]; count: number };
+}
+
+export interface ISentenceRequest {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  channel: string;
+  content: string | null;
+  contentType?: TContentType;
+  prEditor?: string;
+  prId?: string;
+  prUuid?: string;
+  prEditAt?: string;
+  channels?: string;
+  html?: boolean;
+  token?: string | null;
+}
+
+export interface ISentenceData {
+  id?: string;
+  book: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  content: string;
+  content_type?: TContentType;
+  html: string;
+  editor: IUser;
+  channel: IChannel;
+  studio: IStudio;
+  updated_at: string;
+  acceptor?: IUser;
+  pr_edit_at?: string;
+  fork_at?: string;
+  suggestionCount?: ISuggestionCount;
+}
+
+export interface ISentenceResponse {
+  ok: boolean;
+  message: string;
+  data: ISentenceData;
+}
+export interface ISentenceListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentenceData[]; count: number };
+}
+export interface ISentenceNewRequest {
+  sentences: ISentenceDiffData[];
+  channel?: string;
+  copy?: boolean;
+  fork_from?: string;
+}
+
+export interface IPaliToc {
+  book: number;
+  paragraph: number;
+  level: string;
+  toc: string;
+  translation?: string;
+}
+
+export interface IPaliTocListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IPaliToc[]; count: number };
+}
+
+export interface IChapterToc {
+  book: number;
+  paragraph: number;
+  level: number;
+  text: string | null;
+  chapter_len: number;
+  chapter_strlen: number;
+  parent: number;
+}
+
+export interface IChapterTocListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IChapterToc[]; count: number };
+}
+
+export interface IPaliBookListResponse {
+  name: string;
+  tag: string[];
+  children?: IPaliBookListResponse[];
+}
+
+export interface IChapterData {
+  title: string;
+  toc: string;
+  book: number;
+  para: number;
+  path: string;
+  tags: TagNode[];
+  channel: { name: string; owner_uid: string };
+  studio: IStudio;
+  channel_id: string;
+  summary: string;
+  view: number;
+  like: number;
+  status?: number;
+  progress: number;
+  progress_line?: number[];
+  created_at: string;
+  updated_at: string;
+}
+export interface IChapterListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IChapterData[]; count: number };
+}
+
+export interface ILangList {
+  lang: string;
+  count: number;
+}
+export interface IChapterLangListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ILangList[]; count: number };
+}
+
+export interface ISentencePrRequest {
+  book?: number;
+  para?: number;
+  begin?: number;
+  end?: number;
+  channel?: string;
+  text: string;
+}
+export interface ISentencePrResponseData {
+  book_id: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel_uid: string;
+}
+export interface ISentencePrResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    new: ISentencePrResponseData;
+    count: number;
+    webhook: { message: string; ok: boolean };
+  };
+}
+
+export interface ISimSent {
+  sent: string;
+  sim: number;
+}
+export interface ISentenceSimListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISimSent[]; count: number };
+}
+
+export interface ISentenceWbwListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IWidgetSentEditInner[]; count: number };
+}
+
+export interface IEditableSentence {
+  ok: boolean;
+  message: string;
+  data: IWidgetSentEditInner;
+}

+ 227 - 0
dashboard-v6/backup/components/api/Course.ts

@@ -0,0 +1,227 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { IChannel } from "../channel/Channel"
+import type { _TRole } from "./Auth"
+
+export interface ICourseListApiResponse {
+  article: string;
+  title: string;
+  level: string;
+  children: number;
+}
+
+export interface ICourseDataRequest {
+  id?: string; //课程ID
+  title: string; //标题
+  subtitle?: string; //副标题
+  summary?: string; //副标题
+  content?: string | null;
+  sign_up_message?: string | null;
+  cover?: string; //封面图片文件名
+  teacher_id?: string; //UserID
+  publicity: number; //类型-公开/内部
+  anthology_id?: string; //文集ID
+  channel_id?: string; //标准答案channel
+  start_at?: string; //课程开始时间
+  end_at?: string; //课程结束时间
+  sign_up_start_at: string | null; //报名开始时间
+  sign_up_end_at: string | null; //报名结束时间
+  join: string;
+  request_exp: string;
+  number: number;
+}
+export type TCourseRole =
+  | "owner"
+  | "teacher"
+  | "manager"
+  | "assistant"
+  | "student";
+export type TCourseJoinMode = "invite" | "manual" | "open";
+export type TCourseExpRequest = "none" | "begin-end" | "daily";
+
+export interface IMember {
+  role: TCourseRole;
+  status: TCourseMemberStatus;
+}
+export interface ICourseDataResponse {
+  id: string; //课程ID
+  title: string; //标题
+  subtitle: string; //副标题
+  summary?: string; //副标题
+  sign_up_message?: string | null; //报名弹窗消息
+  teacher?: IUser; //UserID
+  course_count?: number; //课程数
+  publicity: number; //类型-公开/内部
+  anthology_id?: string; //文集ID
+  anthology_title?: string; //文集标题
+  anthology_owner?: IStudio; //文集拥有者
+  channel_id: string; //标准答案ID
+  channel_name?: string; //文集标题
+  channel_owner?: IStudio; //文集拥有者
+  studio?: IStudio; //课程拥有者
+  start_at: string; //课程开始时间
+  end_at: string; //课程结束时间
+  sign_up_start_at: string; //报名开始时间
+  sign_up_end_at: string; //报名结束时间
+  content: string; //简介
+  cover: string; //封面图片文件名
+  cover_url?: string[]; //封面图片文件名
+  member_count: number;
+  join: TCourseJoinMode; //报名方式
+  request_exp: TCourseExpRequest;
+  my_status?: TCourseMemberStatus;
+  my_status_id?: string;
+  count_progressing?: number;
+  number: number;
+  members?: IMember[];
+  my_role?: TCourseRole;
+  created_at: string; //创建时间
+  updated_at: string; //修改时间
+}
+export interface ICourseResponse {
+  ok: boolean;
+  message: string;
+  data: ICourseDataResponse;
+}
+export interface ICourseListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ICourseDataResponse[];
+    count: number;
+  };
+}
+
+export interface ICourseCreateRequest {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+export interface IAnthologyCreateRequest {
+  title: string;
+  lang: string;
+  studio: string;
+}
+export interface ICourseNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    create: number;
+    teach: number;
+    study: number;
+  };
+}
+
+export type TCourseMemberStatus =
+  | "none" /*无*/
+  | "normal" /*开放课程直接加入*/
+  | "joined" /*开放课程已经加入*/
+  | "applied" /**学生已经报名 管理员尚未审核 */
+  | "canceled" /**学生取消报名 */
+  | "agreed" /**学生/助教已经接受邀请 */
+  | "disagreed" /**学生/助教已经拒绝邀请 */
+  | "left" /**学生自己退出 */
+  | "invited" /**管理员已经邀请学生加入 */
+  | "revoked" /**管理员撤销邀请 */
+  | "accepted" /**已经被管理员录取 */
+  | "rejected" /**报名已经被管理员拒绝 */
+  | "blocked"; /**被管理员清退 */
+
+export type TCourseMemberAction =
+  | "join" /*加入自学课程*/
+  | "apply" /**学生报名 */
+  | "cancel" /**学生取消报名 */
+  | "agree" /**学生/助教接受邀请 */
+  | "disagree" /**学生/助教拒绝邀请 */
+  | "leave" /**学生/助教自己退出 */
+  | "invite" /**管理员邀请学生加入 */
+  | "revoke" /**管理员撤销邀请 */
+  | "accept" /**管理员录取 */
+  | "reject" /**管理员拒绝 */
+  | "block"; /**管理员清退 */
+
+interface IActionMap {
+  action: TCourseMemberAction;
+  status: TCourseMemberStatus;
+}
+export const actionMap = (action: TCourseMemberAction) => {
+  const data: IActionMap[] = [
+    { action: "join", status: "joined" },
+    { action: "apply", status: "applied" },
+    { action: "cancel", status: "canceled" },
+    { action: "agree", status: "agreed" },
+    { action: "disagree", status: "disagreed" },
+    { action: "leave", status: "left" },
+    { action: "invite", status: "invited" },
+    { action: "revoke", status: "revoked" },
+    { action: "accept", status: "accepted" },
+    { action: "reject", status: "rejected" },
+    { action: "block", status: "blocked" },
+  ];
+
+  const current = data.find((value) => value.action === action);
+  return current?.status;
+};
+
+export interface ICourseMemberData {
+  id?: string;
+  user_id: string;
+  course_id: string;
+  course?: ICourseDataResponse;
+  channel_id?: string;
+  channel?: IChannel;
+  role?: TCourseRole;
+  operating?: "invite" | "sign_up";
+  user?: IUser;
+  editor?: IUser;
+  status?: TCourseMemberStatus;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface ICourseMemberResponse {
+  ok: boolean;
+  message: string;
+  data: ICourseMemberData;
+}
+export interface ICourseMemberListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ICourseMemberData[];
+    role: TCourseRole;
+    count: number;
+  };
+}
+
+export interface ICourseMemberDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: boolean;
+}
+
+export interface ICourseUser {
+  role: TCourseRole;
+  channel_id?: string | null;
+}
+export interface ICourseCurrUserResponse {
+  ok: boolean;
+  message: string;
+  data: ICourseUser;
+}
+
+export interface IExerciseListData {
+  user: IUser;
+  wbw: number;
+  translation: number;
+  question: number;
+  html: string;
+}
+export interface ICourseExerciseResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IExerciseListData[];
+    count: number;
+  };
+}

+ 141 - 0
dashboard-v6/backup/components/api/Dict.ts

@@ -0,0 +1,141 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { ICaseListData } from "../dict/CaseList"
+
+export interface IDictRequest {
+  id?: number;
+  word: string;
+  type?: string | null;
+  grammar?: string | null;
+  mean?: string | null;
+  parent?: string | null;
+  parent2?: string | null;
+  note?: string | null;
+  factors?: string | null;
+  factormean?: string | null;
+  confidence: number;
+  dict_id?: string;
+  dict_name?: string;
+  language?: string;
+  creator_id?: number;
+  editor?: IUser;
+  studio?: IStudio;
+  status?: number;
+  updated_at?: string;
+}
+export interface IUserDictCreate {
+  data: string;
+  view: string;
+}
+export interface IDictResponse {
+  ok: boolean;
+  message: string;
+  data: number[];
+}
+export interface IDictInfo {
+  id: string;
+  name: string;
+  shortname: string;
+}
+export interface IApiResponseDictData {
+  id: string;
+  sn?: number;
+  word: string;
+  type?: string | null;
+  grammar?: string | null;
+  mean?: string | null;
+  parent?: string | null;
+  note?: string | null;
+  factors?: string | null;
+  factormean?: string | null;
+  source: string | null;
+  language: string;
+  dict?: IDictInfo;
+  dict_id: string;
+  dict_name?: string;
+  dict_shortname?: string;
+  shortname?: string;
+  confidence: number;
+  creator_id: number;
+  updated_at: string;
+  exp?: number;
+  editor?: IUser;
+  status?: number;
+  count?: number;
+  created_at?: string;
+}
+export interface IApiResponseDict {
+  ok: boolean;
+  message: string;
+  data: IApiResponseDictData;
+}
+export interface IApiResponseDictList {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IApiResponseDictData[];
+    count: number;
+    time?: number;
+  };
+}
+
+export interface IVocabularyData {
+  word: string;
+  count: number;
+  meaning?: string;
+  strlen: number;
+}
+export interface IVocabularyListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IVocabularyData[];
+    count: number;
+  };
+}
+
+export interface IUserDictDeleteRequest {
+  id: string;
+}
+
+export interface ICaseItem {
+  word: string;
+  case: ICaseListData[];
+  count: number;
+}
+export interface ICaseListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ICaseItem[];
+    count: number;
+  };
+}
+
+export interface IFirstMeaning {
+  word?: string;
+  meaning?: string;
+}
+export interface IDictFirstMeaningResponse {
+  ok: boolean;
+  message: string;
+  data: IFirstMeaning[];
+}
+
+export interface IPreferenceListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IApiResponseDictData[]; count: number };
+}
+export interface IPreferenceRequest {
+  id?: string;
+  word?: string;
+  factors?: string | null;
+  parent?: string | null;
+  confidence?: number;
+}
+export interface IPreferenceResponse {
+  ok: boolean;
+  message: string;
+  data: IApiResponseDictData;
+}

+ 25 - 0
dashboard-v6/backup/components/api/Exp.ts

@@ -0,0 +1,25 @@
+export interface IUserOperationDailyRequest {
+  date_int: number;
+  duration: number;
+  hit?: number;
+}
+
+export interface IUserOperationDailyResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IUserOperationDailyRequest[]; count: number };
+}
+
+export interface IUserStatistic {
+  exp: { sum: number };
+  wbw: { count: number };
+  lookup: { count: number };
+  translation: { count: number; count_pub: number };
+  term: { count: number; count_with_note: number };
+  dict: { count: number };
+}
+export interface IUserStatisticResponse {
+  ok: boolean;
+  message: string;
+  data: IUserStatistic;
+}

+ 80 - 0
dashboard-v6/backup/components/api/Group.ts

@@ -0,0 +1,80 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { TRole } from "./Auth"
+
+export interface IGroupRequest {
+  id?: string;
+  name: string;
+  description?: string;
+  studio_name?: string;
+}
+
+export interface IGroupDataRequest {
+  uid: string;
+  name: string;
+  description: string;
+  owner: string;
+  studio: IStudio;
+  role: TRole;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface IGroupResponse {
+  ok: boolean;
+  message: string;
+  data: IGroupDataRequest;
+}
+export interface IGroupListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IGroupDataRequest[];
+    count: number;
+  };
+}
+export interface IGroupMemberRequest {
+  id?: number;
+  user_id: string;
+  group_id: string;
+  power?: number;
+  level?: number;
+  status?: number;
+}
+export interface IGroupMemberData {
+  id?: number;
+  user_id: string;
+  group_id: string;
+  group: IStudio;
+  power?: number;
+  level?: number;
+  status?: number;
+  user: IUser;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface IGroupMemberResponse {
+  ok: boolean;
+  message: string;
+  data: IGroupMemberData;
+}
+export interface IGroupMemberListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IGroupMemberData[];
+    role: TRole;
+    count: number;
+  };
+}
+
+export interface IGroupMemberDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: boolean;
+}
+export interface IDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}

+ 5 - 0
dashboard-v6/backup/components/api/Guide.ts

@@ -0,0 +1,5 @@
+export interface IGuideResponse {
+  ok: boolean;
+  message: string;
+  data: string;
+}

+ 46 - 0
dashboard-v6/backup/components/api/Share.ts

@@ -0,0 +1,46 @@
+import type { IUser } from "../auth/User"
+import type { IGroup } from "../group/Group"
+import type { TRole } from "./Auth"
+
+export interface IShareRequest {
+  res_id: string;
+  res_type: number;
+  role: TRole;
+  user_id: string[];
+  user_type: string;
+}
+export interface IShareUpdateRequest {
+  role: TRole;
+}
+export interface IShareData {
+  id?: string;
+  res_id: string;
+  res_type: string;
+  power?: number;
+  res_name: string;
+  user?: IUser;
+  group?: IGroup;
+  owner?: IUser;
+  role?: TRole;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface IShareResponse {
+  ok: boolean;
+  message: string;
+  data: IShareData;
+}
+export interface IShareListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IShareData[];
+    role: TRole;
+    count: number;
+  };
+}
+export interface IShareDeleteResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}

+ 30 - 0
dashboard-v6/backup/components/api/Suggestion.ts

@@ -0,0 +1,30 @@
+import type { IUser } from "../auth/User"
+import type { IChannelApiData } from "./Channel"
+
+export interface ISuggestionData {
+  id: string;
+  uid: string;
+  book: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel: IChannelApiData;
+  content: string;
+  html: string;
+  editor: IUser;
+  created_at: string;
+  updated_at: string;
+}
+export interface ISuggestionResponse {
+  ok: boolean;
+  message: string;
+  data: ISuggestionData;
+}
+export interface ISuggestionListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ISuggestionData[];
+    count: number;
+  };
+}

+ 84 - 0
dashboard-v6/backup/components/api/Tag.ts

@@ -0,0 +1,84 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+
+export interface TagNode {
+  id: string;
+  name: string;
+  description?: string;
+}
+
+export interface ITagRequest {
+  id?: string;
+  name?: string;
+  description?: string | null;
+  color?: number;
+  studio?: string;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface ITag {
+  id?: string;
+  name?: string;
+  description?: string | null;
+  color?: number;
+  owner?: IStudio;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface ITagData {
+  id: string;
+  name: string;
+  description?: string | null;
+  color: number;
+  owner?: IStudio;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ITagResponse {
+  ok: boolean;
+  message: string;
+  data: ITagData;
+}
+
+export interface ITagResponseList {
+  ok: boolean;
+  message: string;
+  data: { rows: ITagData[]; count: number };
+}
+
+export interface ITagMapRequest {
+  id?: string;
+  table_name?: string;
+  anchor_id?: string;
+  tag_id?: string;
+  studio?: string;
+  course?: string;
+}
+
+export interface ITagMapData {
+  id: string;
+  table_name: string;
+  anchor_id: string;
+  tag_id: string;
+  name?: string | null;
+  color?: number | null;
+  description?: string | null;
+  title?: string;
+  editor?: IUser;
+  owner?: IStudio;
+  created_at?: string;
+  updated_at?: string;
+}
+
+export interface ITagMapResponse {
+  ok: boolean;
+  message: string;
+  data: ITagMapData;
+}
+
+export interface ITagMapResponseList {
+  ok: boolean;
+  message: string;
+  data: { rows: ITagMapData[]; count: number };
+}

+ 83 - 0
dashboard-v6/backup/components/api/Term.ts

@@ -0,0 +1,83 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { IChannel } from "../channel/Channel"
+import type { TRole } from "./Auth"
+
+export interface ITermDataRequest {
+  id?: string;
+  word: string;
+  tag?: string;
+  meaning: string;
+  other_meaning?: string;
+  note?: string;
+  channel?: string;
+  studioName?: string;
+  studioId?: string;
+  language?: string;
+  parent_channel_id?: string;
+  save_as?: boolean;
+  copy_channel?: string;
+  copy_lang?: string;
+  pr?: boolean;
+}
+export interface ITermDataResponse {
+  id: number;
+  guid: string;
+  word: string;
+  tag: string;
+  meaning: string;
+  other_meaning: string;
+  note: string | null;
+  html?: string;
+  channal: string;
+  channel?: IChannel;
+  studio: IStudio;
+  editor: IUser;
+  role?: TRole;
+  exp?: number;
+  language: string;
+  community?: boolean;
+  summary?: string;
+  summary_is_community?: boolean;
+  created_at: string;
+  updated_at: string;
+}
+export interface ITermResponse {
+  ok: boolean;
+  message: string;
+  data: ITermDataResponse;
+}
+export interface ITermListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ITermDataResponse[];
+    count: number;
+  };
+}
+
+interface IMeaningCount {
+  meaning: string;
+  count: number;
+}
+interface IStudioChannel {
+  name: string;
+  uid: string;
+}
+export interface ITermCreate {
+  word: string;
+  meaningCount: IMeaningCount[];
+  studioChannels: IStudioChannel[];
+  language: string;
+  studio: IStudio;
+}
+export interface ITermCreateResponse {
+  ok: boolean;
+  message: string;
+  data: ITermCreate;
+}
+
+export interface ITermDeleteRequest {
+  uuid: boolean;
+  id: string[];
+}

+ 45 - 0
dashboard-v6/backup/components/api/Transfer.ts

@@ -0,0 +1,45 @@
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { IChannel } from "../channel/Channel"
+import type { TResType } from "../discussion/DiscussionListCard"
+
+export type ITransferStatus = "transferred" | "accept" | "refuse" | "cancel";
+export interface ITransferRequest {
+  res_type?: TResType;
+  res_id?: string[];
+  new_owner?: string;
+  status?: ITransferStatus;
+}
+export interface ITransferResponseData {
+  id: string;
+  origin_owner: IStudio;
+  res_type: TResType;
+  res_id: string;
+  channel?: IChannel;
+  transferor: IUser;
+  new_owner: IStudio;
+  status: ITransferStatus;
+  editor?: IUser | null;
+  created_at: string;
+  updated_at: string;
+}
+export interface ITransferCreateResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+export interface ITransferResponse {
+  ok: boolean;
+  message: string;
+  data: ITransferResponseData;
+}
+export interface ITransferResponseList {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ITransferResponseData[];
+    count: number;
+    out: number;
+    in: number;
+  };
+}

+ 105 - 0
dashboard-v6/backup/components/api/ai.ts

@@ -0,0 +1,105 @@
+import type { IStudio } from "../auth/Studio";
+import type { IUser } from "../auth/User";
+
+export type TPrivacy = "private" | "public" | "disable";
+
+export interface IKimiResponse {
+  id: string;
+  object: string;
+  created: number;
+  model: string;
+  choices: AiChoice[];
+  usage: AiUsage;
+}
+
+export interface AiUsage {
+  prompt_tokens: number;
+  completion_tokens: number;
+  total_tokens: number;
+}
+
+export interface AiChoice {
+  index: number;
+  message: AiMessage;
+  logprobs?: string | null; //volcengine
+  finish_reason: string;
+}
+
+export interface AiMessage {
+  role: string;
+  content: string;
+}
+
+export interface IAiTranslateRequest {
+  origin: string;
+}
+
+export interface IAiTranslateResponse {
+  ok: boolean;
+  message: string;
+  data: IKimiResponse;
+}
+
+export interface IAiModel {
+  uid: string;
+  name: string;
+  description?: string | null;
+  url?: string | null;
+  model?: string;
+  key?: string;
+  privacy: TPrivacy;
+  owner: IStudio;
+  editor: IUser;
+  user: IUser;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface IAiModelRequest {
+  name: string;
+  description?: string | null;
+  system_prompt?: string | null;
+  url?: string | null;
+  model?: string;
+  key?: string;
+  privacy: TPrivacy;
+  studio_name?: string;
+}
+
+export interface IAiModelListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IAiModel[]; total: number };
+}
+
+export interface IAiModelResponse {
+  ok: boolean;
+  message: string;
+  data: IAiModel;
+}
+
+export interface IAiModelLogData {
+  id: string;
+  uid: string;
+  model_id: string;
+  request_headers: string;
+  request_data: string;
+  response_headers?: string | null;
+  response_data?: string | null;
+  status: number;
+  success: boolean;
+  request_at: string;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface IAiModelLogListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IAiModelLogData[]; total: number };
+}
+
+export interface IAiModelSystem {
+  view: string;
+  models: string[];
+}

+ 56 - 0
dashboard-v6/backup/components/api/like.ts

@@ -0,0 +1,56 @@
+import type { IUser } from "../auth/User"
+
+export type TLikeType = "like" | "dislike" | "favorite" | "watch";
+export interface ILikeData {
+  id: string;
+  type: TLikeType;
+  target_id: string;
+  target_type?: string;
+  user: IUser;
+  context?: string | null;
+  selected?: boolean;
+  my_id?: string;
+  count?: number;
+  updated_at?: string;
+  created_at?: string;
+}
+export interface ILikeCount {
+  type: TLikeType;
+  selected?: boolean;
+  my_id?: string;
+  count?: number;
+  user: IUser;
+}
+
+export interface ILikeListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ILikeData[];
+    count: number;
+  };
+}
+
+export interface ILikeResponse {
+  ok: boolean;
+  message: string;
+  data: ILikeData;
+}
+
+export interface ILikeCountListResponse {
+  ok: boolean;
+  message: string;
+  data: ILikeCount[];
+}
+export interface ILikeCountResponse {
+  ok: boolean;
+  message: string;
+  data: ILikeCount;
+}
+
+export interface ILikeRequest {
+  type: TLikeType;
+  target_id: string;
+  target_type: string;
+  user_id?: string;
+}

+ 43 - 0
dashboard-v6/backup/components/api/notification.ts

@@ -0,0 +1,43 @@
+import type { IUser } from "../auth/User"
+import type { IChannel } from "../channel/Channel"
+
+export interface INotificationPutResponse {
+  ok: boolean;
+  data: {
+    unread: number;
+  };
+  message: string;
+}
+
+export interface INotificationListResponse {
+  ok: boolean;
+  data: INotificationListData;
+  message: string;
+}
+
+export interface INotificationListData {
+  rows: INotificationData[];
+  count: number;
+  unread: number;
+}
+
+interface INotificationData {
+  id: string;
+  from: IUser;
+  to: IUser;
+  channel: IChannel;
+  url?: string;
+  title?: string;
+  book_title?: string;
+  content: string;
+  content_type: string;
+  res_type: string;
+  res_id: string;
+  status: string;
+  deleted_at?: string;
+  created_at: string;
+  updated_at: string;
+}
+export interface INotificationRequest {
+  status: string;
+}

+ 260 - 0
dashboard-v6/backup/components/api/task.ts

@@ -0,0 +1,260 @@
+/**
+ *             $table->text('description',512)->nullable();
+            $table->jsonb('assignees')->index()->nullable();
+            $table->jsonb('roles')->index()->nullable();
+            $table->uuid('executor')->index()->nullable();
+            $table->uuid('executor_relation_task')->index()->nullable();
+            $table->uuid('parent')->index()->nullable();
+            $table->jsonb('pre_task')->index()->nullable();
+            $table->uuid('owner')->index();
+            $table->uuid('editor')->index();
+            $table->string('status',32)->index()->default('pending');
+            $table->timestamps();
+ */
+
+import type { IStudio } from "../auth/Studio"
+import type { IUser } from "../auth/User"
+import type { TPrivacy } from "./ai"
+
+export type TTaskStatus =
+  | "pending"
+  | "published"
+  | "running"
+  | "done"
+  | "restarted"
+  | "requested_restart"
+  | "closed"
+  | "canceled"
+  | "expired"
+  | "queue"
+  | "stop"
+  | "quit"
+  | "pause";
+export const StatusButtons: TTaskStatus[] = [
+  "pending",
+  "published",
+  "running",
+  "done",
+  "restarted",
+  "requested_restart",
+  "quit",
+];
+export type TTaskType = "instance" | "workflow" | "group";
+
+export interface IProject {
+  id: string;
+  sn: number;
+  title: string;
+  description: string | null;
+  weight: number;
+}
+
+export type TTaskCategory =
+  | "translate"
+  | "suggest"
+  | "vocabulary"
+  | "team"
+  | "review"
+  | "proofread";
+export const ATaskCategory: TTaskCategory[] = [
+  "translate",
+  "suggest",
+  "vocabulary",
+  "team",
+  "review",
+  "proofread",
+];
+export interface ITaskData {
+  id: string;
+  title: string;
+  description?: string | null;
+  category?: TTaskCategory | null;
+  progress?: number;
+  html?: string | null;
+  type: TTaskType;
+  order?: number;
+  assignees?: IUser[] | null;
+  assignees_id?: string[] | null;
+  parent?: ITaskData | null;
+  parent_id?: string | null;
+  roles?: string[] | null;
+  executor?: IUser | null;
+  executor_id?: string | null;
+  executor_relation_task?: ITaskData | null;
+  executor_relation_task_id?: string | null;
+  pre_task?: ITaskData[] | null;
+  pre_task_id?: string | null;
+  next_task?: ITaskData[] | null;
+  next_task_id?: string | null;
+  is_milestone: boolean;
+  project?: IProject | null;
+  project_id?: string | null;
+  owner?: IStudio;
+  owner_id?: string | null;
+  editor?: IUser;
+  editor_id?: string | null;
+  status?: TTaskStatus;
+  created_at?: string;
+  updated_at?: string;
+  started_at?: string | null;
+  finished_at?: string | null;
+  children?: ITaskData[];
+}
+
+export interface ITaskUpdateRequest {
+  id: string;
+  studio_name: string;
+  title?: string;
+  description?: string | null;
+  category?: TTaskCategory | null;
+  type?: TTaskType;
+  assignees_id?: string[] | null;
+  parent_id?: string | null;
+  project_id?: string | null;
+  roles?: string[] | null;
+  executor_id?: string | null;
+  executor_relation_task_id?: string | null;
+  pre_task_id?: string | null;
+  next_task_id?: string | null;
+  is_milestone?: boolean;
+  status?: string;
+}
+
+export interface ITaskListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ITaskData[];
+    count: number;
+  };
+}
+
+export interface ITaskCreateRequest {
+  title: string;
+  studio: string;
+  type: TTaskType;
+}
+
+export interface ITaskResponse {
+  ok: boolean;
+  message: string;
+  data: ITaskData;
+}
+
+/**
+ *            $table->uuid('id')->primary()->default(DB::raw('uuid_generate_v1mc()'));
+            $table->string('title',512)->index();
+            $table->boolean('is_template')->index()->default(false);
+            $table->text('description')->nullable();
+            $table->jsonb('executors')->index()->nullable();
+            $table->uuid('parent')->index()->nullable();
+            $table->jsonb('milestone')->index()->nullable();
+            $table->uuid('owner')->index();
+            $table->uuid('editor')->index();
+            $table->jsonb('status')->index();
+            $table->timestamps();
+ */
+
+export interface IProjectData {
+  id: string;
+  title: string;
+  type: TProjectType;
+  weight: number;
+  description: string | null;
+  parent?: IProjectData | null;
+  parent_id?: string | null;
+  path?: IProjectData[] | null;
+  executors?: IUser[] | null;
+  milestone?: IMilestoneInProject[] | null;
+  owner: IStudio;
+  editor: IUser;
+  status: ITaskStatusInProject[];
+  privacy: TPrivacy;
+  created_at: string;
+  updated_at: string;
+  deleted_at?: string | null;
+  started_at?: string | null;
+  finished_at?: string | null;
+  children?: IProjectData[];
+}
+
+export interface IProjectUpdateRequest {
+  id?: string;
+  studio_name?: string;
+  title: string;
+  type: TProjectType;
+  privacy?: TPrivacy;
+  weight?: number;
+  description?: string | null;
+  parent_id?: string | null;
+  res_id?: string;
+}
+
+export interface IProjectListResponse {
+  data: { rows: IProjectData[]; count: number };
+  message: string;
+  ok: boolean;
+}
+export interface IProjectResponse {
+  data: IProjectData;
+  message: string;
+  ok: boolean;
+}
+export type TProjectType = "instance" | "workflow" | "endpoint";
+export interface IProjectCreateRequest {
+  title: string;
+  type: TProjectType;
+  studio_name: string;
+}
+
+export interface IMilestoneData {
+  id: string;
+  title: string;
+}
+
+export interface IMilestoneCount {
+  value: number;
+  total: number;
+}
+export interface IMilestoneInProject {
+  milestone: IMilestoneData;
+  projects: IMilestoneCount;
+  chars: IMilestoneCount;
+}
+
+export interface ITaskStatusInProject {
+  status: string;
+  count: number;
+  percent: number;
+}
+
+export interface ITaskGroupInsertRequest {
+  data: ITaskGroupInsertData[];
+}
+export interface ITaskGroupInsertData {
+  project_id: string;
+  tasks: ITaskData[];
+}
+
+export interface ITaskGroupResponse {
+  ok: boolean;
+  message: string;
+  data: { taskCount: number; taskRelationCount: number };
+}
+export interface IProjectTreeInsertRequest {
+  studio_name: string;
+  parent_id?: string | null;
+  title?: string;
+  data: IProjectUpdateRequest[];
+}
+
+export interface IProjectTreeData {
+  id: string;
+  resId?: string;
+  isLeaf: boolean;
+}
+export interface IProjectTreeResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IProjectTreeData[]; count: number };
+}

+ 26 - 0
dashboard-v6/backup/components/api/token.ts

@@ -0,0 +1,26 @@
+export interface IPayload {
+  res_type: string;
+  res_id: string;
+  book?: number;
+  para_start?: number;
+  para_end?: number;
+  power: TPower;
+}
+
+export type TPower = "readonly" | "edit";
+
+export interface ITokenCreate {
+  payload: IPayload[];
+}
+export interface ITokenData {
+  payload: IPayload;
+  token: string;
+}
+export interface ITokenCreateResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ITokenData[];
+    count: number;
+  };
+}

+ 51 - 0
dashboard-v6/backup/components/api/view.ts

@@ -0,0 +1,51 @@
+import type { ArticleType } from "../article/Article"
+
+export interface IViewRequest {
+  target_type: ArticleType;
+  book: number;
+  para: number;
+  channel: string;
+  mode: string;
+}
+export interface IMetaChapter {
+  book: number;
+  para: number;
+  channel: string;
+  mode: string;
+}
+export interface IViewData {
+  id: string;
+  target_id: string;
+  target_type: ArticleType;
+  updated_at: string;
+  title: string;
+  org_title: string;
+  meta: string;
+}
+export interface IViewStoreResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+export interface IViewResponse {
+  ok: boolean;
+  message: string;
+  data: IViewData;
+}
+export interface IViewListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IViewData[];
+    count: number;
+  };
+}
+
+export interface IView {
+  id: string;
+  title: string;
+  subtitle: string;
+  type: ArticleType;
+  updatedAt: string;
+  meta: IMetaChapter;
+}

+ 45 - 0
dashboard-v6/backup/components/api/webhook.ts

@@ -0,0 +1,45 @@
+import type { IUser } from "../auth/User"
+import type { TResType } from "../discussion/DiscussionListCard"
+import type { IWebhookEvent } from "../webhook/WebhookTpl"
+
+export type TReceiverType = "wechat" | "dingtalk";
+
+export interface IWebhookRequest {
+  res_type: TResType;
+  res_id: string;
+  url: string;
+  receiver: TReceiverType;
+  event?: string[] | null;
+  event2?: IWebhookEvent[] | null;
+  status?: string;
+}
+
+export interface IWebhookApiData {
+  id: string;
+  res_type: TResType;
+  res_id: string;
+  url: string;
+  receiver: TReceiverType;
+  event: string[] | null;
+  event2?: IWebhookEvent[] | null;
+  fail: number;
+  success: number;
+  status: string;
+  editor: IUser;
+  created_at: string | null;
+  updated_at: string | null;
+}
+
+export interface IWebhookResponse {
+  ok: boolean;
+  message: string;
+  data: IWebhookApiData;
+}
+export interface IWebhookListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IWebhookApiData[];
+    count: number;
+  };
+}

+ 82 - 0
dashboard-v6/backup/components/article/AddToAnthology.tsx

@@ -0,0 +1,82 @@
+import { message } from "antd";
+import React, { useState } from "react";
+import { post } from "../../request";
+import AnthologyModal from "../anthology/AnthologyModal";
+import type {
+  IArticleMapAddRequest,
+  IArticleMapAddResponse,
+} from "../../api/Article";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  studioName?: string;
+  articleIds?: string[];
+  open?: boolean; // 外部控制
+  onClose?: (open: boolean) => void;
+  onFinally?: () => void;
+}
+
+const AddToAnthologyWidget = ({
+  trigger,
+  studioName,
+  open,
+  articleIds,
+  onClose,
+  onFinally,
+}: IWidget) => {
+  const user = useAppSelector(currentUser);
+
+  /** 是否受控 */
+  const isControlled = open !== undefined;
+
+  /** 内部状态(仅非受控使用) */
+  const [innerOpen, setInnerOpen] = useState(false);
+
+  /** 最终状态来源 */
+  const isOpen = isControlled ? open : innerOpen;
+
+  /** 状态修改统一入口 */
+  const setOpen = (next: boolean) => {
+    if (!isControlled) {
+      setInnerOpen(next);
+    }
+    onClose?.(next);
+  };
+
+  /** 选择文集 */
+  const handleSelect = (id: string) => {
+    if (!articleIds) return;
+
+    post<IArticleMapAddRequest, IArticleMapAddResponse>("/v2/article-map", {
+      anthology_id: id,
+      article_id: articleIds,
+      operation: "add",
+    })
+      .then((json) => {
+        if (json.ok) {
+          message.success(json.data);
+          setOpen(false); // 成功后关闭
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch(console.error)
+      .finally(() => {
+        onFinally?.();
+      });
+  };
+
+  return (
+    <AnthologyModal
+      studioName={studioName ?? user?.realName}
+      trigger={trigger && <span onClick={() => setOpen(true)}>{trigger}</span>}
+      open={isOpen}
+      onClose={setOpen}
+      onSelect={handleSelect}
+    />
+  );
+};
+
+export default AddToAnthologyWidget;

+ 91 - 0
dashboard-v6/backup/components/article/AnchorNav.tsx

@@ -0,0 +1,91 @@
+import { Anchor } from "antd";
+import { useEffect, useState, useRef } from "react";
+import { convertToPlain } from "../../utils";
+
+const { Link } = Anchor;
+
+interface HeadingNode {
+  key: string;
+  label: string;
+  level: number;
+  children?: HeadingNode[];
+}
+
+interface Props {
+  open?: boolean;
+  containerSelector?: string; // 可指定扫描范围
+}
+
+/** 构建树结构 */
+function buildTree(list: HeadingNode[]): HeadingNode[] {
+  const root: HeadingNode = { key: "root", label: "", level: 0, children: [] };
+  const stack = [root];
+
+  for (const node of list) {
+    while (stack.length && stack[stack.length - 1].level >= node.level) {
+      stack.pop();
+    }
+
+    const parent = stack[stack.length - 1];
+    parent.children ??= [];
+    parent.children.push(node);
+
+    stack.push(node);
+  }
+
+  return root.children ?? [];
+}
+
+/** 递归渲染 */
+function renderLinks(nodes: HeadingNode[]): React.ReactNode {
+  return nodes.map((node) => (
+    <Link key={node.key} href={node.key} title={node.label}>
+      {node.children && renderLinks(node.children)}
+    </Link>
+  ));
+}
+
+const AnchorNavWidget = ({ open = false, containerSelector }: Props) => {
+  const [tree, setTree] = useState<HeadingNode[]>([]);
+  const containerRef = useRef<HTMLElement | null>(null);
+
+  /** 获取容器 */
+  useEffect(() => {
+    containerRef.current = containerSelector
+      ? document.querySelector(containerSelector)
+      : document.body;
+  }, [containerSelector]);
+
+  /** 扫描 heading */
+  useEffect(() => {
+    if (!open || !containerRef.current) return;
+
+    const headings = Array.from(
+      containerRef.current.querySelectorAll("h1,h2,h3,h4,h5,h6")
+    );
+
+    const list: HeadingNode[] = headings
+      .map((el) => {
+        if (!el.id) return null;
+
+        return {
+          key: `#${el.id}`,
+          label: convertToPlain(el.innerHTML).slice(0, 30),
+          level: Number(el.tagName[1]),
+        };
+      })
+      .filter(Boolean) as HeadingNode[];
+
+    setTree(buildTree(list));
+  }, [open]);
+
+  if (!open || tree.length === 0) return null;
+
+  return (
+    <div className="article_anchor paper_zh">
+      <Anchor offsetTop={50}>{renderLinks(tree)}</Anchor>
+    </div>
+  );
+};
+
+export default AnchorNavWidget;

+ 72 - 0
dashboard-v6/backup/components/article/AnthologiesAtArticle.tsx

@@ -0,0 +1,72 @@
+import { Space, Typography, message } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import type { IArticleMapListResponse } from "../../api/Article";
+
+const { Link, Paragraph } = Typography;
+interface IList {
+  key?: string;
+  label?: string;
+}
+interface IWidget {
+  articleId?: string;
+  anthologyId?: string | null;
+  onClick?: Function;
+}
+const AnthologiesAtArticleWidget = ({
+  articleId,
+  anthologyId,
+  onClick,
+}: IWidget) => {
+  const [list, setList] = useState<IList[]>();
+  useEffect(() => {
+    //查询这个article 有多少文集
+    const url = `/v2/article-map?view=article&id=${articleId}`;
+    console.log("url", url);
+    get<IArticleMapListResponse>(url).then((json) => {
+      if (json.ok) {
+        const anthologies: IList[] = json.data.rows.map((item) => {
+          return {
+            key: item.collection?.id,
+            label: item.collection?.title,
+          };
+        });
+        console.log("anthologies", anthologies);
+        setList(anthologies.filter((value) => value.key !== anthologyId));
+      } else {
+        message.error("获取文集列表失败");
+      }
+    });
+  }, [articleId]);
+
+  let title = "";
+  if (anthologyId) {
+    title = "其他文集";
+  } else {
+    title = "文集列表";
+  }
+
+  return (
+    <Paragraph style={{ display: list && list.length > 0 ? "block" : "none" }}>
+      <Space>
+        {title}
+        {list?.map((item, index) => {
+          return (
+            <Link
+              key={index}
+              onClick={(e) => {
+                if (typeof onClick !== "undefined") {
+                  onClick(item.key, e);
+                }
+              }}
+            >
+              {item.label}
+            </Link>
+          );
+        })}
+      </Space>
+    </Paragraph>
+  );
+};
+
+export default AnthologiesAtArticleWidget;

+ 68 - 0
dashboard-v6/backup/components/article/AnthologyCard.tsx

@@ -0,0 +1,68 @@
+import { Link } from "react-router";
+import { Row, Col } from "antd";
+import { Card } from "antd";
+import { Typography } from "antd";
+
+import StudioName from "../auth/Studio";
+import type { IStudio } from "../auth/Studio";
+import type { ListNodeData } from "./EditableTree";
+
+const { Title, Text } = Typography;
+
+export interface IArticleData {
+  id: string;
+  title: string;
+  subTitle: string;
+  summary: string;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface IAnthologyData {
+  id: string;
+  title: string;
+  subTitle: string;
+  summary: string;
+  articles: ListNodeData[];
+  studio: IStudio;
+  created_at: string;
+  updated_at: string;
+}
+
+interface IWidgetAnthologyCard {
+  data: IAnthologyData;
+}
+
+const AnthologyCardWidget = (prop: IWidgetAnthologyCard) => {
+  const articleList = prop.data.articles.map((item, id) => {
+    return <div key={id}>{item.title}</div>;
+  });
+  return (
+    <>
+      <Card
+        hoverable
+        bordered={false}
+        style={{ width: "100%", borderRadius: 8 }}
+      >
+        <Title level={4}>
+          <Link to={`/anthology/${prop.data.id}`}>{prop.data.title}</Link>
+        </Title>
+        <div>
+          <Text type="secondary">{prop.data.subTitle}</Text>
+        </div>
+        <div>
+          <Text>{prop.data.summary}</Text>
+        </div>
+        <Link to={`/blog/${prop.data.studio.studioName}/anthology`}>
+          <StudioName data={prop.data.studio} />
+        </Link>
+        <Row>
+          <Col flex={"100px"}>Content</Col>
+          <Col flex={"auto"}>{articleList}</Col>
+        </Row>
+      </Card>
+    </>
+  );
+};
+
+export default AnthologyCardWidget;

+ 126 - 0
dashboard-v6/backup/components/article/AnthologyDetail.tsx

@@ -0,0 +1,126 @@
+import { useState, useEffect } from "react";
+import { Space, Typography, message } from "antd";
+import { get } from "../../request";
+import type {
+  IAnthologyDataResponse,
+  IAnthologyResponse,
+} from "../../api/Article";
+import type { IAnthologyData } from "./AnthologyCard";
+import StudioName from "../auth/Studio";
+import TimeShow from "../general/TimeShow";
+import Marked from "../general/Marked";
+import AnthologyTocTree from "../anthology/AnthologyTocTree";
+import { useIntl } from "react-intl";
+
+const { Title, Text, Paragraph } = Typography;
+
+interface Props {
+  aid?: string;
+  channels?: string[];
+  visible?: boolean;
+  onArticleClick?: (anthologyId: string, id: string, target: string) => void;
+  onTitle?: (title: string) => void;
+  onLoading?: (loading: boolean) => void;
+  onError?: (error: unknown, message?: string) => void;
+}
+
+const AnthologyDetailWidget = ({
+  aid,
+  channels,
+  visible = true,
+  onArticleClick,
+  onLoading,
+  onTitle,
+  onError,
+}: Props) => {
+  const [data, setData] = useState<IAnthologyData>();
+  const intl = useIntl();
+
+  useEffect(() => {
+    if (!aid) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      try {
+        onLoading?.(true);
+
+        const res = await get<IAnthologyResponse>(`/v2/anthology/${aid}`);
+
+        if (!active) return;
+
+        if (!res.ok) {
+          message.error(res.message);
+          onError?.(res.data, res.message);
+          return;
+        }
+
+        const item: IAnthologyDataResponse = res.data;
+
+        const parsed: IAnthologyData = {
+          id: item.uid,
+          title: item.title,
+          subTitle: item.subtitle,
+          summary: item.summary,
+          articles: [],
+          studio: item.studio,
+          created_at: item.created_at,
+          updated_at: item.updated_at,
+        };
+
+        setData(parsed);
+        onTitle?.(item.title);
+      } catch (err) {
+        if (active) {
+          console.error(err);
+          onError?.(err);
+        }
+      } finally {
+        if (active && onLoading) {
+          onLoading(false);
+        }
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [aid, onError, onLoading, onTitle]);
+
+  if (!visible || !data) return null;
+
+  return (
+    <div style={{ padding: 12 }}>
+      <Title level={4}>{data.title}</Title>
+
+      <Text type="secondary">{data.subTitle}</Text>
+
+      <Paragraph>
+        <Space>
+          <StudioName data={data.studio} />
+          <TimeShow updatedAt={data.updated_at} />
+        </Space>
+      </Paragraph>
+
+      <Paragraph>
+        <Marked text={data.summary} />
+      </Paragraph>
+
+      <Title level={5}>
+        {intl.formatMessage({ id: "labels.table-of-content" })}
+      </Title>
+
+      <AnthologyTocTree
+        anthologyId={aid}
+        channels={channels}
+        onClick={(anthologyId, id, target) =>
+          onArticleClick?.(anthologyId, id, target)
+        }
+      />
+    </div>
+  );
+};
+
+export default AnthologyDetailWidget;

+ 198 - 0
dashboard-v6/backup/components/article/AnthologyInfoEdit.tsx

@@ -0,0 +1,198 @@
+import { Form, message } from "antd";
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+  type RequestOptionsType,
+} from "@ant-design/pro-components";
+import MDEditor from "@uiw/react-md-editor";
+
+import { get, put } from "../../request";
+import type {
+  IAnthologyDataRequest,
+  IAnthologyDataResponse,
+  IAnthologyResponse,
+} from "../../api/Article";
+import LangSelect from "../general/LangSelect";
+import PublicitySelect from "../studio/PublicitySelect";
+import { useState } from "react";
+import type { DefaultOptionType } from "antd/lib/select";
+import type { IApiResponseChannelList } from "../../api/Channel";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+interface IFormData {
+  title: string;
+  subtitle: string;
+  summary?: string;
+  lang: string;
+  status: number;
+  defaultChannel?: string;
+}
+
+interface IWidget {
+  anthologyId?: string;
+  studioName?: string;
+  onLoad?: Function;
+}
+const AnthologyInfoEditWidget = ({
+  studioName,
+  anthologyId,
+  onLoad,
+}: IWidget) => {
+  const intl = useIntl();
+  const [channelOption, setChannelOption] = useState<DefaultOptionType[]>([]);
+  const [currChannel, setCurrChannel] = useState<RequestOptionsType>();
+  const [data, setData] = useState<IAnthologyDataResponse>();
+
+  const user = useAppSelector(currentUser);
+
+  return anthologyId ? (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        const url = `/v2/anthology/${anthologyId}`;
+        console.log("url", url);
+        console.log("values", values);
+        const res = await put<IAnthologyDataRequest, IAnthologyResponse>(url, {
+          title: values.title,
+          subtitle: values.subtitle,
+          summary: values.summary,
+          status: values.status,
+          lang: values.lang,
+          default_channel: values.defaultChannel,
+        });
+        console.log(res);
+        if (res.ok) {
+          if (typeof onLoad !== "undefined") {
+            onLoad(res.data);
+          }
+          message.success(
+            intl.formatMessage({
+              id: "flashes.success",
+            })
+          );
+        } else {
+          message.error(res.message);
+        }
+      }}
+      request={async () => {
+        const url = `/v2/anthology/${anthologyId}`;
+        console.log("url", url);
+        const res = await get<IAnthologyResponse>(url);
+        console.log("文集get", res);
+        if (res.ok) {
+          setData(res.data);
+          if (typeof onLoad !== "undefined") {
+            onLoad(res.data);
+          }
+          if (res.data.default_channel) {
+            const channel = {
+              value: res.data.default_channel.id,
+              label: res.data.default_channel.name,
+            };
+            setCurrChannel(channel);
+            setChannelOption([channel]);
+          }
+
+          return {
+            title: res.data.title,
+            subtitle: res.data.subtitle,
+            summary: res.data.summary ? res.data.summary : undefined,
+            lang: res.data.lang,
+            status: res.data.status,
+            defaultChannel: res.data.default_channel?.id,
+          };
+        } else {
+          return {
+            title: "",
+            subtitle: "",
+            summary: "",
+            lang: "",
+            status: 0,
+            defaultChannel: "",
+          };
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.title.required",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="subtitle"
+          label={intl.formatMessage({
+            id: "forms.fields.subtitle.label",
+          })}
+        />
+      </ProForm.Group>
+
+      <ProForm.Group>
+        <LangSelect width="md" />
+        <PublicitySelect
+          width="md"
+          disable={["public_no_list"]}
+          readonly={
+            user?.roles?.includes("basic") ||
+            data?.studio.roles?.includes("basic")
+              ? true
+              : false
+          }
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          options={channelOption}
+          width="md"
+          name="defaultChannel"
+          label={"默认版本"}
+          showSearch
+          debounceTime={300}
+          request={async ({ keyWords }) => {
+            console.log("keyWord", keyWords);
+            if (typeof keyWords === "undefined") {
+              return currChannel ? [currChannel] : [];
+            }
+            const url = `/v2/channel?view=studio&name=${studioName}`;
+            console.log("url", url);
+            const json = await get<IApiResponseChannelList>(url);
+            const textbookList = json.data.rows.map((item) => {
+              return {
+                value: item.uid,
+                label: `${item.studio.nickName}/${item.name}`,
+              };
+            });
+            console.log("json", textbookList);
+            return textbookList;
+          }}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <Form.Item
+          name="summary"
+          label={intl.formatMessage({ id: "forms.fields.summary.label" })}
+        >
+          <MDEditor />
+        </Form.Item>
+      </ProForm.Group>
+    </ProForm>
+  ) : (
+    <></>
+  );
+};
+
+export default AnthologyInfoEditWidget;

+ 87 - 0
dashboard-v6/backup/components/article/AnthologyList.tsx

@@ -0,0 +1,87 @@
+import { useState, useEffect } from "react";
+import { List } from "antd";
+
+import { get } from "../../request";
+import type { IAnthologyListResponse } from "../../api/Article";
+import AnthologyCard from "./AnthologyCard";
+import type { IAnthologyData } from "./AnthologyCard";
+
+interface IWidget {
+  studioName?: string;
+  searchKey?: string;
+}
+const AnthologyListWidget = ({ studioName, searchKey }: IWidget) => {
+  const [tableData, setTableData] = useState<IAnthologyData[]>([]);
+  const [total, setTotal] = useState<number>();
+  const [currPage, setCurrPage] = useState<number>(1);
+  const pageSize = 20;
+
+  useEffect(() => {
+    const offset = (currPage - 1) * pageSize;
+    let url = `/v2/anthology?view=public&offset=${offset}&limit=${pageSize}`;
+    if (typeof studioName !== "undefined") {
+      url += `&studio=${studioName}`;
+    }
+    if (typeof searchKey === "string" && searchKey.length > 0) {
+      url += `&search=${searchKey}`;
+    }
+
+    console.log("get-url", url);
+    get<IAnthologyListResponse>(url).then(function (json) {
+      if (json.ok) {
+        const newTree: IAnthologyData[] = json.data.rows.map((item) => {
+          return {
+            id: item.uid,
+            title: item.title,
+            subTitle: item.subtitle,
+            summary: item.summary,
+            articles: item.article_list.map((al) => {
+              return {
+                key: al.article,
+                title: al.title,
+                level: parseInt(al.level),
+              };
+            }),
+            studio: item.studio,
+            created_at: item.created_at,
+            updated_at: item.updated_at,
+          };
+        });
+        setTableData(newTree);
+        setTotal(json.data.count);
+      } else {
+        setTableData([]);
+        setTotal(0);
+      }
+    });
+  }, [currPage, searchKey, studioName]);
+
+  return (
+    <List
+      itemLayout="vertical"
+      size="large"
+      dataSource={tableData}
+      pagination={{
+        onChange: (page) => {
+          console.log(page);
+          setCurrPage(page);
+        },
+        showQuickJumper: true,
+        showSizeChanger: false,
+        pageSize: pageSize,
+        total: total,
+        position: "both",
+        showTotal: (total) => {
+          return `结果: ${total}`;
+        },
+      }}
+      renderItem={(item) => (
+        <List.Item>
+          <AnthologyCard data={item} />
+        </List.Item>
+      )}
+    />
+  );
+};
+
+export default AnthologyListWidget;

+ 56 - 0
dashboard-v6/backup/components/article/AnthologyStudioList.tsx

@@ -0,0 +1,56 @@
+import { Link } from "react-router";
+import { useState, useEffect } from "react";
+import { List, Space, Card } from "antd";
+
+import StudioName from "../auth/Studio";
+import type { IAnthologyStudioListApiResponse } from "../../api/Article";
+import type { IStudioApiResponse } from "../../api/Auth";
+import { get } from "../../request";
+
+interface IAnthologyStudioData {
+  count: number;
+  studio: IStudioApiResponse;
+}
+/*
+interface IWidgetAnthologyList {
+	data: IAnthologyData[];
+}
+*/
+const AnthologyStudioListWidget = () => {
+  const [tableData, setTableData] = useState<IAnthologyStudioData[]>([]);
+  useEffect(() => {
+    console.log("useEffect");
+    const url = `/v2/anthology?view=studio_list`;
+    get<IAnthologyStudioListApiResponse>(url).then(function (json) {
+      const newTree: IAnthologyStudioData[] = json.data.rows.map((item) => {
+        return {
+          count: item.count,
+          studio: item.studio,
+        };
+      });
+      setTableData(newTree);
+    });
+  }, []);
+
+  return (
+    <Card title="作者" size="small">
+      <List
+        itemLayout="vertical"
+        size="small"
+        dataSource={tableData}
+        renderItem={(item) => (
+          <List.Item>
+            <Link to={`/blog/${item.studio.realName}/anthology`}>
+              <Space>
+                <StudioName data={item.studio} />
+                <span>({item.count})</span>
+              </Space>
+            </Link>
+          </List.Item>
+        )}
+      />
+    </Card>
+  );
+};
+
+export default AnthologyStudioListWidget;

+ 226 - 0
dashboard-v6/backup/components/article/Article.tsx

@@ -0,0 +1,226 @@
+import type { IArticleDataResponse } from "../../api/Article";
+import TypeArticle from "./TypeArticle";
+import TypeAnthology from "./TypeAnthology";
+import TypeTerm from "./TypeTerm";
+import TypePali from "./TypePali";
+import "./article.css";
+import TypePage from "./TypePage";
+import TypeCSPara from "./TypeCSPara";
+import type { ISearchParams } from "../../pages/library/article/show";
+import TypeCourse from "./TypeCourse";
+import { useEffect, useState } from "react";
+import { fullUrl } from "../../utils";
+import TypeSeries from "./TypeSeries";
+import DiscussionCount from "../discussion/DiscussionCount";
+import TypeTask from "./TypeTask";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  parentChannels?: string[];
+  book?: string | null;
+  para?: string | null;
+  anthologyId?: string | null;
+  courseId?: string | null;
+  active?: boolean;
+  focus?: string | null;
+  hideInteractive?: boolean;
+  hideTitle?: boolean;
+  isSubWindow?: boolean;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
+  onLoad?: Function;
+  onAnthologySelect?: Function;
+  onTitle?: Function;
+  onArticleEdit?: Function;
+}
+const ArticleWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  parentChannels,
+  articleId,
+  anthologyId,
+  courseId,
+  mode = "read",
+  active = false,
+  focus,
+  hideInteractive = false,
+  hideTitle = false,
+  isSubWindow = false,
+  onArticleChange,
+  onLoad,
+  onAnthologySelect,
+  onTitle,
+  onArticleEdit,
+}: IWidget) => {
+  const [currId, setCurrId] = useState(articleId);
+  useEffect(() => setCurrId(articleId), [articleId]);
+
+  return (
+    <div>
+      <DiscussionCount courseId={type === "textbook" ? courseId : undefined} />
+      {type === "article" ? (
+        <TypeArticle
+          isSubWindow={isSubWindow}
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          parentChannels={parentChannels}
+          mode={mode}
+          anthologyId={anthologyId}
+          active={active}
+          hideInteractive={hideInteractive}
+          hideTitle={hideTitle}
+          onArticleEdit={(value: IArticleDataResponse) => {
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
+          onArticleChange={onArticleChange}
+          onLoad={(data: IArticleDataResponse) => {
+            if (typeof onLoad !== "undefined") {
+              onLoad(data);
+            }
+            if (typeof onTitle !== "undefined") {
+              onTitle(data.title);
+            }
+          }}
+          onAnthologySelect={(id: string) => {
+            if (typeof onAnthologySelect !== "undefined") {
+              onAnthologySelect(id);
+            }
+          }}
+        />
+      ) : type === "anthology" ? (
+        <TypeAnthology
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            }
+          }}
+          onTitle={(value: string) => {
+            if (typeof onTitle !== "undefined") {
+              onTitle(value);
+            }
+          }}
+        />
+      ) : type === "term" ? (
+        <TypeTerm
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            }
+          }}
+        />
+      ) : type === "chapter" || type === "para" ? (
+        <TypePali
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          book={book}
+          para={para}
+          focus={focus}
+          onArticleChange={(
+            type: ArticleType,
+            id: string,
+            target: string,
+            param?: ISearchParams[]
+          ) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target, param);
+            }
+          }}
+          onLoad={(data: IArticleDataResponse) => {
+            if (typeof onLoad !== "undefined") {
+              onLoad(data);
+            }
+          }}
+          onTitle={(value: string) => {
+            if (typeof onTitle !== "undefined") {
+              onTitle(value);
+            }
+          }}
+        />
+      ) : type === "series" ? (
+        <TypeSeries
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          onArticleChange={(
+            type: ArticleType,
+            id: string,
+            target: string,
+            param: ISearchParams[]
+          ) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target, param);
+            }
+          }}
+        />
+      ) : type === "page" ? (
+        <TypePage
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          focus={focus}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            } else {
+              if (target === "_blank") {
+                let url = `/article/page/${id}?mode=${mode}`;
+                if (channelId) {
+                  url += `&channel=${channelId}`;
+                }
+                window.open(fullUrl(url), "_blank");
+              } else {
+                setCurrId(id);
+              }
+            }
+          }}
+        />
+      ) : type === "cs-para" ? (
+        <TypeCSPara
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            }
+          }}
+        />
+      ) : type === "textbook" ? (
+        <TypeCourse
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          courseId={courseId}
+          mode={mode}
+          onArticleChange={onArticleChange}
+        />
+      ) : type === "task" ? (
+        <TypeTask articleId={articleId} />
+      ) : (
+        <></>
+      )}
+    </div>
+  );
+};
+
+export default ArticleWidget;

+ 90 - 0
dashboard-v6/backup/components/article/ArticleCard.tsx

@@ -0,0 +1,90 @@
+import { useNavigate } from "react-router";
+import { Button, Card, Dropdown, Space } from "antd";
+import { MoreOutlined, ReloadOutlined } from "@ant-design/icons";
+import type { MenuProps } from "antd";
+
+import type { IWidgetArticleData } from "./ArticleView"
+import ArticleCardMainMenu from "./ArticleCardMainMenu";
+import ModeSwitch from "./ModeSwitch";
+
+interface IWidgetArticleCard {
+  type?: string;
+  articleId?: string;
+  data?: IWidgetArticleData;
+  children?: React.ReactNode;
+  onModeChange?: Function;
+  openInCol?: Function;
+  showCol?: Function;
+}
+const ArticleCardWidget = ({
+  type,
+  articleId,
+  data,
+  children,
+  onModeChange,
+  showCol,
+}: IWidgetArticleCard) => {
+  const navigate = useNavigate();
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    console.log("click ", e);
+    switch (e.key) {
+      case "showCol":
+        if (typeof showCol !== "undefined") {
+          showCol();
+        }
+        break;
+
+      default:
+        break;
+    }
+  };
+
+  const items: MenuProps["items"] = [
+    {
+      key: "showCol",
+      label: "显示分栏",
+    },
+  ];
+
+  const contextMenu = (
+    <Dropdown menu={{ items, onClick }} placement="bottomRight">
+      <Button shape="circle" size="small" icon={<MoreOutlined />}></Button>
+    </Dropdown>
+  );
+  return (
+    <Card
+      size="small"
+      title={
+        <Space>
+          {<ArticleCardMainMenu type={type} articleId={articleId} />}
+          {data?.title}
+        </Space>
+      }
+      extra={
+        <Space>
+          <ModeSwitch
+            channel={null}
+            onModeChange={(mode: string) => {
+              if (typeof onModeChange !== "undefined") {
+                onModeChange(mode);
+              }
+              navigate(`/article/${type}/${articleId}/${mode}`);
+            }}
+          />
+          <Button
+            shape="circle"
+            size="small"
+            icon={<ReloadOutlined />}
+          ></Button>
+          {contextMenu}
+        </Space>
+      }
+      bodyStyle={{ height: `calc(100vh - 94px)`, overflowY: "scroll" }}
+    >
+      {children}
+    </Card>
+  );
+};
+
+export default ArticleCardWidget;

+ 78 - 0
dashboard-v6/backup/components/article/ArticleCardMainMenu.tsx

@@ -0,0 +1,78 @@
+import { Tabs, Button, Popover } from "antd";
+import { MenuOutlined, PushpinOutlined } from "@ant-design/icons";
+
+import PaliTextToc from "./PaliTextToc";
+import Find from "./Find";
+import Nav from "./Nav";
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  type?: string;
+  articleId?: string;
+}
+const ArticleCardMainMenuWidget = ({ articleId }: IWidget) => {
+  const intl = useIntl();
+  const id = articleId?.split("_");
+  let tocWidget = <></>;
+  if (id && id.length > 0) {
+    const sentId = id[0].split("-");
+    if (sentId.length > 1) {
+      tocWidget = (
+        <PaliTextToc book={parseInt(sentId[0])} para={parseInt(sentId[1])} />
+      );
+    }
+  }
+  const styleTabBody: React.CSSProperties = {
+    width: 350,
+    height: "calc(100vh - 200px)",
+    overflowY: "scroll",
+  };
+  const mainMenuContent = (
+    <Tabs
+      size="small"
+      defaultActiveKey="1"
+      tabBarExtraContent={{
+        right: <Button type="text" size="small" icon={<PushpinOutlined />} />,
+      }}
+      items={[
+        {
+          label: intl.formatMessage({
+            id: "labels.table-of-content",
+          }),
+          key: "1",
+          children: <div style={styleTabBody}>{tocWidget}</div>,
+        },
+        {
+          label: `定位`,
+          key: "2",
+          children: (
+            <div style={styleTabBody}>
+              <Nav />
+            </div>
+          ),
+        },
+        {
+          label: `查找`,
+          key: "3",
+          children: (
+            <div style={styleTabBody}>
+              <Find />
+            </div>
+          ),
+        },
+      ]}
+    />
+  );
+  return (
+    <Popover
+      placement="bottomLeft"
+      arrow={{ pointAtCenter: true }}
+      content={mainMenuContent}
+      trigger="click"
+    >
+      <Button size="small" icon={<MenuOutlined />} />
+    </Popover>
+  );
+};
+
+export default ArticleCardMainMenuWidget;

+ 139 - 0
dashboard-v6/backup/components/article/ArticleCreate.tsx

@@ -0,0 +1,139 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Alert, Space, message } from "antd";
+
+import { get, post } from "../../request";
+import type {
+  IAnthologyListResponse,
+  IArticleCreateRequest,
+  IArticleDataResponse,
+  IArticleResponse,
+} from "../../api/Article";
+import LangSelect from "../general/LangSelect";
+import { useEffect, useRef, useState } from "react";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+  anthologyId?: string;
+  parentId?: string;
+}
+
+interface IWidget {
+  studio?: string;
+  anthologyId?: string;
+  parentId?: string | null;
+  compact?: boolean;
+  onSuccess?: (data: IArticleDataResponse) => void;
+}
+const ArticleCreateWidget = ({
+  studio,
+  parentId,
+  compact = true,
+  onSuccess,
+}: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  const [parent, setParent] = useState<IArticleDataResponse>();
+  console.log("parentId", parentId);
+  useEffect(() => {
+    if (parentId) {
+      get<IArticleResponse>(`/v2/article/${parentId}`).then((json) => {
+        console.log("article", json);
+
+        if (json.ok) {
+          setParent(json.data);
+        }
+      });
+    }
+  }, []);
+
+  return (
+    <Space orientation="vertical">
+      {parentId ? (
+        <Alert
+          title={`从文章 ${parent?.title} 创建子文章`}
+          type="info"
+          closable
+        />
+      ) : undefined}
+      <ProForm<IFormData>
+        formRef={formRef}
+        onFinish={async (values: IFormData) => {
+          console.log(values);
+          if (typeof studio === "undefined") {
+            return;
+          }
+          values.studio = studio;
+          values.parentId = parentId ? parentId : undefined;
+          const res = await post<IArticleCreateRequest, IArticleResponse>(
+            `/v2/article`,
+            values
+          );
+          console.log(res);
+          if (res.ok) {
+            message.success(intl.formatMessage({ id: "flashes.success" }));
+            if (typeof onSuccess !== "undefined") {
+              onSuccess(res.data);
+              formRef.current?.resetFields(["title"]);
+            }
+          } else {
+            message.error(res.message);
+          }
+        }}
+      >
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="title"
+            required
+            label={intl.formatMessage({ id: "channel.name" })}
+            rules={[
+              {
+                required: true,
+                message: intl.formatMessage({
+                  id: "channel.create.message.noname",
+                }),
+              },
+            ]}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <LangSelect />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormSelect
+            name={"anthologyId"}
+            label={"加入文集"}
+            hidden={compact}
+            width={"md"}
+            showSearch
+            debounceTime={300}
+            request={async ({ keyWords }) => {
+              console.log("keyWord", keyWords);
+              let url = `/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) => {
+                return {
+                  value: item.uid,
+                  label: item.title,
+                };
+              });
+              console.log("json", result);
+              return result;
+            }}
+          />
+        </ProForm.Group>
+      </ProForm>
+    </Space>
+  );
+};
+
+export default ArticleCreateWidget;

+ 122 - 0
dashboard-v6/backup/components/article/ArticleDrawer.tsx

@@ -0,0 +1,122 @@
+import { Button, Drawer, Space, Typography } from "antd";
+import React, { useEffect, useState } from "react";
+import { Link } from "react-router";
+
+import Article, { type ArticleMode, ArticleType } from "./Article";
+import type { IArticleDataResponse } from "../../api/Article";
+const { Text } = Typography;
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  title?: string;
+  type?: ArticleType;
+  book?: string;
+  para?: string;
+  channelId?: string;
+  articleId?: string;
+  anthologyId?: string;
+  mode?: ArticleMode;
+  open?: boolean;
+  onClose?: Function;
+  onTitleChange?: Function;
+  onArticleEdit?: Function;
+}
+
+const ArticleDrawerWidget = ({
+  trigger,
+  title,
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  anthologyId,
+  mode,
+  open,
+  onClose,
+  onTitleChange,
+  onArticleEdit,
+}: IWidget) => {
+  const [openDrawer, setOpenDrawer] = useState(open);
+  const [drawerTitle, setDrawerTitle] = useState(title);
+  useEffect(() => setOpenDrawer(open), [open]);
+  useEffect(() => setDrawerTitle(title), [title]);
+  const showDrawer = () => {
+    setOpenDrawer(true);
+  };
+
+  const onDrawerClose = () => {
+    setOpenDrawer(false);
+    if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  const getUrl = (openMode?: string): string => {
+    let url = `/article/${type}/${articleId}?mode=`;
+    url += openMode ? openMode : mode ? mode : "read";
+    url += channelId ? `&channel=${channelId}` : "";
+    url += book ? `&book=${book}` : "";
+    url += para ? `&par=${para}` : "";
+    return url;
+  };
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={
+          <Text
+            editable={{
+              onChange: (value: string) => {
+                setDrawerTitle(value);
+                if (typeof onTitleChange !== "undefined") {
+                  onTitleChange(value);
+                }
+              },
+            }}
+          >
+            {drawerTitle}
+          </Text>
+        }
+        width={1000}
+        placement="right"
+        onClose={onDrawerClose}
+        open={openDrawer}
+        destroyOnHidden={true}
+        extra={
+          <Space>
+            <Button>
+              <Link to={getUrl()}>在单页面中打开</Link>
+            </Button>
+            <Button>
+              <Link to={getUrl("edit")}>翻译模式</Link>
+            </Button>
+          </Space>
+        }
+      >
+        <Article
+          active={true}
+          type={type as ArticleType}
+          book={book}
+          para={para}
+          channelId={channelId}
+          articleId={articleId}
+          anthologyId={anthologyId}
+          mode={mode}
+          onArticleEdit={(value: IArticleDataResponse) => {
+            setDrawerTitle(value.title_text);
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
+        />
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticleDrawerWidget;

+ 302 - 0
dashboard-v6/backup/components/article/ArticleEdit.tsx

@@ -0,0 +1,302 @@
+import { useRef, useState } from "react";
+
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormSwitch,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+
+import { Alert, Button, Form, message, Result } from "antd";
+
+import { get, put } from "../../request";
+import type {
+  IArticleDataRequest,
+  IArticleDataResponse,
+  IArticleResponse,
+} from "../../api/Article";
+import LangSelect from "../general/LangSelect";
+import PublicitySelect from "../studio/PublicitySelect";
+
+import MDEditor from "@uiw/react-md-editor";
+import ArticlePrevDrawer from "./ArticlePrevDrawer";
+import type { IStudio } from "../auth/Studio";
+import ArticleEditTools from "./ArticleEditTools";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import "./article.css";
+
+interface IFormData {
+  uid: string;
+  title: string;
+  subtitle: string;
+  summary?: string | null;
+  content?: string;
+  content_type?: string;
+  status: number;
+  lang: string;
+  to_tpl?: boolean;
+}
+
+interface IWidget {
+  studioName?: string;
+  articleId?: string;
+  anthologyId?: string;
+  resetButton?: "reset" | "cancel";
+  onReady?: (
+    title: string,
+    readonly: boolean,
+    studioName?: string,
+    parentId?: string
+  ) => void;
+  onChange?: (data: IArticleDataResponse) => void;
+  onCancel?: () => void;
+  onSubmit?: (data: IArticleDataResponse) => void;
+}
+
+const ArticleEditWidget = ({
+  studioName,
+  articleId,
+  anthologyId,
+  resetButton = "reset",
+  onReady,
+  onChange,
+  onCancel,
+  onSubmit,
+}: IWidget) => {
+  const intl = useIntl();
+  const [unauthorized, setUnauthorized] = useState(false);
+  const [readonly, setReadonly] = useState(false);
+  const [content, setContent] = useState<string>();
+  const [owner, setOwner] = useState<IStudio>();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  const [title, setTitle] = useState<string>();
+  const user = useAppSelector(currentUser);
+
+  return unauthorized ? (
+    <Result
+      status="403"
+      title="无权访问"
+      subTitle="您无权访问该内容。您可能没有登录,或者内容的所有者没有给您所需的权限。"
+      extra={<></>}
+    />
+  ) : (
+    <>
+      {readonly ? (
+        <Alert
+          message={`该资源为只读,如果需要修改,请联络拥有者${owner?.nickName}分配权限。`}
+          type="warning"
+          closable
+          action={
+            <Button disabled size="small" type="text">
+              详情
+            </Button>
+          }
+        />
+      ) : undefined}
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <span></span>
+        <ArticleEditTools
+          studioName={studioName}
+          articleId={articleId}
+          title={title}
+        />
+      </div>
+      <ProForm<IFormData>
+        formRef={formRef}
+        submitter={{
+          // 完全自定义整个区域
+          render: (props) => {
+            console.log(props);
+            return [
+              <Button
+                key="rest"
+                onClick={() => {
+                  if (resetButton === "reset") {
+                    props.form?.resetFields();
+                  } else {
+                    if (typeof onCancel !== "undefined") {
+                      onCancel();
+                    }
+                  }
+                }}
+              >
+                {resetButton === "reset" ? "重置" : "取消"}
+              </Button>,
+              <Button
+                type="primary"
+                key="submit"
+                onClick={() => props.form?.submit?.()}
+              >
+                提交
+              </Button>,
+            ];
+          },
+        }}
+        onFinish={async (values: IFormData) => {
+          const request: IArticleDataRequest = {
+            uid: articleId ? articleId : "",
+            title: values.title,
+            subtitle: values.subtitle,
+            summary: values.summary,
+            content: values.content,
+            content_type: "markdown",
+            status: values.status,
+            lang: values.lang,
+            to_tpl: values.to_tpl,
+            anthology_id: anthologyId,
+          };
+          const url = `/v2/article/${articleId}`;
+          console.info("save url", url, request);
+          put<IArticleDataRequest, IArticleResponse>(url, request)
+            .then((res) => {
+              console.debug("save response", res);
+              if (res.ok) {
+                if (typeof onChange !== "undefined") {
+                  onChange(res.data);
+                }
+                if (typeof onSubmit !== "undefined") {
+                  onSubmit(res.data);
+                }
+                formRef.current?.setFieldValue("content", res.data.content);
+                message.success(intl.formatMessage({ id: "flashes.success" }));
+              } else {
+                message.error(res.message);
+              }
+            })
+            .catch((e: IArticleResponse) => {
+              message.error(e.message);
+            });
+        }}
+        request={async () => {
+          const url = `/v2/article/${articleId}`;
+          console.info("url", url);
+          const res = await get<IArticleResponse>(url);
+          console.log("article", res);
+          let mTitle: string,
+            mReadonly = false;
+          if (res.ok) {
+            setOwner(res.data.studio);
+            mReadonly = res.data.role === "editor" ? false : true;
+            setReadonly(mReadonly);
+            mTitle = res.data.title;
+            setContent(res.data.content);
+            setTitle(res.data.title);
+          } else {
+            setUnauthorized(true);
+            mTitle = "无权访问";
+          }
+          if (typeof onReady !== "undefined") {
+            onReady(
+              mTitle,
+              mReadonly,
+              res.data.studio?.realName,
+              res.data.parent_uid
+            );
+          }
+          return {
+            uid: res.data.uid,
+            title: res.data.title,
+            subtitle: res.data.subtitle,
+            summary: res.data.summary,
+            content: res.data.content,
+            content_type: res.data.content_type,
+            lang: res.data.lang,
+            status: res.data.status,
+            studio: res.data.studio,
+          };
+        }}
+      >
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="title"
+            required
+            label={intl.formatMessage({
+              id: "forms.fields.title.label",
+            })}
+            rules={[
+              {
+                required: true,
+                message: intl.formatMessage({
+                  id: "forms.message.title.required",
+                }),
+              },
+            ]}
+          />
+          <ProFormText
+            width="md"
+            name="subtitle"
+            label={intl.formatMessage({
+              id: "forms.fields.subtitle.label",
+            })}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <LangSelect width="md" />
+          <PublicitySelect
+            width="md"
+            disable={["public_no_list"]}
+            readonly={
+              user?.roles?.includes("basic") || owner?.roles?.includes("basic")
+                ? true
+                : false
+            }
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormTextArea
+            name="summary"
+            width="lg"
+            label={intl.formatMessage({
+              id: "forms.fields.summary.label",
+            })}
+          />
+        </ProForm.Group>
+
+        <Form.Item
+          name="content"
+          style={{ width: "100%" }}
+          label={
+            <>
+              {intl.formatMessage({
+                id: "forms.fields.content.label",
+              })}
+              {articleId ? (
+                <ArticlePrevDrawer
+                  trigger={<Button>预览</Button>}
+                  articleId={articleId}
+                  content={content}
+                />
+              ) : undefined}
+            </>
+          }
+        >
+          <MDEditor
+            className="pcd_md_editor paper_zh"
+            onChange={(value: unknown) => {
+              if (typeof value === "string") {
+                setContent(value);
+              }
+            }}
+            height={450}
+            minHeight={200}
+            style={{ width: "100%" }}
+          />
+        </Form.Item>
+
+        <ProForm.Group>
+          <ProFormSwitch
+            name="to_tpl"
+            label="转换为模版"
+            disabled={anthologyId ? false : true}
+          />
+        </ProForm.Group>
+      </ProForm>
+    </>
+  );
+};
+
+export default ArticleEditWidget;

+ 74 - 0
dashboard-v6/backup/components/article/ArticleEditDrawer.tsx

@@ -0,0 +1,74 @@
+import { Drawer } from "antd";
+import React, { useEffect, useState } from "react";
+import type { IArticleDataResponse } from "../../api/Article";
+
+import ArticleEdit from "./ArticleEdit";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  articleId?: string;
+  anthologyId?: string;
+  open?: boolean;
+  onClose?: Function;
+  onChange?: Function;
+}
+
+const ArticleEditDrawerWidget = ({
+  trigger,
+  articleId,
+  anthologyId,
+  open,
+  onClose,
+  onChange,
+}: IWidget) => {
+  const [openDrawer, setOpenDrawer] = useState(open);
+  const [title, setTitle] = useState("loading");
+  const [readonly, setReadonly] = useState(false);
+  const [_studioName, setStudioName] = useState<string>();
+
+  useEffect(() => setOpenDrawer(open), [open]);
+  const showDrawer = () => {
+    setOpenDrawer(true);
+  };
+
+  const onDrawerClose = () => {
+    setOpenDrawer(false);
+    if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={title + (readonly ? "(只读)" : "")}
+        width={1000}
+        placement="right"
+        onClose={onDrawerClose}
+        open={openDrawer}
+        destroyOnHidden={true}
+      >
+        <ArticleEdit
+          anthologyId={anthologyId}
+          articleId={articleId}
+          onReady={(title: string, readonly: boolean, studio?: string) => {
+            setTitle(title);
+            setReadonly(readonly);
+            setStudioName(studio);
+          }}
+          onChange={(data: IArticleDataResponse) => {
+            if (typeof onChange !== "undefined") {
+              onChange(data);
+            }
+          }}
+        />
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticleEditDrawerWidget;

+ 59 - 0
dashboard-v6/backup/components/article/ArticleEditTools.tsx

@@ -0,0 +1,59 @@
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+import { TeamOutlined } from "@ant-design/icons";
+import { Button, Space } from "antd";
+
+import { ArticleTplModal } from "../template/Builder/ArticleTpl";
+import ShareModal from "../share/ShareModal";
+import { EResType } from "../share/Share";
+import AddToAnthology from "./AddToAnthology";
+import Builder from "../template/Builder/Builder";
+
+interface IWidget {
+  studioName?: string;
+  articleId?: string;
+  title?: string;
+}
+const ArticleEditToolsWidget = ({
+  studioName,
+  articleId,
+  title = "title",
+}: IWidget) => {
+  const intl = useIntl();
+  return (
+    <Space>
+      <Builder trigger={<Button type="link">{"<t>"}</Button>} />
+      {articleId ? (
+        <AddToAnthology
+          trigger={<Button type="link">加入文集</Button>}
+          studioName={studioName}
+          articleIds={[articleId]}
+        />
+      ) : undefined}
+      {articleId ? (
+        <ShareModal
+          trigger={
+            <Button type="link" icon={<TeamOutlined />}>
+              {intl.formatMessage({
+                id: "buttons.share",
+              })}
+            </Button>
+          }
+          resId={articleId}
+          resType={EResType.article}
+        />
+      ) : undefined}
+      <Link to={`/article/article/${articleId}`} target="_blank">
+        {intl.formatMessage({ id: "buttons.open.in.tab" })}
+      </Link>
+      <ArticleTplModal
+        title={title}
+        type="article"
+        articleId={articleId}
+        trigger={<Button type="link">获取模版</Button>}
+      />
+    </Space>
+  );
+};
+
+export default ArticleEditToolsWidget;

+ 553 - 0
dashboard-v6/backup/components/article/ArticleList.tsx

@@ -0,0 +1,553 @@
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Popover,
+  Dropdown,
+  Typography,
+  Modal,
+  message,
+  Space,
+  Table,
+  Badge,
+} from "antd";
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  TeamOutlined,
+  ExclamationCircleOutlined,
+  FolderAddOutlined,
+  ReconciliationOutlined,
+} from "@ant-design/icons";
+
+import ArticleCreate from "./ArticleCreate";
+import { delete_, get } from "../../request";
+import type { IArticleListResponse, IDeleteResponse } from "../../api/Article";
+import { PublicityValueEnum } from "../studio/table";
+import { useEffect, useRef, useState } from "react";
+import { ArticleTplModal } from "../template/Builder/ArticleTpl";
+import Share, { EResType } from "../share/Share";
+import AddToAnthology from "./AddToAnthology";
+import AnthologySelect from "../anthology/AnthologySelect";
+import StudioName, { type IStudio } from "../auth/Studio";
+import type { IUser } from "../auth/User";
+import { getSorterUrl } from "../../utils";
+import TransferCreate from "../transfer/TransferCreate";
+import { TransferOutLinedIcon } from "../../assets/icon";
+
+const { Text } = Typography;
+
+interface IArticleNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+const renderBadge = (count: number, active = false) => {
+  return (
+    <Badge
+      count={count}
+      style={{
+        marginBlockStart: -2,
+        marginInlineStart: 4,
+        color: active ? "#1890FF" : "#999",
+        backgroundColor: active ? "#E6F7FF" : "#eee",
+      }}
+    />
+  );
+};
+
+interface DataItem {
+  sn: number;
+  id: string;
+  title: string;
+  subtitle: string;
+  summary?: string | null;
+  anthologyCount?: number;
+  anthologyTitle?: string;
+  publicity: number;
+  studio?: IStudio;
+  editor?: IUser;
+  updated_at?: string;
+}
+
+interface IWidget {
+  studioName?: string;
+  editable?: boolean;
+  multiple?: boolean;
+  onSelect?: (
+    id: string,
+    title: string,
+    event: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => void;
+}
+const ArticleListWidget = ({
+  studioName,
+  multiple = true,
+  editable = false,
+  onSelect,
+}: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [openCreate, setOpenCreate] = useState(false);
+  const [anthologyId, setAnthologyId] = useState<string>();
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("my");
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+  const [transfer, setTransfer] = useState<string[]>();
+  const [transferName, setTransferName] = useState<string>();
+  const [transferOpen, setTransferOpen] = useState(false);
+  const [pageSize, setPageSize] = useState(10);
+
+  useEffect(() => {
+    /**
+     * 获取各种课程的数量
+     */
+    const url = `/v2/article-my-number?studio=${studioName}`;
+    console.log("url", url);
+    get<IArticleNumberResponse>(url).then((json) => {
+      if (json.ok) {
+        setMyNumber(json.data.my);
+        setCollaborationNumber(json.data.collaboration);
+      }
+    });
+  }, [studioName]);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/v2/article/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+  const ref = useRef<ActionType | null>(null);
+
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [shareResId, setShareResId] = useState<string>("");
+  const [shareResType, setShareResType] = useState<EResType>(EResType.article);
+  const showShareModal = (resId: string, resType: EResType) => {
+    setShareResId(resId);
+    setShareResType(resType);
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <ProTable<DataItem>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (_text, row) => {
+              return (
+                <>
+                  <div key={1}>
+                    <Typography.Link
+                      onClick={(
+                        event: React.MouseEvent<HTMLElement, MouseEvent>
+                      ) => {
+                        if (typeof onSelect !== "undefined") {
+                          onSelect(row.id, row.title, event);
+                        }
+                      }}
+                    >
+                      {row.title}
+                    </Typography.Link>
+                  </div>
+                  <div key={2}>
+                    <Text type="secondary">{row.subtitle}</Text>
+                  </div>
+                  {activeKey !== "my" ? (
+                    <div key={3}>
+                      <Text type="secondary">
+                        <StudioName data={row.studio} />
+                      </Text>
+                    </div>
+                  ) : undefined}
+                </>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "columns.library.anthology.title",
+            }),
+            dataIndex: "subtitle",
+            key: "subtitle",
+            render: (_text, row) => {
+              return (
+                <Space>
+                  {row.anthologyTitle}
+                  {row.anthologyCount ? (
+                    <Badge color="geekblue" count={row.anthologyCount} />
+                  ) : undefined}
+                </Space>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.summary.label",
+            }),
+            dataIndex: "summary",
+            key: "summary",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.publicity.label",
+            }),
+            dataIndex: "publicity",
+            key: "publicity",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: PublicityValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated_at",
+            width: 100,
+            search: false,
+            dataIndex: "updated_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            hideInTable: !editable,
+            render: (_text, row, index) => {
+              return [
+                <Dropdown.Button
+                  trigger={["click", "contextMenu"]}
+                  key={index}
+                  type="link"
+                  menu={{
+                    items: [
+                      {
+                        key: "tpl",
+                        label: (
+                          <ArticleTplModal
+                            title={row.title}
+                            type="article"
+                            articleId={row.id}
+                            trigger={<>模版</>}
+                          />
+                        ),
+                        icon: <ReconciliationOutlined />,
+                      },
+                      {
+                        key: "share",
+                        label: intl.formatMessage({
+                          id: "buttons.share",
+                        }),
+                        icon: <TeamOutlined />,
+                      },
+                      {
+                        key: "addToAnthology",
+                        label: (
+                          <AddToAnthology
+                            trigger={<Button type="link">加入文集</Button>}
+                            studioName={studioName}
+                            articleIds={[row.id]}
+                          />
+                        ),
+                        icon: <FolderAddOutlined />,
+                      },
+                      {
+                        key: "transfer",
+                        label: intl.formatMessage({
+                          id: "columns.studio.transfer.title",
+                        }),
+                        icon: <TransferOutLinedIcon />,
+                      },
+                      {
+                        key: "remove",
+                        label: intl.formatMessage({
+                          id: "buttons.delete",
+                        }),
+                        icon: <DeleteOutlined />,
+                        danger: true,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "share":
+                          showShareModal(row.id, EResType.article);
+                          break;
+                        case "remove":
+                          showDeleteConfirm(row.id, row.title);
+                          break;
+                        case "transfer":
+                          setTransfer([row.id]);
+                          setTransferName(row.title);
+                          setTransferOpen(true);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <Link
+                    key={index}
+                    to={`/article/article/${row.id}`}
+                    target="_blank"
+                  >
+                    {intl.formatMessage({
+                      id: "buttons.view",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        rowSelection={
+          multiple
+            ? {
+                // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                // 注释该行则默认不显示下拉选项
+                selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+              }
+            : undefined
+        }
+        tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
+          <Space size={24}>
+            <span>
+              {intl.formatMessage({ id: "buttons.selected" })}
+              {selectedRowKeys.length}
+              <Button type="link" onClick={onCleanSelected}>
+                {intl.formatMessage({ id: "buttons.unselect" })}
+              </Button>
+            </span>
+          </Space>
+        )}
+        tableAlertOptionRender={({ selectedRowKeys, onCleanSelected }) => {
+          return (
+            <Space>
+              <Button
+                type="link"
+                onClick={() => {
+                  const resId = selectedRowKeys.map((item) => item.toString());
+                  setTransfer(resId);
+                  setTransferName(resId.length + "个文章");
+                  setTransferOpen(true);
+                }}
+              >
+                转让
+              </Button>
+              <AddToAnthology
+                studioName={studioName}
+                trigger={<Button type="link">加入文集</Button>}
+                articleIds={selectedRowKeys.map((item) => item.toString())}
+                onFinally={() => {
+                  onCleanSelected();
+                }}
+              />
+            </Space>
+          );
+        }}
+        request={async (params = {}, sorter) => {
+          let url = `/v2/article?view=studio&view2=${activeKey}&name=${studioName}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : pageSize);
+          if (params.pageSize) {
+            setPageSize(params.pageSize);
+          }
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+
+          if (typeof anthologyId !== "undefined") {
+            url += "&anthology=" + anthologyId;
+          }
+
+          url += getSorterUrl(sorter);
+          console.log("url", url);
+          const res = await get<IArticleListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + offset + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              summary: item.summary,
+              anthologyCount: item.anthology_count,
+              anthologyTitle: item.anthology_first?.title,
+              publicity: item.status,
+              updated_at: item.updated_at,
+              studio: item.studio,
+              editor: item.editor,
+            };
+          });
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          pageSize: pageSize,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          activeKey === "my" ? (
+            <AnthologySelect
+              studioName={studioName}
+              onSelect={(value: string) => {
+                setAnthologyId(value);
+                ref.current?.reload();
+              }}
+            />
+          ) : undefined,
+          <Popover
+            content={
+              <ArticleCreate
+                studio={studioName}
+                anthologyId={anthologyId}
+                onSuccess={() => {
+                  setOpenCreate(false);
+                  ref.current?.reload();
+                }}
+              />
+            }
+            placement="bottomRight"
+            trigger="click"
+            open={openCreate}
+            onOpenChange={(open: boolean) => {
+              setOpenCreate(open);
+            }}
+          >
+            <Button key="button" icon={<PlusOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.create" })}
+            </Button>
+          </Popover>,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "my",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.this-studio" })}
+                    {renderBadge(myNumber, activeKey === "my")}
+                  </span>
+                ),
+              },
+              {
+                key: "collaboration",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.collaboration" })}
+                    {renderBadge(
+                      collaborationNumber,
+                      activeKey === "collaboration"
+                    )}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              setAnthologyId(undefined);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+
+      <Modal
+        destroyOnHidden={true}
+        width={700}
+        title={intl.formatMessage({ id: "labels.collaboration" })}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Share resId={shareResId} resType={shareResType} />
+      </Modal>
+
+      <TransferCreate
+        studioName={studioName}
+        resId={transfer}
+        resType="article"
+        resName={transferName}
+        open={transferOpen}
+        onOpenChange={(visible: boolean) => setTransferOpen(visible)}
+      />
+    </>
+  );
+};
+
+export default ArticleListWidget;

+ 58 - 0
dashboard-v6/backup/components/article/ArticleListModal.tsx

@@ -0,0 +1,58 @@
+import { useState } from "react";
+import { Modal } from "antd";
+
+import ArticleList from "./ArticleList";
+
+interface IWidget {
+  studioName?: string;
+  trigger?: React.ReactNode;
+  multiple?: boolean;
+  onSelect?: Function;
+}
+const ArticleListModalWidget = ({
+  studioName,
+  trigger = "Article",
+  multiple = true,
+  onSelect,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        title="文章列表"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <ArticleList
+          studioName={studioName}
+          editable={false}
+          multiple={multiple}
+          onSelect={(id: string, title: string) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(id, title);
+            }
+            handleOk();
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default ArticleListModalWidget;

+ 109 - 0
dashboard-v6/backup/components/article/ArticleListPublic.tsx

@@ -0,0 +1,109 @@
+import { Link } from "react-router";
+import { useRef } from "react";
+import { Space } from "antd";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+
+import { get } from "../../request";
+import type { IArticleListResponse } from "../../api/Article";
+
+import type { IStudio } from "../auth/Studio";
+import type { IUser } from "../auth/User";
+import TimeShow from "../general/TimeShow";
+
+interface DataItem {
+  sn: number;
+  id: string;
+  title: string;
+  subtitle: string;
+  summary?: string | null;
+  anthologyCount?: number;
+  anthologyTitle?: string;
+  publicity: number;
+  createdAt?: string;
+  updatedAt: string;
+  studio?: IStudio;
+  editor?: IUser;
+}
+
+interface IWidget {
+  search?: string;
+  studioName?: string;
+}
+const ArticleListWidget = ({ studioName }: IWidget) => {
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProList<DataItem>
+        rowKey="id"
+        actionRef={ref}
+        metas={{
+          title: {
+            render: (_text, row) => {
+              return <Link to={`/article/article/${row.id}`}>{row.title}</Link>;
+            },
+          },
+          description: {
+            dataIndex: "summary",
+          },
+          subTitle: {
+            render: (_text, row) => {
+              return (
+                <Space>
+                  {row.editor?.nickName}
+                  <TimeShow
+                    updatedAt={row.updatedAt}
+                    showLabel={false}
+                    showIcon={false}
+                  />
+                </Space>
+              );
+            },
+          },
+        }}
+        request={async (params = {}) => {
+          let url = `/v2/article?view=public`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += studioName ? "&studio=" + studioName : "";
+          const res = await get<IArticleListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              summary: item.summary,
+              anthologyCount: item.anthology_count,
+              anthologyTitle: item.anthology_first?.title,
+              publicity: item.status,
+              updatedAt: item.updated_at,
+              studio: item.studio,
+              editor: item.editor,
+            };
+          });
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          pageSize: 20,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+      />
+    </>
+  );
+};
+
+export default ArticleListWidget;

+ 90 - 0
dashboard-v6/backup/components/article/ArticlePrevDrawer.tsx

@@ -0,0 +1,90 @@
+import { Drawer, Typography } from "antd";
+import React, { useEffect, useState } from "react";
+import { put } from "../../request";
+import type { IArticleDataResponse, IArticleResponse } from "../../api/Article";
+import ArticleView from "./ArticleView";
+
+const { Paragraph } = Typography;
+
+interface IArticlePrevRequest {
+  content: string;
+}
+interface IWidget {
+  trigger?: React.ReactNode;
+  title?: React.ReactNode;
+  content?: string;
+  articleId: string;
+}
+
+const ArticlePrevDrawerWidget = ({
+  trigger,
+  title,
+  content,
+  articleId,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [open, setOpen] = useState(false);
+  const [errorMsg, setErrorMsg] = useState<string>();
+
+  const showDrawer = () => {
+    setOpen(true);
+  };
+
+  const onClose = () => {
+    setOpen(false);
+  };
+
+  useEffect(() => {
+    put<IArticlePrevRequest, IArticleResponse>(
+      `/v2/article-preview/${articleId}`,
+      {
+        content: content ? content : "",
+      }
+    )
+      .then((res) => {
+        console.log("save response", res);
+        if (res.ok) {
+          setArticleData(res.data);
+        } else {
+          setErrorMsg(res.message);
+        }
+      })
+      .catch((e: IArticleResponse) => {
+        setErrorMsg(e.message);
+      });
+  }, [articleId, content]);
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={title}
+        width={900}
+        placement="right"
+        onClose={onClose}
+        open={open}
+        destroyOnHidden={true}
+      >
+        <Paragraph type="danger">{errorMsg}</Paragraph>
+        {articleData ? (
+          <ArticleView
+            id={articleData.uid}
+            title={articleData.title}
+            subTitle={articleData.subtitle}
+            summary={articleData.summary}
+            content={articleData.content ? articleData.content : ""}
+            html={articleData.html ? [articleData.html] : []}
+            path={articleData.path}
+            created_at={articleData.created_at}
+            updated_at={articleData.updated_at}
+            articleId={articleId}
+          />
+        ) : (
+          <></>
+        )}
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticlePrevDrawerWidget;

+ 13 - 0
dashboard-v6/backup/components/article/ArticleSkeleton.tsx

@@ -0,0 +1,13 @@
+import { Divider, Skeleton } from "antd";
+
+const ArticleSkeletonWidget = () => {
+  return (
+    <div style={{ paddingTop: "1em" }}>
+      <Skeleton title={{ width: 200 }} paragraph={{ rows: 1 }} active />
+      <Divider />
+      <Skeleton title={{ width: 200 }} paragraph={{ rows: 10 }} active />
+    </div>
+  );
+};
+
+export default ArticleSkeletonWidget;

+ 151 - 0
dashboard-v6/backup/components/article/ArticleView.tsx

@@ -0,0 +1,151 @@
+import { Typography, Divider, Skeleton, Space } from "antd";
+
+import MdView from "../template/MdView";
+import TocPath, { type ITocPathNode } from "../corpus/TocPath";
+import PaliChapterChannelList from "../corpus/PaliChapterChannelList";
+import type { ArticleMode, ArticleType } from "./Article";
+import VisibleObserver from "../general/VisibleObserver";
+import type { IStudio } from "../auth/Studio";
+
+const { Paragraph, Title, Text } = Typography;
+export interface IFirstAnthology {
+  id: string;
+  title: string;
+  count: number;
+}
+export interface IWidgetArticleData {
+  id?: string;
+  title?: string;
+  subTitle?: string;
+  summary?: string | null;
+  content?: string;
+  html?: string[];
+  path?: ITocPathNode[];
+  mode?: ArticleMode | null;
+  created_at?: string;
+  updated_at?: string;
+  owner?: IStudio;
+  channels?: string[];
+  type?: ArticleType;
+  articleId?: string;
+  remains?: boolean;
+  anthology?: IFirstAnthology;
+  hideTitle?: boolean;
+  onEnd?: () => void;
+  onPathChange?: (
+    node: ITocPathNode,
+    e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
+  ) => void;
+}
+
+const ArticleViewWidget = ({
+  title = "",
+  subTitle,
+  summary,
+  content,
+  html = [],
+  path = [],
+  channels,
+  type,
+  articleId,
+  hideTitle,
+  onEnd,
+  remains,
+  onPathChange,
+}: IWidgetArticleData) => {
+  console.log("ArticleViewWidget render");
+
+  let currChannelList = <></>;
+  switch (type) {
+    case "chapter": {
+      const chapterProps = articleId?.split("-");
+      if (Array.isArray(chapterProps) && chapterProps.length >= 2) {
+        const book = Number(chapterProps[0]);
+        const para = Number(chapterProps[1]);
+
+        if (!Number.isNaN(book) && !Number.isNaN(para)) {
+          currChannelList = (
+            <PaliChapterChannelList
+              para={{ book, para }}
+              channelId={channels}
+              openTarget="_self"
+            />
+          );
+        }
+      }
+
+      break;
+    }
+
+    default:
+      break;
+  }
+
+  return (
+    <>
+      <Space orientation="vertical">
+        {hideTitle ? (
+          <></>
+        ) : (
+          <TocPath
+            data={path}
+            channels={channels}
+            onChange={(
+              node: ITocPathNode,
+              e: React.MouseEvent<
+                HTMLSpanElement | HTMLAnchorElement,
+                MouseEvent
+              >
+            ) => {
+              if (typeof onPathChange !== "undefined") {
+                onPathChange(node, e);
+              }
+            }}
+          />
+        )}
+        {hideTitle ? (
+          <></>
+        ) : (
+          <Title level={4}>
+            <div
+              dangerouslySetInnerHTML={{
+                __html: title ? title : "",
+              }}
+            />
+          </Title>
+        )}
+
+        <Text type="secondary">{subTitle}</Text>
+        {currChannelList}
+        <Paragraph ellipsis={{ rows: 2, expandable: true, symbol: "more" }}>
+          {summary}
+        </Paragraph>
+        <Divider />
+      </Space>
+      {html
+        ? html.map((item, id) => {
+            return (
+              <div key={id}>
+                <MdView className="pcd_article paper paper_zh" html={item} />
+              </div>
+            );
+          })
+        : content}
+      {remains ? (
+        <>
+          <VisibleObserver
+            onVisible={(visible: boolean) => {
+              console.log("visible", visible);
+              if (visible && typeof onEnd !== "undefined") {
+                onEnd();
+              }
+            }}
+          />
+          <Skeleton title={{ width: 200 }} paragraph={{ rows: 5 }} active />
+        </>
+      ) : undefined}
+    </>
+  );
+};
+
+export default ArticleViewWidget;

+ 73 - 0
dashboard-v6/backup/components/article/ChapterToc.tsx

@@ -0,0 +1,73 @@
+import type { Key } from "antd/lib/table/interface";
+import { useState, useEffect } from "react";
+
+import { get } from "../../request";
+import type { IChapterToc, IChapterTocListResponse } from "../../api/Corpus";
+import type { ListNodeData } from "./EditableTree";
+import TocTree from "./TocTree";
+import { Skeleton } from "antd";
+
+interface IWidget {
+  book?: number;
+  para?: number;
+  maxLevel?: number;
+  onSelect?: (selectedKeys: Key[]) => void;
+  onData?: (data: IChapterToc[]) => void;
+}
+const ChapterTocWidget = ({
+  book,
+  para,
+  maxLevel = 8,
+  onSelect,
+  onData,
+}: IWidget) => {
+  const [tocList, setTocList] = useState<ListNodeData[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    const url = `/v2/chapter?view=toc&book=${book}&para=${para}`;
+    setLoading(true);
+    console.info("api request", url);
+    get<IChapterTocListResponse>(url)
+      .then((json) => {
+        console.info("api response", json);
+        const chapters = json.data.rows.filter(
+          (value) => value.level <= maxLevel
+        );
+        onData && onData(chapters);
+        const toc = chapters.map((item, _id) => {
+          return {
+            key: `${item.book}-${item.paragraph}`,
+            title: item.text,
+            level: item.level,
+          };
+        });
+        setTocList(toc);
+        if (chapters.length > 0) {
+          const path: string[] = [];
+          for (let index = chapters.length - 1; index >= 0; index--) {
+            const element = chapters[index];
+            if (element.book === book && para && element.paragraph <= para) {
+              path.push(`${element.book}-${element.paragraph}`);
+              break;
+            }
+          }
+        }
+      })
+      .finally(() => setLoading(false));
+  }, [book, maxLevel, para]);
+
+  return loading ? (
+    <Skeleton active />
+  ) : (
+    <TocTree
+      treeData={tocList}
+      onSelect={(selectedKeys: Key[]) => {
+        if (typeof onSelect !== "undefined") {
+          onSelect && onSelect(selectedKeys);
+        }
+      }}
+    />
+  );
+};
+
+export default ChapterTocWidget;

+ 463 - 0
dashboard-v6/backup/components/article/EditableTree.tsx

@@ -0,0 +1,463 @@
+import React, { useState } from "react";
+import { useEffect } from "react";
+import { message, Modal, Tree } from "antd";
+import type { DataNode, TreeProps } from "antd/es/tree";
+import type { Key } from "antd/lib/table/interface";
+import { DeleteOutlined, SaveOutlined } from "@ant-design/icons";
+import { FileAddOutlined, LinkOutlined } from "@ant-design/icons";
+
+import { Button, Divider, Space } from "antd";
+import { useIntl } from "react-intl";
+import EditableTreeNode from "./EditableTreeNode";
+import { randomString } from "../../utils";
+
+export interface TreeNodeData {
+  key: string;
+  id: string;
+  title: string | React.ReactNode;
+  title_text?: string;
+  icon?: React.ReactNode;
+  children: TreeNodeData[];
+  status?: number;
+  deletedAt?: string | null;
+  level: number;
+}
+export type ListNodeData = {
+  key: string;
+  title: string | React.ReactNode;
+  title_text?: string;
+  level: number;
+  status?: number;
+  children?: number;
+  deletedAt?: string | null;
+};
+
+let tocActivePath: TreeNodeData[] = [];
+function tocGetTreeData(articles: ListNodeData[], active = "") {
+  const treeData = [];
+
+  const treeParents = [];
+
+  const rootNode: TreeNodeData = {
+    key: randomString(),
+    id: "0",
+    title: "root",
+    title_text: "root",
+    level: 0,
+    children: [],
+  };
+  treeData.push(rootNode);
+  let lastInsNode: TreeNodeData = rootNode;
+
+  let iCurrLevel = 0;
+  const keys: string[] = [];
+  for (let index = 0; index < articles.length; index++) {
+    const element = articles[index];
+
+    const newNode: TreeNodeData = {
+      key: randomString(),
+      id: element.key,
+      title: element.title,
+      title_text: element.title_text,
+      children: [],
+      icon: keys.includes(element.key) ? <LinkOutlined /> : undefined,
+      status: element.status,
+      level: element.level,
+      deletedAt: element.deletedAt,
+    };
+    if (!keys.includes(element.key)) {
+      keys.push(element.key);
+    }
+    /*
+		if (active == element.article) {
+			newNode["extraClasses"] = "active";
+		}
+*/
+    if (newNode.level > iCurrLevel) {
+      //新的层级比较大,为上一个的子目录
+      treeParents.push(lastInsNode);
+      lastInsNode.children.push(newNode);
+    } else if (newNode.level === iCurrLevel) {
+      //目录层级相同,为平级
+      treeParents[treeParents.length - 1].children.push(newNode);
+    } else {
+      // 小于 挂在上一个层级
+      while (treeParents.length > 1) {
+        treeParents.pop();
+        if (treeParents[treeParents.length - 1].level < newNode.level) {
+          break;
+        }
+      }
+      treeParents[treeParents.length - 1].children.push(newNode);
+    }
+    lastInsNode = newNode;
+    iCurrLevel = newNode.level;
+
+    if (active === element.key) {
+      tocActivePath = [];
+      for (let index = 1; index < treeParents.length; index++) {
+        tocActivePath.push(treeParents[index]);
+      }
+    }
+  }
+  return treeData[0].children;
+}
+
+function treeToList(treeNode: TreeNodeData[]): ListNodeData[] {
+  let iTocTreeCurrLevel = 1;
+
+  const arrTocTree: ListNodeData[] = [];
+
+  for (const iterator of treeNode) {
+    getTreeNodeData(iterator);
+  }
+
+  function getTreeNodeData(node: TreeNodeData) {
+    let children = 0;
+    if (typeof node.children != "undefined") {
+      children = node.children.length;
+    }
+    arrTocTree.push({
+      key: node.id,
+      title: node.title,
+      title_text: node.title_text,
+      level: iTocTreeCurrLevel,
+      children: children,
+      deletedAt: node.deletedAt,
+    });
+    if (children > 0) {
+      iTocTreeCurrLevel++;
+      for (const iterator of node.children) {
+        getTreeNodeData(iterator);
+      }
+      iTocTreeCurrLevel--;
+    }
+  }
+
+  return arrTocTree;
+}
+interface IWidget {
+  treeData: ListNodeData[];
+  addFileButton?: React.ReactNode;
+  addOnArticle?: TreeNodeData;
+  updatedNode?: TreeNodeData;
+  onChange?: Function;
+  onSelect?: Function;
+  onSave?: Function;
+  onAddFile?: Function;
+  onAppend?: Function;
+  onTitleClick?: Function;
+}
+const EditableTreeWidget = ({
+  treeData,
+  addFileButton,
+  addOnArticle,
+  updatedNode,
+  onChange,
+  onSelect,
+  onSave,
+  onAppend,
+  onTitleClick,
+}: IWidget) => {
+  const intl = useIntl();
+  const [checkKeys, setCheckKeys] = useState<string[]>([]);
+  const [checkNodes, setCheckNodes] = useState<TreeNodeData[]>([]);
+  const [gData, setGData] = useState<TreeNodeData[]>([]);
+  const [listTreeData, setListTreeData] = useState<ListNodeData[]>();
+  const [keys, setKeys] = useState<Key>("");
+
+  useEffect(() => {
+    if (typeof onChange !== "undefined") {
+      onChange(listTreeData);
+    }
+  }, [listTreeData]);
+
+  useEffect(() => {
+    //找到节点并更新
+    if (typeof updatedNode === "undefined") {
+      return;
+    }
+    const update = (_node: TreeNodeData[]) => {
+      _node.forEach((value, index, array) => {
+        if (value.id === updatedNode.id) {
+          array[index].title = updatedNode.title;
+          array[index].title_text = updatedNode.title_text;
+          console.log("key found");
+          return;
+        } else {
+          update(array[index].children);
+        }
+        return;
+      });
+    };
+    const newTree = [...gData];
+    update(newTree);
+    setGData(newTree);
+    const list = treeToList(newTree);
+    setListTreeData(list);
+  }, [updatedNode]);
+
+  const appendNode = (key: string, node: TreeNodeData) => {
+    console.log("key", key);
+    const append = (_node: TreeNodeData[]) => {
+      _node.forEach((value, index, array) => {
+        if (value.key === key) {
+          array[index].children.push(node);
+          console.log("key found");
+          return;
+        } else {
+          append(array[index].children);
+        }
+        return;
+      });
+    };
+    const newTree = [...gData];
+    append(newTree);
+    setGData(newTree);
+    const list = treeToList(newTree);
+    setListTreeData(list);
+  };
+
+  useEffect(() => {
+    if (typeof addOnArticle === "undefined") {
+      return;
+    }
+    console.log("add ", addOnArticle);
+
+    const newTreeData = [...gData, addOnArticle];
+    setGData(newTreeData);
+    const list = treeToList(newTreeData);
+    setListTreeData(list);
+  }, [addOnArticle]);
+
+  useEffect(() => {
+    const data = tocGetTreeData(treeData);
+    console.log("tree data", data);
+    setGData(data);
+  }, [treeData]);
+
+  const onCheck: TreeProps["onCheck"] = (checkedKeys, info) => {
+    console.log("onCheck", checkedKeys, info);
+    setCheckKeys(checkedKeys as string[]);
+    setCheckNodes(info.checkedNodes as TreeNodeData[]);
+  };
+
+  const onDragEnter: TreeProps["onDragEnter"] = (info) => {
+    console.log(info);
+    // expandedKeys 需要受控时设置
+    // setExpandedKeys(info.expandedKeys)
+  };
+
+  const onDrop: TreeProps["onDrop"] = (info) => {
+    console.log(info);
+    const dropKey = info.node.key;
+    const dragKey = info.dragNode.key;
+    const dropPos = info.node.pos.split("-");
+    const dropPosition =
+      info.dropPosition - Number(dropPos[dropPos.length - 1]);
+
+    const loop = (
+      data: DataNode[],
+      key: React.Key,
+      callback: (node: DataNode, i: number, data: DataNode[]) => void
+    ) => {
+      for (let i = 0; i < data.length; i++) {
+        if (data[i].key === key) {
+          return callback(data[i], i, data);
+        }
+        if (data[i].children) {
+          loop(data[i].children!, key, callback);
+        }
+      }
+    };
+    const data = [...gData];
+
+    // Find dragObject
+    let dragObj: DataNode;
+    loop(data, dragKey, (item, index, arr) => {
+      arr.splice(index, 1);
+      dragObj = item;
+    });
+
+    if (!info.dropToGap) {
+      // Drop on the content
+      loop(data, dropKey, (item) => {
+        item.children = item.children || [];
+        // where to insert 示例添加到头部,可以是随意位置
+        item.children.unshift(dragObj);
+      });
+    } else if (
+      ((info.node as any).props.children || []).length > 0 && // Has children
+      (info.node as any).props.expanded && // Is expanded
+      dropPosition === 1 // On the bottom gap
+    ) {
+      loop(data, dropKey, (item) => {
+        item.children = item.children || [];
+        // where to insert 示例添加到头部,可以是随意位置
+        item.children.unshift(dragObj);
+        // in previous version, we use item.children.push(dragObj) to insert the
+        // item to the tail of the children
+      });
+    } else {
+      let ar: DataNode[] = [];
+      let i: number;
+      loop(data, dropKey, (_item, index, arr) => {
+        ar = arr;
+        i = index;
+      });
+      if (dropPosition === -1) {
+        ar.splice(i!, 0, dragObj!);
+      } else {
+        ar.splice(i! + 1, 0, dragObj!);
+      }
+    }
+    setGData(data);
+    const list = treeToList(data);
+    setListTreeData(list);
+  };
+
+  return (
+    <>
+      <Space>
+        {addFileButton}
+        <Button
+          icon={<FileAddOutlined />}
+          onClick={async () => {
+            if (typeof onAppend !== "undefined") {
+              const newNode = await onAppend({
+                key: "",
+                title: "",
+                children: [],
+                level: 0,
+              });
+              console.log("newNode", newNode);
+              if (newNode) {
+                const append = [...gData, newNode];
+                setGData(append);
+                const list = treeToList(append);
+                setListTreeData(list);
+                return true;
+              } else {
+                message.error("添加失败");
+                return false;
+              }
+            } else {
+              return false;
+            }
+          }}
+        >
+          {intl.formatMessage({ id: "buttons.create" })}
+        </Button>
+        <Button
+          icon={<DeleteOutlined />}
+          danger
+          disabled={checkKeys.length === 0}
+          onClick={() => {
+            const delTree = (node: TreeNodeData[]): boolean => {
+              for (let index = 0; index < node.length; index++) {
+                if (checkKeys.includes(node[index].key)) {
+                  node.splice(index, 1);
+                  return true;
+                } else {
+                  const cf = delTree(node[index].children);
+                  if (cf) {
+                    return cf;
+                  }
+                }
+              }
+              return false;
+            };
+
+            Modal.confirm({
+              title: "从文集移除下列文章吗?(文章不会被删除)",
+              content: (
+                <>
+                  {checkNodes.map((item, id) => (
+                    <div key={id}>
+                      {id + 1} {item.title}
+                    </div>
+                  ))}
+                </>
+              ),
+              onOk() {
+                const tmp = [...gData];
+                const find = delTree(tmp);
+
+                console.log("delete", keys, find, tmp);
+                setGData(tmp);
+                const list = treeToList(tmp);
+                setListTreeData(list);
+              },
+            });
+          }}
+        >
+          {intl.formatMessage({ id: "buttons.remove" })}
+        </Button>
+        <Button
+          icon={<SaveOutlined />}
+          onClick={() => {
+            if (typeof onSave !== "undefined") {
+              onSave(listTreeData);
+            }
+          }}
+          type="primary"
+        >
+          {intl.formatMessage({ id: "buttons.save" })}
+        </Button>
+      </Space>
+      <Divider></Divider>
+      <Tree
+        showLine
+        showIcon
+        checkable
+        rootClassName="draggable-tree"
+        draggable
+        blockNode
+        selectable={false}
+        onDragEnter={onDragEnter}
+        onDrop={onDrop}
+        onCheck={onCheck}
+        onSelect={(selectedKeys: Key[]) => {
+          if (selectedKeys.length > 0) {
+            setKeys(selectedKeys[0]);
+          } else {
+            setKeys("");
+          }
+          if (typeof onSelect !== "undefined") {
+            onSelect(selectedKeys);
+          }
+        }}
+        treeData={gData}
+        titleRender={(node: TreeNodeData) => {
+          return (
+            <EditableTreeNode
+              node={node}
+              onAdd={async () => {
+                if (typeof onAppend !== "undefined") {
+                  const newNode = await onAppend(node);
+                  console.log("newNode", newNode);
+                  if (newNode) {
+                    appendNode(node.key, newNode);
+                    return true;
+                  } else {
+                    message.error("添加失败");
+                    return false;
+                  }
+                } else {
+                  return false;
+                }
+              }}
+              onTitleClick={(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
+                if (typeof onTitleClick !== "undefined") {
+                  onTitleClick(e, node);
+                }
+              }}
+            />
+          );
+        }}
+      />
+    </>
+  );
+};
+
+export default EditableTreeWidget;

+ 72 - 0
dashboard-v6/backup/components/article/EditableTreeNode.tsx

@@ -0,0 +1,72 @@
+import { Button, message, Space, Typography } from "antd";
+import { useState } from "react";
+import { PlusOutlined } from "@ant-design/icons";
+
+import type { TreeNodeData } from "./EditableTree"
+const { Text } = Typography;
+
+interface IWidget {
+  node: TreeNodeData;
+  onAdd?: Function;
+  onTitleClick?: Function;
+}
+const EditableTreeNodeWidget = ({ node, onAdd, onTitleClick }: IWidget) => {
+  const [showNodeMenu, setShowNodeMenu] = useState(false);
+  const [loading, setLoading] = useState(false);
+
+  const title = (
+    <Text type={node.status === 10 ? "secondary" : undefined}>
+      {node.title_text ? node.title_text : node.title}
+    </Text>
+  );
+
+  const TitleText = () =>
+    node.deletedAt ? (
+      <Text delete disabled>
+        {title}
+      </Text>
+    ) : (
+      <Text
+        onClick={(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
+          if (typeof onTitleClick !== "undefined") {
+            onTitleClick(e);
+          }
+        }}
+      >
+        {title}
+      </Text>
+    );
+
+  const Menu = () => (
+    <Space style={{ visibility: showNodeMenu ? "visible" : "hidden" }}>
+      <Button
+        loading={loading}
+        size="middle"
+        icon={<PlusOutlined />}
+        type="text"
+        onClick={async () => {
+          if (typeof onAdd !== "undefined") {
+            setLoading(true);
+            const ok = await onAdd();
+            setLoading(false);
+            if (!ok) {
+              message.error("error");
+            }
+          }
+        }}
+      />
+    </Space>
+  );
+
+  return (
+    <Space
+      onMouseEnter={() => setShowNodeMenu(true)}
+      onMouseLeave={() => setShowNodeMenu(false)}
+    >
+      <TitleText />
+      <Menu />
+    </Space>
+  );
+};
+
+export default EditableTreeNodeWidget;

+ 83 - 0
dashboard-v6/backup/components/article/ExerciseList.tsx

@@ -0,0 +1,83 @@
+import { useEffect, useState } from "react";
+import { Collapse, Space, Tag } from "antd";
+
+import type { ICourseExerciseResponse } from "../../api/Course";
+import { get } from "../../request";
+import type { IUser } from "../auth/User";
+import MdView from "../template/MdView";
+
+const { Panel } = Collapse;
+
+interface DataItem {
+  sn: number;
+  name: string;
+  user: IUser;
+  wbw: number;
+  translation: number;
+  question: number;
+  html: string;
+}
+interface IWidget {
+  courseId?: string;
+  articleId?: string;
+  exerciseId?: string;
+}
+const ExerciseListWidget = ({ courseId, articleId, exerciseId }: IWidget) => {
+  const [data, setData] = useState<DataItem[]>();
+
+  useEffect(() => {
+    const url = `/v2/exercise?course_id=${courseId}&article_id=${articleId}&exercise_id=${exerciseId}`;
+    console.log(url);
+    get<ICourseExerciseResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          console.log(json.data);
+          const items: DataItem[] = json.data.rows.map((item, id) => {
+            const member: DataItem = {
+              sn: id,
+              name: item.user.nickName,
+              user: item.user,
+              wbw: item.wbw,
+              translation: item.translation,
+              question: item.question,
+              html: item.html,
+            };
+            return member;
+          });
+          setData(items);
+        } else {
+          console.error(json.message);
+        }
+      })
+      .catch((error) => {
+        console.error(error);
+      });
+  }, [courseId, articleId, exerciseId]);
+  return (
+    <>
+      <Collapse>
+        {data?.map((item, id) => {
+          const header = (
+            <Space>
+              <span>{item.name}</span>
+              {item.wbw === 0 ? <></> : <Tag color="blue">wbw-{item.wbw}</Tag>}
+              {item.question === 0 ? (
+                <></>
+              ) : (
+                <Tag color="#5BD8A6">Q-{item.question}</Tag>
+              )}
+            </Space>
+          );
+
+          return (
+            <Panel header={header} key={id}>
+              <MdView html={item.html} />
+            </Panel>
+          );
+        })}
+      </Collapse>
+    </>
+  );
+};
+
+export default ExerciseListWidget;

+ 56 - 0
dashboard-v6/backup/components/article/Find.tsx

@@ -0,0 +1,56 @@
+import { useState } from "react";
+import { Input, Space, Select } from "antd";
+
+const { Search } = Input;
+
+const FindWidget = () => {
+  const [isLoading, setIsLoading] = useState(false);
+
+  const onSearch = (value: string) => {
+    setIsLoading(true);
+    console.log(value);
+  };
+  const onReplace = (value: string) => {
+    console.log(value);
+  };
+  return (
+    <div>
+      <Space orientation="vertical">
+        <Search
+          placeholder="input search text"
+          allowClear
+          onSearch={onSearch}
+          style={{ width: "100%" }}
+          loading={isLoading}
+        />
+        <Search
+          placeholder="input search text"
+          allowClear
+          enterButton="替换"
+          style={{ width: "100%" }}
+          onSearch={onReplace}
+        />
+        <Select
+          defaultValue="current"
+          style={{ width: "100%" }}
+          onChange={(value: string) => {
+            console.log(`selected ${value}`);
+          }}
+          options={[
+            {
+              value: "current",
+              label: "当前文档",
+            },
+            {
+              value: "all",
+              label: "全部译文",
+            },
+          ]}
+        />
+        <div>搜索结果</div>
+      </Space>
+    </div>
+  );
+};
+
+export default FindWidget;

+ 21 - 0
dashboard-v6/backup/components/article/MainMenu.tsx

@@ -0,0 +1,21 @@
+import { Button, Dropdown } from "antd";
+import { AppstoreOutlined } from "@ant-design/icons";
+import { mainMenuItems } from "../library/HeadBar";
+
+const MainMenuWidget = () => {
+  return (
+    <Dropdown
+      menu={{ items: mainMenuItems }}
+      placement="bottomLeft"
+      trigger={["click"]}
+    >
+      <Button
+        type="text"
+        style={{ display: "block", color: "white" }}
+        icon={<AppstoreOutlined />}
+      />
+    </Dropdown>
+  );
+};
+
+export default MainMenuWidget;

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно