Browse Source

Merge branch 'master' into development

visuddhinanda 1 year ago
parent
commit
8a3c18535f
88 changed files with 6267 additions and 705 deletions
  1. 1 0
      .gitignore
  2. 49 0
      api-v8/app/Console/Commands/ExportAiPaliWordToken.php
  3. 96 0
      api-v8/app/Console/Commands/ExportAiTrainingData.php
  4. 8 2
      api-v8/app/Console/Commands/ExportOffline.php
  5. 53 23
      api-v8/app/Console/Commands/ExportZip.php
  6. 8 0
      api-v8/app/Console/Commands/InitSystemDict.php
  7. 208 69
      api-v8/app/Console/Commands/MqDiscussion.php
  8. 118 0
      api-v8/app/Console/Commands/UpgradeDictSysPreference.php
  9. 47 0
      api-v8/app/Http/Api/ProjectApi.php
  10. 108 0
      api-v8/app/Http/Api/TaskApi.php
  11. 37 19
      api-v8/app/Http/Api/UserApi.php
  12. 76 0
      api-v8/app/Http/Controllers/CommandController.php
  13. 111 0
      api-v8/app/Http/Controllers/DictPreferenceController.php
  14. 0 35
      api-v8/app/Http/Controllers/MilestoneController.php
  15. 1 1
      api-v8/app/Http/Controllers/OfflineIndexController.php
  16. 162 0
      api-v8/app/Http/Controllers/ProjectController.php
  17. 85 0
      api-v8/app/Http/Controllers/TaskAssigneeController.php
  18. 255 15
      api-v8/app/Http/Controllers/TaskController.php
  19. 85 0
      api-v8/app/Http/Controllers/TaskRelationController.php
  20. 99 0
      api-v8/app/Http/Controllers/UserMilestoneController.php
  21. 30 0
      api-v8/app/Http/Resources/DictPreferenceResource.php
  22. 57 3
      api-v8/app/Http/Resources/NotificationResource.php
  23. 37 0
      api-v8/app/Http/Resources/ProjectResource.php
  24. 104 0
      api-v8/app/Http/Resources/TaskResource.php
  25. 11 0
      api-v8/app/Models/Milestone.php
  26. 16 0
      api-v8/app/Models/Project.php
  27. 23 0
      api-v8/app/Models/Task.php
  28. 17 0
      api-v8/app/Models/TaskAssignee.php
  29. 13 0
      api-v8/app/Models/TaskRelation.php
  30. 66 0
      api-v8/database/migrations/2024_10_24_124140_create_tasks_table.php
  31. 44 0
      api-v8/database/migrations/2024_10_25_015946_create_projects_table.php
  32. 34 0
      api-v8/database/migrations/2024_11_27_160039_create_task_assignees_table.php
  33. 34 0
      api-v8/database/migrations/2024_11_29_124111_create_task_relations_table.php
  34. 0 48
      api-v8/patch/db_fix/.vscode/launch.json
  35. 0 21
      api-v8/public/app/.vscode/launch.json
  36. 158 158
      api-v8/public/dicttext/cm/pm-grammar-paper/pm-grammar1.csv
  37. 44 44
      api-v8/public/dicttext/cm/sys_irregular/sys_irregular.csv
  38. 2 2
      api-v8/public/dicttext/cm/sys_irregular/sys_irregular.ini
  39. 39 42
      api-v8/public/dicttext/zh/formula/index.csv
  40. 10 1
      api-v8/routes/api.php
  41. 0 25
      dashboard-v4/.vscode/extensions.json
  42. 0 35
      dashboard-v4/.vscode/settings.json
  43. 15 0
      dashboard-v4/dashboard/src/Router.tsx
  44. 45 0
      dashboard-v4/dashboard/src/assets/icon/index.tsx
  45. 34 0
      dashboard-v4/dashboard/src/assets/icon/wikipali stamp2.ai
  46. 11 1
      dashboard-v4/dashboard/src/components/admin/LeftSider.tsx
  47. 185 0
      dashboard-v4/dashboard/src/components/api/task.ts
  48. 5 1
      dashboard-v4/dashboard/src/components/article/Article.tsx
  49. 38 0
      dashboard-v4/dashboard/src/components/article/TypeTask.tsx
  50. 7 5
      dashboard-v4/dashboard/src/components/auth/User.tsx
  51. 16 14
      dashboard-v4/dashboard/src/components/blog/TimeLine.tsx
  52. 153 112
      dashboard-v4/dashboard/src/components/dict/Community.tsx
  53. 241 0
      dashboard-v4/dashboard/src/components/dict/DictPreferenceEditor.tsx
  54. 38 0
      dashboard-v4/dashboard/src/components/studio/LeftSider.tsx
  55. 166 0
      dashboard-v4/dashboard/src/components/task/Filter.tsx
  56. 155 0
      dashboard-v4/dashboard/src/components/task/MyTasks.tsx
  57. 46 0
      dashboard-v4/dashboard/src/components/task/Options.tsx
  58. 109 0
      dashboard-v4/dashboard/src/components/task/PreTask.tsx
  59. 273 0
      dashboard-v4/dashboard/src/components/task/Project.tsx
  60. 83 0
      dashboard-v4/dashboard/src/components/task/ProjectCreate.tsx
  61. 79 0
      dashboard-v4/dashboard/src/components/task/ProjectEdit.tsx
  62. 52 0
      dashboard-v4/dashboard/src/components/task/ProjectEditDrawer.tsx
  63. 43 0
      dashboard-v4/dashboard/src/components/task/Task.tsx
  64. 68 0
      dashboard-v4/dashboard/src/components/task/TaskCreate.tsx
  65. 95 0
      dashboard-v4/dashboard/src/components/task/TaskEdit.tsx
  66. 166 0
      dashboard-v4/dashboard/src/components/task/TaskEditButton.tsx
  67. 64 0
      dashboard-v4/dashboard/src/components/task/TaskEditDrawer.tsx
  68. 604 0
      dashboard-v4/dashboard/src/components/task/TaskList.tsx
  69. 24 0
      dashboard-v4/dashboard/src/components/task/TaskLoader.tsx
  70. 351 0
      dashboard-v4/dashboard/src/components/task/TaskProjects.tsx
  71. 152 0
      dashboard-v4/dashboard/src/components/task/TaskReader.tsx
  72. 69 0
      dashboard-v4/dashboard/src/components/task/TaskRelation.tsx
  73. 145 0
      dashboard-v4/dashboard/src/components/task/TaskTable.tsx
  74. 25 7
      dashboard-v4/dashboard/src/components/template/UserSelect.tsx
  75. 1 1
      dashboard-v4/dashboard/src/components/template/Wbw/WbwFactors.tsx
  76. 42 0
      dashboard-v4/dashboard/src/components/template/Wbw/WbwFactorsEditor.tsx
  77. 76 0
      dashboard-v4/dashboard/src/components/template/Wbw/WbwLookup.tsx
  78. 6 0
      dashboard-v4/dashboard/src/locales/en-US/forms.ts
  79. 16 0
      dashboard-v4/dashboard/src/locales/en-US/label.ts
  80. 6 0
      dashboard-v4/dashboard/src/locales/zh-Hans/forms.ts
  81. 19 0
      dashboard-v4/dashboard/src/locales/zh-Hans/label.ts
  82. 7 0
      dashboard-v4/dashboard/src/pages/admin/dictionary/preference.tsx
  83. 36 21
      dashboard-v4/dashboard/src/pages/library/download/Download.tsx
  84. 27 0
      dashboard-v4/dashboard/src/pages/studio/task/hall.tsx
  85. 20 0
      dashboard-v4/dashboard/src/pages/studio/task/index.tsx
  86. 83 0
      dashboard-v4/dashboard/src/pages/studio/task/project.tsx
  87. 10 0
      dashboard-v4/dashboard/src/pages/studio/task/projects.tsx
  88. 15 0
      dashboard-v4/dashboard/src/pages/studio/task/tasks.tsx

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 /tmp/
+/.vscode/launch.json

+ 49 - 0
api-v8/app/Console/Commands/ExportAiPaliWordToken.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\DictApi;
+
+class ExportAiPaliWordToken extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:ai.pali.word.token';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'export ai pali word token';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $this->info('export ai pali word token');
+        $dict_id = DictApi::getSysDict('robot_compound');
+        if(!$dict_id){
+            $this->error('没有找到 robot_compound 字典');
+            return 1;
+        }
+        return 0;
+    }
+}

+ 96 - 0
api-v8/app/Console/Commands/ExportAiTrainingData.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use Illuminate\Support\Str;
+use App\Http\Api\MdRender;
+
+class ExportAiTrainingData extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:ai.training.data {--format=gz  : zip file format 7z,lzma,gz }';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'export ai training data';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('task export offline sentence-table start');
+        $filename = 'wikipali-offline-ai-training-'.date("Y-m-d").'.tsv';
+        $exportFile = storage_path('app/public/export/offline/'.$filename);
+        $fp = fopen($exportFile, 'w');
+        if ($fp === false) {
+            die('无法创建文件');
+        }
+
+        $channels = [
+            '19f53a65-81db-4b7d-8144-ac33f1217d34',
+        ];
+        $start = time();
+        foreach ($channels as $key => $channel) {
+            $db = Sentence::where('channel_uid',$channel);
+            $bar = $this->output->createProgressBar($db->count());
+            $srcDb = $db->select(['book_id','paragraph',
+                                    'word_start','word_end',
+                                    'content','content_type'])->cursor();
+            foreach ($srcDb as $sent) {
+                $content = MdRender::render($sent->content,
+                                [$channel],
+                                null,
+                                'read',
+                                'translation',
+                                $sent->content_type,
+                                'text',
+                                );
+                $origin = PaliSentence::where('book',$sent->book_id)
+                                        ->where('paragraph',$sent->paragraph)
+                                        ->where('word_begin',$sent->word_start)
+                                        ->where('word_end',$sent->word_end)
+                                        ->value('text');
+                $currData = array(
+                    $origin,
+                    str_replace("\n", "", $content),
+                    );
+
+                fwrite($fp, implode("\t", $currData)."\n");
+
+                $bar->advance();
+            }
+        }
+        fclose($fp);
+        $this->info((time() - $start).' seconds');
+        $this->call('export:zip',[
+            'filename'=>$filename,
+            'title' => 'wikipali ai training data',
+            'format'=> $this->option('format'),
+        ]);
+        return 0;
+    }
+}

+ 8 - 2
api-v8/app/Console/Commands/ExportOffline.php

@@ -54,6 +54,9 @@ class ExportOffline extends Command
             }
         }
 
+        //清空redis
+        RedisClusters::put('/offline/index',[]);
+
         //删除全部的旧文件
         foreach (scandir($exportDir) as $key => $file) {
             if(is_file($exportDir.'/'.$file)){
@@ -103,14 +106,17 @@ class ExportOffline extends Command
 
         sleep(5);
         $this->call('export:zip',[
-            'db'=>'wikipali-offline-index',
+            'filename'=>'wikipali-offline-index'.'-'.date("Y-m-d").'.db3',
+            'title' => 'wikipali 离线包索引',
             'format'=>$this->argument('format'),
         ]);
         $this->call('export:zip',[
-            'db'=>'wikipali-offline',
+            'filename'=>'wikipali-offline'.'-'.date("Y-m-d").'.db3',
+            'title' => 'wikipali 离线包',
             'format'=>$this->argument('format'),
         ]);
 
+        $this->call('export:ai.training.data');
         unlink($exportStop);
         return 0;
     }

+ 53 - 23
api-v8/app/Console/Commands/ExportZip.php

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Log;
 use App\Tools\RedisClusters;
 use Illuminate\Support\Facades\App;
 
+use Symfony\Component\Process\Process;
+use Symfony\Component\Process\Exception\ProcessFailedException;
+
 class ExportZip extends Command
 {
     /**
@@ -15,14 +18,14 @@ class ExportZip extends Command
      *
      * @var string
      */
-    protected $signature = 'export:zip {db : db filename} {format?  : zip file format 7z,lzma,gz }';
+    protected $signature = 'export:zip {filename : filename} {title : title} {format?  : zip file format 7z,lzma,gz }';
 
     /**
      * The console command description.
      *
      * @var string
      */
-    protected $description = 'Command description';
+    protected $description = '压缩导出的文件';
 
     /**
      * Create a new command instance.
@@ -44,7 +47,7 @@ class ExportZip extends Command
         Log::debug('export offline: 开始压缩');
         $this->info('export offline: 开始压缩');
         $exportPath = 'app/public/export/offline';
-        $exportFile = $this->argument('db').'-'.date("Y-m-d").'.db3';
+        $exportFile = $this->argument('filename');
 
         Log::debug('export offline: zip file {filename} {format}',
                     [
@@ -77,28 +80,39 @@ class ExportZip extends Command
         }
 
         shell_exec("cd ".storage_path($exportPath));
-        if($this->argument('format')==='7z'){
-            $command = "7z a -t7z -m0=lzma -mx=9 -mfb=64 -md=32m -ms=on {$zipFullFileName} {$exportFullFileName}";
-        }else if($this->argument('format')==='lzma'){
-            $command = "xz -k -9 --format=lzma {$exportFullFileName}";
-        }else{
-            $command = "gzip -k -q --best -c {$exportFullFileName} > {$zipFullFileName}";
+        switch ($this->argument('format')) {
+            case '7z':
+                $command = [
+                    '7z', 'a', '-t7z', '-m0=lzma',
+                    '-mx=9', '-mfb=64', '-md=32m', '-ms=on',
+                    $zipFullFileName,$exportFullFileName
+                ];
+                break;
+            case 'lzma':
+                $command = ['xz', '-k', '-9', '--format=lzma',$exportFullFileName];
+                break;
+            default:
+                $command = ['gzip', $exportFullFileName];
+                break;
         }
-        $this->info($command);
-        Log::debug('export offline: zip command:'.$command);
-        shell_exec($command);
+
+        $this->info( implode(' ',$command));
+        Log::debug('export offline zip start',['command'=>$command,'format'=>$this->argument('format')]);
+        $process = new Process($command);
+        $process->run();
+        $this->info($process->getOutput());
         $this->info('压缩完成');
         Log::debug('zip file {filename} in {format} saved.',
                     [
                         'filename'=>$exportFile,
                         'format'=>$this->argument('format')
                     ]);
-        $info = array();
+
         $url = array();
         foreach (config('mint.server.cdn_urls') as $key => $cdn) {
             $url[] = [
                     'link' => $cdn . '/' . $zipFile,
-                    'hostname' =>'cdn-' . $key,
+                    'hostname' =>'china cdn-' . $key,
                 ];
         }
 
@@ -135,15 +149,31 @@ class ExportZip extends Command
             'link'=>$link,
             'hostname'=>'Amazon cloud storage(Hongkong)',
         ];
-        $info[] = ['filename'=>$zipFile,
-                    'url' => $url,
-                   'create_at'=>date("Y-m-d H:i:s"),
-                   'chapter'=>RedisClusters::get("/export/chapter/count"),
-                   'filesize'=>filesize($zipFullFileName),
-                   'min_app_ver'=>'1.3',
-                    ];
-        RedisClusters::put('/offline/index/'.$this->argument('db'),$info);
-        unlink($exportFullFileName);
+        $info = RedisClusters::get('/offline/index');
+        if(!is_array($info)){
+            $info = array();
+        }
+        $info[] = [
+            'title' => $this->argument('title'),
+            'filename'=>$zipFile,
+            'url' => $url,
+            'create_at'=>date("Y-m-d H:i:s"),
+            'chapter'=>RedisClusters::get("/export/chapter/count"),
+            'filesize'=>filesize($zipFullFileName),
+            'min_app_ver'=>'1.3',
+            ];
+        RedisClusters::put('/offline/index',$info);
+        sleep(5);
+        try {
+            unlink($exportFullFileName);
+        } catch (\Throwable $th) {
+            Log::error('export offline: delete  file fail {Exception}',
+                        [
+                            'exception'=>$th,
+                            'file'=>$exportFullFileName
+                        ]);
+        }
+
         return 0;
     }
 }

+ 8 - 0
api-v8/app/Console/Commands/InitSystemDict.php

@@ -11,6 +11,7 @@ class InitSystemDict extends Command
      * The name and signature of the console command.
      *
      * @var string
+     * php artisan init:system.dict
      */
     protected $signature = 'init:system.dict';
 
@@ -53,6 +54,13 @@ class InitSystemDict extends Command
             'src_lang'=>'pa',
             'dest_lang'=>'cm',
         ],
+        [
+            "name"=>'system_preference',
+            'shortname'=>'系统单词首选项',
+            'description'=>'通过系统筛选出的首选项,只包含语法信息',
+            'src_lang'=>'pa',
+            'dest_lang'=>'cm',
+        ],
     ];
 
     /**

+ 208 - 69
api-v8/app/Console/Commands/MqDiscussion.php

@@ -3,15 +3,22 @@
 namespace App\Console\Commands;
 
 use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+
 use App\Models\Sentence;
 use App\Models\WebHook;
 use App\Models\Discussion;
 use App\Models\Article;
+use App\Models\DhammaTerm;
+use App\Models\Wbw;
+use App\Models\WbwBlock;
 use App\Http\Api\Mq;
 use App\Tools\WebHook as WebHookSend;
 use App\Http\Api\MdRender;
 use App\Http\Api\UserApi;
-use Illuminate\Support\Facades\Log;
+use App\Http\Controllers\NotificationController;
+
 
 class MqDiscussion extends Command
 {
@@ -56,12 +63,57 @@ class MqDiscussion extends Command
         Mq::worker($exchange,$queue,function ($message){
             Log::info('mq discussion receive {message}',['message'=>json_encode($message,JSON_UNESCAPED_UNICODE)]);
             $result = 0;
+            $msgParam = array();
+            $msgParam['nickname'] = $message->editor->nickName;
+            $link = config('app.url')."/pcd/discussion/topic/";
+            if($message->parent){
+                $msgParam['topic-title'] = Discussion::where('id',$message->parent)->value('title');
+                $id = $message->id;
+                $msgParam['link'] = $link . $message->parent.'#'.$id;
+                $msgParam['card_title'] = "回复讨论";
+                $type = 'reply';
+            }else{
+                $msgParam['title'] = $message->title;
+                $msgParam['link'] = $link . $message->id;
+                $msgParam['card_title'] = "创建讨论";
+                $type = 'create';
+            }
+            if($message->content){
+                $msgParam['content'] = $message->content;
+            }
+
             switch ($message->res_type) {
                 case 'sentence':
                     $sentence = Sentence::where('uid',$message->res_id)->first();
                     if(!$sentence){
-                        return 0;
+                        Log::error('invalid sentence id '.$message->res_id);
+                        $result = 1;
+                        break;
+                    }
+
+                    //站内信
+                    try{
+                        $sendTo = array();
+                        //句子的channel拥有者
+                        //$sendTo[] = $prData->channel->studio_id;
+                        //句子的作者
+                        if(!in_array($sentence->editor_uid,$sendTo)){
+                            $sendTo[] = $sentence->editor_uid;
+                        }
+                        //句子的采纳者
+                        if(!empty($sentence->acceptor_uid) && !in_array($sentence->acceptor_uid,$sendTo)){
+                            $sendTo[] = $sentence->acceptor_uid;
+                        }
+                        $this->notification($message->editor->uid,
+                                            $sendTo,
+                                            'discussion',
+                                            $message->id,
+                                            $sentence->channel_uid);
+                    }catch(\Exception $e){
+                        Log::error('send notification failed',['exception'=>$e]);
                     }
+
+                    //webhook
                     $contentHtml = MdRender::render($sentence->content,
                                              [$sentence->channel_uid],
                                              null,
@@ -71,90 +123,177 @@ class MqDiscussion extends Command
                     $contentTxt = strip_tags($contentHtml);
                     /**生成消息内容 */
 
-                    $msgParam = array();
                     $msgParam['anchor-content'] = $contentTxt;
-                    $msgParam['nickname'] = $message->editor->nickName;
-                    $link = config('app.url')."/pcd/discussion/topic/";
-                    if($message->parent){
-                        $msgParam['topic-title'] = Discussion::where('id',$message->parent)->value('title');
-                        $id = $message->id;
-                        $msgParam['link'] = $link . $message->parent.'#'.$id;
-                        $msgTitle = "回复讨论";
-                        $type = 'reply';
-                    }else{
-                        $msgParam['title'] = $message->title;
-                        $msgParam['link'] = $link . $message->id;
-                        $msgTitle = "创建讨论";
-                        $type = 'create';
+                    $WebHookResId = $sentence->channel_uid;
+
+                    $this->WebHook($msgParam,$type,$WebHookResId);
+                    break;
+                case 'wbw':
+                    $wbw = Wbw::where('uid',$message->res_id)->first();
+                    if(!$wbw){
+                        Log::error('invalid wbw id '.$message->res_id);
+                        $result = 1;
+                        break;
                     }
-                    if($message->content){
-                        $msgParam['content'] = $message->content;
+                    $wbwBlock = WbwBlock::where('uid',$wbw->block_uid)->first();
+                    if(!$wbwBlock){
+                        Log::error('invalid wbw-block id '.$message->res_id);
+                        $result = 1;
+                        break;
                     }
 
-                    $rootId = UserApi::getById(0)['uid'];
-                    $articleTitle = "webhook://discussion/{$type}/zh-hans";
-                    $tpl = Article::where('owner',$rootId)
-                                  ->where('title',$articleTitle)
-                                  ->value('content');
-                    if(empty($tpl)){
-                        Log::error('mq:discussion 模版不能为空',['tpl_title'=>$articleTitle]);
-                        return 1;
-                    }
-                    $m = new \Mustache_Engine(array('entity_flags'=>ENT_QUOTES,
-                                                'delimiters' => '{% %}',));
-                    $msgContent = $m->render($tpl,$msgParam);
-
-                    $webhooks = WebHook::where('res_id',$sentence->channel_uid)
-                                    ->where('status','active')
-                                    ->get();
-                    foreach ($webhooks as $key => $hook) {
-                        $event = json_decode($hook->event);
-
-                        if(is_array($event)){
-                            if(!in_array('discussion',$event)){
-                                continue;
-                            }
-                        }else{
-                            continue;
-                        }
-                        $command = '';
-                        $whSend = new WebHookSend;
-                        $ok = 0;
-                        switch ($hook->receiver) {
-                            case 'dingtalk':
-                                $ok = $whSend->dingtalk($hook->url,$msgTitle,$msgContent);
-                                break;
-                            case 'wechat':
-                                $ok = $whSend->wechat($hook->url,null,$msgContent);
-                                break;
-                            default:
-                                $ok=2;
-                                break;
+                    //站内信
+                    try{
+                        $sendTo = array();
+                        //channel拥有者
+                        //$sendTo[] = $prData->channel->studio_id;
+                        //作者
+                        if(!in_array($wbw->creator_uid,$sendTo)){
+                            $sendTo[] = $wbw->creator_uid;
                         }
-                        $result += $ok;
-                        $logMsg = "{$command}  ok={$ok}";
-                        if($ok === 0){
-                            $this->info($logMsg);
-                        }else{
-                            $this->error($logMsg);
+                        //提问者
+                        if(!empty($message->parent)){
+                            $topicEditor = Discussion::where('id',$message->parent)
+                                                ->value('editor_uid');
+                            if(!empty($topicEditor) && !in_array($topicEditor,$sendTo)){
+                                $sendTo[] = $topicEditor;
+                                Log::debug('发送给提问者',['data'=>$topicEditor]);
+                            }
                         }
 
-                        if($ok === 0){
-                            Log::debug('mq:discussion: send success {url}',['url'=>$hook->url]);
-                            WebHook::where('id',$hook->id)->increment('success');
-                        }else{
-                            Log::error('mq:discussion: send fail {url}',['url'=>$hook->url]);
-                            WebHook::where('id',$hook->id)->increment('fail');
+                        $this->notification($message->editor->id,
+                                            $sendTo,
+                                            'discussion',
+                                            $message->id,
+                                            $wbwBlock->channel_uid);
+                    }catch(\Exception $e){
+                        Log::error('send notification failed',['exception'=>$e]);
+                    }
+
+                    $msgParam['anchor-content'] = $wbw->word;
+                    $WebHookResId = $wbwBlock->channel_uid;
+                    $this->WebHook($msgParam,$type,$WebHookResId);
+                    break;
+                case 'term':
+                    $term = DhammaTerm::where('guid',$message->res_id)->first();
+                    if(!$term){
+                        Log::error('invalid term id '.$message->res_id);
+                        $result = 1;
+                        break;
+                    }
+                    if(empty($term->channal) || !Str::isUuid($term->channal)){
+                        break;
+                    }
+
+
+                    //站内信
+                    try{
+                        $sendTo = array();
+                        //拥有者
+                        $sendTo[] = $term->term;
+                        //作者
+                        $editor = App\Http\Api\UserApi::getById($term->editor_id);
+                        if($editor['id'] !== 0 && !in_array($editor['uid'],$sendTo)){
+                            $sendTo[] = $editor['uid'];
                         }
+                        $this->notification($message->editor->uid,
+                                            $sendTo,
+                                            'discussion',
+                                            $message->id,
+                                            $term->channal);
+                    }catch(\Exception $e){
+                        Log::error('send notification failed',['exception'=>$e]);
                     }
+                    //webhook
+                    $msgParam['anchor-content'] = $term->meaning . '(' . $term->word . ')';
+                    $WebHookResId = $term->channal;
+                    $this->WebHook($msgParam,$WebHookResId);
+
                     break;
                 default:
                     # code...
                     break;
             }
+
             return $result;
         });
 
         return 0;
     }
+
+    private function WebHook($msgParam,$type,$resId){
+        $rootId = UserApi::getById(0)['uid'];
+        $articleTitle = "webhook://discussion/{$type}/zh-hans";
+        $tpl = Article::where('owner',$rootId)
+                      ->where('title',$articleTitle)
+                      ->value('content');
+        if(empty($tpl)){
+            Log::error('mq:discussion 模版不能为空',['tpl_title'=>$articleTitle]);
+            return 1;
+        }
+        $m = new \Mustache_Engine(array('entity_flags'=>ENT_QUOTES,
+                                    'delimiters' => '{% %}',));
+        $msgContent = $m->render($tpl,$msgParam);
+
+        $webhooks = WebHook::where('res_id',$resId)
+                        ->where('status','active')
+                        ->get();
+        foreach ($webhooks as $key => $hook) {
+            $event = json_decode($hook->event);
+
+            if(is_array($event)){
+                if(!in_array('discussion',$event)){
+                    continue;
+                }
+            }else{
+                continue;
+            }
+            $command = '';
+            $whSend = new WebHookSend;
+            $ok = 0;
+            switch ($hook->receiver) {
+                case 'dingtalk':
+                    $ok = $whSend->dingtalk($hook->url,$msgParam['card_title'],$msgContent);
+                    break;
+                case 'wechat':
+                    $ok = $whSend->wechat($hook->url,null,$msgContent);
+                    break;
+                default:
+                    $ok=2;
+                    break;
+            }
+            $result += $ok;
+            $logMsg = "{$command}  ok={$ok}";
+            if($ok === 0){
+                $this->info($logMsg);
+            }else{
+                $this->error($logMsg);
+            }
+
+            if($ok === 0){
+                Log::debug('mq:discussion: send success {url}',['url'=>$hook->url]);
+                WebHook::where('id',$hook->id)->increment('success');
+            }else{
+                Log::error('mq:discussion: send fail {url}',['url'=>$hook->url]);
+                WebHook::where('id',$hook->id)->increment('fail');
+            }
+        }
+    }
+
+    private function notification($from,$to,$resType,$resId,$channel){
+            //发送站内信
+            try{
+
+                $sendCount = NotificationController::insert(
+                                    $from,
+                                    $to,
+                                    $resType,
+                                    $resId,
+                                    $channel);
+                $this->info("send notification success to [".$sendCount.'] users');
+            }catch(\Exception $e){
+                Log::error('send notification failed',['exception'=>$e]);
+            }
+            return;
+    }
 }

+ 118 - 0
api-v8/app/Console/Commands/UpgradeDictSysPreference.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\DictApi;
+use App\Models\UserDict;
+use App\Models\WordIndex;
+
+class UpgradeDictSysPreference extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     * php artisan upgrade:dict.sys.preference
+     */
+    protected $signature = 'upgrade:dict.sys.preference';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'upgrade dict system preference';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $this->info("start");
+        $dictList = [
+            'community_extract',
+            'robot_compound',
+            'system_regular',
+            'system_preference',
+        ];
+        $dict_id = array();
+        foreach ($dictList as $key => $value) {
+            $dict_id[$value] = DictApi::getSysDict($value);
+            if(!$dict_id[$value]){
+                $this->error("没有找到 {$value} 字典");
+                return 1;
+            }else{
+                $this->info("{$value} :{$dict_id[$value]}");
+            }
+        }
+
+        //搜索顺序
+        $order = [
+            '4d3a0d92-0adc-4052-80f5-512a2603d0e8',/* system irregular */
+            $dict_id['community_extract'],/* 社区字典*/
+            $dict_id['robot_compound'],
+            $dict_id['system_regular'],
+        ];
+        $words = WordIndex::orderBy('count', 'desc')->cursor();
+        $rows = 0;
+        $found = 0;
+        foreach ($words as $key => $word) {
+            if (preg_match('/\d/', $word->word)) {
+                continue;
+            }
+            $rows++;
+            $preference = null;
+            foreach ($order as $key => $dict) {
+                $preference = UserDict::where('word', $word->word)
+                                ->where('dict_id', $dict)
+                                ->whereNotNull('factors')
+                                ->where('factors','<>','')
+                                ->orderBy('confidence', 'desc')
+                                ->first();                # code...
+                if($preference){
+                    break;
+                }
+            }
+            if($preference){
+                $userDict = UserDict::firstOrNew([
+                    'word'=>$word->word,
+                    'dict_id'=>$dict_id['system_preference']
+                ],
+                [
+                    'id' => app('snowflake')->id(),
+                    'source' => '_ROBOT_',
+                    'create_time'=>(int)(microtime(true)*1000)
+                ]);
+                $userDict->factors = $preference->factors;
+                $userDict->parent = $preference->parent;
+                $userDict->confidence = $preference->confidence;
+                $userDict->language = 'cm';
+                $userDict->creator_id = 1;
+                $userDict->save();
+                $found++;
+            }
+            if($rows % 100 == 0){
+                $output = "[{$rows}] {$word->word} found:{$found}";
+                $this->info($output);
+                $found=0;
+            }
+        }
+        return 0;
+    }
+}

