visuddhinanda пре 1 месец
родитељ
комит
d7965b9dab

+ 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' => [],
+            ];
+        }
+    }
+}

+ 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;
+    }
+}

+ 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,
+        ]);
+    }
+}

+ 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) { ... }
+        }
+      }}
+    />
+  );
+};
+*/

+ 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;