+ 47 - 0
api-v8/app/Http/Api/ProjectApi.php

@@ -0,0 +1,47 @@
+<?php
+namespace App\Http\Api;
+
+use App\Models\Project;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\App;
+
+class ProjectApi{
+    public static function getById($id){
+        if(!$id){
+            return null;
+        };
+        $project = Project::where('id',$id)->first();
+        if($project){
+            return [
+                'id'=>$id,
+                'title'=>$project->title,
+                'type'=>$project->type,
+                'description'=>$project->description,
+            ];
+        }else{
+            return null;
+        }
+    }
+
+    public static function getListByIds($ids){
+        if(!$ids){
+            return null;
+        };
+        $projects = Project::whereIn('id',$ids)->get();
+        $output = array();
+        foreach ($ids as $key => $id) {
+            foreach ($projects as $project) {
+                if($project->id === $id){
+                    $output[] = [
+                        'id'=>$id,
+                        'title'=>$project->title,
+                        'type'=>$project->type,
+                        'description'=>$project->description,
+                    ];
+                    continue;
+                };
+            }
+        }
+        return $output;
+    }
+}

+ 108 - 0
api-v8/app/Http/Api/TaskApi.php

@@ -0,0 +1,108 @@
+<?php
+namespace App\Http\Api;
+
+use App\Models\Task;
+use App\Models\TaskRelation;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\App;
+use App\Tools\RedisClusters;
+use Illuminate\Support\Str;
+
+class TaskApi{
+    public static function getById($id){
+        if(!$id){
+            return null;
+        };
+        $task = Task::where('id',$id)->first();
+        if($task){
+            return [
+                'id'=>$id,
+                'title'=>$task->title,
+                'description'=>$task->description,
+            ];
+        }else{
+            return null;
+        }
+    }
+
+    public static function getListByIds($ids){
+        if(!$ids){
+            return null;
+        };
+        $tasks = Task::whereIn('id',$ids)->get();
+        $output = array();
+        foreach ($ids as $key => $id) {
+            foreach ($tasks as $task) {
+                if($task->id === $id){
+                    $output[] = [
+                        'id'=>$id,
+                        'title'=>$task->title,
+                        'description'=>$task->description,
+                    ];
+                    continue;
+                };
+            }
+        }
+        return $output;
+    }
+
+    public static function setRelationTasks($taskId,$relationTasksId,$editor_id,$relation='pre'){
+        if($relation==='pre'){
+            $where = 'next_task_id';
+            $task1 = 'task_id';
+            $task2 = 'next_task_id';
+        }else{
+            $where = 'task_id';
+            $task1 = 'next_task_id';
+            $task2 = 'task_id';
+        }
+        $delete = TaskRelation::where($where,$taskId)
+                        ->delete();
+        foreach ($relationTasksId as $key => $id) {
+            $data[] = [
+                $task1 => $id,
+                $task2 => $taskId,
+                'editor_id' => $editor_id,
+                'created_at' => now(),
+                'updated_at' => now(),
+            ];
+        }
+        if(isset($data)){
+            TaskRelation::insert($data);
+        }
+        TaskApi::removeTaskRelationRedisKey($taskId,$relation);
+    }
+    public static function getRelationTasks($taskId,$relation='pre'){
+        $key = TaskApi::taskRelationRedisKey($taskId,$relation);
+        return RedisClusters::remember($key,3*24*3600,function() use($taskId,$relation){
+            if($relation==='pre'){
+                $where = 'next_task_id';
+                $select = 'task_id';
+            }else{
+                $where = 'task_id';
+                $select = 'next_task_id';
+            }
+            $tasks = TaskRelation::where($where,$taskId)
+                        ->select($select)->get();
+            $tasksId = [];
+            foreach ($tasks as $key => $task) {
+                $tasksId[] = $task[$select];
+            }
+            return TaskApi::getListByIds($tasksId);
+		});
+    }
+
+    public static function getNextTasks($taskId){
+        return TaskApi::getRelationTasks($taskId,'next');
+    }
+    public static function getPreTasks($taskId){
+        return TaskApi::getRelationTasks($taskId,'pre');
+    }
+    public static function removeTaskRelationRedisKey($taskId,$relation='pre'){
+        $key = TaskApi::taskRelationRedisKey($taskId,$relation);
+        RedisClusters::forget($key);
+    }
+    public static function taskRelationRedisKey($taskId,$relation='pre'){
+        return "task/relation/{$relation}/{$taskId}";
+    }
+}

+ 37 - 19
api-v8/app/Http/Api/UserApi.php

@@ -42,24 +42,7 @@ class UserApi{
     public static function getByUuid($id){
         $user = UserInfo::where('userid',$id)->first();
         if($user){
-            $data = [
-                'id'=>$id,
-                'nickName'=>$user['nickname'],
-                'userName'=>$user['username'],
-                'realName'=>$user['username'],
-            ];
-            if(!empty($user->role)){
-                $data['roles'] = json_decode($user->role);
-            }
-            if($user->avatar){
-                $img = str_replace('.jpg','_s.jpg',$user->avatar);
-                if (App::environment('local')) {
-                    $data['avatar'] = Storage::url($img);
-                }else{
-                    $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
-                }
-            }
-            return $data;
+            return UserApi::userInfo($user);
         }else{
             Log::error('$user=null;$id='.$id);
             return [
@@ -70,6 +53,41 @@ class UserApi{
                 'avatar'=>'',
             ];
         }
-
+    }
+    public static function getListByUuid($uuid){
+        if(!$uuid || !is_array($uuid)){
+            return null;
+        };
+        $users = UserInfo::whereIn('userid',$uuid)->get();
+        $output = array();
+        foreach ($uuid as $key => $id) {
+            foreach ($users as $user) {
+                if($user->userid === $id){
+                    $output[] = UserApi::userInfo($user);
+                    continue;
+                };
+            }
+        }
+        return $output;
+    }
+    public static function userInfo($user){
+        $data = [
+            'id' => $user->userid,
+            'nickName'=>$user->nickname,
+            'userName'=>$user->username,
+            'realName'=>$user->username,
+        ];
+        if(!empty($user->role)){
+            $data['roles'] = json_decode($user->role);
+        }
+        if($user->avatar){
+            $img = str_replace('.jpg','_s.jpg',$user->avatar);
+            if (App::environment('local')) {
+                $data['avatar'] = Storage::url($img);
+            }else{
+                $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
+            }
+        }
+        return $data;
     }
 }

+ 76 - 0
api-v8/app/Http/Controllers/CommandController.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\Mq;
+
+class CommandController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+        return $this->ok('ok');
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user || $user['user_uid'] !== 'ba5463f3-72d1-4410-858e-eadd10884713'){
+            return $this->error(__('auth.failed'),403,403);
+        }
+
+        Mq::publish('task',[
+            'name'=>$request->get('name'),
+            'param'=>$request->get('param'),
+        ]);
+        return $this->ok('ok');
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 111 - 0
api-v8/app/Http/Controllers/DictPreferenceController.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\Request;
+
+use App\Models\UserDict;
+use App\Models\WordIndex;
+use App\Http\Resources\DictPreferenceResource;
+use App\Http\Api\DictApi;
+
+class DictPreferenceController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $dict_id = DictApi::getSysDict('system_preference');
+        if(!$dict_id){
+            return $this->error('没有找到 system_preference 字典',200,200);
+        }
+        $table = WordIndex::where('user_dicts.dict_id',$dict_id)
+                    ->leftJoin('user_dicts','word_indices.word','=','user_dicts.word')
+                    ->select([
+                        'user_dicts.id',
+                        'word_indices.word',
+                        'word_indices.count',
+                        'user_dicts.factors',
+                        'user_dicts.parent',
+                        'user_dicts.note',
+                        'user_dicts.confidence',
+                    ]);
+        //处理搜索
+        if(!empty($request->get("keyword"))){
+            $table = $table->where('word_indices.word', 'like', "%".$request->get("keyword")."%");
+        }
+
+        //获取记录总条数
+        $count = $table->count();
+        //处理排序
+        $table = $table->orderBy($request->get("order",'word_indices.count'),
+                                    $request->get("dir",'desc'));
+        //处理分页
+        $table = $table->skip($request->get("offset",0))
+                        ->take($request->get("limit",200));
+        //获取数据
+        $result = $table->get();
+        return $this->ok([
+            "rows"=>DictPreferenceResource::collection($result),
+            "count"=>$count
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function show(UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request,  $id)
+    {
+        //
+		$newData = $request->all();
+		$result = UserDict::where('id', $id)
+				->update($newData);
+		if($result){
+			return $this->ok('ok');
+		}else{
+		    return $this->error("没有查询到数据");
+		}
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserDict $userDict)
+    {
+        //
+    }
+}

+ 0 - 35
api-v8/app/Http/Controllers/MilestoneController.php

@@ -43,42 +43,7 @@ class MilestoneController extends Controller
     public function show($studioName)
     {
         //
-        $user_uid = StudioApi::getIdByName($studioName);
 
-        $milestone = [];
-        $milestone[] = ['date'=>UserInfo::where('userid',$user_uid)->value('created_at'),'event'=>'sign-in'] ;
-        if(Wbw::where('creator_uid',$user_uid)->exists()){
-            $milestone[] = ['date'=>Wbw::where('creator_uid',$user_uid)
-                                       ->orderBy('created_at')
-                                       ->value('created_at'),
-                                       'event'=>'first-wbw'
-                           ] ;
-        }
-        if(Sentence::where('editor_uid',$user_uid)->exists()){
-            $milestone[] = ['date'=>Sentence::where('editor_uid',$user_uid)
-                                            ->orderBy('created_at')
-                                            ->value('created_at'),
-                                            'event'=>'first-translation'
-                            ] ;
-        }
-        if(DhammaTerm::where('owner',$user_uid)->exists()){
-            $milestone[] = ['date'=>DhammaTerm::where('owner',$user_uid)
-                                              ->orderBy('created_at')
-                                              ->value('created_at'),
-                                              'event'=>'first-term'
-                        ] ;
-
-        }
-        if(Course::where('studio_id',$user_uid)->exists()){
-            $milestone[] = ['date'=>Course::where('studio_id',$user_uid)
-                                           ->orderBy('created_at')
-                                           ->value('created_at'),
-                                           'event'=>'first-course'
-                                           ] ;
-        }
-
-
-        return $this->ok($milestone);
     }
 
     /**

+ 1 - 1
api-v8/app/Http/Controllers/OfflineIndexController.php

@@ -20,7 +20,7 @@ class OfflineIndexController extends Controller
     public function index(Request $request)
     {
         //
-        $key = '/offline/index/wikipali-offline';
+        $key = '/offline/index';
         if($request->has('file')){
             $key .= '-'.$request->get('file');
         }

+ 162 - 0
api-v8/app/Http/Controllers/ProjectController.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Project;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Resources\ProjectResource;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+
+class ProjectController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if(!$user){
+            Log::error('notification auth failed {request}',['request'=>$request]);
+            return $this->error(__('auth.failed'),401,401);
+        }
+        switch ($request->get('view')) {
+            case 'studio':
+                $table = Project::where('owner_id',$user['user_uid'])
+                            ->whereNull('parent_id');
+                if($request->get('type','normal') !== 'all'){
+                    $table = $table->where('type',$request->get('type','normal'));
+                }
+                break;
+            case 'project-tree':
+                $table = Project::where('id',$request->get('project_id'))
+                                ->orWhereJsonContains('path',$request->get('project_id'));
+                break;
+            default:
+                # code...
+                break;
+        }
+
+        if($request->has('keyword')){
+            $table = $table->where('title','like','%'.$request->get('keyword').'%');
+        }
+        if($request->has('status')){
+            $table = $table->whereIn('status',explode(',',$request->get('status')) );
+        }
+        $count = $table->count();
+
+        $sql = $table->toSql();
+        Log::debug('sql',['sql'=>$sql]);
+
+        $table = $table->orderBy($request->get('order','created_at'),$request->get('dir','desc'));
+
+        $table = $table->skip($request->get("offset",0))
+                    ->take($request->get('limit',10000));
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+            "rows" => ProjectResource::collection($result),
+            "count" => $count,
+            ]);
+    }
+
+    public function canEdit($user_uid,$studio_uid){
+        return $user_uid == $studio_uid;
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }
+        $studioId = StudioApi::getIdByName($request->get('studio_name'));
+        if(!$this->canEdit($user['user_uid'],$studioId)){
+            return $this->error(__('auth.failed'),403,403);
+        }
+        $new = Project::firstOrNew(['id'=>$request->get('id')]);
+        if(Str::isUuid($request->get('id'))){
+            $new->id = $request->get('id');
+        }else{
+            $new->id =  Str::uuid();
+        }
+        $new->title = $request->get('title');
+        $new->description = $request->get('description');
+        $new->parent_id = $request->get('parent_id');
+        $new->editor_id = $user['user_uid'];
+        $new->owner_id = $studioId;
+        $new->type = $request->get('type','normal');
+
+        if(Str::isUuid($request->get('parent_id'))){
+            $parentPath = Project::where('id',$request->get('parent_id'))->value('path');
+            $parentPath = json_decode($parentPath);
+            if(!is_array($parentPath)){
+                $parentPath = array();
+            }
+            array_push($parentPath,$new->parent_id);
+            $new->path = json_encode($parentPath,JSON_UNESCAPED_UNICODE);
+        }
+        $new->save();
+
+        return $this->ok(new ProjectResource($new));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Project $project)
+    {
+        //
+        return $this->ok(new ProjectResource($project));
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(Project $project)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Project $project)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Project $project)
+    {
+        //
+    }
+}

+ 85 - 0
api-v8/app/Http/Controllers/TaskAssigneeController.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\TaskAssignee;
+use Illuminate\Http\Request;
+
+class TaskAssigneeController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\TaskAssignee  $taskAssignee
+     * @return \Illuminate\Http\Response
+     */
+    public function show(TaskAssignee $taskAssignee)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\TaskAssignee  $taskAssignee
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(TaskAssignee $taskAssignee)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\TaskAssignee  $taskAssignee
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, TaskAssignee $taskAssignee)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\TaskAssignee  $taskAssignee
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(TaskAssignee $taskAssignee)
+    {
+        //
+    }
+}

+ 255 - 15
api-v8/app/Http/Controllers/TaskController.php

@@ -3,8 +3,19 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+
+use App\Models\Task;
+use App\Models\TaskAssignee;
+use App\Models\TaskRelation;
+use App\Models\Project;
+use App\Http\Resources\TaskResource;
+
 use App\Http\Api\AuthApi;
-use App\Http\Api\Mq;
+use App\Http\Api\StudioApi;
+use App\Http\Api\TaskApi;
+
 
 class TaskController extends Controller
 {
@@ -13,10 +24,93 @@ class TaskController extends Controller
      *
      * @return \Illuminate\Http\Response
      */
-    public function index()
+    public function index(Request $request)
     {
         //
-        return $this->ok('ok');
+        $user = AuthApi::current($request);
+        if(!$user){
+            Log::error('notification auth failed {request}',['request'=>$request]);
+            return $this->error(__('auth.failed'),401,401);
+        }
+
+        switch ($request->get('view')) {
+            case 'all':
+                $table = Task::whereNotNull('owner_id');
+                break;
+            case 'studio':
+                $table = Task::where('owner_id',$user['user_uid']);
+                break;
+            case 'project':
+                $projects = Project::where('id',$request->get('project_id'))
+                                ->orWhereJsonContains('path',$request->get('project_id'))
+                                ->select('id')->get();
+                $table = Task::whereIn('project_id',$projects);
+                break;
+            default:
+                # code...
+                break;
+        }
+        if($request->has('executor_id_includes')){
+            $table = $table->whereIn('executor_id',
+                        explode(',',$request->get('executor_id_includes')));
+        }
+        if($request->has('executor_id_not-includes')){
+            $table = $table->whereNotIn('executor_id',
+                        explode(',',$request->get('executor_id_not-includes')));
+        }
+        if($request->has('assignees_id_includes')){
+            $table = $table->whereJsonContains('assignees_id',
+                        explode(',',$request->get('assignees_id_includes')));
+        }
+        if($request->has('assignees_id_not-includes')){
+            $table = $table->whereJsonDoesntContain('assignees_id',
+                        explode(',',$request->get('assignees_id_not-includes')));
+        }
+        if($request->get('sign_up_equals')==='true'){
+            $table = $table->whereNull('assignees_id')
+                        ->whereNull('executor_id');
+        }
+
+        if($request->has('participants_id_includes')){
+            $id = explode(',',$request->get('participants_id_includes'));
+            $tasks_id = TaskAssignee::whereIn('assignee_id',$id)->select('task_id')->get();
+            $table = $table->where(function ($query) use ($id,$tasks_id) {
+                $query->whereIn('executor_id',$id)
+                    ->orWhereIn('id',$tasks_id);
+            });
+        }
+
+        if($request->has('participants_id_not-includes')){
+            $id = explode(',',$request->get('participants_id_not-includes'));
+            $table = $table->where(function ($query) use ($id) {
+                $query->whereJsonDoesntContain('assignees_id',$id)
+                    ->whereNotIn('executor_id',$id);
+            });
+        }
+
+        if($request->has('keyword')){
+            $table = $table->where('title','like','%'.$request->get('keyword').'%');
+        }
+        if($request->has('status') && $request->get('status') !== 'all'){
+            $table = $table->whereIn('status',explode(',',$request->get('status')) );
+        }
+        $count = $table->count();
+
+        $table = $table->orderBy($request->get('order','created_at'),
+                                $request->get('dir','asc'));
+
+        $table = $table->skip($request->get("offset",0))
+                    ->take($request->get('limit',1000));
+
+        Log::debug('sql',['sql'=>$table->toSql()]);
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+            "rows" => TaskResource::collection($result),
+            "count" => $count,
+            ]);
     }
 
     /**
@@ -29,48 +123,194 @@ class TaskController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if(!$user || $user['user_uid'] !== 'ba5463f3-72d1-4410-858e-eadd10884713'){
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }
+        $studioId = StudioApi::getIdByName($request->get('studio_name'));
+
+        if(!$this->canEdit($user['user_uid'],$studioId)){
             return $this->error(__('auth.failed'),403,403);
         }
+        $new = Task::firstOrNew(
+            [
+                'id'=>$request->get('id')
+            ],
+            [
+                'owner_id'=>$studioId,
+                'project_id'=>$request->get('project_id'),
+            ],
+        );
+        if(Str::isUuid($request->get('id'))){
+            $new->id = $request->get('id');
+        }else{
+            $new->id =  Str::uuid();
+        }
+        $new->title = $request->get('title');
+        $new->editor_id = $user['user_uid'];
+        $new->parent_id = $request->get('parent_id');
+        //处理任务顺序
+        if($request->get('parent_id')){
+            $maxOrder = Task::where('parent_id',$request->get('parent_id'))
+                        ->max('order');
+        }else{
+            $maxOrder = Task::where('project_id',$request->get('project_id'))
+                        ->max('order');
+        }
+        if($maxOrder === null){
+            $new->order = 1;
+        }else{
+            $new->order = $maxOrder + 1;
+        }
+        $new->save();
 
-        Mq::publish('task',[
-            'name'=>$request->get('name'),
-            'param'=>$request->get('param'),
-        ]);
-        return $this->ok('ok');
+        return $this->ok(new TaskResource($new));
     }
 
     /**
      * Display the specified resource.
      *
-     * @param  int  $id
+     * @param  Task  $task
      * @return \Illuminate\Http\Response
      */
-    public function show($id)
+    public function show(Task $task)
     {
         //
+        return $this->ok(new TaskResource($task));
     }
 
     /**
      * Update the specified resource in storage.
      *
      * @param  \Illuminate\Http\Request  $request
-     * @param  int  $id
+     * @param  Task $task
      * @return \Illuminate\Http\Response
      */
-    public function update(Request $request, $id)
+    public function update(Request $request, Task $task)
     {
         //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }
+        if(!$this->canEdit($user['user_uid'],$task->owner_id)){
+            return $this->error(__('auth.failed'),403,403);
+        }
+        if($request->has('title')){
+            $task->title = $request->get('title');
+        }
+        if($request->has('description')){
+            $task->description = $request->get('description');
+        }
+        if($request->has('assignees_id')){
+            $delete = TaskAssignee::where('task_id',$task->id)->delete();
+            $assigneesData = [];
+            foreach ($request->get('assignees_id') as $key => $id) {
+                $assigneesData[] = [
+                    'id' => Str::uuid(),
+                    'task_id' => $task->id,
+                    'assignee_id' => $id,
+                    'editor_id' => $user['user_uid'],
+                ];
+            }
+            TaskAssignee::insert($assigneesData);
+        }
+        if($request->has('roles')){
+            $task->roles = json_encode($request->get('roles'),JSON_UNESCAPED_UNICODE);
+        }
+        if($request->has('executor_id')){
+            $task->executor_id = $request->get('executor_id');
+        }
+        if($request->has('executor_relation_task_id')){
+            $task->executor_relation_task_id = $request->get('executor_relation_task_id');
+        }
+        if($request->has('project_id')){
+            $task->project_id = $request->get('project_id');
+        }
+        if($request->has('pre_task_id')){
+            TaskApi::setRelationTasks($task->id,
+                explode(',',$request->get('pre_task_id')),
+                $user['user_uid'],
+                'pre');
+        }
+        if($request->has('next_task_id')){
+            $task->next_task_id = $request->get('next_task_id');
+            TaskApi::setRelationTasks($task->id,
+                explode(',',$request->get('next_task_id')),
+                $user['user_uid'],
+                'next');
+        }
+        if($request->has('is_milestone')){
+            $task->is_milestone = $request->get('is_milestone');
+        }
+        if($request->has('order')){
+            $task->order = $request->get('order');
+        }
+        $relatedId = [];
+        if($request->has('status')){
+            switch ($request->get('status')) {
+                case 'publish':
+                    # code...
+                    break;
+                case 'running':
+                    $task->started_at = now();
+                    $task->executor_id = $user['user_uid'];
+                break;
+                case 'done':
+                    $task->finished_at = now();
+                    //开启相关任务
+                    $preTasks = Task::where('pre_task_id',$task->id)
+                                    ->where('status','pending')->select('id')->get();
+                    foreach ($preTasks as $key => $value) {
+                        $relatedId[] = $value->id;
+                    }
+                    if($task->next_task_id && Str::isUuid($task->next_task_id)){
+                        $nextTasks = Task::where('id',$task->next_task_id)
+                            ->where('status','pending')
+                            ->select('id')->get();
+                        foreach ($nextTasks as $key => $value) {
+                            $relatedId[] = $value->id;
+                        }
+                    }
+                    Task::whereIn('id',$relatedId)
+                            ->update(['status'=>'published']);
+                    break;
+                default:
+                    # code...
+                    break;
+            }
+            $task->status = $request->get('status');
+        }
+        $task->editor_id = $user['user_uid'];
+        $task->save();
+
+        return $this->ok(new TaskResource($task));
     }
 
     /**
      * Remove the specified resource from storage.
      *
-     * @param  int  $id
+     * @param  Task  $task
      * @return \Illuminate\Http\Response
      */
-    public function destroy($id)
+    public function destroy(Task  $task)
     {
         //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }
+        if(!$this->canEdit($user['user_uid'],$task->owner)){
+            return $this->error(__('auth.failed'),403,403);
+        }
+        $task->delete();
+        if($task->trashed()){
+            return $this->ok('ok');
+        }else{
+            return $this->error('fail',500,500);
+        }
+    }
+
+    private function canEdit($user_uid,$owner_uid){
+        return $user_uid===$owner_uid;
     }
 }

+ 85 - 0
api-v8/app/Http/Controllers/TaskRelationController.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\TaskRelation;
+use Illuminate\Http\Request;
+
+class TaskRelationController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\TaskRelation  $taskRelation
+     * @return \Illuminate\Http\Response
+     */
+    public function show(TaskRelation $taskRelation)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\TaskRelation  $taskRelation
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(TaskRelation $taskRelation)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\TaskRelation  $taskRelation
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, TaskRelation $taskRelation)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\TaskRelation  $taskRelation
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(TaskRelation $taskRelation)
+    {
+        //
+    }
+}

+ 99 - 0
api-v8/app/Http/Controllers/UserMilestoneController.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class UserMilestoneController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($studioName)
+    {
+        //
+        $user_uid = StudioApi::getIdByName($studioName);
+
+        $milestone = [];
+        $milestone[] = ['date'=>UserInfo::where('userid',$user_uid)->value('created_at'),'event'=>'sign-in'] ;
+        if(Wbw::where('creator_uid',$user_uid)->exists()){
+            $milestone[] = ['date'=>Wbw::where('creator_uid',$user_uid)
+                                       ->orderBy('created_at')
+                                       ->value('created_at'),
+                                       'event'=>'first-wbw'
+                           ] ;
+        }
+        if(Sentence::where('editor_uid',$user_uid)->exists()){
+            $milestone[] = ['date'=>Sentence::where('editor_uid',$user_uid)
+                                            ->orderBy('created_at')
+                                            ->value('created_at'),
+                                            'event'=>'first-translation'
+                            ] ;
+        }
+        if(DhammaTerm::where('owner',$user_uid)->exists()){
+            $milestone[] = ['date'=>DhammaTerm::where('owner',$user_uid)
+                                              ->orderBy('created_at')
+                                              ->value('created_at'),
+                                              'event'=>'first-term'
+                        ] ;
+
+        }
+        if(Course::where('studio_id',$user_uid)->exists()){
+            $milestone[] = ['date'=>Course::where('studio_id',$user_uid)
+                                           ->orderBy('created_at')
+                                           ->value('created_at'),
+                                           'event'=>'first-course'
+                                           ] ;
+        }
+
+
+        return $this->ok($milestone);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 30 - 0
api-v8/app/Http/Resources/DictPreferenceResource.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class DictPreferenceResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     */
+    public function toArray($request)
+    {
+        $data = [
+            'id' => strval($this->id),
+            'word'=>$this->word,
+            'count'=>$this->count,
+            'parent'=>$this->parent,
+            'note'=>$this->note,
+            'factors'=>$this->factors,
+            'confidence'=>$this->confidence,
+            'updated_at'=>$this->updated_at,
+            'creator_id'=>$this->creator_id,
+           ];
+        return $data;
+    }
+}

+ 57 - 3
api-v8/app/Http/Resources/NotificationResource.php

@@ -4,9 +4,13 @@ namespace App\Http\Resources;
 
 use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Support\Facades\Log;
-use App\Http\Api\UserApi;
+
 use App\Models\SentPr;
+use App\Models\Wbw;
+use App\Models\Discussion;
 use App\Models\Sentence;
+
+use App\Http\Api\UserApi;
 use App\Http\Api\PaliTextApi;
 use App\Http\Api\ChannelApi;
 
@@ -33,10 +37,9 @@ class NotificationResource extends JsonResource
             "created_at"=> $this->created_at,
             "updated_at"=> $this->updated_at,
         ];
-
+        $data['channel'] = ChannelApi::getById($this->channel);
         switch ($this->res_type) {
             case 'suggestion':
-                $data['channel'] = ChannelApi::getById($this->channel);
                 $prData = SentPr::where('uid',$this->res_id)->first();
                 if($prData){
                     $link = config('mint.server.dashboard_base_path')."/article/para/{$prData->book_id}-{$prData->paragraph}";
@@ -65,6 +68,57 @@ class NotificationResource extends JsonResource
                     }
                 }
                 break;
+            case 'discussion':
+                    $discussion = Discussion::where('id',$this->res_id)->first();
+                    if($discussion->parent){
+                        $topic = Discussion::where('id',$discussion->parent)->first();
+                    }
+                    if($discussion){
+                        $link = config('mint.server.dashboard_base_path').'/discussion/topic/';
+                        if(isset($topic)){
+                            $link .= "{$topic->id}#{$discussion->id}";
+                        }else{
+                            $link .= "{$discussion->id}";
+                        }
+                        $data['url'] = $link;
+                        //标题
+                        switch ($discussion->res_type) {
+                            case 'sentence':
+                                break;
+                            case 'wbw':
+                                $wbw = Wbw::where('uid',$discussion->res_id)->first();
+                                if($wbw){
+                                    $data['title'] = $wbw->word;
+
+                                }
+
+                                break;
+                            default:
+                                break;
+                        }
+                        /*
+                        {
+                            $path = json_decode(PaliTextApi::getChapterPath($prData->book_id,$prData->paragraph));
+                            if(count($path)>0){
+                                $data['title'] = end($path)->title;
+                                $data['book_title'] = $path[0]->title;
+                            }else{
+                                Log::error('no path data',['pr data'=>$prData]);
+                            }
+                            //内容
+                            $orgContent = Sentence::where('book_id',$prData->book_id)
+                                                    ->where('paragraph',$prData->paragraph)
+                                                    ->where('word_start',$prData->word_start)
+                                                    ->where('word_end',$prData->word_end)
+                                                    ->where('channel_uid',$prData->channel_uid)
+                                                    ->value('content');
+                            $content = '>'. mb_substr($orgContent,0,70,"UTF-8")."\n\n";
+                            $content .= mb_substr($prData->content,0,140,"UTF-8");
+                            $data['content'] = $content;
+                        }
+                        */
+                    }
+                    break;
             default:
                 # code...
                 break;

+ 37 - 0
api-v8/app/Http/Resources/ProjectResource.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+use App\Http\Api\UserApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\ProjectApi;
+
+class ProjectResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     */
+    public function toArray($request)
+    {
+        $data = [
+            'id' => $this->id,
+            'title' => $this->title,
+            'type' => $this->type,
+            'description' => $this->description,
+            'executors_id' => json_decode($this->executors_id),
+            'parent_id' => $this->parent_id,
+            'parent' => ProjectApi::getById($this->parent_id),
+            'path' => ProjectApi::getListByIds(json_decode($this->path)),
+            'description' => $this->description,
+            "owner"=> StudioApi::getById($this->owner_id),
+            "editor"=> UserApi::getIdByUuid($this->editor_id),
+            'created_at' => $this->created_at,
+            'updated_at' => $this->updated_at,
+        ];
+        return $data;
+    }
+}

+ 104 - 0
api-v8/app/Http/Resources/TaskResource.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+use App\Http\Api\UserApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\TaskApi;
+use App\Http\Api\ProjectApi;
+use App\Http\Api\MdRender;
+use App\Models\TaskAssignee;
+
+use Illuminate\Support\Str;
+
+class TaskResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     */
+    public function toArray($request)
+    {
+        /*
+  id: string;
+  title: string;
+  description?: string | null;
+  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;
+  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;
+         */
+        $htmlRender = new MdRender([
+            'mode' => 'read',
+            'format'=> 'react',
+            'footnote' => true,
+            'origin' => $request->get('origin',true),
+            'paragraph' => $request->get('paragraph',false),
+        ]);
+        $data = [
+            'id' => $this->id,
+            'title' => $this->title,
+            'description' => $this->description,
+            'parent_id' => $this->parent_id,
+            'parent' => TaskApi::getById($this->parent_id),
+            'roles' => $this->roles,
+            'executor_id' => $this->executor_id,
+            'executor_relation_task_id' => $this->executor_relation_task_id,
+            'executor_relation_task' => TaskApi::getById($this->executor_relation_task_id),
+            'pre_task' => TaskApi::getPreTasks($this->id),
+            'next_task' => TaskApi::getNextTasks($this->id),
+            'is_milestone' => $this->is_milestone,
+            'project_id' => $this->project_id,
+            'project' => ProjectApi::getById($this->project_id),
+            'owner_id' => $this->owner_id,
+            "owner"=> StudioApi::getById($this->owner_id),
+            'editor_id' => $this->editor_id,
+            "editor"=> UserApi::getByUuid($this->editor_id),
+            'order' => $this->order,
+            'status' => $this->status,
+            'created_at' => $this->created_at,
+            'updated_at' => $this->updated_at,
+            'started_at' => $this->started_at,
+            'finished_at' => $this->finished_at,
+        ];
+        $assignees = TaskAssignee::where('task_id',$this->id)->select('assignee_id')->get();
+        if(count($assignees)>0){
+            $assignees_id = [];
+            foreach ($assignees as $key => $value) {
+                $assignees_id[] = $value->assignee_id;
+            }
+            $data['assignees_id'] = $assignees_id;
+            $data['assignees'] = UserApi::getListByUuid($assignees_id);
+        }
+        if(!empty($this->description)){
+            $data["html"] = $htmlRender->convert($this->description,[]);
+        }
+
+        if(Str::isUuid($this->executor_id)){
+            $data["executor"] = UserApi::getByUuid($this->executor_id);
+        }
+        return $data;
+    }
+}

+ 11 - 0
api-v8/app/Models/Milestone.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Milestone extends Model
+{
+    use HasFactory;
+}

+ 16 - 0
api-v8/app/Models/Project.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Project extends Model
+{
+    use HasFactory;
+    protected $primaryKey = 'id';
+    protected $casts = [
+        'id' => 'string'
+    ];
+    protected $fillable = ['id'];
+}

+ 23 - 0
api-v8/app/Models/Task.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Task extends Model
+{
+    use HasFactory;
+    protected $primaryKey = 'id';
+    protected $casts = [
+        'id' => 'string'
+    ];
+    protected $fillable = ['id','owner_id','project_id'];
+
+    protected $dates = [
+        'created_at',
+        'updated_at',
+        'started_at',
+        'finished_at',
+    ];
+}

+ 17 - 0
api-v8/app/Models/TaskAssignee.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class TaskAssignee extends Model
+{
+    use HasFactory;
+
+    protected $primaryKey = 'id';
+    protected $casts = [
+        'id' => 'string'
+    ];
+    protected $fillable = ['id','task_id','assignee_id','editor_id'];
+}

+ 13 - 0
api-v8/app/Models/TaskRelation.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class TaskRelation extends Model
+{
+    use HasFactory;
+    protected $fillable = ['id','task_id','next_task_id','editor_id'];
+
+}

+ 66 - 0
api-v8/database/migrations/2024_10_24_124140_create_tasks_table.php

@@ -0,0 +1,66 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateTasksTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * 标题 	手输文本
+     * 类型 	固定标签 project / 翻译主笔
+     * 描述 	任务内容文本
+     * 特别指派 	用户ID 	多个,选填
+     * 角色 	角色标签 	与特别指派互斥
+     * 实际执行人 	用户ID 	单个
+     * 实际执行人关联任务 	ID 	与所关联的任务实际执行人相同
+     * 工程 	ID 	单个
+     * //工程路径 json  这个任务所属的project 的路径 studio/project1/project2
+     * 前置任务ID 		选填
+     * 状态 	列表  待定	待发布、待领取、进行中、待审核、重做、通过、完结
+     * 拥有者 	用户ID 	单个
+     * 修改者 	用户ID 	单个
+     * 里程碑   uuid 	单个
+     * 建立日期
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('tasks', function (Blueprint $table) {
+            $table->uuid('id')->primary()->default(DB::raw('uuid_generate_v1mc()'));
+            $table->string('title',512)->index();
+            $table->string('type',32)->index()->default('project');
+            $table->text('description')->nullable();
+            $table->uuid('parent_id')->index()->nullable();
+            $table->jsonb('assignees_id')->index()->nullable();
+            $table->jsonb('roles')->index()->nullable();
+            $table->uuid('executor_id')->index()->nullable();
+            $table->uuid('executor_relation_task_id')->index()->nullable();
+            $table->uuid('project_id')->index();
+            $table->uuid('pre_task_id')->index()->nullable();
+            $table->uuid('next_task_id')->index()->nullable();
+            $table->boolean('is_milestone')->index()->default(false);
+            $table->uuid('owner_id')->index();
+            $table->uuid('editor_id')->index();
+            $table->integer('order')->index()->default(1);
+            $table->string('status',32)->index()->default('pending');
+            $table->boolean('closed_by_subtask')->default(true);
+            $table->timestamp('started_at')->nullable()->index();
+            $table->timestamp('finished_at')->nullable()->index();
+            $table->softDeletes();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('tasks');
+    }
+}

+ 44 - 0
api-v8/database/migrations/2024_10_25_015946_create_projects_table.php

@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateProjectsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('projects', function (Blueprint $table) {
+            $table->uuid('id')->primary()->default(DB::raw('uuid_generate_v1mc()'));
+            $table->string('title',512)->index();
+            $table->string('type',32)->index()->default('normal');
+            $table->text('description')->nullable();
+            $table->jsonb('executors_id')->index()->nullable();
+            $table->uuid('parent_id')->index()->nullable();
+            $table->jsonb('path')->index()->nullable();
+            $table->jsonb('milestones')->index()->nullable();
+            $table->uuid('owner_id')->index();
+            $table->uuid('editor_id')->index();
+            $table->jsonb('status')->index()->nullable();
+            $table->timestamp('started_at')->nullable()->index();
+            $table->timestamp('finished_at')->nullable()->index();
+            $table->softDeletes();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('projects');
+    }
+}

+ 34 - 0
api-v8/database/migrations/2024_11_27_160039_create_task_assignees_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateTaskAssigneesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('task_assignees', function (Blueprint $table) {
+            $table->uuid('id')->primary()->default(DB::raw('uuid_generate_v1mc()'));
+            $table->uuid('task_id')->index();
+            $table->uuid('assignee_id')->index();
+            $table->uuid('editor_id')->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('task_assignees');
+    }
+}

+ 34 - 0
api-v8/database/migrations/2024_11_29_124111_create_task_relations_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateTaskRelationsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('task_relations', function (Blueprint $table) {
+            $table->id();
+            $table->uuid('task_id')->index();
+            $table->uuid('next_task_id')->index();
+            $table->uuid('editor_id')->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('task_relations');
+    }
+}

+ 0 - 48
api-v8/patch/db_fix/.vscode/launch.json

@@ -1,48 +0,0 @@
-{
-    // Use IntelliSense to learn about possible attributes.
-    // Hover to view descriptions of existing attributes.
-    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
-    "version": "0.2.0",
-    "configurations": [
-        {
-            "name": "Listen for Xdebug",
-            "type": "php",
-            "request": "launch",
-            "port": 9000
-        },
-        {
-            "name": "Launch currently open script",
-            "type": "php",
-            "request": "launch",
-            "program": "${file}",
-            "cwd": "${fileDirname}",
-            "port": 0,
-            "runtimeArgs": [
-                "-dxdebug.start_with_request=yes"
-            ],
-            "env": {
-                "XDEBUG_MODE": "debug,develop",
-                "XDEBUG_CONFIG": "client_port=${port}"
-            }
-        },
-        {
-            "name": "Launch Built-in web server",
-            "type": "php",
-            "request": "launch",
-            "runtimeArgs": [
-                "-dxdebug.mode=debug",
-                "-dxdebug.start_with_request=yes",
-                "-S",
-                "localhost:0"
-            ],
-            "program": "",
-            "cwd": "${workspaceRoot}",
-            "port": 9000,
-            "serverReadyAction": {
-                "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started",
-                "uriFormat": "http://localhost:%s",
-                "action": "openExternally"
-            }
-        }
-    ]
-}

+ 0 - 21
api-v8/public/app/.vscode/launch.json

@@ -1,21 +0,0 @@
-{
-    // 使用 IntelliSense 了解相关属性。 
-    // 悬停以查看现有属性的描述。
-    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
-    "version": "0.2.0",
-    "configurations": [
-        {
-            "type": "chrome",
-            "request": "launch",
-            "name": "Launch Chrome ",
-            "url": "http://localhost/wikipali/",
-            "webRoot": "${workspaceFolder}"
-        },
-        {
-            "name":"Listen for XDebug",
-            "type":"php",
-            "request":"launch",
-            "port":9000
-        }
-    ]
-}

File diff suppressed because it is too large
+ 158 - 158
api-v8/public/dicttext/cm/pm-grammar-paper/pm-grammar1.csv


+ 44 - 44
api-v8/public/dicttext/cm/sys_irregular/sys_irregular.csv

@@ -1547,39 +1547,39 @@ id,word,type,grammar,parent,meaning,note,factors,factor_meaning,status,confidenc
 1547,dhammañca,.un.,,,,,dhammaṃ+ca,,1,100,9,vn,cm
 1548,eka,.num:base.,,,一$单$独,,,,1,100,3,Kosalla_China,zh-hans
 1549,eka,.num:base.,,,one$single$alone,,,,1,100,3,Kosalla_China,cm
-1550,eko,.num.,.m.$.sg.$.nom.,eka,,,,,1,100,3,vn,cm
-1551,ekaṃ,.num.,.m.$.sg.$.acc.,eka,,,,,1,100,4,vn,cm
-1552,ekena,.num.,.m.$.sg.$.inst.,eka,,,,,1,100,5,vn,cm
-1553,ekassa,.num.,.m.$.sg.$.dat.,eka,,,,,1,100,6,vn,cm
-1554,ekamhā,.num.,.m.$.sg.$.abl.,eka,,,,,1,100,6,vn,cm
-1555,ekasmā,.num.,.m.$.sg.$.abl.,eka,,,,,1,100,6,vn,cm
-1556,ekassa,.num.,.m.$.sg.$.gen.,eka,,,,,1,100,6,vn,cm
-1557,ekamhi,.num.,.m.$.sg.$.loc.,eka,,,,,1,100,6,vn,cm
-1558,ekasmiṃ,.num.,.m.$.sg.$.acc.,eka,,,,,1,100,7,vn,cm
-1559,ekā,.num.,.f.$.sg.$.nom.,eka,,,,,1,100,3,vn,cm
-1560,ekaṃ,.num.,.f.$.sg.$.acc.,eka,,,,,1,100,4,vn,cm
-1561,ekāya,.num.,.f.$.sg.$.inst.,eka,,,,,1,100,5,vn,cm
-1562,ekissā,.num.,.f.$.sg.$.dat.,eka,,,,,1,100,6,vn,cm
-1563,ekassā,.num.,.f.$.sg.$.nom.,eka,,,,,1,100,6,vn,cm
-1564,ekāya,.num.,.f.$.sg.$.nom.,eka,,,,,1,100,5,vn,cm
-1565,ekāya,.num.,.f.$.sg.$.abl.,eka,,,,,1,100,5,vn,cm
-1566,ekissā,.num.,.f.$.sg.$.gen.,eka,,,,,1,100,6,vn,cm
-1567,ekassā,.num.,.f.$.sg.$.gen.,eka,,,,,1,100,6,vn,cm
-1568,ekāya,.num.,.f.$.sg.$.gen.,eka,,,,,1,100,5,vn,cm
-1569,ekissā,.num.,.f.$.sg.$.loc.,eka,,,,,1,100,6,vn,cm
-1570,ekissāṃ,.num.,.f.$.sg.$.loc.,eka,,,,,1,100,7,vn,cm
-1571,ekassāṃ,.num.,.f.$.sg.$.loc.,eka,,,,,1,100,7,vn,cm
-1572,ekāya,.num.,.f.$.sg.$.acc.,eka,,,,,1,100,5,vn,cm
-1573,ekāyaṃ,.num.,.f.$.sg.$.acc.,eka,,,,,1,100,6,vn,cm
-1574,ekaṃ,.num.,.nt.$.sg.$.nom.,eka,,,,,1,100,4,vn,cm
-1575,ekaṃ,.num.,.nt.$.sg.$.acc.,eka,,,,,1,100,4,vn,cm
-1576,ekena,.num.,.nt.$.sg.$.inst.,eka,,,,,1,100,5,vn,cm
-1577,ekassa,.num.,.nt.$.sg.$.dat.,eka,,,,,1,100,6,vn,cm
-1578,ekamhā,.num.,.nt.$.sg.$.abl.,eka,,,,,1,100,6,vn,cm
-1579,ekasmā,.num.,.nt.$.sg.$.abl.,eka,,,,,1,100,6,vn,cm
-1580,ekassa,.num.,.nt.$.sg.$.gen.,eka,,,,,1,100,6,vn,cm
-1581,ekamhi,.num.,.nt.$.sg.$.loc.,eka,,,,,1,100,6,vn,cm
-1582,ekasmiṃ,.num.,.nt.$.sg.$.loc.,eka,,,,,1,100,7,vn,cm
+1550,eko,.num.,.m.$.sg.$.nom.,eka,,,eka+[o],,1,100,3,vn,cm
+1551,ekaṃ,.num.,.m.$.sg.$.acc.,eka,,,eka+[ṃ],,1,100,4,vn,cm
+1552,ekena,.num.,.m.$.sg.$.inst.,eka,,,eka+[ena],,1,100,5,vn,cm
+1553,ekassa,.num.,.m.$.sg.$.dat.,eka,,,eka+[ssa],,1,100,6,vn,cm
+1554,ekamhā,.num.,.m.$.sg.$.abl.,eka,,,eka+[mhā],,1,100,6,vn,cm
+1555,ekasmā,.num.,.m.$.sg.$.abl.,eka,,,eka+[smā],,1,100,6,vn,cm
+1556,ekassa,.num.,.m.$.sg.$.gen.,eka,,,eka+[ssa],,1,100,6,vn,cm
+1557,ekamhi,.num.,.m.$.sg.$.loc.,eka,,,eka+[mhi],,1,100,6,vn,cm
+1558,ekasmiṃ,.num.,.m.$.sg.$.acc.,eka,,,eka+[smiṃ],,1,100,7,vn,cm
+1559,ekā,.num.,.f.$.sg.$.nom.,eka,,,eka+[ā],,1,100,3,vn,cm
+1560,ekaṃ,.num.,.f.$.sg.$.acc.,eka,,,eka+[ṃ],,1,100,4,vn,cm
+1561,ekāya,.num.,.f.$.sg.$.inst.,eka,,,eka+[āya],,1,100,5,vn,cm
+1562,ekissā,.num.,.f.$.sg.$.dat.,eka,,,eka+[issā],,1,100,6,vn,cm
+1563,ekassā,.num.,.f.$.sg.$.nom.,eka,,,eka+[assā],,1,100,6,vn,cm
+1564,ekāya,.num.,.f.$.sg.$.nom.,eka,,,eka+[āya],,1,100,5,vn,cm
+1565,ekāya,.num.,.f.$.sg.$.abl.,eka,,,eka+[āya],,1,100,5,vn,cm
+1566,ekissā,.num.,.f.$.sg.$.gen.,eka,,,eka+[issā],,1,100,6,vn,cm
+1567,ekassā,.num.,.f.$.sg.$.gen.,eka,,,eka+[ssa],,1,100,6,vn,cm
+1568,ekāya,.num.,.f.$.sg.$.gen.,eka,,,eka+[āya],,1,100,5,vn,cm
+1569,ekissā,.num.,.f.$.sg.$.loc.,eka,,,eka+[issā],,1,100,6,vn,cm
+1570,ekissāṃ,.num.,.f.$.sg.$.loc.,eka,,,eka+[issāṃ],,1,100,7,vn,cm
+1571,ekassāṃ,.num.,.f.$.sg.$.loc.,eka,,,eka+[ssāṃ],,1,100,7,vn,cm
+1572,ekāya,.num.,.f.$.sg.$.acc.,eka,,,eka+[āya],,1,100,5,vn,cm
+1573,ekāyaṃ,.num.,.f.$.sg.$.acc.,eka,,,eka+[āyaṃ],,1,100,6,vn,cm
+1574,ekaṃ,.num.,.nt.$.sg.$.nom.,eka,,,eka+[ṃ],,1,100,4,vn,cm
+1575,ekaṃ,.num.,.nt.$.sg.$.acc.,eka,,,eka+[ṃ],,1,100,4,vn,cm
+1576,ekena,.num.,.nt.$.sg.$.inst.,eka,,,eka+[ena],,1,100,5,vn,cm
+1577,ekassa,.num.,.nt.$.sg.$.dat.,eka,,,eka+[ssa],,1,100,6,vn,cm
+1578,ekamhā,.num.,.nt.$.sg.$.abl.,eka,,,eka+[mhā],,1,100,6,vn,cm
+1579,ekasmā,.num.,.nt.$.sg.$.abl.,eka,,,eka+[smā],,1,100,6,vn,cm
+1580,ekassa,.num.,.nt.$.sg.$.gen.,eka,,,eka+[ssa],,1,100,6,vn,cm
+1581,ekamhi,.num.,.nt.$.sg.$.loc.,eka,,,eka+[mhi],,1,100,6,vn,cm
+1582,ekasmiṃ,.num.,.nt.$.sg.$.loc.,eka,,,eka+[smiṃ],,1,100,7,vn,cm
 1583,dvi,.num:base.,,,二,,,,1,100,3,vn,zh-hans
 1584,dvi,.num:base.,,,two,,,,1,100,3,Kosalla_China,cm
 1585,dve,.num.,$.pl.$.nom.,dvi,,,,,1,100,3,vn,cm
@@ -1650,14 +1650,14 @@ id,word,type,grammar,parent,meaning,note,factors,factor_meaning,status,confidenc
 1650,pañca,.num:base.,,,five,,,,1,100,5,Kosalla_China,cm
 1651,pañca,.num.,$.pl.$.nom.,pañca,,,,,1,100,5,vn,cm
 1652,pañca,.num.,$.pl.$.acc.,pañca,,,,,1,100,5,vn,cm
-1653,pañcahi,.num.,$.pl.$.inst.,pañca,,,,,1,100,7,vn,cm
-1654,pañcannaṃ,.num.,$.pl.$.dat.,pañca,,,,,1,100,9,vn,cm
-1655,pañcahi,.num.,$.pl.$.abl.,pañca,,,,,1,100,7,vn,cm
-1656,pañcannaṃ,.num.,$.pl.$.gen.,pañca,,,,,1,100,9,vn,cm
-1657,pañcasu,.num.,$.pl.$.loc.,pañca,,,,,1,100,7,vn,cm
-1657,ādiṃ,.n.,.nt.$.sg.$.acc.,ādi,,,,,1,100,4,vn,cm
-1657,ādinā,.n.,.nt.$.sg.$.inst.,ādi,,,,,1,100,5,vn,cm
-1657,ādito,.n.,.nt.$.sg.$.abl.,ādi,,,,,1,100,5,vn,cm
-1657,ādimhi,.n.,.nt.$.sg.$.loc.,ādi,,,,,1,100,6,vn,cm
-1657,ādayo,.n.,.nt.$.pl.$.nom.,ādi,,,,,1,100,5,vn,cm
-1657,ādīni,.n.,.nt.$.pl.$.nom.,ādi,,,,,1,100,5,vn,cm
+1653,pañcahi,.num.,$.pl.$.inst.,pañca,,,pañca+[hi],,1,100,7,vn,cm
+1654,pañcannaṃ,.num.,$.pl.$.dat.,pañca,,,pañca+[naṃ],,1,100,9,vn,cm
+1655,pañcahi,.num.,$.pl.$.abl.,pañca,,,pañca+[hi],,1,100,7,vn,cm
+1656,pañcannaṃ,.num.,$.pl.$.gen.,pañca,,,pañca+[naṃ],,1,100,9,vn,cm
+1657,pañcasu,.num.,$.pl.$.loc.,pañca,,,pañca+[su],,1,100,7,vn,cm
+1657,ādiṃ,.n.,.nt.$.sg.$.acc.,ādi,,,ādi+[ṃ],,1,100,4,vn,cm
+1657,ādinā,.n.,.nt.$.sg.$.inst.,ādi,,,ādi+[nā],,1,100,5,vn,cm
+1657,ādito,.n.,.nt.$.sg.$.abl.,ādi,,,ādi+[to],,1,100,5,vn,cm
+1657,ādimhi,.n.,.nt.$.sg.$.loc.,ādi,,,ādi+[mhi],,1,100,6,vn,cm
+1657,ādayo,.n.,.nt.$.pl.$.nom.,ādi,,,ādi+[ayo],,1,100,5,vn,cm
+1657,ādīni,.n.,.nt.$.pl.$.nom.,ādi,,,ādi+[īni],,1,100,5,vn,cm

+ 2 - 2
api-v8/public/dicttext/cm/sys_irregular/sys_irregular.ini

@@ -1,12 +1,12 @@
 [meta]
 dictname = "system irregular"
 shortname = "sys_irregular"
-description = "system irregular"
+description = "wikipali 不规则词典"
 src_lang = "pa"
 dest_lang = "cm"
 isbn = ""
 publisher = ""
-year = 1980
+year = 2024
 url = ""
 author = ""
 uuid = "4d3a0d92-0adc-4052-80f5-512a2603d0e8"

+ 39 - 42
api-v8/public/dicttext/zh/formula/index.csv

@@ -1,58 +1,55 @@
 id,word,type,grammar,mean
 1,_formula_,,.1p.$.pl.$.aor.,"{我们}${吾等}/{曾}~$~{完了}$~{了}$~{过}"
-7,_formula_,,.1p.$.sg.$.aor.,"{我}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
-13,_formula_,,.2p.$.pl.$.aor.,"{你们}${您}${大家}${汝等}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
-20,_formula_,,.2p.$.sg.$.aor.,"{你}${汝}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
-26,_formula_,,.3p.$.pl.$.aor.,"{其等}${他们}${她们}${它们}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
-33,_formula_,,.3p.$.sg.$.aor.,"{其}${他}${她}${它}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
+2,_formula_,,.1p.$.pl.$.cond.,"{我们若}~${如果我们}~${若我们}~${我们如果}~"
 3,_formula_,,.1p.$.pl.$.fut.,"{我们将}~${我们会}~${我们将要}~${让我们}~${请我们}~"
-9,_formula_,,.1p.$.sg.$.fut.,"{我将}~${我会}~${我要}~${我将要}~"
-15,_formula_,,.2p.$.pl.$.fut.,"{你们}${您}${大家}${汝等}/{将}~${会}~${要}~${将要}~"
-22,_formula_,,.2p.$.sg.$.fut.,"{你}${汝}/{将}~${会}~${要}~${将要}~"
-28,_formula_,,.3p.$.pl.$.fut.,"{其等}${他们}${她们}${它们}/{将}~${会}~${要}~${将要}~"
-35,_formula_,,.3p.$.sg.$.fut.,"{其}${他}${她}${它}/{将}~${会}~${要}~${将要}~"
+4,_formula_,,.1p.$.pl.$.imp.,"{我们}${吾等}/{来}~${来}~{啊}${请}~${可以}~${去}~{啊}"
+5,_formula_,,.1p.$.pl.$.opt.,"{我们}${吾等}/{应}~${愿}~${想}~${必须}~${不得不}~${该}~${应该}~"
 6,_formula_,,.1p.$.pl.$.pres.,"{我们}${吾等}/~${在}~{呢}${正}~${正在}~"
+7,_formula_,,.1p.$.sg.$.aor.,"{我}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
+8,_formula_,,.1p.$.sg.$.cond.,"{我若}~${如果我}~${若我}~${我如果}~"
+9,_formula_,,.1p.$.sg.$.fut.,"{我将}~${我会}~${我要}~${我将要}~"
+10,_formula_,,.1p.$.sg.$.imp.,"{让我}~${请我}~${我来}~${我来}~{啊}${我请}~${我可以}~${我去}~{啊}${我去}~"
+11,_formula_,,.1p.$.sg.$.opt.,"{我}/{应}~${愿}~${想}~${必须}~${不得不}~${该}~${应该}~"
 12,_formula_,,.1p.$.sg.$.pres.,"{我}/~${在}~{呢}${正}~${正在}~"
-19,_formula_,,.2p.$.pl.$.pres.,"{你们}${您}${大家}${汝等}/~${在}~{呢}${正}~${正在}~"
-25,_formula_,,.2p.$.sg.$.pres.,"{你}~${你在}~{呢}${你正}~${你正在}~"
-32,_formula_,,.3p.$.pl.$.pres.,"{其等}${他们}${她们}${它们}/~${在}~{呢}${正}~${正在}~"
-38,_formula_,,.3p.$.sg.$.pres.,"{其}${他}${她}${它}/~${在}~{呢}${正}~${正在}~"
-5,_formula_,,.1p.$.pl.$.opt.,"{我们}${吾等}/{若}~${应该}~${能够}~${希望}~${或许}~${不得不}"
-11,_formula_,,.1p.$.sg.$.opt.,"{我}/{若}~${应该}~${能够}~${希望}~${或许}~${不得不}"
-18,_formula_,,.2p.$.pl.$.opt.,"{你们}${您}${大家}${汝等}/{若}~${应该}~${能够}~${希望}~${或许}~${不得不}"
-24,_formula_,,.2p.$.sg.$.opt.,"{你}${汝}/{若}~${应该}~${能够}~${希望}~${或许}~${不得不}"
-31,_formula_,,.3p.$.pl.$.opt.,"{其等}${他们}${她们}${它们}/{若}~${应该}~${能够}~${希望}~${或许}~${不得不}"
-37,_formula_,,.3p.$.sg.$.opt.,"{其}${他}${她}${它}/{若}~${应该}~${能够}~${希望}~${或许}~${不得不}"
-10,_formula_,,.1p.$.sg.$.imp.,"{让我}${祝愿我}${我去}${我}${吾}/~$~{吧}${来}~${来}~{吧}${可以}~{啊}"
-4,_formula_,,.1p.$.pl.$.imp.,"{让我们}${祝愿我们}${我们去}${我们}${吾等}/~$~{吧}${来}~${来}~{吧}${可以}~{啊}"
-23,_formula_,,.2p.$.sg.$.imp.,"${请你}~${你来}~${你来}~{啊}${你请}~${你可以}~${你去}~{啊}${你去}~"
+13,_formula_,,.2p.$.pl.$.aor.,"{你们}${您}${大家}${汝等}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
+14,_formula_,,.2p.$.pl.$.cond.,"{你们}${您}${大家}${汝等}/{如果}$~{若}~"
+15,_formula_,,.2p.$.pl.$.fut.,"{你们}${您}${大家}${汝等}/{将}~${会}~${要}~${将要}~"
 16,_formula_,,.2p.$.pl.$.imp.,"{让}${请}/{你们}~${您}~${大家}~${汝等}~"
 17,_formula_,,.2p.$.pl.$.imp.,"{你们}${您}${大家}${汝等}/{来}~${来}~{啊}${请}~${可以}~${去}~{啊}${去}~"
+18,_formula_,,.2p.$.pl.$.opt.,"{你们}${您}${大家}${汝等}/{应}~${愿}~${想}~${必须}~${不得不}~${该}~${应该}~"
+19,_formula_,,.2p.$.pl.$.pres.,"{你们}${您}${大家}${汝等}/~${在}~{呢}${正}~${正在}~"
+20,_formula_,,.2p.$.sg.$.aor.,"{你}${汝}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
+21,_formula_,,.2p.$.sg.$.cond.,"{你若}~${如果你}~${若你}~${你如果}~"
+22,_formula_,,.2p.$.sg.$.fut.,"{你}${汝}/{将}~${会}~${要}~${将要}~"
+23,_formula_,,.2p.$.sg.$.imp.,"{让你}~${请你}~${你来}~${你来}~{啊}${你请}~${你可以}~${你去}~{啊}${你去}~"
+24,_formula_,,.2p.$.sg.$.opt.,"{你}${汝}/{应}~${愿}~${想}~${必须}~${不得不}~${该}~${应该}~"
+25,_formula_,,.2p.$.sg.$.pres.,"{你}~${你在}~{呢}${你正}~${你正在}~"
+26,_formula_,,.3p.$.pl.$.aor.,"{其等}${他们}${她们}${它们}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
+27,_formula_,,.3p.$.pl.$.cond.,"{其等}${他们}${她们}${它们}/{如果}~{若}~"
+28,_formula_,,.3p.$.pl.$.fut.,"{其等}${他们}${她们}${它们}/{将}~${会}~${要}~${将要}~"
 29,_formula_,,.3p.$.pl.$.imp.,"{让}${请}/{其等}~${他们}~${她们}~${它们}~"
-36,_formula_,,.3p.$.sg.$.imp.,"{让}${请}/{其}${他}${她}${它}/~${来}~${来}~{啊}${请}~${可以}~${去}~{啊}"
 30,_formula_,,.3p.$.pl.$.imp.,"{其等}${他们}${她们}${它们}/{来}~${来}~{啊}${请}~${可以}~${去}~{啊}${去}~"
-14,_formula_,,.2p.$.pl.$.cond.,"{你们}${您}${大家}${汝等}/{如果}$~{若}~"
-21,_formula_,,.2p.$.sg.$.cond.,"{你若}~${如果你}~${若你}~${你如果}~"
+31,_formula_,,.3p.$.pl.$.opt.,"{其等}${他们}${她们}${它们}/{应}~${愿}~${想}~${必须}~${不得不}~${该}~${应该}~"
+32,_formula_,,.3p.$.pl.$.pres.,"{其等}${他们}${她们}${它们}/~${在}~{呢}${正}~${正在}~"
+33,_formula_,,.3p.$.sg.$.aor.,"{其}${他}${她}${它}/{曾}~$~{完了}$~{了}$~{过}${曾}~{过}"
 34,_formula_,,.3p.$.sg.$.cond.,"{其}${他}${她}${它}/{如果}~${若}~"
-27,_formula_,,.3p.$.pl.$.cond.,"{其等}${他们}${她们}${它们}/{如果}~{若}~"
-2,_formula_,,.1p.$.pl.$.cond.,"{我们若}~${如果我们}~${若我们}~${我们如果}~"
-8,_formula_,,.1p.$.sg.$.cond.,"{我若}~${如果我}~${若我}~${我如果}~"
-39,_formula_,,*.pl.$.abl.,"{由}${从}@{而来}${从}${比}${由于}${因为}${作为}~/{诸}~$~{等}$~{们}$~{些}"
-47,_formula_,,*.sg.$.abl.,"{由}~${从}~{而来}${从}~${比}~${由于}~${因为}~${作为}~"
-43,_formula_,,*.pl.$.inst.,"{以}${通过}${跟}${被}${经过}${与}${由}/{诸}~$~{等}$~{们}$~{些}"
-51,_formula_,,*.sg.$.inst.,"{以}~${通过}~${跟}~${被}~${经过}~${由经}~${与}~"
+35,_formula_,,.3p.$.sg.$.fut.,"{其}${他}${她}${它}/{将}~${会}~${要}~${将要}~"
+36,_formula_,,.3p.$.sg.$.imp.,"{让}${请}/{其}${他}${她}${它}/~${来}~${来}~{啊}${请}~${可以}~${去}~{啊}"
+37,_formula_,,.3p.$.sg.$.opt.,"{其}${他}${她}${它}/{应}~${愿}~${想}~${必须}~${不得不}~${该}~${应该}~"
+38,_formula_,,.3p.$.sg.$.pres.,"{其}${他}${她}${它}/~${在}~{呢}${正}~${正在}~"
+39,_formula_,,*.pl.$.abl.,"{由}${从}@{而来}${从}${比}${由于}${因为}~/{诸}~$~{等}$~{们}$~{些}"
 40,_formula_,,*.pl.$.acc.,"{把诸}~${把}~{等}${把}~{们}${把}~{些}${诸}~$~{等}$~{们}$~{些}"
+41,_formula_,,*.pl.$.dat.,"{对于}${为了}${向}${对}/{诸}~$~{等}$~{们}$~{些}/ ${来说}"
+42,_formula_,,*.pl.$.gen.,"{诸}~{的}$~{等的}$~{们的}$~{些的}"
+43,_formula_,,*.pl.$.inst.,"{以}${通过}${被}${经过}${由}/{诸}~$~{等}$~{们}$~{些}"
+44,_formula_,,*.pl.$.loc.,"{在}${于}@{处}${处于}${在}@{状态}${在}@{情形下}/{诸}~$~{等}$~{们}$~{些}"
+45,_formula_,,*.pl.$.nom.,"{诸}~$~{等}$~{们}$~{些}"
+46,_formula_,,*.pl.$.voc.,"{诸}~$~{等}$~{们}$~{些}/{!}${啊}"
+47,_formula_,,*.sg.$.abl.,"{由}~${从}~{而来}${从}~${比}~${由于}~${因为}~"
 48,_formula_,,*.sg.$.acc.,"{把}~$~"
 49,_formula_,,*.sg.$.dat.,"{对于}~${对}~{来说}${为了}~${向}~${对}~"
-41,_formula_,,*.pl.$.dat.,"{对于}${为了}${向}${对}/{诸}~$~{等}$~{们}$~{些}/ ${来说}"
-42,_formula_,,*.pl.$.gen.,"{诸}~{的}$~{等的}$~{们的}$~{些的}$~{中的}"
 50,_formula_,,*.sg.$.gen.,"~{的}$~{之}"
-52,_formula_,,*.sg.$.loc.,"{在}${于}${处于}/~$~{处}$~{时}$~{情况下}$~{上的}"
-44,_formula_,,*.pl.$.loc.,"{在}${于}@{处}${处于}${在}@{状态}${在}@{情形下}${在}~{中的}/{诸}~$~{等}$~{们}$~{些}"
-45,_formula_,,*.pl.$.nom.,"{诸}~$~{等}$~{们}$~{些}"
+51,_formula_,,*.sg.$.inst.,"{以}~${通过}~${被}~${经过}~${由经}~"
+52,_formula_,,*.sg.$.loc.,"{在}${于}${处于}/~$~{处}$~{状态}$~{情形下}"
 53,_formula_,,*.sg.$.nom.,"~"
-46,_formula_,,*.pl.$.voc.,"{诸}~$~{等}$~{们}$~{些}/{!}${啊}"
 54,_formula_,,*.sg.$.voc.,"~{!}$~{啊}"
-55,_formula_,,.abs.,"~{完之后}$~{然后}$~{之后}"
-56,_formula_,,.ger.,"~{完之后}$~{然后}$~{之后}"
-57,_formula_,,.inf.,"{用来}~${为了}~${来}~"

+ 10 - 1
api-v8/routes/api.php

@@ -98,6 +98,12 @@ use App\Http\Controllers\EditableSentenceController;
 use App\Http\Controllers\ArticleFtsController;
 use App\Http\Controllers\NissayaCoverController;
 use App\Http\Controllers\AiTranslateController;
+use App\Http\Controllers\DictPreferenceController;
+use App\Http\Controllers\CommandController;
+use App\Http\Controllers\UserMilestoneController;
+use App\Http\Controllers\ProjectController;
+
+
 
 /*
 |--------------------------------------------------------------------------
@@ -252,7 +258,10 @@ Route::group(['prefix' => 'v2'],function(){
     Route::apiResource('article-fts',ArticleFtsController::class);
     Route::apiResource('nissaya-cover',NissayaCoverController::class);
     Route::apiResource('ai-translate',AiTranslateController::class);
-
+    Route::apiResource('dict-preference',DictPreferenceController::class);
+    Route::apiResource('command',CommandController::class);
+    Route::apiResource('user-milestone',UserMilestoneController::class);
+    Route::apiResource('project',ProjectController::class);
 
     Route::get('download/{type1}/{type2}/{uuid}/{filename}', function ($type1,$type2,$uuid,$filename) {
         header("Content-Type: {$type1}/{$type1}");

+ 0 - 25
dashboard-v4/.vscode/extensions.json

@@ -1,25 +0,0 @@
-{
-  "recommendations": [
-    "ms-vscode.cpptools-extension-pack",
-    "ms-python.python",
-    "golang.go",
-    "rust-lang.rust-analyzer",
-    "bmewburn.vscode-intelephense-client",
-    "redhat.ansible",
-    "redhat.vscode-xml",
-    "redhat.vscode-yaml",
-    "tamasfe.even-better-toml",
-    "gaborv.flatbuffers",
-    "yzhang.markdown-all-in-one",
-    "zxh404.vscode-proto3",
-    "ms-vscode-remote.remote-ssh",
-    "ms-azuretools.vscode-docker",
-    "vscode-icons-team.vscode-icons",
-    "akamud.vscode-theme-onedark",
-    "dbaeumer.vscode-eslint",
-    "esbenp.prettier-vscode",
-    "editorconfig.editorconfig",
-    "gruntfuggly.todo-tree",
-    "streetsidesoftware.code-spell-checker"
-  ]
-}

+ 0 - 35
dashboard-v4/.vscode/settings.json

@@ -1,35 +0,0 @@
-{
-  "files.autoSave": "onFocusChange",
-  "files.insertFinalNewline": true,
-  "workbench.colorTheme": "Atom One Dark",
-  "editor.formatOnSave": true,
-  "editor.fontFamily": "source code pro",
-  "editor.mouseWheelZoom": true,
-  "editor.codeActionsOnSave": {
-    "source.fixAll.eslint": true
-  },
-  "editor.defaultFormatter": "esbenp.prettier-vscode",
-  // https://code.visualstudio.com/docs/setup/linux#_visual-studio-code-is-unable-to-watch-for-file-changes-in-this-large-workspace-error-enospc
-  "files.watcherExclude": {
-    "**/.git/**": true,
-    "**/node_modules/**": true
-  },
-  "[cpp]": {
-    "editor.defaultFormatter": "ms-vscode.cpptools"
-  },
-  "[rust]": {
-    "editor.defaultFormatter": "rust-lang.rust-analyzer"
-  },
-  "[proto3]": {
-    "editor.defaultFormatter": "zxh404.vscode-proto3"
-  },
-  "[xml]": {
-    "editor.defaultFormatter": "redhat.vscode-xml"
-  },
-  "[python]": {
-    "editor.defaultFormatter": "ms-python.python"
-  },
-  "[php]": {
-    "editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
-  }
-}

+ 15 - 0
dashboard-v4/dashboard/src/Router.tsx

@@ -27,6 +27,7 @@ import AdminNissayaEnding from "./pages/admin/nissaya-ending";
 import AdminNissayaEndingList from "./pages/admin/nissaya-ending/list";
 import AdminDictionary from "./pages/admin/dictionary";
 import AdminDictionaryList from "./pages/admin/dictionary/list";
+import AdminDictionaryPreference from "./pages/admin/dictionary/preference";
 import AdminApi from "./pages/admin/api";
 import AdminApiDashboard from "./pages/admin/api/dashboard";
 
@@ -144,6 +145,12 @@ import StudioTagList from "./pages/studio/tags/list";
 import StudioTagShow from "./pages/studio/tags/show";
 import StudioTagEdit from "./pages/studio/tags/edit";
 
+import StudioTask from "./pages/studio/task";
+import StudioTaskHall from "./pages/studio/task/hall";
+import StudioTaskList from "./pages/studio/task/tasks";
+import StudioTaskProjects from "./pages/studio/task/projects";
+import StudioTaskProject from "./pages/studio/task/project";
+
 import { ConfigProvider } from "antd";
 import { useAppSelector } from "./hooks";
 import { currTheme } from "./reducers/theme";
@@ -166,6 +173,7 @@ const Widget = () => {
           </Route>
           <Route path="dictionary" element={<AdminDictionary />}>
             <Route path="list" element={<AdminDictionaryList />} />
+            <Route path="preference" element={<AdminDictionaryPreference />} />
           </Route>
           <Route path="users" element={<AdminUsers />}>
             <Route path="list" element={<AdminUsersList />} />
@@ -328,6 +336,13 @@ const Widget = () => {
             <Route path=":courseId/edit" element={<StudioCourseEdit />} />
           </Route>
 
+          <Route path="task" element={<StudioTask />}>
+            <Route path="hall" element={<StudioTaskHall />} />
+            <Route path="list" element={<StudioTaskList />} />
+            <Route path="projects" element={<StudioTaskProjects />} />
+            <Route path="project/:projectId" element={<StudioTaskProject />} />
+          </Route>
+
           <Route path="dict" element={<StudioDict />}>
             <Route path="list" element={<StudioDictList />} />
           </Route>

File diff suppressed because it is too large
+ 45 - 0
dashboard-v4/dashboard/src/assets/icon/index.tsx


File diff suppressed because it is too large
+ 34 - 0
dashboard-v4/dashboard/src/assets/icon/wikipali stamp2.ai


+ 11 - 1
dashboard-v4/dashboard/src/components/admin/LeftSider.tsx

@@ -40,8 +40,18 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
           key: "nissaya-ending",
         },
         {
-          label: <Link to="/admin/dictionary/list">Dictionary</Link>,
+          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>,

+ 185 - 0
dashboard-v4/dashboard/src/components/api/task.ts

@@ -0,0 +1,185 @@
+/**
+ *             $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 { IStudio } from "../auth/Studio";
+import { IUser } from "../auth/User";
+
+export type TTaskStatus =
+  | "pending"
+  | "published"
+  | "running"
+  | "done"
+  | "restarted"
+  | "closed"
+  | "canceled"
+  | "expired";
+
+export interface IProject {
+  id: string;
+  title: string;
+  description: string | null;
+}
+export interface ITaskData {
+  id: string;
+  title: string;
+  description?: string | null;
+  html?: string | null;
+  type?: "task" | "group";
+  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;
+  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;
+}
+
+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;
+  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[];
+  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;
+  description?: string | null;
+  parent_id?: string | null;
+}
+
+export interface IProjectListResponse {
+  data: { rows: IProjectData[]; count: number };
+  message: string;
+  ok: boolean;
+}
+export interface IProjectResponse {
+  data: IProjectData;
+  message: string;
+  ok: boolean;
+}
+export type TProjectType = "normal" | "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;
+}

+ 5 - 1
dashboard-v4/dashboard/src/components/article/Article.tsx

@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
 import { fullUrl } from "../../utils";
 import TypeSeries from "./TypeSeries";
 import DiscussionCount from "../discussion/DiscussionCount";
+import TypeTask from "./TypeTask";
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
@@ -31,7 +32,8 @@ export type ArticleType =
   | "sent-commentary"
   | "sent-nissaya"
   | "sent-translation"
-  | "term";
+  | "term"
+  | "task";
 /**
  * 每种article type 对应的路由参数
  * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
@@ -245,6 +247,8 @@ const ArticleWidget = ({
           mode={mode}
           onArticleChange={onArticleChange}
         />
+      ) : type === "task" ? (
+        <TypeTask articleId={articleId} />
       ) : (
         <></>
       )}

+ 38 - 0
dashboard-v4/dashboard/src/components/article/TypeTask.tsx

@@ -0,0 +1,38 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import { ExclamationCircleOutlined } from "@ant-design/icons";
+import { IArticleDataResponse } from "../api/Article";
+import { ArticleMode, ArticleType } from "./Article";
+import TypeArticleReader from "./TypeArticleReader";
+import ArticleEdit from "./ArticleEdit";
+import TaskEdit from "../task/TaskEdit";
+import { ITaskData } from "../api/task";
+import TaskReader from "../task/TaskReader";
+import Task from "../task/Task";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: (data: ITaskData) => void;
+  onArticleEdit?: Function;
+  onLoad?: (data: ITaskData) => void;
+}
+const TypeTask = ({
+  type,
+  channelId,
+  articleId,
+  mode = "read",
+  onArticleChange,
+  onLoad,
+  onArticleEdit,
+}: IWidget) => {
+  return (
+    <div>
+      <Task taskId={articleId} />
+    </div>
+  );
+};
+
+export default TypeTask;

+ 7 - 5
dashboard-v4/dashboard/src/components/auth/User.tsx

@@ -1,5 +1,7 @@
-import { Avatar, Popover, Space } from "antd";
+import { Avatar, Popover, Space, Typography } from "antd";
+
 import { getAvatarColor } from "./Studio";
+const { Text } = Typography;
 
 export interface IUser {
   id: string;
@@ -38,7 +40,7 @@ const UserWidget = ({
               {nickName?.slice(0, 2)}
             </Avatar>
           </div>
-          <div>{`${nickName}@${userName}`}</div>
+          <Text>{`${nickName}@${userName}`}</Text>
         </div>
       }
     >
@@ -52,9 +54,9 @@ const UserWidget = ({
             {nickName?.slice(0, 2)}
           </Avatar>
         ) : undefined}
-        {showName ? nickName : undefined}
-        {showName && showUserName ? "@" : undefined}
-        {showUserName ? userName : undefined}
+        {showName ? <Text>{nickName}</Text> : undefined}
+        {showName && showUserName ? <Text>@</Text> : undefined}
+        {showUserName ? <Text>{userName}</Text> : undefined}
       </Space>
     </Popover>
   );

+ 16 - 14
dashboard-v4/dashboard/src/components/blog/TimeLine.tsx

@@ -1,7 +1,7 @@
 import { Timeline } from "antd";
 import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
-import { StartUpIcon, TermIcon2, TermOutlinedIcon } from "../../assets/icon";
+import { StartUpIcon, TermIcon2 } from "../../assets/icon";
 import { get } from "../../request";
 import TimeShow from "../general/TimeShow";
 
@@ -9,7 +9,7 @@ interface IMilestone {
   date: string;
   event: string;
 }
-interface IMilestoneResponse {
+interface IUserMilestoneResponse {
   ok: boolean;
   message: string;
   data: IMilestone[];
@@ -26,19 +26,21 @@ const TimeLineWidget = ({ studioName }: IWidget) => {
     if (typeof studioName === "undefined") {
       return;
     }
-    get<IMilestoneResponse>(`/v2/milestone/${studioName}`).then((json) => {
-      if (json.ok) {
-        setMilestone(
-          json.data.sort((a, b) => {
-            if (a.date > b.date) {
-              return -1;
-            } else {
-              return 1;
-            }
-          })
-        );
+    get<IUserMilestoneResponse>(`/v2/user-milestone/${studioName}`).then(
+      (json) => {
+        if (json.ok) {
+          setMilestone(
+            json.data.sort((a, b) => {
+              if (a.date > b.date) {
+                return -1;
+              } else {
+                return 1;
+              }
+            })
+          );
+        }
       }
-    });
+    );
   }, [studioName]);
 
   return (

+ 153 - 112
dashboard-v4/dashboard/src/components/dict/Community.tsx

@@ -1,21 +1,23 @@
 import {
   Badge,
+  Button,
   Card,
   Dropdown,
   MenuProps,
   Popover,
+  Skeleton,
   Space,
   Typography,
 } from "antd";
 import { DownOutlined } from "@ant-design/icons";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
 import { useIntl } from "react-intl";
 import { get } from "../../request";
 import { IApiResponseDictList } from "../api/Dict";
 import { IUser } from "../auth/User";
 import GrammarPop from "./GrammarPop";
-import Marked from "../general/Marked";
 import MdView from "../template/MdView";
+import MyCreate from "./MyCreate";
 
 const { Title, Link, Text } = Typography;
 
@@ -38,14 +40,16 @@ interface IWidget {
 const CommunityWidget = ({ word }: IWidget) => {
   const intl = useIntl();
   const [loaded, setLoaded] = useState(false);
+  const [loading, setLoading] = useState(false);
   const [wordData, setWordData] = useState<IWord>();
+  const [showCreate, setShowCreate] = useState(false);
+  const [myRefresh, setMyRefresh] = useState(false);
+
   const minScore = 100; //分数阈值。低于这个分数只显示在弹出菜单中
 
-  useEffect(() => {
-    if (typeof word === "undefined") {
-      return;
-    }
-    const url = `/v2/userdict?view=community&word=${word}`;
+  const dictLoad = useCallback(async (input: string) => {
+    setLoading(true);
+    const url = `/v2/userdict?view=community&word=${input}`;
     console.info("dict community url", url);
     get<IApiResponseDictList>(url)
       .then((json) => {
@@ -141,12 +145,22 @@ const CommunityWidget = ({ word }: IWidget) => {
         setWordData(_data);
         if (_data.editor.length > 0) {
           setLoaded(true);
+        } else {
+          setLoaded(false);
         }
       })
+      .finally(() => setLoading(false))
       .catch((error) => {
         console.error(error);
       });
-  }, [word, setWordData]);
+  }, []);
+
+  useEffect(() => {
+    if (typeof word === "undefined") {
+      return;
+    }
+    dictLoad(word);
+  }, [word, setWordData, dictLoad]);
 
   const isShow = (score: number, index: number) => {
     const Ms = 500,
@@ -199,118 +213,145 @@ const CommunityWidget = ({ word }: IWidget) => {
     ) : undefined
   ) : undefined;
 
-  return loaded ? (
+  return (
     <Card>
       <Title level={5} id={`community`}>
         {"社区字典"}
       </Title>
-      <div key="meaning">
-        <Space style={{ flexWrap: "wrap" }}>
-          <Text strong>{"意思:"}</Text>
-          {wordData?.meaning
-            .filter((value, index: number) => isShow(value.score, index))
-            .map((item, id) => {
-              return (
-                <Space key={id}>
-                  {item.value}
-                  <Badge color="geekblue" size="small" count={item.score} />
-                </Space>
-              );
-            })}
-          {meaningLow && meaningLow.length > 0 ? (
-            <Popover content={<Space>{meaningExtra}</Space>} placement="bottom">
-              <Link>
-                <Space>
-                  {intl.formatMessage({
-                    id: `buttons.more`,
-                  })}
-                  <DownOutlined />
-                </Space>
-              </Link>
-            </Popover>
-          ) : undefined}
-        </Space>
-      </div>
-      <div key="grammar">
-        <Space style={{ flexWrap: "wrap" }}>
-          <Text strong>{"语法:"}</Text>
-          {wordData?.grammar
-            .filter((value) => value.score >= minScore)
-            .map((item, id) => {
-              const grammar = item.value.split("$");
-              const grammarGuide = grammar.map((item, id) => {
-                const strCase = item.replaceAll(".", "");
+      {loading ? (
+        <Skeleton />
+      ) : loaded ? (
+        <div>
+          <div key="meaning">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"意思:"}</Text>
+              {wordData?.meaning
+                .filter((value, index: number) => isShow(value.score, index))
+                .map((item, id) => {
+                  return (
+                    <Space key={id}>
+                      {item.value}
+                      <Badge color="geekblue" size="small" count={item.score} />
+                    </Space>
+                  );
+                })}
+              {meaningLow && meaningLow.length > 0 ? (
+                <Popover
+                  content={<Space>{meaningExtra}</Space>}
+                  placement="bottom"
+                >
+                  <Link>
+                    <Space>
+                      {intl.formatMessage({
+                        id: `buttons.more`,
+                      })}
+                      <DownOutlined />
+                    </Space>
+                  </Link>
+                </Popover>
+              ) : undefined}
+            </Space>
+          </div>
+          <div key="grammar">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"语法:"}</Text>
+              {wordData?.grammar
+                .filter((value) => value.score >= minScore)
+                .map((item, id) => {
+                  const grammar = item.value.split("$");
+                  const grammarGuide = grammar.map((item, id) => {
+                    const strCase = item.replaceAll(".", "");
 
-                return strCase.length > 0 ? (
-                  <GrammarPop
-                    key={id}
-                    gid={strCase}
-                    text={intl.formatMessage({
-                      id: `dict.fields.type.${strCase}.label`,
-                      defaultMessage: strCase,
-                    })}
-                  />
-                ) : undefined;
-              });
-              return (
-                <Space key={id}>
-                  <Space
-                    style={{
-                      backgroundColor: "rgba(0.5,0.5,0.5,0.2)",
-                      borderRadius: 5,
-                      paddingLeft: 5,
-                      paddingRight: 5,
-                    }}
-                  >
-                    {grammarGuide}
-                  </Space>
-                  <Badge color="geekblue" size="small" count={item.score} />
-                </Space>
-              );
-            })}
-        </Space>
-      </div>
-      <div key="base">
-        <Space style={{ flexWrap: "wrap" }}>
-          <Text strong>{"词干:"}</Text>
-          {wordData?.parent
-            .filter((value) => value.score >= minScore)
-            .map((item, id) => {
-              return (
-                <Space key={id}>
-                  {item.value}
-                  <Badge color="geekblue" size="small" count={item.score} />
-                </Space>
-              );
-            })}
-        </Space>
-      </div>
-      <div key="collaborator">
-        <Space style={{ flexWrap: "wrap" }}>
-          <Text strong>{"贡献者:"}</Text>
-          {wordData?.editor
-            .filter((value, index) => index < mainCollaboratorNum)
-            .map((item, id) => {
-              return collaboratorRender(item.value.nickName, id, item.score);
-            })}
-          {more}
-        </Space>
-      </div>
+                    return strCase.length > 0 ? (
+                      <GrammarPop
+                        key={id}
+                        gid={strCase}
+                        text={intl.formatMessage({
+                          id: `dict.fields.type.${strCase}.label`,
+                          defaultMessage: strCase,
+                        })}
+                      />
+                    ) : undefined;
+                  });
+                  return (
+                    <Space key={id}>
+                      <Space
+                        style={{
+                          backgroundColor: "rgba(0.5,0.5,0.5,0.2)",
+                          borderRadius: 5,
+                          paddingLeft: 5,
+                          paddingRight: 5,
+                        }}
+                      >
+                        {grammarGuide}
+                      </Space>
+                      <Badge color="geekblue" size="small" count={item.score} />
+                    </Space>
+                  );
+                })}
+            </Space>
+          </div>
+          <div key="base">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"词干:"}</Text>
+              {wordData?.parent
+                .filter((value) => value.score >= minScore)
+                .map((item, id) => {
+                  return (
+                    <Space key={id}>
+                      {item.value}
+                      <Badge color="geekblue" size="small" count={item.score} />
+                    </Space>
+                  );
+                })}
+            </Space>
+          </div>
+          <div key="collaborator">
+            <Space style={{ flexWrap: "wrap" }}>
+              <Text strong>{"贡献者:"}</Text>
+              {wordData?.editor
+                .filter((value, index) => index < mainCollaboratorNum)
+                .map((item, id) => {
+                  return collaboratorRender(
+                    item.value.nickName,
+                    id,
+                    item.score
+                  );
+                })}
+              {more}
+            </Space>
+          </div>
 
-      <div key="note">
-        <Text strong>{"注释:"}</Text>
-        <div>
-          {wordData?.note
-            .filter((value) => value.score >= minScore)
-            .slice(0, 1)
-            .map((item, id) => {
-              return <MdView html={item.value} key={id} />;
-            })}
+          <div key="note">
+            <Text strong>{"注释:"}</Text>
+            <div>
+              {wordData?.note
+                .filter((value) => value.score >= minScore)
+                .slice(0, 1)
+                .map((item, id) => {
+                  return <MdView html={item.value} key={id} />;
+                })}
+            </div>
+          </div>
         </div>
-      </div>
+      ) : showCreate ? (
+        <MyCreate
+          word={word}
+          onSave={() => {
+            setMyRefresh(true);
+            if (word) {
+              dictLoad(word);
+            }
+          }}
+        />
+      ) : (
+        <>
+          <Button type="link" onClick={() => setShowCreate(true)}>
+            新建
+          </Button>
+        </>
+      )}
     </Card>
-  ) : (
-    <></>
   );
 };
 

+ 241 - 0
dashboard-v4/dashboard/src/components/dict/DictPreferenceEditor.tsx

@@ -0,0 +1,241 @@
+import { EditableProTable, ProColumns } from "@ant-design/pro-components";
+import { get, post, put } from "../../request";
+import WbwFactors from "../template/Wbw/WbwFactors";
+import { IWbw } from "../template/Wbw/WbwWord";
+import WbwLookup from "../template/Wbw/WbwLookup";
+import { useState } from "react";
+import { Progress } from "antd";
+import WbwFactorsEditor from "../template/Wbw/WbwFactorsEditor";
+
+interface IPreferenceListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: DataSourceType[]; count: number };
+}
+interface IPreferenceRequest {
+  id?: string;
+  word?: string;
+  factors?: string;
+  parent?: string;
+  confidence?: number;
+}
+export interface IPreferenceResponse {
+  ok: boolean;
+  message: string;
+  data: string;
+}
+
+type DataSourceType = {
+  sn?: number;
+  id: string;
+  count?: number;
+  word: string;
+  factors?: string;
+  parent?: string;
+  confidence?: number;
+  note?: string;
+  created_at?: number;
+  update_at?: number;
+};
+const DictPreferenceEditor = () => {
+  const [lookupWords, setLookupWords] = useState<string[]>([]);
+  const [lookupRun, setLookupRun] = useState(false);
+  const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
+
+  const columns: ProColumns<DataSourceType>[] = [
+    {
+      title: "序号",
+      dataIndex: "sn",
+      readonly: true,
+    },
+    {
+      title: "数量",
+      dataIndex: "count",
+      tooltip: "在三藏中出现的次数",
+      readonly: true,
+    },
+    {
+      title: "词头",
+      dataIndex: "word",
+      readonly: true,
+      width: "15%",
+    },
+    {
+      title: "factors",
+      key: "factors",
+      dataIndex: "factors",
+      render(dom, entity, index, action, schema) {
+        const wbw: IWbw = {
+          book: 1,
+          para: 1,
+          sn: [1],
+          word: { value: entity.word, status: 5 },
+          real: { value: entity.word, status: 5 },
+          factors: { value: entity.factors ?? "", status: 5 },
+          parent: { value: entity.parent ?? "", status: 5 },
+          confidence: entity.confidence ?? 0,
+        };
+        return (
+          <WbwFactorsEditor
+            key="factors"
+            initValue={wbw}
+            display={"block"}
+            onChange={async (e: string): Promise<IPreferenceResponse> => {
+              console.log("factor change", e);
+              const url = `/v2/dict-preference/${entity.id}`;
+              const data: IPreferenceRequest = {
+                factors: e,
+                confidence: 100,
+              };
+              console.log("api request", url, data);
+              return await put<IPreferenceRequest, IPreferenceResponse>(
+                url,
+                data
+              );
+            }}
+          />
+        );
+      },
+    },
+    {
+      title: "parent",
+      dataIndex: "parent",
+      fieldProps: (form, { rowKey, rowIndex }) => {
+        if (form.getFieldValue([rowKey || "", "title"]) === "不好玩") {
+          return {
+            disabled: true,
+          };
+        }
+        if (rowIndex > 9) {
+          return {
+            disabled: true,
+          };
+        }
+        return {};
+      },
+    },
+    {
+      title: "note",
+      dataIndex: "note",
+      valueType: "textarea",
+    },
+    {
+      title: "信心指数",
+      dataIndex: "confidence",
+      valueType: "digit",
+      render(dom, entity, index, action, schema) {
+        return (
+          <Progress
+            size="small"
+            percent={Math.round(entity.confidence ?? 0)}
+            status={
+              entity.confidence !== undefined && entity.confidence < 50
+                ? "exception"
+                : undefined
+            }
+          />
+        );
+      },
+    },
+    {
+      title: "操作",
+      valueType: "option",
+      width: 200,
+      render: (text, record, _, action) => [
+        <a
+          key="editable"
+          onClick={() => {
+            action?.startEditable?.(record.id);
+          }}
+        >
+          编辑
+        </a>,
+      ],
+    },
+  ];
+
+  return (
+    <div>
+      <WbwLookup words={lookupWords} run={lookupRun} />
+      <EditableProTable<DataSourceType>
+        rowKey="id"
+        headerTitle="词典默认值"
+        maxLength={5}
+        scroll={{
+          x: 960,
+        }}
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          pageSize: 100,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        recordCreatorProps={false}
+        columns={columns}
+        request={async (params = {}, sorter, filter) => {
+          let url = `/v2/dict-preference`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `?limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&keyword=" + params.keyword : "";
+          console.info("api request", url);
+          const res = await get<IPreferenceListResponse>(url);
+          console.info("api response", res);
+          if (res.ok === false) {
+          }
+          return {
+            data: res.data.rows.map((item, id) => {
+              return {
+                sn: id + offset + 1,
+                id: item.id,
+                count: item.count,
+                word: item.word,
+                factors: item.factors,
+                parent: item.parent,
+                confidence: item.confidence,
+                note: item.note,
+                created_at: item.created_at,
+                update_at: item.update_at,
+              };
+            }),
+            total: res.data.count,
+            success: true,
+          };
+        }}
+        onRow={(record) => {
+          return {
+            onMouseEnter: () => {
+              console.info(`点击了行:${record.word}`);
+              setLookupWords([record.word]);
+              setLookupRun(true);
+            },
+            onMouseLeave: () => {
+              setLookupRun(false);
+            },
+          };
+        }}
+        editable={{
+          type: "single",
+          editableKeys,
+          onSave: async (rowKey, data, row) => {
+            console.log(rowKey, data, row);
+            const url = `/v2/dict-preference/${data.id}`;
+            console.log("api request", url, data);
+            await put<IPreferenceRequest, IPreferenceResponse>(url, {
+              factors: data.factors,
+              parent: data.parent,
+              confidence: 100,
+            });
+          },
+          onChange: setEditableRowKeys,
+        }}
+      />
+      ;
+    </div>
+  );
+};
+export default DictPreferenceEditor;

+ 38 - 0
dashboard-v4/dashboard/src/components/studio/LeftSider.tsx

@@ -95,6 +95,44 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
       key: "advance",
       icon: <AppstoreOutlined />,
       children: [
+        {
+          label: intl.formatMessage({
+            id: "labels.task",
+          }),
+          key: "task",
+          children: [
+            {
+              label: (
+                <Link to={`/studio/${studioname}/task/hall`}>
+                  {intl.formatMessage({
+                    id: "labels.task.hall",
+                  })}
+                </Link>
+              ),
+              key: "task_hall",
+            },
+            {
+              label: (
+                <Link to={`/studio/${studioname}/task/list`}>
+                  {intl.formatMessage({
+                    id: "labels.task.mine",
+                  })}
+                </Link>
+              ),
+              key: "task_mine",
+            },
+            {
+              label: (
+                <Link to={`/studio/${studioname}/task/projects`}>
+                  {intl.formatMessage({
+                    id: "labels.task.my.project",
+                  })}
+                </Link>
+              ),
+              key: "task_projects",
+            },
+          ],
+        },
         {
           label: (
             <Link to={linkCourse}>

+ 166 - 0
dashboard-v4/dashboard/src/components/task/Filter.tsx

@@ -0,0 +1,166 @@
+import { Button, Popover, Select, Space, Typography } from "antd";
+import { IFilter } from "./TaskList";
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import UserSelect from "../template/UserSelect";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormSelect,
+} from "@ant-design/pro-components";
+import { DeleteOutlined, FilterOutlined } from "@ant-design/icons";
+
+const { Text } = Typography;
+
+interface IProps {
+  item: IFilter;
+  sn: number;
+  onRemove?: () => void;
+}
+const FilterItem = ({ item, sn, onRemove }: IProps) => {
+  const intl = useIntl();
+  return (
+    <ProForm.Group>
+      <Text>{sn === 0 ? "当" : "且"}</Text>
+      <ProFormSelect
+        initialValue={item.field}
+        name={`field_${sn}`}
+        style={{ width: 120 }}
+        options={[
+          {
+            value: "executor_id",
+            label: intl.formatMessage({ id: "forms.fields.executor.label" }),
+          },
+          {
+            value: "assignees_id",
+            label: intl.formatMessage({ id: "forms.fields.assignees.label" }),
+          },
+          {
+            value: "participants_id",
+            label: intl.formatMessage({ id: "labels.participants" }),
+          },
+        ]}
+      />
+      <ProFormSelect
+        initialValue={item.operator}
+        name={`operator_${sn}`}
+        style={{ width: 120 }}
+        options={[
+          {
+            value: "includes",
+            label: "包含",
+          },
+          {
+            value: "not-includes",
+            label: "不包含",
+          },
+        ]}
+      />
+      <UserSelect
+        name={"value_" + sn}
+        multiple={true}
+        initialValue={item.value}
+        required={false}
+        hiddenTitle
+      />
+      <Button type="link" icon={<DeleteOutlined />} danger onClick={onRemove} />
+    </ProForm.Group>
+  );
+};
+
+interface IWidget {
+  initValue?: IFilter[];
+  onChange?: (value: IFilter[]) => void;
+}
+const Filter = ({ initValue, onChange }: IWidget) => {
+  const [filterList, setFilterList] = useState(initValue ?? []);
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+  return (
+    <Popover
+      placement="bottomLeft"
+      trigger={"click"}
+      arrowPointAtCenter
+      title={intl.formatMessage({ id: "labels.filter" })}
+      content={
+        <div style={{ width: 750 }}>
+          <ProForm
+            formRef={formRef}
+            submitter={{
+              render(props, dom) {
+                return [
+                  <Button
+                    onClick={() => {
+                      setFilterList((origin) => {
+                        return [
+                          ...origin,
+                          {
+                            field: "executor_id",
+                            operator: "includes",
+                            value: [],
+                          },
+                        ];
+                      });
+                    }}
+                  >
+                    添加条件
+                  </Button>,
+                  ...dom,
+                ];
+              },
+            }}
+            onFinish={async () => {
+              const value = formRef.current?.getFieldsValue();
+              console.log(value);
+              let counter = 0;
+              let newValue: IFilter[] = [];
+              while (counter < Object.keys(value).length) {
+                const field = `field_${counter}`;
+                const field2 = `operator_${counter}`;
+                const field3 = `value_${counter}`;
+                if (value.hasOwnProperty(field)) {
+                  newValue.push({
+                    field: value[field],
+                    operator: value[field2],
+                    value: value[field3],
+                  });
+                }
+                counter++;
+              }
+              console.log(newValue);
+              if (onChange) {
+                onChange(newValue);
+              }
+            }}
+          >
+            {filterList.map((item, id) => {
+              return (
+                <FilterItem
+                  item={item}
+                  key={id}
+                  sn={id}
+                  onRemove={() => {
+                    setFilterList((origin) => {
+                      return origin.filter(
+                        (value, index: number) => index !== id
+                      );
+                    });
+                  }}
+                />
+              );
+            })}
+          </ProForm>
+        </div>
+      }
+    >
+      <Button
+        type={filterList.length === 0 ? "text" : "primary"}
+        icon={<FilterOutlined />}
+      >
+        筛选 {filterList.length}
+      </Button>
+    </Popover>
+  );
+};
+
+export default Filter;

+ 155 - 0
dashboard-v4/dashboard/src/components/task/MyTasks.tsx

@@ -0,0 +1,155 @@
+import { Tabs } from "antd";
+import React, { useRef, useState } from "react";
+import TaskList from "./TaskList";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+type TargetKey = React.MouseEvent | React.KeyboardEvent | string;
+
+const TaskRunning = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      status={["running", "restarted"]}
+      filters={[
+        { field: "executor_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+const TaskAssignee = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      status={["published"]}
+      filters={[
+        { field: "assignees_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+const TaskDone = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      status={["done"]}
+      filters={[
+        { field: "executor_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+
+const TaskNew = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      filters={[
+        { field: "executor_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+
+interface IWidget {
+  studioName?: string;
+}
+const MyTasks = ({ studioName }: IWidget) => {
+  const currUser = useAppSelector(currentUser);
+
+  console.info("currUser", currUser);
+  const initialItems = [
+    {
+      label: "进行中",
+      closable: false,
+      key: "running",
+      children: <TaskRunning studioName={studioName} />,
+    },
+    {
+      label: "待领取",
+      closable: false,
+      key: "2",
+      children: <TaskAssignee studioName={studioName} />,
+    },
+    {
+      label: "已完成",
+      key: "done",
+      closable: false,
+      children: <TaskDone studioName={studioName} />,
+    },
+  ];
+
+  const [activeKey, setActiveKey] = useState(initialItems[0].key);
+  const [items, setItems] = useState(initialItems);
+  const newTabIndex = useRef(0);
+  const onChange = (newActiveKey: string) => {
+    setActiveKey(newActiveKey);
+  };
+
+  const add = () => {
+    const newActiveKey = `newTab${newTabIndex.current++}`;
+    const newPanes = [...items];
+    newPanes.push({
+      label: "New Tab",
+      key: newActiveKey,
+      closable: true,
+      children: <TaskNew studioName={studioName} />,
+    });
+    setItems(newPanes);
+    setActiveKey(newActiveKey);
+  };
+
+  const remove = (targetKey: TargetKey) => {
+    let newActiveKey = activeKey;
+    let lastIndex = -1;
+    items.forEach((item, i) => {
+      if (item.key === targetKey) {
+        lastIndex = i - 1;
+      }
+    });
+    const newPanes = items.filter((item) => item.key !== targetKey);
+    if (newPanes.length && newActiveKey === targetKey) {
+      if (lastIndex >= 0) {
+        newActiveKey = newPanes[lastIndex].key;
+      } else {
+        newActiveKey = newPanes[0].key;
+      }
+    }
+    setItems(newPanes);
+    setActiveKey(newActiveKey);
+  };
+
+  const onEdit = (
+    targetKey: React.MouseEvent | React.KeyboardEvent | string,
+    action: "add" | "remove"
+  ) => {
+    if (action === "add") {
+      add();
+    } else {
+      remove(targetKey);
+    }
+  };
+  return (
+    <Tabs
+      type="editable-card"
+      onChange={onChange}
+      activeKey={activeKey}
+      onEdit={onEdit}
+      items={items}
+    />
+  );
+};
+
+export default MyTasks;

+ 46 - 0
dashboard-v4/dashboard/src/components/task/Options.tsx

@@ -0,0 +1,46 @@
+import { Button, Dropdown, MenuProps } from "antd";
+import exp from "constants";
+import { useState } from "react";
+
+export interface IMenu {
+  key: string;
+  label: string;
+}
+interface IWidget {
+  items: IMenu[];
+  icon?: React.ReactNode;
+  text?: string;
+  initKey?: string;
+  onChange?: (key: string) => void;
+}
+const Options = ({ items, icon, text, initKey = "1", onChange }: IWidget) => {
+  const [currKey, setCurrKey] = useState(initKey);
+  const currValue = items.find(
+    (item) => item.key === currKey ?? initKey
+  )?.label;
+  const onClick: MenuProps["onClick"] = ({ key }) => {
+    if (onChange) {
+      onChange(key);
+    }
+    setCurrKey(key);
+  };
+  return (
+    <Dropdown
+      menu={{
+        items,
+        onClick,
+        selectable: true,
+        defaultSelectedKeys: [currKey],
+      }}
+      trigger={["click"]}
+      placement="bottomLeft"
+    >
+      <Button type="text" icon={icon}>
+        {text}
+        {currValue}
+      </Button>
+    </Dropdown>
+  );
+};
+
+export default Options;

+ 109 - 0
dashboard-v4/dashboard/src/components/task/PreTask.tsx

@@ -0,0 +1,109 @@
+import { Button, List, Popover, Tag, Typography } from "antd";
+import { ITaskData, ITaskListResponse } from "../api/task";
+import { get } from "../../request";
+import { useEffect, useState } from "react";
+import  { ArrowLeftOutlined, ArrowRightOutlined,CheckOutlined } from "@ant-design/icons";
+import { TRelation } from "./TaskEditButton";
+
+const { Text } = Typography;
+
+interface IProTaskListProps {
+  task?: ITaskData;
+  type: TRelation;
+  onClick?: (data?: ITaskData | null) => void;
+  onClose?: () => void;
+}
+const ProTaskList = ({ task, type, onClick, onClose }: IProTaskListProps) => {
+  const [res, setRes] = useState<ITaskData[]>();
+  useEffect(() => {
+    const url = `/v2/task?view=project&project_id=${task?.project_id}`;
+
+    console.info("api request", url);
+    get<ITaskListResponse>(url).then((json) => {
+      console.info("project api response", json);
+      const res = json.data.rows;
+      setRes(res);
+    });
+  }, [task?.project_id]);
+
+  return (
+    <List
+      header={
+        <div>
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <Text strong>{type === "pre" ? "前置任务" : "后置任务"}</Text>
+            <div>
+              <Button type="link" onClick={onClose}>
+                关闭
+              </Button>
+            </div>
+          </div>
+        </div>
+      }
+      footer={false}
+      dataSource={res}
+      renderItem={(item) => {
+        let checked = false
+        if(type === "pre"){
+          checked = task?.pre_task?.find((value)=>value.id===item.id)!==undefined
+        }else{
+          checked = task?.next_task?.find((value)=>value.id===item.id)!==undefined
+        }
+        return (
+        <List.Item
+        actions={[checked?<CheckOutlined />:<></>]}
+          onClick={() => {
+            onClick && onClick(item);
+          }}
+        >
+          {item.title}
+        </List.Item>
+      )}}
+    />
+  );
+};
+
+interface IWidget {
+  task?: ITaskData;
+  open?: boolean;
+  type: TRelation;
+  onClick?: (data?: ITaskData | null) => void;
+  onClose?: () => void;
+}
+const PreTask = ({ task, type, open = false, onClick, onClose }: IWidget) => {
+  const preTaskShow = open || task?.pre_task;
+  const nextTaskShow = open || task?.next_task;
+  let tag = <></>;
+  if (preTaskShow && type === "pre") {
+    tag = (
+      <Tag color="warning" icon={<ArrowLeftOutlined />}>
+        {task?.pre_task? `${task?.pre_task?.length} 个前置任务`:''}
+      </Tag>
+    );
+  } else if (nextTaskShow && type === "next") {
+    tag = (
+      <Tag color="warning" icon={<ArrowRightOutlined />}>
+        {task?.next_task?`阻塞 ${task?.next_task?.length} 个任务`:''}
+      </Tag>
+    );
+  }
+  return (
+    <Popover
+      trigger="click"
+      open={open}
+      content={
+        <div style={{ width: 400 }}>
+          <ProTaskList
+            type={type}
+            task={task}
+            onClick={onClick}
+            onClose={onClose}
+          />
+        </div>
+      }
+    >
+      {tag}
+    </Popover>
+  );
+};
+export default PreTask;

+ 273 - 0
dashboard-v4/dashboard/src/components/task/Project.tsx

@@ -0,0 +1,273 @@
+import type { ActionType, ProColumns } from "@ant-design/pro-components";
+import { EditableProTable, useRefFunction } from "@ant-design/pro-components";
+import { Button, Form, Space, Typography } from "antd";
+import React, { useEffect, useRef, useState } from "react";
+import { useIntl } from "react-intl";
+
+import ProjectEditDrawer from "./ProjectEditDrawer";
+import {
+  IProjectData,
+  IProjectListResponse,
+  IProjectResponse,
+  IProjectUpdateRequest,
+} from "../api/task";
+
+import { get, post } from "../../request";
+
+const { Text } = Typography;
+function generateUUID() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    var r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}
+
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  onRowClick?: (data: IProjectData) => void;
+  onSelect?: (id: string) => void;
+}
+const Project = ({ studioName, projectId, onRowClick, onSelect }: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+  const [editId, setEditId] = useState<string>();
+
+  const [title, setTitle] = useState<React.ReactNode>();
+
+  const actionRef = useRef<ActionType>();
+  const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
+  const [dataSource, setDataSource] = useState<readonly IProjectData[]>([]);
+  const [form] = Form.useForm();
+
+  const ProjectTitle = ({ data }: { data?: IProjectData }) => (
+    <Space>
+      <Text strong>{data?.title}</Text>
+      {data?.path?.reverse().map((item, id) => {
+        return (
+          <Text key={id} type="secondary">
+            {" <"}
+            <Button
+              type="text"
+              onClick={() => {
+                if (onSelect) {
+                  onSelect(item.id);
+                }
+              }}
+            >
+              {item.title}
+            </Button>
+          </Text>
+        );
+      })}
+    </Space>
+  );
+
+  const loopDataSourceFilter = (
+    data: readonly IProjectData[],
+    id: React.Key | undefined
+  ): IProjectData[] => {
+    return data
+      .map((item) => {
+        if (item.id !== id) {
+          if (item.children) {
+            const newChildren = loopDataSourceFilter(item.children, id);
+            return {
+              ...item,
+              children: newChildren.length > 0 ? newChildren : undefined,
+            };
+          }
+          return item;
+        }
+        return null;
+      })
+      .filter(Boolean) as IProjectData[];
+  };
+  const removeRow = useRefFunction((record: IProjectData) => {
+    setDataSource(loopDataSourceFilter(dataSource, record.id));
+  });
+
+  const columns: ProColumns<IProjectData>[] = [
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.title.label",
+      }),
+      dataIndex: "title",
+      formItemProps: {
+        rules: [
+          {
+            required: true,
+            message: "此项为必填项",
+          },
+        ],
+      },
+      width: "30%",
+      render: (dom, record, _, action) => {
+        return (
+          <Button
+            type="link"
+            size="small"
+            onClick={() => {
+              if (onSelect) {
+                onSelect(record.id);
+              }
+            }}
+          >
+            {record.title}
+          </Button>
+        );
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.milestone.label",
+      }),
+      key: "state",
+      dataIndex: "state",
+      readonly: true,
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.status.label",
+      }),
+      key: "state",
+      dataIndex: "state",
+      readonly: true,
+    },
+    {
+      title: "操作",
+      valueType: "option",
+      width: 250,
+      render: (text, record, _, action) => [
+        <a
+          key="editable"
+          onClick={() => {
+            setEditId(record.id);
+            setOpen(true);
+          }}
+        >
+          编辑
+        </a>,
+        <EditableProTable.RecordCreator
+          key="copy"
+          parentKey={record.id}
+          record={{
+            id: generateUUID(),
+            parent_id: record.id,
+          }}
+        >
+          <Button size="small" type="link">
+            插入子节点
+          </Button>
+        </EditableProTable.RecordCreator>,
+        <Button
+          type="link"
+          danger
+          size="small"
+          key="delete"
+          onClick={() => {
+            removeRow(record);
+          }}
+        >
+          删除
+        </Button>,
+      ],
+    },
+  ];
+
+  const getChildren = (
+    record: IProjectData,
+    findIn: IProjectData[]
+  ): IProjectData[] | undefined => {
+    const children = findIn
+      .filter((item) => item.parent?.id === record.id)
+      .map((item) => {
+        return { ...item, children: getChildren(item, findIn) };
+      });
+    console.debug("children", findIn, record, children);
+    if (children.length > 0) {
+      return children;
+    }
+    return undefined;
+  };
+
+  useEffect(() => {
+    actionRef.current?.reload();
+  }, [projectId]);
+  return (
+    <>
+      <Space></Space>
+      <EditableProTable<IProjectData>
+        onRow={(record) => ({
+          onClick: () => {
+            if (onRowClick) {
+              onRowClick(record);
+            }
+          },
+        })}
+        rowKey="id"
+        scroll={{
+          x: 960,
+        }}
+        actionRef={actionRef}
+        headerTitle={title}
+        maxLength={5}
+        search={false}
+        // 关闭默认的新建按钮
+        recordCreatorProps={false}
+        columns={columns}
+        request={async () => {
+          const url = `/v2/project?view=project-tree&project_id=${projectId}`;
+          console.info("api request", url);
+          const res = await get<IProjectListResponse>(url);
+          console.info("project api response", res);
+          const root = res.data.rows
+            .filter((item) => item.id === projectId)
+            .map((item) => {
+              return { ...item, children: getChildren(item, res.data.rows) };
+            });
+          return {
+            data: root,
+            total: res.data.count,
+            success: res.ok,
+          };
+        }}
+        value={dataSource}
+        onChange={(value: readonly IProjectData[]) => {
+          const root = value.find((item) => item.id === projectId);
+          setTitle(ProjectTitle({ data: root }));
+          setDataSource(value);
+        }}
+        editable={{
+          form,
+          editableKeys,
+          onSave: async (key, values) => {
+            const data: IProjectUpdateRequest = {
+              ...values,
+              studio_name: studioName ?? "",
+            };
+            const url = `/v2/project`;
+            console.info("save api request", url, values);
+            const res = await post<IProjectUpdateRequest, IProjectResponse>(
+              url,
+              data
+            );
+            console.info("save api response", res);
+          },
+
+          onChange: setEditableRowKeys,
+          actionRender: (row, config, dom) => [dom.save, dom.cancel],
+        }}
+      />
+      <ProjectEditDrawer
+        studioName={studioName}
+        projectId={editId}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
+      />
+    </>
+  );
+};
+
+export default Project;

+ 83 - 0
dashboard-v4/dashboard/src/components/task/ProjectCreate.tsx

@@ -0,0 +1,83 @@
+import { useIntl } from "react-intl";
+import { message } from "antd";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+
+import { post } from "../../request";
+import { useRef } from "react";
+import {
+  IProjectCreateRequest,
+  IProjectResponse,
+  ITaskCreateRequest,
+  ITaskResponse,
+  TProjectType,
+} from "../api/task";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  type?: TProjectType;
+  onCreate?: Function;
+}
+const TaskCreate = ({
+  studio = "",
+  type = "normal",
+  onCreate,
+}: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<IProjectCreateRequest>
+      formRef={formRef}
+      onFinish={async (values: IProjectCreateRequest) => {
+        console.log(values);
+        values.studio_name = studio;
+        values.type = type;
+        const url = `/v2/project`;
+        console.info("project api request", url, values);
+        const res = await post<IProjectCreateRequest, IProjectResponse>(
+          url,
+          values
+        );
+        console.debug("CourseCreateWidget api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          formRef.current?.resetFields(["title"]);
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+          }
+        } 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>
+  );
+};
+
+export default TaskCreate;

+ 79 - 0
dashboard-v4/dashboard/src/components/task/ProjectEdit.tsx

@@ -0,0 +1,79 @@
+import {
+  ProForm,
+  ProFormRadio,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { Col, Row, Space, message } from "antd";
+import { useState } from "react";
+import { IProjectData, IProjectResponse } from "../api/task";
+import { get } from "../../request";
+import { useIntl } from "react-intl";
+
+type LayoutType = Parameters<typeof ProForm>[0]["layout"];
+const LAYOUT_TYPE_HORIZONTAL = "horizontal";
+
+const waitTime = (time: number = 100) => {
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(true);
+    }, time);
+  });
+};
+
+interface IWidget {
+  projectId?: string;
+  studioName?: string;
+}
+const ProjectEdit = ({ projectId }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <ProForm<IProjectData>
+      onFinish={async (values) => {
+        await waitTime(2000);
+        console.log(values);
+        message.success("提交成功");
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/v2/project/${projectId}`;
+        console.info("api request", url);
+        const res = await get<IProjectResponse>(url);
+        console.log("api response", res);
+        return res.data;
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="type"
+          label={intl.formatMessage({
+            id: "forms.fields.type.label",
+          })}
+          readonly
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({
+            id: "forms.fields.description.label",
+          })}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default ProjectEdit;

+ 52 - 0
dashboard-v4/dashboard/src/components/task/ProjectEditDrawer.tsx

@@ -0,0 +1,52 @@
+import { Button, Drawer, Space } from "antd";
+import { useEffect, useState } from "react";
+
+import ProjectEdit from "./ProjectEdit";
+
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  openDrawer?: boolean;
+  onClose?: () => void;
+}
+const ProjectEditDrawer = ({
+  studioName,
+  projectId,
+  openDrawer = false,
+  onClose,
+}: IWidget) => {
+  const [open, setOpen] = useState(openDrawer);
+
+  useEffect(() => {
+    setOpen(openDrawer);
+  }, [openDrawer]);
+
+  const onCloseDrawer = () => {
+    setOpen(false);
+    if (onClose) {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <Drawer
+        title={<></>}
+        placement={"right"}
+        width={650}
+        onClose={onCloseDrawer}
+        open={open}
+        destroyOnClose
+        extra={
+          <Space>
+            <Button type="primary">从模版创建任务</Button>
+          </Space>
+        }
+      >
+        <ProjectEdit studioName={studioName} projectId={projectId} />
+      </Drawer>
+    </>
+  );
+};
+
+export default ProjectEditDrawer;

+ 43 - 0
dashboard-v4/dashboard/src/components/task/Task.tsx

@@ -0,0 +1,43 @@
+import { useState } from "react";
+import { ITaskData } from "../api/task";
+import TaskReader from "./TaskReader";
+import TaskEdit from "./TaskEdit";
+import { set } from "lodash";
+
+interface IWidget {
+  taskId?: string;
+  onLoad?: (task: ITaskData) => void;
+  onChange?: (task: ITaskData) => void;
+}
+const Task = ({ taskId, onLoad, onChange }: IWidget) => {
+  const [isEdit, setIsEdit] = useState(false);
+  const [task, setTask] = useState<ITaskData>();
+  return (
+    <div>
+      {isEdit ? (
+        <TaskEdit
+          taskId={taskId}
+          onLoad={(data: ITaskData) => {}}
+          onChange={(data: ITaskData) => {
+            onChange && onChange(data);
+            setTask(data);
+            setIsEdit(false);
+          }}
+        />
+      ) : (
+        <TaskReader
+          taskId={taskId}
+          task={task}
+          onLoad={(data: ITaskData) => setTask(data)}
+          onChange={(data: ITaskData) => {
+            onChange && onChange(data);
+            setTask(data);
+          }}
+          onEdit={() => setIsEdit(true)}
+        />
+      )}
+    </div>
+  );
+};
+
+export default Task;

+ 68 - 0
dashboard-v4/dashboard/src/components/task/TaskCreate.tsx

@@ -0,0 +1,68 @@
+import { useIntl } from "react-intl";
+import { message } from "antd";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+
+import { post } from "../../request";
+import { useRef } from "react";
+import { ITaskCreateRequest, ITaskResponse } from "../api/task";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  onCreate?: Function;
+}
+const TaskCreate = ({ studio = "", onCreate }: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        console.log(values);
+        values.studio = studio;
+        const url = `/v2/task`;
+        console.info("task api request", url, values);
+        const res = await post<ITaskCreateRequest, ITaskResponse>(url, values);
+        console.debug("CourseCreateWidget api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          formRef.current?.resetFields(["title"]);
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+          }
+        } 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>
+  );
+};
+
+export default TaskCreate;

+ 95 - 0
dashboard-v4/dashboard/src/components/task/TaskEdit.tsx

@@ -0,0 +1,95 @@
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+  RequestOptionsType,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { useState } from "react";
+import { ITaskData, ITaskResponse, ITaskUpdateRequest } from "../api/task";
+import { get, patch, post } from "../../request";
+import { useIntl } from "react-intl";
+import UserSelect from "../template/UserSelect";
+import User from "../auth/User";
+
+interface IWidget {
+  taskId?: string;
+  onLoad?: (data: ITaskData) => void;
+  onChange?: (data: ITaskData) => void;
+}
+const TaskEdit = ({ taskId, onLoad, onChange }: IWidget) => {
+  const intl = useIntl();
+  const [assignees, setAssignees] = useState<RequestOptionsType[]>();
+
+  return (
+    <ProForm<ITaskData>
+      onFinish={async (values) => {
+        const url = `/v2/task/${taskId}`;
+        const data: ITaskUpdateRequest = { ...values, studio_name: "" };
+        console.info("task save api request", url, data);
+        const res = await patch<ITaskUpdateRequest, ITaskResponse>(url, data);
+        if (res.ok) {
+          onChange && onChange(res.data);
+          message.success("提交成功");
+        } else {
+          message.error(res.message);
+        }
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/v2/task/${taskId}`;
+        console.info("api request", url);
+        const res = await get<ITaskResponse>(url);
+        console.log("api response", res);
+        const assigneesOptions = res.data.assignees?.map((item, id) => {
+          return { label: <User {...item} />, value: item.id };
+        });
+        console.log("assigneesOptions", assigneesOptions);
+        setAssignees(assigneesOptions);
+        if (onLoad) {
+          onLoad(res.data);
+        }
+        return res.data;
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="type"
+          label={intl.formatMessage({
+            id: "forms.fields.type.label",
+          })}
+          readonly
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({
+            id: "forms.fields.description.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <UserSelect
+          name="assignees_id"
+          multiple={true}
+          required={false}
+          options={assignees}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default TaskEdit;

+ 166 - 0
dashboard-v4/dashboard/src/components/task/TaskEditButton.tsx

@@ -0,0 +1,166 @@
+import { Button, Dropdown, Space, message } from "antd";
+import {
+  CheckOutlined,
+  ArrowLeftOutlined,
+  CodeSandboxOutlined,
+  DeleteOutlined,
+  FieldTimeOutlined,
+  EditOutlined,
+  ArrowRightOutlined,
+} from "@ant-design/icons";
+
+import {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+  TTaskStatus,
+} from "../api/task";
+import { patch } from "../../request";
+import { useIntl } from "react-intl";
+
+export type TRelation = "pre" | "next";
+interface IWidget {
+  task?: ITaskData;
+  studioName?: string;
+  onChange?: (task: ITaskData) => void;
+  onEdit?: () => void;
+  onPreTask?: (type: TRelation) => void;
+}
+const TaskEditButton = ({ task, onChange, onEdit, onPreTask }: IWidget) => {
+  const intl = useIntl();
+
+  const setValue = (setting: ITaskUpdateRequest) => {
+    const url = `/v2/task/${setting.id}`;
+
+    patch<ITaskUpdateRequest, ITaskResponse>(url, setting).then((json) => {
+      if (json.ok) {
+        message.success("Success");
+        onChange && onChange(json.data);
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+
+  let newStatus: TTaskStatus = "pending";
+  let buttonText = "发布";
+  switch (task?.status) {
+    case "pending":
+      newStatus = "published";
+      buttonText = "发布";
+      break;
+    case "published":
+      newStatus = "running";
+      buttonText = "领取";
+
+      break;
+    case "running":
+      newStatus = "done";
+      buttonText = "完成任务";
+
+      break;
+    case "done":
+      newStatus = "restarted";
+      buttonText = "重做";
+
+      break;
+    case "restarted":
+      newStatus = "done";
+      buttonText = "完成任务";
+      break;
+    default:
+      break;
+  }
+
+  return (
+    <Space>
+      <Dropdown.Button
+        key={1}
+        type="link"
+        trigger={["click", "contextMenu"]}
+        menu={{
+          items: [
+            {
+              key: "edit",
+              label: intl.formatMessage({ id: "buttons.edit" }),
+              icon: <EditOutlined />,
+            },
+            {
+              key: "milestone",
+              label: task?.is_milestone ? "取消里程碑" : "设为里程碑",
+              icon: <CodeSandboxOutlined />,
+            },
+            {
+              key: "pre-task",
+              label: "设置前置任务",
+              icon: <ArrowLeftOutlined />,
+            },
+            {
+              key: "next-task",
+              label: "设置后置任务",
+              icon: <ArrowRightOutlined />,
+            },
+            {
+              type: "divider",
+            },
+            {
+              label: "历史记录",
+              key: "timeline",
+              icon: <FieldTimeOutlined />,
+            },
+            {
+              label: "删除",
+              key: "delete",
+              icon: <DeleteOutlined />,
+              danger: true,
+            },
+          ],
+          onClick: (e) => {
+            switch (e.key) {
+              case "edit":
+                onEdit && onEdit();
+                break;
+              case "milestone":
+                if (task) {
+                  if (task.id) {
+                    setValue({
+                      id: task.id,
+                      is_milestone: !task.is_milestone,
+                      studio_name: task.owner?.realName ?? "",
+                    });
+                  }
+                }
+                break;
+              case "pre-task":
+                onPreTask && onPreTask("pre");
+                break;
+              case "next-task":
+                onPreTask && onPreTask("next");
+                break;
+              default:
+                break;
+            }
+          },
+        }}
+      >
+        <Button
+          type="primary"
+          icon={<CheckOutlined />}
+          onClick={() => {
+            if (task?.id) {
+              setValue({
+                id: task.id,
+                status: newStatus,
+                studio_name: "",
+              });
+            }
+          }}
+        >
+          {buttonText}
+        </Button>
+      </Dropdown.Button>
+    </Space>
+  );
+};
+
+export default TaskEditButton;

+ 64 - 0
dashboard-v4/dashboard/src/components/task/TaskEditDrawer.tsx

@@ -0,0 +1,64 @@
+import { Button, Drawer, Space, Tag } from "antd";
+import { useEffect, useState } from "react";
+
+import { ITaskData } from "../api/task";
+import TaskEditButton from "./TaskEditButton";
+import Task from "./Task";
+import { useIntl } from "react-intl";
+import { fullUrl } from "../../utils";
+
+interface IWidget {
+  taskId?: string;
+  openDrawer?: boolean;
+  onClose?: () => void;
+  onChange?: (data: ITaskData) => void;
+}
+const TaskEditDrawer = ({
+  taskId,
+  openDrawer = false,
+  onClose,
+  onChange,
+}: IWidget) => {
+  const [open, setOpen] = useState(openDrawer);
+  const intl = useIntl();
+
+  useEffect(() => {
+    setOpen(openDrawer);
+  }, [openDrawer]);
+
+  const onCloseDrawer = () => {
+    setOpen(false);
+    if (onClose) {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <Drawer
+        title={""}
+        placement={"right"}
+        width={1000}
+        onClose={onCloseDrawer}
+        open={open}
+        destroyOnClose={true}
+        extra={
+          <Button
+            type="link"
+            onClick={() => {
+              window.open(fullUrl(`/article/task/${taskId}`), "_blank");
+            }}
+          >
+            {intl.formatMessage({
+              id: "buttons.open.in.new.tab",
+            })}
+          </Button>
+        }
+      >
+        <Task taskId={taskId} onChange={onChange} />
+      </Drawer>
+    </>
+  );
+};
+
+export default TaskEditDrawer;

+ 604 - 0
dashboard-v4/dashboard/src/components/task/TaskList.tsx

@@ -0,0 +1,604 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import { Avatar, Button, Form, Space, Typography } from "antd";
+import type { ActionType, ProColumns } from "@ant-design/pro-components";
+import { EditableProTable, useRefFunction } from "@ant-design/pro-components";
+
+import {
+  IProject,
+  ITaskData,
+  ITaskListResponse,
+  ITaskResponse,
+  ITaskUpdateRequest,
+  TTaskStatus,
+} from "../api/task";
+import { get, post } from "../../request";
+import TaskEditDrawer from "./TaskEditDrawer";
+import User, { IUser } from "../auth/User";
+import { GroupIcon } from "../../assets/icon";
+import Options, { IMenu } from "./Options";
+import Filter from "./Filter";
+import { Milestone, Status } from "./TaskReader";
+
+const { Text } = Typography;
+function generateUUID() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    var r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}
+
+export const Executors = ({
+  data,
+  all,
+}: {
+  data: ITaskData;
+  all: readonly ITaskData[];
+}) => {
+  const children = all.filter((value) => value.parent_id === data.id);
+  let executors: IUser[] = data.executor ? [data.executor] : [];
+  children.forEach((task) => {
+    executors = executors.concat(task.executor ?? []);
+  });
+  return (
+    <Avatar.Group>
+      {executors.map((item, id) => {
+        return <User {...item} key={id} showName={executors.length === 1} />;
+      })}
+    </Avatar.Group>
+  );
+};
+export const Assignees = ({ data }: { data: ITaskData }) => {
+  return (
+    <Avatar.Group>
+      {data.assignees?.map((item, id) => {
+        return <User {...item} key={id} showName={false} />;
+      })}
+    </Avatar.Group>
+  );
+};
+export interface IFilter {
+  field:
+    | "executor_id"
+    | "owner_id"
+    | "finished_at"
+    | "assignees_id"
+    | "participants_id"
+    | "sign_up";
+  operator:
+    | "includes"
+    | "not-includes"
+    | "equals"
+    | "not-equals"
+    | ">="
+    | "<="
+    | ">"
+    | "<"
+    | null;
+  value: string | string[] | null;
+}
+
+interface IParams {
+  status?: string;
+  orderby?: string;
+  direction?: string;
+}
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  editable?: boolean;
+  filters?: IFilter[];
+  status?: TTaskStatus[];
+  sortBy?: "order" | "created_at" | "updated_at" | "started_at" | "finished_at";
+  groupBy?: "executor_id" | "owner_id" | "status" | "project_id";
+  onLoad?: (data: ITaskData[]) => void;
+  onChange?: (data: ITaskData) => void;
+}
+const TaskList = ({
+  studioName,
+  projectId,
+  editable = false,
+  status,
+  sortBy = "order",
+  groupBy,
+  filters,
+  onLoad,
+  onChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+  const [title, setTitle] = useState<React.ReactNode>();
+
+  const actionRef = useRef<ActionType>();
+  const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
+  const [dataSource, setDataSource] = useState<readonly ITaskData[]>([]);
+  const [rawData, setRawData] = useState<readonly ITaskData[]>([]);
+  const [form] = Form.useForm();
+  const [selectedTask, setSelectedTask] = useState<string>();
+
+  const [currFilter, setCurrFilter] = useState(filters);
+  const loopDataSourceFilter = (
+    data: readonly ITaskData[],
+    id: React.Key | undefined
+  ): ITaskData[] => {
+    return data
+      .map((item) => {
+        if (item.id !== id) {
+          if (item.children) {
+            const newChildren = loopDataSourceFilter(item.children, id);
+            return {
+              ...item,
+              children: newChildren.length > 0 ? newChildren : undefined,
+            };
+          }
+          return item;
+        }
+        return null;
+      })
+      .filter(Boolean) as ITaskData[];
+  };
+  const removeRow = useRefFunction((record: ITaskData) => {
+    setDataSource(loopDataSourceFilter(dataSource, record.id));
+  });
+
+  const columns: ProColumns<ITaskData>[] = [
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.title.label",
+      }),
+      dataIndex: "title",
+      search: false,
+      formItemProps: {
+        rules: [
+          {
+            required: true,
+            message: "此项为必填项",
+          },
+        ],
+      },
+      width: "30%",
+      render(dom, entity, index, action, schema) {
+        return (
+          <Space>
+            <Button
+              type="link"
+              onClick={() => {
+                setSelectedTask(entity.id);
+                setOpen(true);
+              }}
+            >
+              {entity.title}
+            </Button>
+            {entity.type === "group" ? (
+              <Text type="secondary">{entity.order}</Text>
+            ) : (
+              <></>
+            )}
+            <Status task={entity} />
+            <Milestone task={entity} />
+            {entity.project ? (
+              <Text type="secondary">
+                {"< "}
+                {entity.project?.title}
+              </Text>
+            ) : (
+              <></>
+            )}
+          </Space>
+        );
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.executor.label",
+      }),
+      key: "executor",
+      dataIndex: "executor",
+      search: false,
+      readonly: true,
+      render(dom, entity, index, action, schema) {
+        return <Executors data={entity} all={rawData} />;
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.assignees.label",
+      }),
+      key: "assignees",
+      dataIndex: "assignees",
+      search: false,
+      readonly: true,
+      render(dom, entity, index, action, schema) {
+        return <Assignees data={entity} />;
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.started-at.label",
+      }),
+      key: "state",
+      dataIndex: "started_at",
+      readonly: true,
+      valueType: "date",
+      sorter: true,
+      search: false,
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.finished-at.label",
+      }),
+      key: "state",
+      dataIndex: "finished_at",
+      readonly: true,
+      valueType: "date",
+      search: false,
+    },
+    {
+      title: "状态",
+      hideInTable: true,
+      dataIndex: "status",
+      valueType: "select",
+      initialValue: status ? status.join("_") : "all",
+      valueEnum: {
+        all: { text: "全部任务" },
+        done: { text: "已完成" },
+        running_restarted: { text: "未完成" },
+        running_restarted_published: { text: "待办" },
+        published: { text: "未开始" },
+        pending: { text: "未发布" },
+      },
+    },
+    {
+      title: "排序",
+      hideInTable: true,
+      dataIndex: "orderby",
+      valueType: "select",
+      initialValue: sortBy,
+      valueEnum: {
+        order: { text: "拖拽排序" },
+        started_at: { text: "开始时间" },
+        created_at: { text: "创建时间" },
+        updated_at: { text: "更新时间" },
+        finished_at: { text: "完成时间" },
+      },
+    },
+    {
+      title: "顺序",
+      hideInTable: true,
+      dataIndex: "direction",
+      valueType: "select",
+      initialValue: "asc",
+      valueEnum: {
+        desc: { text: "降序" },
+        asc: { text: "升序" },
+      },
+    },
+    editable
+      ? {
+          title: "操作",
+          valueType: "option",
+          width: 250,
+          search: false,
+          render: (text, record, _, action) => [
+            <EditableProTable.RecordCreator
+              key="copy"
+              parentKey={record.id}
+              record={{
+                id: generateUUID(),
+                parent_id: record.id,
+              }}
+            >
+              <Button size="small" type="link">
+                插入子节点
+              </Button>
+            </EditableProTable.RecordCreator>,
+            <Button
+              type="link"
+              danger
+              size="small"
+              key="delete"
+              onClick={() => {
+                removeRow(record);
+              }}
+            >
+              删除
+            </Button>,
+          ],
+        }
+      : { search: false },
+  ];
+
+  const getChildren = (
+    record: ITaskData,
+    findIn: ITaskData[]
+  ): ITaskData[] | undefined => {
+    const children = findIn
+      .filter((item) => item.parent_id === record.id)
+      .map((item) => {
+        return { ...item, children: getChildren(item, findIn) };
+      });
+    console.debug("children", findIn, record, children);
+    if (children.length > 0) {
+      return children;
+    }
+    return undefined;
+  };
+
+  useEffect(() => {
+    actionRef.current?.reload();
+  }, [projectId]);
+
+  const groupItems: IMenu[] = [
+    {
+      key: "none",
+      label: "无分组",
+    },
+    {
+      key: "project",
+      label: "任务组",
+    },
+    {
+      key: "title",
+      label: "任务名称",
+    },
+    {
+      key: "status",
+      label: "状态",
+    },
+    {
+      key: "creator",
+      label: "创建人",
+    },
+    {
+      key: "executor",
+      label: "执行人",
+    },
+    {
+      key: "started_at",
+      label: "开始时间",
+    },
+  ];
+
+  return (
+    <>
+      <EditableProTable<ITaskData, IParams>
+        rowKey="id"
+        scroll={{
+          x: 960,
+        }}
+        search={{
+          filterType: "light",
+        }}
+        options={{
+          search: true,
+        }}
+        actionRef={actionRef}
+        headerTitle={title}
+        // 关闭默认的新建按钮
+        recordCreatorProps={
+          editable
+            ? {
+                record: () => ({
+                  id: generateUUID(),
+                  title: "新建任务",
+                  is_milestone: false,
+                }),
+              }
+            : false
+        }
+        columns={columns}
+        request={async (params = {}, sorter, filter) => {
+          let url = `/v2/task?a=a`;
+          if (projectId) {
+            url += `&view=project&project_id=${projectId}`;
+          } else {
+            url += `&view=all`;
+          }
+          if (currFilter) {
+            url += `&`;
+            url += currFilter
+              .map((item) => {
+                return item.field + "_" + item.operator + "=" + item.value;
+              })
+              .join("&");
+          }
+
+          url += params.status
+            ? `&status=${params.status.replaceAll("_", ",")}`
+            : "";
+          url += params.orderby ? `&order=${params.orderby}` : "";
+          url += params.direction ? `&dir=${params.direction}` : "";
+
+          console.info("api request", url);
+          const res = await get<ITaskListResponse>(url);
+          console.info("project api response", res);
+          setRawData(res.data.rows);
+          onLoad && onLoad(res.data.rows);
+          const root = res.data.rows
+            .filter((item) => item.parent_id === null)
+            .map((item) => {
+              return { ...item, children: getChildren(item, res.data.rows) };
+            });
+          return {
+            data: root,
+            total: res.data.count,
+            success: res.ok,
+          };
+        }}
+        value={dataSource}
+        onChange={(value: readonly ITaskData[]) => {
+          const root = value.find((item) => item.id === projectId);
+          setDataSource(value);
+        }}
+        editable={{
+          form,
+          editableKeys,
+          onSave: async (key, values) => {
+            const data: ITaskUpdateRequest = {
+              ...values,
+              studio_name: studioName ?? "",
+              project_id: projectId,
+            };
+            const url = `/v2/task`;
+            console.info("task save api request", url, values);
+            const res = await post<ITaskUpdateRequest, ITaskResponse>(
+              url,
+              data
+            );
+            onChange && onChange(res.data);
+            console.info("task save api response", res);
+          },
+
+          onChange: setEditableRowKeys,
+          actionRender: (row, config, dom) => [dom.save, dom.cancel],
+        }}
+        toolBarRender={() => [
+          <Options
+            items={groupItems}
+            initKey="none"
+            icon={<GroupIcon />}
+            onChange={(key: string) => {
+              switch (key) {
+                case "status":
+                  let statuses = new Map<string, number>();
+                  rawData.forEach((task) => {
+                    if (task.status) {
+                      if (statuses.has(task.status)) {
+                        statuses.set(
+                          task.status,
+                          statuses.get(task.status)! + 1
+                        );
+                      } else {
+                        statuses.set(task.status, 1);
+                      }
+                    }
+                  });
+                  let group: ITaskData[] = [];
+                  statuses.forEach((value, key) => {
+                    group.push({
+                      id: key,
+                      title: intl.formatMessage({
+                        id: `labels.task.status.${key}`,
+                      }),
+                      order: value,
+                      type: "group",
+                      is_milestone: false,
+                    });
+                  });
+                  const newGroup = group.map((item, id) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.status === item.id
+                      ),
+                    };
+                  });
+                  setDataSource(newGroup);
+                  break;
+                case "project":
+                  let projectsId = new Map<string, number>();
+                  let projects = new Map<string, IProject>();
+                  rawData.forEach((task) => {
+                    if (task.project_id && task.project) {
+                      if (projectsId.has(task.project_id)) {
+                        projectsId.set(
+                          task.project_id,
+                          projectsId.get(task.project_id)! + 1
+                        );
+                      } else {
+                        projectsId.set(task.project_id, 1);
+                        projects.set(task.project_id, task.project);
+                      }
+                    }
+                  });
+                  let projectList: ITaskData[] = [];
+                  projectsId.forEach((value, key) => {
+                    const project = projects.get(key)!;
+                    projectList.push({
+                      id: project.id,
+                      title: `${project.title}`,
+                      type: "group",
+                      order: value,
+                      is_milestone: false,
+                    });
+                  });
+                  const newProject = projectList.map((item, id) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.project_id === item.id
+                      ),
+                    };
+                  });
+                  setDataSource(newProject);
+                  break;
+                case "title":
+                  let titles = new Map<string, number>();
+                  rawData.forEach((task) => {
+                    if (task.title) {
+                      if (titles.has(task.title)) {
+                        titles.set(task.title, titles.get(task.title)! + 1);
+                      } else {
+                        titles.set(task.title, 1);
+                      }
+                    }
+                  });
+                  let titleGroups: ITaskData[] = [];
+                  titles.forEach((value, key) => {
+                    titleGroups.push({
+                      id: key,
+                      title: key,
+                      order: value,
+                      type: "group",
+                      is_milestone: false,
+                    });
+                  });
+                  const newTitleGroup = titleGroups.map((item, id) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.title === item.title
+                      ),
+                    };
+                  });
+                  setDataSource(newTitleGroup);
+                  break;
+                default:
+                  break;
+              }
+            }}
+          />,
+          <Filter
+            initValue={filters}
+            onChange={(data) => {
+              setCurrFilter(data);
+              actionRef.current?.reload();
+            }}
+          />,
+        ]}
+      />
+      <TaskEditDrawer
+        taskId={selectedTask}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
+        onChange={(data: ITaskData) => {
+          console.debug("task change", data);
+          setDataSource((origin) => {
+            const update = (item: ITaskData): ITaskData => {
+              item.children = item.children?.map(update);
+              if (item.id === data.id) {
+                return { ...data, children: item.children };
+              }
+              return item;
+            };
+            return origin.map(update);
+          });
+          onChange && onChange(data);
+        }}
+      />
+    </>
+  );
+};
+
+export default TaskList;

+ 24 - 0
dashboard-v4/dashboard/src/components/task/TaskLoader.tsx

@@ -0,0 +1,24 @@
+import { useEffect } from "react";
+import { get } from "../../request";
+import { ITaskData, ITaskListResponse } from "../api/task";
+
+interface IWidget {
+  projectId?: string;
+  onLoad?: (data: ITaskData[]) => void;
+}
+const TaskLoader = ({ projectId, onLoad }: IWidget) => {
+  useEffect(() => {
+    let url = `/v2/task?a=a`;
+    if (projectId) {
+      url += `&view=project&project_id=${projectId}`;
+    }
+    console.info("api request", url);
+    get<ITaskListResponse>(url).then((json) => {
+      console.debug("api response", json);
+      onLoad && onLoad(json.data.rows);
+    });
+  }, [projectId]);
+  return <></>;
+};
+
+export default TaskLoader;

+ 351 - 0
dashboard-v4/dashboard/src/components/task/TaskProjects.tsx

@@ -0,0 +1,351 @@
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import { FormattedMessage, useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+import {
+  Alert,
+  Badge,
+  Button,
+  message,
+  Modal,
+  Popover,
+  Typography,
+} from "antd";
+import { Dropdown } from "antd";
+import {
+  ExclamationCircleOutlined,
+  DeleteOutlined,
+  PlusOutlined,
+} from "@ant-design/icons";
+
+import { delete_, get } from "../../request";
+import { TChannelType } from "../api/Channel";
+import { PublicityValueEnum } from "../studio/table";
+import { IDeleteResponse } from "../api/Article";
+import { useEffect, useRef, useState } from "react";
+import StudioName, { IStudio } from "../auth/Studio";
+
+import { getSorterUrl } from "../../utils";
+import { TransferOutLinedIcon } from "../../assets/icon";
+import { IProjectData, IProjectListResponse } from "../api/task";
+import ProjectCreate from "./ProjectCreate";
+
+const { Text } = Typography;
+
+export const channelTypeFilter = {
+  all: {
+    text: <FormattedMessage id="channel.type.all.title" />,
+    status: "Default",
+  },
+  translation: {
+    text: <FormattedMessage id="channel.type.translation.label" />,
+    status: "Success",
+  },
+  nissaya: {
+    text: <FormattedMessage id="channel.type.nissaya.label" />,
+    status: "Processing",
+  },
+  commentary: {
+    text: <FormattedMessage id="channel.type.commentary.label" />,
+    status: "Default",
+  },
+  original: {
+    text: <FormattedMessage id="channel.type.original.label" />,
+    status: "Default",
+  },
+};
+
+export interface IResNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+export const renderBadge = (count: number, active = false) => {
+  return (
+    <Badge
+      count={count}
+      style={{
+        marginBlockStart: -2,
+        marginInlineStart: 4,
+        color: active ? "#1890FF" : "#999",
+        backgroundColor: active ? "#E6F7FF" : "#eee",
+      }}
+    />
+  );
+};
+
+interface IWidget {
+  studioName?: string;
+  type?: string;
+  disableChannels?: string[];
+  channelType?: TChannelType;
+  onSelect?: Function;
+}
+
+const ProjectListWidget = ({
+  studioName,
+  disableChannels,
+  channelType,
+  type,
+  onSelect,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("all");
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+  const [openCreate, setOpenCreate] = useState(false);
+  useEffect(() => {
+    ref.current?.reload();
+  }, [disableChannels]);
+
+  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() {
+        const url = `/v2/channel/${id}`;
+        console.log("delete api request", url);
+        return delete_<IDeleteResponse>(url)
+          .then((json) => {
+            console.info("api response", 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>();
+
+  return (
+    <>
+      {channelType ? (
+        <Alert
+          message={`仅显示版本类型${channelType}`}
+          type="success"
+          closable
+        />
+      ) : undefined}
+      <ProTable<IProjectData>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            width: 250,
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render(dom, entity, index, action, schema) {
+              return (
+                <Link to={`/studio/${studioName}/task/project/${entity.id}`}>
+                  {entity.title}
+                </Link>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.executors.label",
+            }),
+            dataIndex: "executors",
+            key: "executors",
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.milestone.label",
+            }),
+            dataIndex: "milestone",
+            key: "milestone",
+            width: 80,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.status.label",
+            }),
+            dataIndex: "status",
+            key: "status",
+            width: 80,
+            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: 100,
+            valueType: "option",
+            render: (text, row, index, action) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  trigger={["click", "contextMenu"]}
+                  menu={{
+                    items: [
+                      {
+                        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 "remove":
+                          showDeleteConfirm(row.id, row.title);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <Link to={`/studio/${studioName}/channel/${row.id}/setting`}>
+                    {intl.formatMessage({
+                      id: "buttons.setting",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/v2/project?view=studio&type=${activeKey}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+
+          url += params.keyword ? "&keyword=" + params.keyword : "";
+          url += channelType ? "&type=" + channelType : "";
+          url += getSorterUrl(sorter);
+          console.log("project list api request", url);
+          const res = await get<IProjectListResponse>(url);
+          console.info("project list api response", res);
+          return {
+            total: res.data.count,
+            succcess: res.ok,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Popover
+            content={
+              <ProjectCreate
+                studio={studioName}
+                type={activeKey === "workflow" ? "workflow" : "normal"}
+                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>,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "all",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.all" })}
+                    {renderBadge(myNumber, activeKey === "all")}
+                  </span>
+                ),
+              },
+              {
+                key: "workflow",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.workflow" })}
+                    {renderBadge(collaborationNumber, activeKey === "workflow")}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+    </>
+  );
+};
+
+export default ProjectListWidget;

+ 152 - 0
dashboard-v4/dashboard/src/components/task/TaskReader.tsx

@@ -0,0 +1,152 @@
+import { useEffect, useState } from "react";
+
+import { Divider, Space, Tag, Typography, message } from "antd";
+import { CodeSandboxOutlined } from "@ant-design/icons";
+
+import { ITaskData, ITaskResponse, ITaskUpdateRequest } from "../api/task";
+import { get, patch } from "../../request";
+import MdView from "../template/MdView";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+import TaskEditButton, { TRelation } from "./TaskEditButton";
+import PreTask from "./PreTask";
+
+const { Text, Title } = Typography;
+
+export const Milestone = ({ task }: { task?: ITaskData }) => {
+  return task?.is_milestone ? (
+    <Tag icon={<CodeSandboxOutlined />} color="error">
+      里程碑
+    </Tag>
+  ) : null;
+};
+
+export const Status = ({ task }: { task?: ITaskData }) => {
+  return task?.status === "pending" ? (
+    <Tag color="default">未发布</Tag>
+  ) : task?.status === "published" ? (
+    <Tag color="warning">待领取</Tag>
+  ) : task?.status === "running" ? (
+    <Tag color="processing">进行中</Tag>
+  ) : task?.status === "done" ? (
+    <Tag color="success">已完成</Tag>
+  ) : task?.status === "restarted" ? (
+    <Tag color="error">已重启</Tag>
+  ) : null;
+};
+
+interface IWidget {
+  taskId?: string;
+  task?: ITaskData;
+  onLoad?: (data: ITaskData) => void;
+  onChange?: (data: ITaskData) => void;
+  onEdit?: () => void;
+}
+const TaskReader = ({ taskId, task, onLoad, onChange, onEdit }: IWidget) => {
+  const [openPreTask, setOpenPreTask] = useState(false);
+  const [openNextTask, setOpenNextTask] = useState(false);
+  useEffect(() => {
+    const url = `/v2/task/${taskId}`;
+    console.info("api request", url);
+    get<ITaskResponse>(url).then((json) => {
+      if (json.ok) {
+        onLoad && onLoad(json.data);
+      }
+    });
+  }, [taskId]);
+
+  const updatePreTask = (type: TRelation, data?: ITaskData | null) => {
+    if (!taskId || !data) {
+      return;
+    }
+    let setting: ITaskUpdateRequest = {
+      id: taskId,
+      studio_name: "",
+    };
+    if (type === "pre") {
+      const hasPre = task?.pre_task?.find((value)=>value.id===data.id)
+      if(hasPre){
+        setting.pre_task_id = task?.pre_task?.filter((value)=>value.id!==data.id).map((item)=>item.id).join()
+      }else{
+        const newRelation = task?.pre_task? [...task.pre_task.map((item)=>item.id),data.id]:[data.id];
+        setting.pre_task_id = newRelation.join();
+      }
+    } else if (type === "next") {
+      const hasPre = task?.next_task?.find((value)=>value.id===data.id)
+      if(hasPre){
+        setting.next_task_id = task?.next_task?.filter((value)=>value.id!==data.id).map((item)=>item.id).join()
+      }else{
+        const newRelation = task?.next_task? [...task.next_task.map((item)=>item.id),data.id]:[data.id];
+        setting.next_task_id = newRelation.join();
+      }
+    }
+
+    const url = `/v2/task/${setting.id}`;
+    console.info("api request", url, setting);
+    patch<ITaskUpdateRequest, ITaskResponse>(url, setting).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        message.success("Success");
+        onChange && onChange(json.data);
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+  return (
+    <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Space>
+          <Status task={task} />
+          <Milestone task={task} />
+          <PreTask
+            task={task}
+            open={openPreTask}
+            type="pre"
+            onClick={(data) => {
+              updatePreTask("pre", data);
+              setOpenPreTask(false);
+            }}
+            onClose={() => setOpenPreTask(false)}
+          />
+          <PreTask
+            task={task}
+            open={openNextTask}
+            type="next"
+            onClick={(data) => {
+              updatePreTask("next", data);
+              setOpenNextTask(false);
+            }}
+            onClose={() => setOpenNextTask(false)}
+          />
+        </Space>
+        <div>
+          <TaskEditButton
+            task={task}
+            onChange={(task: ITaskData) => {
+              onChange && onChange(task);
+            }}
+            onEdit={onEdit}
+            onPreTask={(type: TRelation) => {
+              if (type === "pre") {
+                setOpenPreTask(true);
+              } else if (type === "next") {
+                setOpenNextTask(true);
+              }
+            }}
+          />
+        </div>
+      </div>
+      <Title>{task?.title}</Title>
+      <div>
+        <Space>
+          <User {...task?.editor} />
+          <TimeShow updatedAt={task?.updated_at} />
+        </Space>
+      </div>
+      <Divider />
+      <MdView html={task?.html} />
+    </div>
+  );
+};
+export default TaskReader;

+ 69 - 0
dashboard-v4/dashboard/src/components/task/TaskRelation.tsx

@@ -0,0 +1,69 @@
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import {  ITaskData, ITaskListResponse } from "../api/task";
+
+import "../article/article.css";
+
+import Mermaid from "../general/Mermaid";
+
+interface IWidget {
+  projectId?: string;
+  tasks?:ITaskData[];
+}
+const TaskRelation = ({ tasks }: IWidget) => {
+
+  let mermaidText = "flowchart LR\n";
+
+  //节点样式
+  const color = [
+    { status: "pending", fill: "white" },
+    { status: "published", fill: "orange" },
+    { status: "running", fill: "green" },
+    { status: "done", fill: "blue" },
+    { status: "restarted", fill: "red" },
+    { status: "closed", fill: "yellow" },
+    { status: "canceled", fill: "gray" },
+    { status: "expired", fill: "brown" },
+  ];
+
+  color.forEach((value) => {
+    mermaidText += `classDef ${value.status} fill:${value.fill},stroke:#333,stroke-width:2px;\n`;
+  });
+
+  let relationLine = new Map<string,number>();
+  tasks?.forEach((task: ITaskData, index: number, array: ITaskData[]) => {
+    //输出节点
+    mermaidText += `${task.id}[${task.title}]:::${task.status};\n`;
+
+    //输出带有子任务的节点
+    const children = array.filter(
+      (value: ITaskData, index: number, array: ITaskData[]) => {
+        return value.parent_id === task.id;
+      }
+    );
+    if (children.length > 0) {
+      mermaidText += `subgraph ${task.id} ["${task.title}"]\n`;
+      mermaidText += `${children.map((task) => task.id).join(`;\n`)}`;
+      mermaidText += ";\nend\n";
+    }
+
+    //关系线
+    task.pre_task?.map((item)=>relationLine.set(`${item.id} --> ${task.id};\n`,0))
+    task.next_task?.map((item)=>relationLine.set(`${task.id} --> ${item.id};\n`,0))
+
+  });
+
+  Array.from(relationLine.keys()).forEach((value)=>{
+    mermaidText += value;
+  })
+
+  console.debug(mermaidText);
+
+  return (
+    <div>
+      <Mermaid text={mermaidText} />
+    </div>
+  );
+};
+
+export default TaskRelation;

+ 145 - 0
dashboard-v4/dashboard/src/components/task/TaskTable.tsx

@@ -0,0 +1,145 @@
+import { useEffect, useState } from "react";
+
+import { IProject, ITaskData } from "../api/task";
+import "../article/article.css";
+import { Status } from "./TaskReader";
+import User from "../auth/User";
+import { Assignees } from "./TaskList";
+
+interface ITaskHeading {
+  id: string;
+  title: string;
+  children: number;
+}
+
+interface IWidget {
+  tasks?: ITaskData[];
+}
+const TaskTable = ({ tasks }: IWidget) => {
+  const [tasksTitle, setTasksTitle] = useState<ITaskHeading[][]>();
+  const [dataHeading, setDataHeading] = useState<string[]>();
+  const [projects, setProjects] = useState<IProject[]>();
+
+  useEffect(() => {
+    let projectsId = new Map<string, number>();
+    let projectMap = new Map<string, IProject>();
+    tasks?.forEach((task) => {
+      if (task.project_id && task.project) {
+        if (projectsId.has(task.project_id)) {
+          projectsId.set(task.project_id, projectsId.get(task.project_id)! + 1);
+        } else {
+          projectsId.set(task.project_id, 1);
+          projectMap.set(task.project_id, task.project);
+        }
+      }
+    });
+
+    setProjects(Array.from(projectMap.values()));
+
+    const getNodeChildren = (task:ITaskData):number=>{
+      const children = tasks?.filter((value) => value.parent_id === task.id)
+      if(children && children.length>0){
+          return children.reduce((acc, cur) => {
+            return acc + getNodeChildren(cur)
+          }, children.length);
+      }else{
+        return 0
+      }
+    }
+    //列表头
+    let titles1: ITaskHeading[] = [];
+    let titles2: ITaskHeading[] = [];
+    let titles3: string[] = [];
+    tasks
+      ?.filter((value: ITaskData) => !value.parent_id)
+      .forEach((task) => {
+        const children = tasks
+          ?.filter((value1) => value1.parent_id === task.id)
+          .map((task1) => {
+            const child: ITaskHeading = {
+              id: task1.id,
+              title: task1.title ?? "",
+              children: 0,
+            };
+            return child;
+          });
+        titles2 = [...titles2, ...children];
+
+        titles1.push({
+          title: task.title ?? "",
+          id: task.id,
+          children: getNodeChildren(task),
+        });
+
+        if (children.length === 0) {
+          titles3.push(task.title);
+        } else {
+          titles3 = [...titles3, ...children.map((item) => item.title)];
+        }
+      });
+    const heading = [titles1, titles2];
+    console.log("heading", heading);
+    setTasksTitle(heading);
+    setDataHeading(titles3);
+  }, [tasks]);
+
+  return (
+    <div className="pcd_article">
+      <table>
+        <thead>
+          {tasksTitle?.map((row, level) => {
+            return (
+              <tr>
+                {level === 0 ? <th rowSpan={2}>project</th> : undefined}
+                {row.map((task, index) => {
+                  return (
+                    <th
+                      key={index}
+                      colSpan={task.children === 0 ? undefined : task.children}
+                      rowSpan={task.children === 0 ? 2 : undefined}
+                    >
+                      {task.title}
+                    </th>
+                  );
+                })}
+              </tr>)
+            
+          })}
+        </thead>
+        <tbody>
+          {projects?.map((row, index) => (
+            <tr key={index}>
+              <td>{row.title}</td>
+              {dataHeading?.map((task, id) => {
+                const taskData = tasks?.find(
+                  (value: ITaskData) =>
+                    value.title === task && value.project_id === row.id
+                );
+                return (
+                  <td key={id}>
+                    <div>
+                      <div>
+                        {taskData?.executor ? (
+                          <User {...taskData.executor} />
+                        ) : taskData?.assignees ? (
+                          <Assignees data={taskData} />
+                        ) : (
+                          <></>
+                        )}
+                      </div>
+                      <div>
+                        <Status task={taskData} />
+                      </div>
+                    </div>
+                  </td>
+                );
+              })}
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+export default TaskTable;

+ 25 - 7
dashboard-v4/dashboard/src/components/template/UserSelect.tsx

@@ -1,4 +1,4 @@
-import { ProFormSelect } from "@ant-design/pro-components";
+import { ProFormSelect, RequestOptionsType } from "@ant-design/pro-components";
 import { useIntl } from "react-intl";
 
 import { get } from "../../request";
@@ -9,24 +9,38 @@ interface IWidget {
   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={intl.formatMessage({ id: "forms.fields.user.label" })}
+      label={
+        hiddenTitle
+          ? undefined
+          : intl.formatMessage({ id: "forms.fields.user.label" })
+      }
       hidden={hidden}
       width={width}
+      initialValue={initialValue}
       showSearch
       debounceTime={300}
       fieldProps={{
-        mode: multiple ? "multiple" : undefined,
+        mode: multiple ? "tags" : undefined,
       }}
       request={async ({ keyWords }) => {
         console.log("keyWord", keyWords);
@@ -35,21 +49,25 @@ const UserSelectWidget = ({
           const json = await get<IUserListResponse>(
             `/v2/user?view=key&key=${keyWords}`
           );
-          const userList = json.data.rows.map((item) => {
+          console.info("api response user select", json);
+          const userList: RequestOptionsType[] = json.data.rows.map((item) => {
             return {
               value: item.id,
-              label: `${item.userName}-${item.nickName}`,
+              label: `${item.nickName}`,
             };
           });
           console.log("json", userList);
           return userList;
         } else {
-          return [];
+          const defaultOptions: RequestOptionsType[] = options.map((item) => {
+            return { label: item.label, value: item.value?.toString() };
+          });
+          return defaultOptions;
         }
       }}
       rules={[
         {
-          required: true,
+          required: required,
         },
       ]}
     />

+ 1 - 1
dashboard-v4/dashboard/src/components/template/Wbw/WbwFactors.tsx

@@ -81,7 +81,7 @@ const WbwFactorsWidget = ({ data, answer, display, onChange }: IWidget) => {
         factors.push(key);
       });
 
-      const menu = factors.map((item) => {
+      const menu = [...factors, data.real.value].map((item) => {
         return { key: item, label: item };
       });
       setItems(menu);

+ 42 - 0
dashboard-v4/dashboard/src/components/template/Wbw/WbwFactorsEditor.tsx

@@ -0,0 +1,42 @@
+import { useState } from "react";
+import { LoadingOutlined, WarningOutlined } from "@ant-design/icons";
+
+import WbwFactors from "./WbwFactors";
+import { IWbw, TWbwDisplayMode } from "./WbwWord";
+import { IPreferenceResponse } from "../../dict/DictPreferenceEditor";
+
+interface IWidget {
+  initValue: IWbw;
+  display?: TWbwDisplayMode;
+  onChange?: (key: string) => Promise<IPreferenceResponse>;
+}
+const WbwFactorsEditor = ({ initValue, display, onChange }: IWidget) => {
+  const [value, setValue] = useState(initValue);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState(false);
+  return (
+    <>
+      {loading ? <LoadingOutlined /> : error ? <WarningOutlined /> : <></>}
+      <WbwFactors
+        key="factors"
+        data={value}
+        display={display}
+        onChange={async (e: string) => {
+          console.log("factor change", e);
+          setValue({ ...value, factors: { value: e, status: 5 } });
+          if (onChange) {
+            setLoading(true);
+            setError(false);
+            const response = await onChange(e);
+            setLoading(false);
+            if (!response.ok) {
+              setError(true);
+            }
+          }
+        }}
+      />
+    </>
+  );
+};
+
+export default WbwFactorsEditor;

+ 76 - 0
dashboard-v4/dashboard/src/components/template/Wbw/WbwLookup.tsx

@@ -0,0 +1,76 @@
+import { useEffect, useRef } from "react";
+import { useAppSelector } from "../../../hooks";
+import { add, updateIndex, wordIndex } from "../../../reducers/inline-dict";
+import { get } from "../../../request";
+import { IApiResponseDictList } from "../../api/Dict";
+import store from "../../../store";
+
+interface IWidget {
+  run?: boolean;
+  words?: string[];
+  delay?: number;
+}
+const WbwLookup = ({ words, run = false, delay = 300 }: IWidget) => {
+  const inlineWordIndex = useAppSelector(wordIndex);
+
+  const intervalRef = useRef<number | null>(null); //防抖计时器句柄
+
+  useEffect(() => {
+    // 监听store中的words变化
+    if (run && words && words.length > 0) {
+      // 开始查字典
+      intervalRef.current = window.setInterval(lookup, delay, words);
+    } else {
+      stopLookup();
+    }
+    return () => {
+      // 组件销毁时清除计时器
+      clearInterval(intervalRef.current!);
+    };
+  }, [run, words]);
+  /**
+   * 停止查字典计时
+   * 在两种情况下停止计时
+   * 1. 开始查字典
+   * 2. 防抖时间内鼠标移出单词区
+   */
+  const stopLookup = () => {
+    if (intervalRef.current) {
+      window.clearInterval(intervalRef.current);
+      intervalRef.current = null;
+    }
+  };
+  /**
+   * 查字典
+   * @param word 要查的单词
+   */
+  const lookup = (words: string[]) => {
+    stopLookup();
+
+    //查询这个词在内存字典里是否有
+    const searchWord = words.filter((value) => {
+      if (inlineWordIndex.includes(value)) {
+        //已经有了
+        return false;
+      } else {
+        return true;
+      }
+    });
+    if (searchWord.length === 0) {
+      return;
+    }
+    const url = `/v2/wbwlookup?word=${searchWord.join()}`;
+    console.info("api request", url);
+    get<IApiResponseDictList>(url).then((json) => {
+      console.debug("api response", json);
+      //存储到redux
+      store.dispatch(add(json.data.rows));
+      store.dispatch(updateIndex(searchWord));
+    });
+
+    console.log("lookup", searchWord);
+  };
+  return <></>;
+};
+
+export default WbwLookup;

+ 6 - 0
dashboard-v4/dashboard/src/locales/en-US/forms.ts

@@ -90,6 +90,12 @@ const items = {
   "forms.status.cancel.label": "canceled",
   "forms.fields.sign-up-message.label": "sign up message",
   "forms.fields.color.label": "color",
+  "forms.fields.executors.label": "executors",
+  "forms.fields.executor.label": "executor",
+  "forms.fields.milestone.label": "milestones",
+  "forms.fields.started-at.label": "started at",
+  "forms.fields.finished-at.label": "finished at",
+  "forms.fields.assignees.label": "assignees",
 };
 
 export default items;

+ 16 - 0
dashboard-v4/dashboard/src/locales/en-US/label.ts

@@ -52,6 +52,22 @@ const items = {
   "labels.error.404": "没有找到指定的资源",
   "labels.error.429": "请求过于频繁",
   "labels.error.500": "服务器内部错误",
+  "labels.task": "task",
+  "labels.task.hall": "task hall",
+  "labels.task.mine": "my task",
+  "labels.task.my.project": "my projects",
+  "labels.all": "all",
+  "labels.mention.me": "mention",
+  "labels.task.status.pending": "pending",
+  "labels.task.status.published": "published",
+  "labels.task.status.running": "running",
+  "labels.task.status.done": "done",
+  "labels.task.status.restarted": "restarted",
+  "labels.task.status.closed": "closed",
+  "labels.task.status.canceled": "canceled",
+  "labels.task.status.expired": "expired",
+  "labels.filter": "filter",
+  "labels.participants": "participants",
 };
 
 export default items;

+ 6 - 0
dashboard-v4/dashboard/src/locales/zh-Hans/forms.ts

@@ -90,6 +90,12 @@ const items = {
   "forms.status.cancel.label": "已经撤回",
   "forms.fields.sign-up-message.label": "报名消息",
   "forms.fields.color.label": "颜色",
+  "forms.fields.executors.label": "执行者们",
+  "forms.fields.executor.label": "执行者",
+  "forms.fields.milestone.label": "里程碑",
+  "forms.fields.started-at.label": "开始时间",
+  "forms.fields.finished-at.label": "完成时间",
+  "forms.fields.assignees.label": "指派给",
 };
 
 export default items;

+ 19 - 0
dashboard-v4/dashboard/src/locales/zh-Hans/label.ts

@@ -57,6 +57,25 @@ const items = {
   "labels.error.404": "没有找到指定的资源",
   "labels.error.429": "请求过于频繁",
   "labels.error.500": "服务器内部错误",
+  "labels.task": "任务",
+  "labels.task.hall": "任务大厅",
+  "labels.task.mine": "我的任务",
+  "labels.task.my.project": "我的项目",
+  "labels.all": "全部",
+  "labels.mention.me": "提及我的",
+  "labels.in.progressing": "进行中",
+  "labels.closed": "已关闭",
+  "labels.workflow": "工作流",
+  "labels.task.status.pending": "待办",
+  "labels.task.status.published": "已发布",
+  "labels.task.status.running": "进行中",
+  "labels.task.status.done": "已完成",
+  "labels.task.status.restarted": "已重启",
+  "labels.task.status.closed": "已关闭",
+  "labels.task.status.canceled": "已取消",
+  "labels.task.status.expired": "已过期",
+  "labels.filter": "过滤器",
+  "labels.participants": "参与者",
 };
 
 export default items;

+ 7 - 0
dashboard-v4/dashboard/src/pages/admin/dictionary/preference.tsx

@@ -0,0 +1,7 @@
+import DictPreferenceEditor from "../../../components/dict/DictPreferenceEditor";
+
+const Widget = () => {
+  return <DictPreferenceEditor />;
+};
+
+export default Widget;

+ 36 - 21
dashboard-v4/dashboard/src/pages/library/download/Download.tsx

@@ -1,7 +1,6 @@
-import { Button, Card, Typography } from "antd";
+import { Button, Card, Space, Typography } from "antd";
 
 import { useEffect, useState } from "react";
-import { GithubOutlined } from "@ant-design/icons";
 
 import bg_png from "../../../assets/library/images/download_bg.png";
 import Marked from "../../../components/general/Marked";
@@ -9,9 +8,15 @@ import { get } from "../../../request";
 
 const { Paragraph } = Typography;
 
+interface IUrl {
+  link: string;
+  hostname: string;
+}
+
 interface IOfflineIndex {
+  title: string;
   filename: string;
-  url: string;
+  url: IUrl[];
   create_at: string;
   chapter: number;
   filesize: number;
@@ -76,28 +81,38 @@ const ChapterNewWidget = () => {
         </Paragraph>
       </Card>
       <Card title={"离线数据包"} style={{ margin: 10, borderRadius: 8 }}>
-        <Paragraph>
-          {"点链接下载离线数据包"}
+        <Space direction="vertical">
           {offlineIndex?.map((item, id) => {
             return (
-              <ul key={id}>
-                <li>
-                  {"文件名:"}
-                  {item.filename}
-                </li>
-                <li>
-                  {"文件大小:"}
-                  {item.filesize}
-                </li>
-                <li>
-                  <Button type="primary">
-                    <a href={item.url}>下载</a>
-                  </Button>
-                </li>
-              </ul>
+              <Card title={item.title} size="small" key={id}>
+                <ul key={id}>
+                  <li>
+                    {"文件名:"}
+                    {item.filename}
+                  </li>
+                  <li>
+                    {"文件大小:"}
+                    {item.filesize}
+                  </li>
+                  <li>
+                    <ul>
+                      {item.url.map((url, id) => {
+                        return (
+                          <li key={id}>
+                            {`镜像${id + 1}:`}
+                            <a href={url.link} target="_blank" rel="noreferrer">
+                              {url.hostname}
+                            </a>
+                          </li>
+                        );
+                      })}
+                    </ul>
+                  </li>
+                </ul>
+              </Card>
             );
           })}
-        </Paragraph>
+        </Space>
       </Card>
     </Paragraph>
   );

+ 27 - 0
dashboard-v4/dashboard/src/pages/studio/task/hall.tsx

@@ -0,0 +1,27 @@
+import { useParams } from "react-router-dom";
+
+import TaskList from "../../../components/task/TaskList";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const { studioname } = useParams();
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioname}
+      status={["published"]}
+      filters={[
+        {
+          field: "sign_up",
+          operator: "equals",
+          value: "true",
+        },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+
+export default Widget;

+ 20 - 0
dashboard-v4/dashboard/src/pages/studio/task/index.tsx

@@ -0,0 +1,20 @@
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
+
+import LeftSider from "../../../components/studio/LeftSider";
+import { styleStudioContent } from "../style";
+
+const { Content } = Layout;
+
+const Widget = () => {
+  return (
+    <Layout>
+      <LeftSider selectedKeys="task" />
+      <Content style={{ ...styleStudioContent }}>
+        <Outlet />
+      </Content>
+    </Layout>
+  );
+};
+
+export default Widget;

+ 83 - 0
dashboard-v4/dashboard/src/pages/studio/task/project.tsx

@@ -0,0 +1,83 @@
+import { useNavigate, useParams } from "react-router-dom";
+
+import Project from "../../../components/task/Project";
+import TaskList from "../../../components/task/TaskList";
+import { Tabs } from "antd";
+import TaskTable from "../../../components/task/TaskTable";
+import TaskRelation from "../../../components/task/TaskRelation";
+import { useState } from "react";
+import { ITaskData } from "../../../components/api/task";
+import TaskLoader from "../../../components/task/TaskLoader";
+
+const Widget = () => {
+  const { studioname } = useParams();
+  const { projectId } = useParams();
+  const navigate = useNavigate();
+  const [tasks, setTasks] = useState<ITaskData[]>();
+  return (
+    <>
+      <Project
+        studioName={studioname}
+        projectId={projectId}
+        onSelect={(id: string) => {
+          navigate(`/studio/${studioname}/task/project/${id}`);
+        }}
+      />
+      <Tabs
+        type="card"
+        items={[
+          {
+            label: "列表",
+            key: "list",
+            children: (
+              <TaskList
+                editable
+                studioName={studioname}
+                projectId={projectId}
+                onLoad={(data) => setTasks(data)}
+                onChange={(data) =>
+                  setTasks((origin) => {
+                    const old = origin?.find((value) => value.id === data.id);
+                    let newData: ITaskData[] = [];
+                    if (old) {
+                      origin?.forEach(
+                        (
+                          value: ITaskData,
+                          index: number,
+                          array: ITaskData[]
+                        ) => {
+                          if (value.id === data.id) {
+                            array[index] = data;
+                          }
+                        }
+                      );
+                    } else {
+                      if (origin) {
+                        newData = [...origin, data];
+                      } else {
+                        newData = [data];
+                      }
+                    }
+                    return origin;
+                  })
+                }
+              />
+            ),
+          },
+          {
+            label: "表格",
+            key: "table",
+            children: <TaskTable tasks={tasks} />,
+          },
+          {
+            label: "关系图",
+            key: "relation",
+            children: <TaskRelation tasks={tasks} />,
+          },
+        ]}
+      ></Tabs>
+    </>
+  );
+};
+
+export default Widget;

+ 10 - 0
dashboard-v4/dashboard/src/pages/studio/task/projects.tsx

@@ -0,0 +1,10 @@
+import { useParams } from "react-router-dom";
+
+import TaskProjects from "../../../components/task/TaskProjects";
+
+const Widget = () => {
+  const { studioname } = useParams();
+  return <TaskProjects studioName={studioname} />;
+};
+
+export default Widget;

+ 15 - 0
dashboard-v4/dashboard/src/pages/studio/task/tasks.tsx

@@ -0,0 +1,15 @@
+import { useParams } from "react-router-dom";
+
+import TaskList from "../../../components/task/TaskList";
+import TaskList2 from "../../../components/task/TaskList";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+import MyTasks from "../../../components/task/MyTasks";
+
+const Widget = () => {
+  const { studioname } = useParams();
+
+  return <MyTasks studioName={studioname} />;
+};
+
+export default Widget;

Some files were not shown because too many files changed in this diff