Przeglądaj źródła

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

Jeremy Zheng 1 rok temu
rodzic
commit
c7775575cf
73 zmienionych plików z 1947 dodań i 744 usunięć
  1. 7 4
      api-v8/app/Console/Commands/MqAiTranslate.php
  2. 2 2
      api-v8/app/Console/Commands/TestAiTask.php
  3. 84 0
      api-v8/app/Console/Commands/UsersDesensitize.php
  4. 1 0
      api-v8/app/Http/Api/AiAssistantApi.php
  5. 27 26
      api-v8/app/Http/Api/AiTaskPrepare.php
  6. 2 0
      api-v8/app/Http/Api/ProjectApi.php
  7. 11 8
      api-v8/app/Http/Controllers/AccessTokenController.php
  8. 9 1
      api-v8/app/Http/Controllers/AiAssistantController.php
  9. 65 0
      api-v8/app/Http/Controllers/AttachmentMapController.php
  10. 239 211
      api-v8/app/Http/Controllers/ChannelController.php
  11. 42 12
      api-v8/app/Http/Controllers/ProjectController.php
  12. 6 1
      api-v8/app/Http/Controllers/ProjectTreeController.php
  13. 85 0
      api-v8/app/Http/Controllers/SentenceAttachmentController.php
  14. 9 7
      api-v8/app/Http/Controllers/SentenceController.php
  15. 1 0
      api-v8/app/Http/Controllers/TaskGroupController.php
  16. 30 0
      api-v8/app/Http/Requests/StoreAttachmentMapRequest.php
  17. 30 0
      api-v8/app/Http/Requests/StoreSentenceAttachmentRequest.php
  18. 30 0
      api-v8/app/Http/Requests/UpdateAttachmentMapRequest.php
  19. 30 0
      api-v8/app/Http/Requests/UpdateSentenceAttachmentRequest.php
  20. 9 9
      api-v8/app/Http/Resources/AttachmentResource.php
  21. 3 1
      api-v8/app/Http/Resources/ProjectResource.php
  22. 43 0
      api-v8/app/Http/Resources/SentenceAttachmentResource.php
  23. 1 0
      api-v8/app/Models/AccessToken.php
  24. 11 0
      api-v8/app/Models/AttachmentMap.php
  25. 11 0
      api-v8/app/Models/SentenceAttachment.php
  26. 36 0
      api-v8/database/migrations/2025_03_15_112630_create_attachment_maps_table.php
  27. 35 0
      api-v8/database/migrations/2025_03_16_113614_add_privacy_in_projects.php
  28. 74 0
      api-v8/resources/views/book.blade.php
  29. 2 0
      api-v8/routes/api.php
  30. 13 10
      api-v8/routes/web.php
  31. 8 1
      dashboard-v4/dashboard/src/Router.tsx
  32. 4 4
      dashboard-v4/dashboard/src/components/ai/AiAssistantSelect.tsx
  33. 12 0
      dashboard-v4/dashboard/src/components/api/Attachments.ts
  34. 1 1
      dashboard-v4/dashboard/src/components/api/ai.ts
  35. 7 0
      dashboard-v4/dashboard/src/components/api/task.ts
  36. 3 0
      dashboard-v4/dashboard/src/components/api/token.ts
  37. 92 0
      dashboard-v4/dashboard/src/components/article/Token.tsx
  38. 60 0
      dashboard-v4/dashboard/src/components/article/TokenModal.tsx
  39. 1 42
      dashboard-v4/dashboard/src/components/article/TypePali.tsx
  40. 6 8
      dashboard-v4/dashboard/src/components/auth/Avatar.tsx
  41. 2 7
      dashboard-v4/dashboard/src/components/auth/LoginAlert.tsx
  42. 20 0
      dashboard-v4/dashboard/src/components/auth/LoginButton.tsx
  43. 2 7
      dashboard-v4/dashboard/src/components/auth/SignInAvatar.tsx
  44. 29 0
      dashboard-v4/dashboard/src/components/channel/ChannelMy.tsx
  45. 89 0
      dashboard-v4/dashboard/src/components/channel/ChannelSelectWithToken.tsx
  46. 2 5
      dashboard-v4/dashboard/src/components/course/Status.tsx
  47. 1 1
      dashboard-v4/dashboard/src/components/nut/users/NonSignInSharedLinks.tsx
  48. 2 9
      dashboard-v4/dashboard/src/components/nut/users/ResetPassword.tsx
  49. 13 2
      dashboard-v4/dashboard/src/components/nut/users/SignIn.tsx
  50. 10 0
      dashboard-v4/dashboard/src/components/studio/LeftSider.tsx
  51. 53 28
      dashboard-v4/dashboard/src/components/task/Filter.tsx
  52. 21 18
      dashboard-v4/dashboard/src/components/task/ProjectEdit.tsx
  53. 1 6
      dashboard-v4/dashboard/src/components/task/ProjectEditDrawer.tsx
  54. 89 149
      dashboard-v4/dashboard/src/components/task/ProjectList.tsx
  55. 118 39
      dashboard-v4/dashboard/src/components/task/TaskBuilderChapter.tsx
  56. 18 3
      dashboard-v4/dashboard/src/components/task/TaskBuilderProjects.tsx
  57. 77 60
      dashboard-v4/dashboard/src/components/task/TaskBuilderProp.tsx
  58. 2 4
      dashboard-v4/dashboard/src/components/task/TaskList.tsx
  59. 1 0
      dashboard-v4/dashboard/src/components/task/TaskProjects.tsx
  60. 7 1
      dashboard-v4/dashboard/src/components/task/TaskTable.tsx
  61. 62 16
      dashboard-v4/dashboard/src/components/task/Workflow.tsx
  62. 1 0
      dashboard-v4/dashboard/src/components/template/SentEdit.tsx
  63. 35 0
      dashboard-v4/dashboard/src/components/template/SentEdit/SentAttachment.tsx
  64. 34 12
      dashboard-v4/dashboard/src/components/template/SentEdit/SentCanRead.tsx
  65. 19 2
      dashboard-v4/dashboard/src/components/template/SentEdit/SentCell.tsx
  66. 28 25
      dashboard-v4/dashboard/src/components/template/SentEdit/SentTab.tsx
  67. 1 0
      dashboard-v4/dashboard/src/locales/en-US/buttons.ts
  68. 9 0
      dashboard-v4/dashboard/src/locales/en-US/label.ts
  69. 1 0
      dashboard-v4/dashboard/src/locales/zh-Hans/buttons.ts
  70. 9 0
      dashboard-v4/dashboard/src/locales/zh-Hans/label.ts
  71. 26 2
      dashboard-v4/dashboard/src/pages/library/article/show.tsx
  72. 12 0
      dashboard-v4/dashboard/src/pages/studio/task/project-edit.tsx
  73. 11 0
      dashboard-v4/dashboard/src/pages/studio/task/workflow.tsx

+ 7 - 4
api-v8/app/Console/Commands/MqAiTranslate.php

@@ -53,7 +53,10 @@ class MqAiTranslate extends Command
         $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
         Log::debug("mq:progress start.");
         Mq::worker($exchange, $queue, function ($message) {
-            Log::debug('start', ['message' => $message]);
+            if (\App\Tools\Tools::isStop()) {
+                return 0;
+            }
+            Log::debug('ai translate start', ['message' => $message]);
             //写入 model log
             $modelLog = new ModelLog();
             $modelLog->uid = Str::uuid();
@@ -145,8 +148,8 @@ class MqAiTranslate extends Command
             $data = [
                 'res_id' => $sUid,
                 'res_type' => 'sentence',
-                'title' => 'AI ' . $message->task->info->title,
-                'content' => 'AI ' . $message->task->info->category,
+                'title' => $message->task->info->title,
+                'content' => $message->task->info->category,
                 'content_type' => 'markdown',
                 'type' => 'discussion',
             ];
@@ -196,7 +199,7 @@ class MqAiTranslate extends Command
                 $this->info('task progress successful progress=' . $response->json()['data']['progress']);
             }
 
-            //完成 修改状态
+            //任务完成 修改任务状态为 done
             if ($taskProgress->current === $taskProgress->total) {
                 $url = config('app.url') . '/api/v2/task-status/' . $message->task->info->id;
                 $data = [

+ 2 - 2
api-v8/app/Console/Commands/TestAiTask.php

@@ -10,7 +10,7 @@ class TestAiTask extends Command
     /**
      * The name and signature of the console command.
      * php artisan test:ai.task c77af42f-ffb5-48ae-af71-4c32e1c30dab
-     * php artisan test:ai.task 81bd0b28-c7ea-4fc5-902d-0b188ba79d35
+     * php artisan test:ai.task 41640b40-e153-407d-9d29-da631c5b88f8
      * @var string
      */
     protected $signature = 'test:ai.task {id}';
@@ -40,7 +40,7 @@ class TestAiTask extends Command
     public function handle()
     {
         $taskId = $this->argument('id');
-        $params = AiTaskPrepare::translate($taskId);
+        $params = AiTaskPrepare::translate($taskId, false);
         var_dump($params);
         return 0;
     }

+ 84 - 0
api-v8/app/Console/Commands/UsersDesensitize.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\UserInfo;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Str;
+
+class UsersDesensitize extends Command
+{
+    /**
+     * 对用户表进行脱敏,改写password email 两个字段数据.以test & admin开头的不会被改写
+     * php artisan users:desensitize --test
+     * @var string
+     */
+    protected $signature = 'users:desensitize {--test}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'desensitize users information';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (App::environment('product')) {
+            $this->error('environment is product');
+            return 1;
+        }
+        $this->info('environment is ' . App::environment());
+        if ($this->option('test')) {
+            $this->info('test mode');
+        } else {
+            $this->error('this is not test mode');
+        }
+        if (!$this->confirm("desensitize all users information?")) {
+            return 0;
+        }
+        $users = UserInfo::cursor();
+        $total = UserInfo::count();
+        $desensitized = 0;
+        $jumped = 0;
+        foreach ($users as $key => $user) {
+
+            if (
+                mb_substr($user->username, 0, 4) === 'test' ||
+                $user->username === 'admin'
+            ) {
+                $this->info('test user jump' . $user->username);
+                $jumped++;
+                continue;
+            }
+            $desensitized++;
+            if (!$this->option('test')) {
+                $curr = UserInfo::find($user->id);
+                $curr->password = Str::uuid() . '*';
+                $curr->email = Str::uuid() . '@email.com';
+                $curr->username = mb_substr($curr->username, 0, 2) . mt_rand(1000, 9999) . '****';
+                $curr->nickname = mb_substr($curr->nickname, 0, 1) . '**';
+                $curr->save();
+            }
+            $this->info('desensitized ' . $user->username);
+        }
+        $this->info("all done total={$total} desensitized={$desensitized} jumped={$jumped}");
+        return 0;
+    }
+}

+ 1 - 0
api-v8/app/Http/Api/AiAssistantApi.php

@@ -31,6 +31,7 @@ class AiAssistantApi
             'nickName' => $user->name,
             'userName' => $user->real_name,
             'realName' => $user->real_name,
+            'roles' => ['ai'],
             'sn' => 0,
         ];
 

+ 27 - 26
api-v8/app/Http/Api/AiTaskPrepare.php

@@ -34,6 +34,7 @@ class AiTaskPrepare
             }
         }
         if (!isset($params['type'])) {
+            Log::error('no $params.type');
             return false;
         }
 
@@ -43,12 +44,14 @@ class AiTaskPrepare
         switch ($params['type']) {
             case 'sentence':
                 if (!isset($params['id'])) {
+                    Log::error('no $params.id');
                     return false;
                 }
                 $sentences[] = explode('-', $params['id']);
                 break;
             case 'para':
                 if (!isset($params['book']) || !isset($params['paragraphs'])) {
+                    Log::error('no $params.book or paragraphs');
                     return false;
                 }
                 $sent = PaliSentence::where('book', $params['book'])
@@ -67,13 +70,14 @@ class AiTaskPrepare
                 }
                 break;
             case 'chapter':
-                if (!isset($params['book']) || !isset($params['para'])) {
+                if (!isset($params['book']) || !isset($params['paragraphs'])) {
+                    Log::error('no $params.book or paragraphs');
                     return false;
                 }
                 $chapterLen = PaliText::where('book', $params['book'])
-                    ->where('paragraph', $params['para'])->value('chapter_len');
+                    ->where('paragraph', $params['paragraphs'])->value('chapter_len');
                 $sent = PaliSentence::where('book', $params['book'])
-                    ->whereBetween('paragraph', [$params['para'], $params['para'] + $chapterLen - 1])
+                    ->whereBetween('paragraph', [$params['paragraphs'], $params['paragraphs'] + $chapterLen - 1])
                     ->orderBy('paragraph')
                     ->orderBy('word_begin')->get();
                 foreach ($sent as $key => $value) {
@@ -115,38 +119,35 @@ class AiTaskPrepare
             $sumLen += $sentence['strlen'];
             $sid = implode('-', $sentence['id']);
             Log::debug($sid);
+            $sentChannelInfo = explode('@', $params['channel']);
+            $channelId = $sentChannelInfo[0];
+            $data = [];
             $data['origin'] = '{{' . $sid . '}}';
             $data['translation'] = '{{sent|id=' . $sid;
-            $data['translation'] .= '|channel=' . $params['channel'];
+            $data['translation'] .= '|channel=' . $channelId;
             $data['translation'] .= '|text=translation}}';
-            if (isset($params['nissaya'])) {
-                $data['nissaya'] = [];
-                $nissayaChannels = explode(',', $params['nissaya']);
-                foreach ($nissayaChannels as $key => $channel) {
-                    $channelInfo = ChannelApi::getById($channel);
-                    if (!$channelInfo) {
-                        continue;
-                    }
+            if (isset($params['nissaya']) && !empty($params['nissaya'])) {
+                $nissayaChannel = explode('@', $params['nissaya']);
+                $channelInfo = ChannelApi::getById($nissayaChannel[0]);
+                if ($channelInfo) {
                     //查看句子是否存在
                     $nissayaSent = Sentence::where('book_id', $sentence['id'][0])
                         ->where('paragraph', $sentence['id'][1])
                         ->where('word_start', $sentence['id'][2])
                         ->where('word_end', $sentence['id'][3])
-                        ->where('channel_uid', $channel)->first();
-                    if (!$nissayaSent) {
-                        continue;
+                        ->where('channel_uid', $nissayaChannel[0])->first();
+                    if ($nissayaSent && !empty($nissayaSent->content)) {
+                        $nissayaData = [];
+                        $nissayaData['channel'] = $channelInfo;
+                        $nissayaData['data'] = '{{sent|id=' . $sid;
+                        $nissayaData['data'] .= '|channel=' . $nissayaChannel[0];
+                        $nissayaData['data'] .= '|text=translation}}';
+                        $data['nissaya'] = $nissayaData;
                     }
-                    if (empty($nissayaSent->content)) {
-                        continue;
-                    }
-                    $nissayaData = [];
-                    $nissayaData['channel'] = $channelInfo;
-                    $nissayaData['data'] = '{{sent|id=' . $sid;
-                    $nissayaData['data'] .= '|channel=' . $channel;
-                    $nissayaData['data'] .= '|text=translation}}';
-                    $data['nissaya'][] = $nissayaData;
                 }
             }
+
+            Log::debug('mustache render', ['tpl' => $description, 'data' => $data]);
             $content = $m->render($description, $data);
             $prompt = $mdRender->convert($content, []);
             //gen mq
@@ -165,10 +166,10 @@ class AiTaskPrepare
                     'paragraph' => $sentence['id'][1],
                     'word_start' => $sentence['id'][2],
                     'word_end' => $sentence['id'][3],
-                    'channel_uid' => $params['channel'],
+                    'channel_uid' => $channelId,
                     'content' => $prompt,
                     'content_type' => 'markdown',
-                    'access_token' => $params['token'],
+                    'access_token' => $sentChannelInfo[1] ?? $params['token'],
                 ],
             ];
             array_push($mqData, $aiMqData);

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

@@ -19,6 +19,7 @@ class ProjectApi
                 'id' => $id,
                 'title' => $project->title,
                 'type' => $project->type,
+                'weight' => $project->weight,
                 'description' => $project->description,
             ];
         } else {
@@ -40,6 +41,7 @@ class ProjectApi
                         'id' => $id,
                         'title' => $project->title,
                         'type' => $project->type,
+                        'weight' => $project->weight,
                         'description' => $project->description,
                     ];
                     continue;

+ 11 - 8
api-v8/app/Http/Controllers/AccessTokenController.php

@@ -35,16 +35,19 @@ class AccessTokenController extends Controller
         $result = array();
         foreach ($payload as $key => $value) {
             //获取token
-            $token = AccessToken::where('res_type', $value['res_type'])
-                ->where('res_id', $value['res_id'])
-                ->first();
-            if (!$token) {
-                $token = new AccessToken();
-                $token->res_type = $value['res_type'];
-                $token->res_id = $value['res_id'];
-                $token->token = Str::uuid();
+            $token = AccessToken::firstOrNew(
+                [
+                    'res_type' => $value['res_type'],
+                    'res_id' => $value['res_id']
+                ],
+                [
+                    'token' => (string)Str::uuid()
+                ]
+            );
+            if (!$token->exists) {
                 $token->save();
             }
+
             try {
                 $jwt = JWT::encode($value, $token->token, 'HS512');
             } catch (\Exception $e) {

+ 9 - 1
api-v8/app/Http/Controllers/AiAssistantController.php

@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
 use App\Models\AiModel;
 use Illuminate\Http\Request;
 use App\Http\Resources\AiAssistantResource;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Facades\Log;
 
 class AiAssistantController extends Controller
 {
@@ -16,7 +18,13 @@ class AiAssistantController extends Controller
     public function index(Request $request)
     {
         //
-        $table = AiModel::whereNotNull('owner_id');
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $table = AiModel::where('owner_id', $request->get('user_id'))
+            ->orWhere('privacy', 'public');
         if ($request->has('keyword')) {
             $table = $table->where('name', 'like', '%' . $request->get('keyword') . '%');
         }

+ 65 - 0
api-v8/app/Http/Controllers/AttachmentMapController.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreAttachmentMapRequest;
+use App\Http\Requests\UpdateAttachmentMapRequest;
+use App\Models\AttachmentMap;
+
+class AttachmentMapController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreAttachmentMapRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(StoreAttachmentMapRequest $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\AttachmentMap  $attachmentMap
+     * @return \Illuminate\Http\Response
+     */
+    public function show(AttachmentMap $attachmentMap)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateAttachmentMapRequest  $request
+     * @param  \App\Models\AttachmentMap  $attachmentMap
+     * @return \Illuminate\Http\Response
+     */
+    public function update(UpdateAttachmentMapRequest $request, AttachmentMap $attachmentMap)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\AttachmentMap  $attachmentMap
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(AttachmentMap $attachmentMap)
+    {
+        //
+    }
+}

+ 239 - 211
api-v8/app/Http/Controllers/ChannelController.php

@@ -34,71 +34,80 @@ class ChannelController extends Controller
     public function index(Request $request)
     {
         //
-		$result=false;
-		$indexCol = ['uid','name','summary',
-                    'type','owner_uid','lang',
-                    'status','is_system','updated_at','created_at'];
-		switch ($request->get('view')) {
+        $result = false;
+        $indexCol = [
+            'uid',
+            'name',
+            'summary',
+            'type',
+            'owner_uid',
+            'lang',
+            'status',
+            'is_system',
+            'updated_at',
+            'created_at'
+        ];
+        switch ($request->get('view')) {
             case 'public':
                 $table = Channel::select($indexCol)
-                            ->where('status',30);
+                    ->where('status', 30);
 
                 break;
             case 'studio':
-				# 获取studio内所有channel
+                # 获取studio内所有channel
                 $user = AuthApi::current($request);
-                if(!$user){
+                if (!$user) {
                     return $this->error(__('auth.failed'));
                 }
                 //判断当前用户是否有指定的studio的权限
                 $studioId = StudioApi::getIdByName($request->get('name'));
-                if($user['user_uid'] !== $studioId){
+                if ($user['user_uid'] !== $studioId) {
                     return $this->error(__('auth.failed'));
                 }
 
                 $table = Channel::select($indexCol);
-                if($request->get('view2','my')==='my'){
+                if ($request->get('view2', 'my') === 'my') {
                     $table = $table->where('owner_uid', $studioId);
-                }else{
+                } else {
                     //协作
-                    $resList = ShareApi::getResList($studioId,2);
-                    $resId=[];
+                    $resList = ShareApi::getResList($studioId, 2);
+                    $resId = [];
                     foreach ($resList as $res) {
                         $resId[] = $res['res_id'];
                     }
                     $table = $table->whereIn('uid', $resId);
-                    if($request->get('collaborator','all') !== 'all'){
+                    if ($request->get('collaborator', 'all') !== 'all') {
                         $table = $table->where('owner_uid', $request->get('collaborator'));
-                    }else{
-                        $table = $table->where('owner_uid','<>', $studioId);
+                    } else {
+                        $table = $table->where('owner_uid', '<>', $studioId);
                     }
                 }
-				break;
+                break;
             case 'studio-all':
                 /**
                  * studio 的和协作的
                  */
                 #获取user所有有权限的channel列表
                 $user = AuthApi::current($request);
-                if(!$user){
+                if (!$user) {
                     return $this->error(__('auth.failed'));
                 }
                 //判断当前用户是否有指定的studio的权限
-                if($user['user_uid'] !== StudioApi::getIdByName($request->get('name'))){
+                if ($user['user_uid'] !== StudioApi::getIdByName($request->get('name'))) {
                     return $this->error(__('auth.failed'));
                 }
                 $channelById = [];
                 $channelId = [];
                 //获取共享channel
-                $allSharedChannels = ShareApi::getResList($user['user_uid'],2);
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
                 foreach ($allSharedChannels as $key => $value) {
                     # code...
                     $channelId[] = $value['res_id'];
                     $channelById[$value['res_id']] = $value;
                 }
                 $table = Channel::select($indexCol)
-                            ->whereIn('uid', $channelId)
-                            ->orWhere('owner_uid',$user['user_uid']);
+                    ->whereIn('uid', $channelId)
+                    ->orWhere('owner_uid', $user['user_uid']);
                 break;
             case 'user-edit':
                 /**
@@ -106,131 +115,136 @@ class ChannelController extends Controller
                  */
                 #获取user所有有权限的channel列表
                 $user = AuthApi::current($request);
-                if(!$user){
+                if (!$user) {
                     return $this->error(__('auth.failed'));
                 }
                 $channelById = [];
                 $channelId = [];
                 //获取共享channel
-                $allSharedChannels = ShareApi::getResList($user['user_uid'],2);
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
                 foreach ($allSharedChannels as $key => $value) {
                     # code...
-                    if($value['power']>=20){
+                    if ($value['power'] >= 20) {
                         $channelId[] = $value['res_id'];
                         $channelById[$value['res_id']] = $value;
                     }
                 }
                 $table = Channel::select($indexCol)
-                            ->whereIn('uid', $channelId)
-                            ->orWhere('owner_uid',$user['user_uid']);
+                    ->whereIn('uid', $channelId)
+                    ->orWhere('owner_uid', $user['user_uid']);
                 break;
             case 'user-in-chapter':
                 #获取user 在某章节 所有有权限的channel列表
                 $user = AuthApi::current($request);
-                if(!$user){
+                if (!$user) {
                     return $this->error(__('auth.failed'));
                 }
                 $channelById = [];
                 $channelId = [];
                 //获取共享channel
-                $allSharedChannels = ShareApi::getResList($user['user_uid'],2);
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
                 foreach ($allSharedChannels as $key => $value) {
                     # code...
                     $channelId[] = $value['res_id'];
                     $channelById[$value['res_id']] = $value;
                 }
                 //获取全网公开channel
-                $chapter = PaliTextApi::getChapterStartEnd($request->get('book'),$request->get('para'));
-                $publicChannelsWithContent = Sentence::where('book_id',$request->get('book'))
-                                            ->whereBetween('paragraph',$chapter)
-                                            ->where('strlen','>',0)
-                                            ->where('status',30)
-                                            ->groupBy('channel_uid')
-                                            ->select('channel_uid')
-                                            ->get();
+                $chapter = PaliTextApi::getChapterStartEnd($request->get('book'), $request->get('para'));
+                $publicChannelsWithContent = Sentence::where('book_id', $request->get('book'))
+                    ->whereBetween('paragraph', $chapter)
+                    ->where('strlen', '>', 0)
+                    ->where('status', 30)
+                    ->groupBy('channel_uid')
+                    ->select('channel_uid')
+                    ->get();
                 foreach ($publicChannelsWithContent as $key => $value) {
                     # code...
-                    $value['res_id']=$value->channel_uid;
+                    $value['res_id'] = $value->channel_uid;
                     $value['power'] = 10;
                     $value['type'] = 2;
-                    if(!isset($channelById[$value['res_id']])){
+                    if (!isset($channelById[$value['res_id']])) {
                         $channelId[] = $value['res_id'];
                         $channelById[$value['res_id']] = $value;
                     }
                 }
                 $table = Channel::select($indexCol)
-                        ->whereIn('uid', $channelId)
-                        ->orWhere('owner_uid',$user['user_uid']);
+                    ->whereIn('uid', $channelId)
+                    ->orWhere('owner_uid', $user['user_uid']);
                 break;
             case 'system':
                 $table = Channel::select($indexCol)
-                            ->where('owner_uid',config("mint.admin.root_uuid"));
+                    ->where('owner_uid', config("mint.admin.root_uuid"));
                 break;
+            case 'id':
+                $table = Channel::select($indexCol)
+                    ->whereIn('uid', explode(',', $request->get("id")));
         }
         //处理搜索
-        if(!empty($request->get("search"))){
-            $table = $table->where('name', 'like', "%".$request->get("search")."%");
+        if (!empty($request->get("search"))) {
+            $table = $table->where('name', 'like', "%" . $request->get("search") . "%");
         }
-        if($request->has("type")){
+        if ($request->has("type")) {
             $table = $table->where('type', $request->get("type"));
         }
-        if($request->has("updated_at")){
-            $table = $table->where('updated_at','>', $request->get("updated_at"));
+        if ($request->has("updated_at")) {
+            $table = $table->where('updated_at', '>', $request->get("updated_at"));
         }
-        if($request->has("created_at")){
-            $table = $table->where('created_at','>', $request->get("created_at"));
+        if ($request->has("created_at")) {
+            $table = $table->where('created_at', '>', $request->get("created_at"));
         }
         //获取记录总条数
         $count = $table->count();
         //处理排序
-        $table = $table->orderBy($request->get("order",'created_at'),
-                                 $request->get("dir",'desc'));
+        $table = $table->orderBy(
+            $request->get("order", 'created_at'),
+            $request->get("dir", 'desc')
+        );
         //处理分页
-        $table = $table->skip($request->get("offset",0))
-                       ->take($request->get("limit",200));
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get("limit", 200));
         //获取数据
         $result = $table->get();
-//TODO 将下面代码转移到resource
-        if($result){
-            if($request->has('progress')){
+        //TODO 将下面代码转移到resource
+        if ($result) {
+            if ($request->has('progress')) {
                 //获取进度
                 //获取单句长度
-                $sentLen = PaliSentence::where('book',$request->get('book'))
-                                        ->whereBetween('paragraph',$chapter)
-                                        ->orderBy('word_begin')
-                                        ->select(['book','paragraph','word_begin','word_end','length'])
-                                        ->get();
+                $sentLen = PaliSentence::where('book', $request->get('book'))
+                    ->whereBetween('paragraph', $chapter)
+                    ->orderBy('word_begin')
+                    ->select(['book', 'paragraph', 'word_begin', 'word_end', 'length'])
+                    ->get();
             }
             foreach ($result as $key => $value) {
-                if($request->has('progress')){
+                if ($request->has('progress')) {
                     //获取进度
-                    $finalTable = Sentence::where('book_id',$request->get('book'))
-                                        ->whereBetween('paragraph',$chapter)
-                                        ->where('channel_uid',$value->uid)
-                                        ->where('strlen','>',0)
-                                        ->select(['strlen','book_id','paragraph','word_start','word_end']);
-                    if($finalTable->count()>0){
+                    $finalTable = Sentence::where('book_id', $request->get('book'))
+                        ->whereBetween('paragraph', $chapter)
+                        ->where('channel_uid', $value->uid)
+                        ->where('strlen', '>', 0)
+                        ->select(['strlen', 'book_id', 'paragraph', 'word_start', 'word_end']);
+                    if ($finalTable->count() > 0) {
                         $finished = $finalTable->get();
-                        $final=[];
+                        $final = [];
                         foreach ($sentLen as  $sent) {
                             # code...
-                            $first = Arr::first($finished, function ($value, $key) use($sent) {
-                                return ($value->book_id==$sent->book &&
-                                        $value->paragraph==$sent->paragraph &&
-                                        $value->word_start==$sent->word_begin &&
-                                        $value->word_end==$sent->word_end);
+                            $first = Arr::first($finished, function ($value, $key) use ($sent) {
+                                return ($value->book_id == $sent->book &&
+                                    $value->paragraph == $sent->paragraph &&
+                                    $value->word_start == $sent->word_begin &&
+                                    $value->word_end == $sent->word_end);
                             });
-                            $final[] = [$sent->length,$first?true:false];
+                            $final[] = [$sent->length, $first ? true : false];
                         }
                         $value['final'] = $final;
                     }
                 }
                 //角色
-                if(isset($user['user_uid'])){
-                    if($value->owner_uid===$user['user_uid']){
+                if (isset($user['user_uid'])) {
+                    if ($value->owner_uid === $user['user_uid']) {
                         $value['role'] = 'owner';
-                    }else{
-                        if(isset($channelById[$value->uid])){
+                    } else {
+                        if (isset($channelById[$value->uid])) {
                             switch ($channelById[$value->uid]['power']) {
                                 case 10:
                                     # code...
@@ -253,11 +267,10 @@ class ChannelController extends Controller
                 # 获取studio信息
                 $value->studio = StudioApi::getById($value->owner_uid);
             }
-			return $this->ok(["rows"=>$result,"count"=>$count]);
-		}else{
-			return $this->error("没有查询到数据");
-		}
-
+            return $this->ok(["rows" => $result, "count" => $count]);
+        } else {
+            return $this->error("没有查询到数据");
+        }
     }
 
     /**
@@ -265,27 +278,28 @@ class ChannelController extends Controller
      *
      * @return \Illuminate\Http\Response
      */
-    public function showMyNumber(Request $request){
+    public function showMyNumber(Request $request)
+    {
         $user = AuthApi::current($request);
-        if(!$user){
+        if (!$user) {
             return $this->error(__('auth.failed'));
         }
         //判断当前用户是否有指定的studio的权限
         $studioId = StudioApi::getIdByName($request->get('studio'));
-        if($user['user_uid'] !== $studioId){
+        if ($user['user_uid'] !== $studioId) {
             return $this->error(__('auth.failed'));
         }
         //我的
         $my = Channel::where('owner_uid', $studioId)->count();
         //协作
-        $resList = ShareApi::getResList($studioId,2);
-        $resId=[];
+        $resList = ShareApi::getResList($studioId, 2);
+        $resId = [];
         foreach ($resList as $res) {
             $resId[] = $res['res_id'];
         }
-        $collaboration = Channel::whereIn('uid', $resId)->where('owner_uid','<>', $studioId)->count();
+        $collaboration = Channel::whereIn('uid', $resId)->where('owner_uid', '<>', $studioId)->count();
 
-        return $this->ok(['my'=>$my,'collaboration'=>$collaboration]);
+        return $this->ok(['my' => $my, 'collaboration' => $collaboration]);
     }
     /**
      * 获取章节的进度
@@ -293,10 +307,11 @@ class ChannelController extends Controller
      * @param  \Illuminate\Http\Request  $request
      * @return \Illuminate\Http\Response
      */
-    public function progress(Request $request){
-		$indexCol = ['uid','name','summary','type','owner_uid','lang','status','updated_at','created_at'];
+    public function progress(Request $request)
+    {
+        $indexCol = ['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'status', 'updated_at', 'created_at'];
 
-        $sent = $request->get('sentence') ;
+        $sent = $request->get('sentence');
         $query = [];
         $queryWithChannel = [];
         $sentContainer = [];
@@ -306,17 +321,17 @@ class ChannelController extends Controller
         $customBookChannel = array();
 
         foreach ($sent as $value) {
-            $ids = explode('-',$value);
+            $ids = explode('-', $value);
             $idWithChannel = $ids;
-            if(count($ids)===4){
-                if($ids[0] < 1000){
+            if (count($ids) === 4) {
+                if ($ids[0] < 1000) {
                     $idWithChannel[] = $paliChannel;
-                }else{
-                    if(!isset($customBookChannel[$ids[0]])){
-                        $cbChannel = CustomBook::where('book_id',$ids[0])->value('channel_id');
-                        if($cbChannel){
+                } else {
+                    if (!isset($customBookChannel[$ids[0]])) {
+                        $cbChannel = CustomBook::where('book_id', $ids[0])->value('channel_id');
+                        if ($cbChannel) {
                             $customBookChannel[$ids[0]] = $cbChannel;
-                        }else{
+                        } else {
                             $customBookChannel[$ids[0]] = $paliChannel;
                         }
                     }
@@ -328,14 +343,14 @@ class ChannelController extends Controller
             }
         }
         //获取单句长度
-        if(count($query)>0){
-            $table = Sentence::whereIns(['book_id','paragraph','word_start','word_end','channel_uid'],$queryWithChannel)
-                                    ->select(['book_id','paragraph','word_start','word_end','strlen']);
+        if (count($query) > 0) {
+            $table = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end', 'channel_uid'], $queryWithChannel)
+                ->select(['book_id', 'paragraph', 'word_start', 'word_end', 'strlen']);
             $sentLen = $table->get();
 
             foreach ($sentLen as $value) {
                 $strlen = $value->strlen;
-                if(empty($strlen)){
+                if (empty($strlen)) {
                     $strlen = 0;
                 }
                 $sentId = "{$value->book_id}-{$value->paragraph}-{$value->word_start}-{$value->word_end}";
@@ -347,20 +362,20 @@ class ChannelController extends Controller
         $channelId = [];
 
         //获取全网公开的有译文的channel
-        if($request->get('owner')==='all' || $request->get('owner')==='public'){
-            if(count($query)>0){
-                $publicChannelsWithContent = Sentence::whereIns(['book_id','paragraph','word_start','word_end'],$query)
-                                            ->where('strlen','>',0)
-                                            ->where('status',30)
-                                            ->groupBy('channel_uid')
-                                            ->select('channel_uid')
-                                            ->get();
+        if ($request->get('owner') === 'all' || $request->get('owner') === 'public') {
+            if (count($query) > 0) {
+                $publicChannelsWithContent = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query)
+                    ->where('strlen', '>', 0)
+                    ->where('status', 30)
+                    ->groupBy('channel_uid')
+                    ->select('channel_uid')
+                    ->get();
                 foreach ($publicChannelsWithContent as $key => $value) {
                     # code...
-                    $value['res_id']=$value->channel_uid;
+                    $value['res_id'] = $value->channel_uid;
                     $value['power'] = 10;
                     $value['type'] = 2;
-                    if(!isset($channelById[$value['res_id']])){
+                    if (!isset($channelById[$value['res_id']])) {
                         $channelId[] = $value['res_id'];
                         $channelById[$value['res_id']] = $value;
                     }
@@ -370,25 +385,26 @@ class ChannelController extends Controller
 
         #获取 user 在某章节 所有有权限的 channel 列表
         $user = AuthApi::current($request);
-        if($user !== false){
+        if ($user !== false) {
             //我自己的
-            if($request->get('owner')==='all' || $request->get('owner')==='my'){
+            if ($request->get('owner') === 'all' || $request->get('owner') === 'my') {
                 $my = Channel::select($indexCol)->where('owner_uid', $user['user_uid'])->get();
                 foreach ($my as $key => $value) {
                     $channelId[] = $value->uid;
-                    $channelById[$value->uid] = ['res_id'=>$value->uid,
-                                                'power'=>30,
-                                                'type'=>2,
-                                                ];
+                    $channelById[$value->uid] = [
+                        'res_id' => $value->uid,
+                        'power' => 30,
+                        'type' => 2,
+                    ];
                 }
             }
 
             //获取共享channel
-            if($request->get('owner')==='all' || $request->get('owner')==='collaborator'){
-                $allSharedChannels = ShareApi::getResList($user['user_uid'],2);
+            if ($request->get('owner') === 'all' || $request->get('owner') === 'collaborator') {
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
                 foreach ($allSharedChannels as $key => $value) {
                     # code...
-                    if(!in_array($value['res_id'],$channelId)){
+                    if (!in_array($value['res_id'], $channelId)) {
                         $channelId[] = $value['res_id'];
                         $channelById[$value['res_id']] = $value;
                     }
@@ -397,28 +413,28 @@ class ChannelController extends Controller
         }
 
         //所有有这些句子译文的channel
-        if(count($query) > 0){
-            $allChannels = Sentence::whereIns(['book_id','paragraph','word_start','word_end'],$query)
-                                        ->where('strlen','>',0)
-                                        ->groupBy('channel_uid')
-                                        ->select('channel_uid')
-                                        ->get();
+        if (count($query) > 0) {
+            $allChannels = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query)
+                ->where('strlen', '>', 0)
+                ->groupBy('channel_uid')
+                ->select('channel_uid')
+                ->get();
         }
 
         //所有需要查询的channel
-        $table = Channel::select(['uid','name','summary','type','owner_uid','lang','status','updated_at','created_at'])
-                        ->whereIn('uid', $channelId);
-        if($user !== false){
-            $table->orWhere('owner_uid',$user['user_uid']);
+        $table = Channel::select(['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'status', 'updated_at', 'created_at'])
+            ->whereIn('uid', $channelId);
+        if ($user !== false) {
+            $table->orWhere('owner_uid', $user['user_uid']);
         }
         $result = $table->get();
 
         foreach ($result as $key => $value) {
             //角色
-            if($user!==false && $value->owner_uid===$user['user_uid']){
+            if ($user !== false && $value->owner_uid === $user['user_uid']) {
                 $value['role'] = 'owner';
-            }else{
-                if(isset($channelById[$value->uid])){
+            } else {
+                if (isset($channelById[$value->uid])) {
                     switch ($channelById[$value->uid]['power']) {
                         case 10:
                             # code...
@@ -441,50 +457,49 @@ class ChannelController extends Controller
             $result[$key]["studio"] = \App\Http\Api\StudioApi::getById($value->owner_uid);
 
             //获取进度
-            if(count($query) > 0){
+            if (count($query) > 0) {
                 $currChannelId = $value->uid;
-                $hasContent = Arr::first($allChannels, function ($value, $key) use($currChannelId) {
-                        return ($value->channel_uid===$currChannelId);
-                    });
-                if($hasContent && count($query)>0){
-                    $finalTable = Sentence::whereIns(['book_id','paragraph','word_start','word_end'],$query)
-                                            ->where('channel_uid',$currChannelId)
-                                            ->where('strlen','>',0)
-                                            ->select(['strlen','book_id','paragraph','word_start','word_end','created_at','updated_at']);
+                $hasContent = Arr::first($allChannels, function ($value, $key) use ($currChannelId) {
+                    return ($value->channel_uid === $currChannelId);
+                });
+                if ($hasContent && count($query) > 0) {
+                    $finalTable = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query)
+                        ->where('channel_uid', $currChannelId)
+                        ->where('strlen', '>', 0)
+                        ->select(['strlen', 'book_id', 'paragraph', 'word_start', 'word_end', 'created_at', 'updated_at']);
                     $created_at = time();
                     $edit_at = 0;
-                    if($finalTable->count()>0){
+                    if ($finalTable->count() > 0) {
                         $finished = $finalTable->get();
                         $currChannel = [];
                         foreach ($finished as $rowFinish) {
                             $createTime = strtotime($rowFinish->created_at);
                             $updateTime = strtotime($rowFinish->updated_at);
-                            if($createTime < $created_at){
+                            if ($createTime < $created_at) {
                                 $created_at = $createTime;
                             }
-                            if($updateTime > $edit_at){
+                            if ($updateTime > $edit_at) {
                                 $edit_at = $updateTime;
                             }
                             $currChannel["{$rowFinish->book_id}-{$rowFinish->paragraph}-{$rowFinish->word_start}-{$rowFinish->word_end}"] = 1;
                         }
-                        $final=[];
-                        foreach ($sentContainer as $sentId=>$rowSent) {
+                        $final = [];
+                        foreach ($sentContainer as $sentId => $rowSent) {
                             # code...
-                            if(isset($currChannel[$sentId])){
-                                $final[] = [$sentLenContainer[$sentId],true];
-                            }else{
-                                $final[] = [$sentLenContainer[$sentId],false];
+                            if (isset($currChannel[$sentId])) {
+                                $final[] = [$sentLenContainer[$sentId], true];
+                            } else {
+                                $final[] = [$sentLenContainer[$sentId], false];
                             }
                         }
                         $result[$key]['final'] = $final;
-                        $result[$key]['content_created_at'] = date('Y-m-d H:i:s',$created_at);
-                        $result[$key]['content_updated_at'] = date('Y-m-d H:i:s',$edit_at);
+                        $result[$key]['content_created_at'] = date('Y-m-d H:i:s', $created_at);
+                        $result[$key]['content_updated_at'] = date('Y-m-d H:i:s', $edit_at);
                     }
                 }
             }
         }
-        return $this->ok(["rows"=>$result,count($result)]);
-
+        return $this->ok(["rows" => $result, count($result)]);
     }
     /**
      * Store a newly created resource in storage.
@@ -496,20 +511,21 @@ class ChannelController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),401,401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
         }
         //判断当前用户是否有指定的studio的权限
         $studioId = StudioApi::getIdByName($request->get('studio'));
-        if($user['user_uid'] !== $studioId){
-            return $this->error(__('auth.failed'),403,403);
+        if ($user['user_uid'] !== $studioId) {
+            return $this->error(__('auth.failed'), 403, 403);
         }
         $studio = StudioApi::getById($studioId);
         //查询是否重复
-        if(Channel::where('name',$request->get('name'))
-                  ->where('owner_uid',$user['user_uid'])
-                  ->exists()){
-            return $this->error(__('validation.exists',['name']),200,200);
+        if (Channel::where('name', $request->get('name'))
+            ->where('owner_uid', $user['user_uid'])
+            ->exists()
+        ) {
+            return $this->error(__('validation.exists', ['name']), 200, 200);
         }
 
         $channel = new Channel;
@@ -519,13 +535,13 @@ class ChannelController extends Controller
         $channel->type = $request->get('type');
         $channel->lang = $request->get('lang');
         $channel->editor_id = $user['user_id'];
-        if(isset($studio['roles'])){
-            if(in_array('basic',$studio['roles'])){
+        if (isset($studio['roles'])) {
+            if (in_array('basic', $studio['roles'])) {
                 $channel->status = 5;
             }
         }
-        $channel->create_time = time()*1000;
-        $channel->modify_time = time()*1000;
+        $channel->create_time = time() * 1000;
+        $channel->modify_time = time() * 1000;
         $channel->save();
         return $this->ok($channel);
     }
@@ -539,18 +555,18 @@ class ChannelController extends Controller
     public function show($id)
     {
         //
-        $indexCol = ['uid','name','summary','type','owner_uid','lang','is_system','status','updated_at','created_at'];
-		$channel = Channel::where("uid",$id)->select($indexCol)->first();
-        if(!$channel){
+        $indexCol = ['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'is_system', 'status', 'updated_at', 'created_at'];
+        $channel = Channel::where("uid", $id)->select($indexCol)->first();
+        if (!$channel) {
             return $this->error('no res');
         }
         $studio = StudioApi::getById($channel->owner_uid);
         $channel->studio = $studio;
-        $channel->owner_info = ['nickname'=>$studio['nickName'],'username'=>$studio['realName']];
-		return $this->ok($channel);
+        $channel->owner_info = ['nickname' => $studio['nickName'], 'username' => $studio['realName']];
+        return $this->ok($channel);
     }
 
-        /**
+    /**
      * Display the specified resource.
      *
      * @param  string  $name
@@ -559,11 +575,11 @@ class ChannelController extends Controller
     public function showByName(string $name)
     {
         //
-        $indexCol = ['uid','name','summary','type','owner_uid','lang','is_system','status','updated_at','created_at'];
-		$channel = Channel::where("name",$name)->select($indexCol)->first();
-        if($channel){
+        $indexCol = ['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'is_system', 'status', 'updated_at', 'created_at'];
+        $channel = Channel::where("name", $name)->select($indexCol)->first();
+        if ($channel) {
             return $this->ok(new ChannelResource($channel));
-        }else{
+        } else {
             return $this->error('no channel');
         }
     }
@@ -579,17 +595,17 @@ class ChannelController extends Controller
     {
         //鉴权
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),401,401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
         }
-        if($channel->is_system){
-            return $this->error('system channel',403,403);
+        if ($channel->is_system) {
+            return $this->error('system channel', 403, 403);
         }
-        if($channel->owner_uid !== $user["user_uid"]){
+        if ($channel->owner_uid !== $user["user_uid"]) {
             //判断是否为协作
-            $power = ShareApi::getResPower($user["user_uid"],$request->get('id'));
-            if($power < 30){
-                return $this->error(__('auth.failed'),403,403);
+            $power = ShareApi::getResPower($user["user_uid"], $request->get('id'));
+            if ($power < 30) {
+                return $this->error(__('auth.failed'), 403, 403);
             }
         }
         $channel->name = $request->get('name');
@@ -611,25 +627,37 @@ class ChannelController extends Controller
     {
         //鉴权
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),[],401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [], 401);
         }
-        if($channel->is_system){
-            return $this->error('system channel',403,403);
+        if ($channel->is_system) {
+            return $this->error('system channel', 403, 403);
         }
-        if($channel->owner_uid !== $user["user_uid"]){
+        if ($channel->owner_uid !== $user["user_uid"]) {
             //判断是否为协作
-            $power = ShareApi::getResPower($user["user_uid"],$request->get('id'));
-            if($power < 30){
-                return $this->error(__('auth.failed'),[],403);
+            $power = ShareApi::getResPower($user["user_uid"], $request->get('id'));
+            if ($power < 30) {
+                return $this->error(__('auth.failed'), [], 403);
             }
         }
-        if($request->has('name')){$channel->name = $request->get('name');}
-        if($request->has('type')){$channel->type = $request->get('type');}
-        if($request->has('summary')){$channel->summary = $request->get('summary');}
-        if($request->has('lang')){$channel->lang = $request->get('lang');}
-        if($request->has('status')){$channel->status = $request->get('status');}
-        if($request->has('config')){$channel->status = $request->get('config');}
+        if ($request->has('name')) {
+            $channel->name = $request->get('name');
+        }
+        if ($request->has('type')) {
+            $channel->type = $request->get('type');
+        }
+        if ($request->has('summary')) {
+            $channel->summary = $request->get('summary');
+        }
+        if ($request->has('lang')) {
+            $channel->lang = $request->get('lang');
+        }
+        if ($request->has('status')) {
+            $channel->status = $request->get('status');
+        }
+        if ($request->has('config')) {
+            $channel->status = $request->get('config');
+        }
         $channel->save();
         return $this->ok($channel);
     }
@@ -639,29 +667,29 @@ class ChannelController extends Controller
      * @param  \App\Models\Channel  $channel
      * @return \Illuminate\Http\Response
      */
-    public function destroy(Request $request,Channel $channel)
+    public function destroy(Request $request, Channel $channel)
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
+        if (!$user) {
             return $this->error(__('auth.failed'));
         }
         //判断当前用户是否有指定的studio的权限
-        if($user['user_uid'] !== $channel->owner_uid){
+        if ($user['user_uid'] !== $channel->owner_uid) {
             return $this->error(__('auth.failed'));
         }
         //查询其他资源
-        if(Sentence::where("channel_uid",$channel->uid)->exists()){
+        if (Sentence::where("channel_uid", $channel->uid)->exists()) {
             return $this->error("译文有数据无法删除");
         }
-        if(DhammaTerm::where("channal",$channel->uid)->exists()){
+        if (DhammaTerm::where("channal", $channel->uid)->exists()) {
             return $this->error("术语有数据无法删除");
         }
-        if(WbwBlock::where("channel_uid",$channel->uid)->exists()){
+        if (WbwBlock::where("channel_uid", $channel->uid)->exists()) {
             return $this->error("逐词解析有数据无法删除");
         }
         $delete = 0;
-        DB::transaction(function() use($channel,$delete){
+        DB::transaction(function () use ($channel, $delete) {
             //TODO 删除相关资源
             $delete = $channel->delete();
         });

+ 42 - 12
api-v8/app/Http/Controllers/ProjectController.php

@@ -24,9 +24,15 @@ class ProjectController extends Controller
             Log::error('notification auth failed {request}', ['request' => $request]);
             return $this->error(__('auth.failed'), 401, 401);
         }
+        if ($request->has('studio')) {
+            $studioId = StudioApi::getIdByName($request->get('studio'));
+        } else {
+            $studioId = $user['user_uid'];
+        }
+
         switch ($request->get('view')) {
             case 'studio':
-                $table = Project::where('owner_id', $user['user_uid'])
+                $table = Project::where('owner_id', $studioId)
                     ->whereNull('parent_id')
                     ->where('type', $request->get('type', 'instance'));
                 break;
@@ -34,8 +40,14 @@ class ProjectController extends Controller
                 $table = Project::where('uid', $request->get('project_id'))
                     ->orWhereJsonContains('path', $request->get('project_id'));
                 break;
+            case 'community':
+                $table = Project::where('owner_id', '<>', $studioId)
+                    ->whereNull('parent_id')
+                    ->where('privacy', 'public')
+                    ->where('type', $request->get('type', 'instance'));
+                break;
             default:
-                # code...
+                return $this->error('view', 200, 200);
                 break;
         }
 
@@ -100,6 +112,7 @@ class ProjectController extends Controller
         $new->owner_id = $studioId;
         $new->type = $request->get('type', 'instance');
 
+
         if (Str::isUuid($request->get('parent_id'))) {
             $parentPath = Project::where('uid', $request->get('parent_id'))->value('path');
             $parentPath = json_decode($parentPath);
@@ -126,16 +139,6 @@ class ProjectController extends Controller
         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.
@@ -147,6 +150,33 @@ class ProjectController extends Controller
     public function update(Request $request, Project $project)
     {
         //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!self::canEdit($user['user_uid'], $project->owner_id)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+
+        $project->title = $request->get('title');
+        $project->description = $request->get('description');
+        $project->parent_id = $request->get('parent_id');
+        $project->editor_id = $user['user_uid'];
+        $project->privacy = $request->get('privacy');
+
+
+        if (Str::isUuid($request->get('parent_id'))) {
+            $parentPath = Project::where('uid', $request->get('parent_id'))->value('path');
+            $parentPath = json_decode($parentPath);
+            if (!is_array($parentPath)) {
+                $parentPath = array();
+            }
+            array_push($parentPath, $project->parent_id);
+            $project->path = json_encode($parentPath, JSON_UNESCAPED_UNICODE);
+        }
+        $project->save();
+
+        return $this->ok(new ProjectResource($project));
     }
 
     /**

+ 6 - 1
api-v8/app/Http/Controllers/ProjectTreeController.php

@@ -41,11 +41,12 @@ class ProjectTreeController extends Controller
         }
         $newData = [];
         foreach ($request->get('data') as $key => $value) {
-            $newData[] = [
+            $data = [
                 'uid' => Str::uuid(),
                 'old_id' => $value['id'],
                 'title' => $value['title'],
                 'type' => $value['type'],
+
                 'res_id' => $value['res_id'],
                 'parent_id' => $value['parent_id'],
                 'path' => null,
@@ -54,6 +55,10 @@ class ProjectTreeController extends Controller
                 'created_at' => now(),
                 'updated_at' => now(),
             ];
+            if (isset($value['weight'])) {
+                $data['weight'] = $value['weight'];
+            }
+            $newData[] = $data;
         }
         foreach ($newData as $key => $value) {
             if ($value['parent_id']) {

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

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreSentenceAttachmentRequest;
+use App\Http\Requests\UpdateSentenceAttachmentRequest;
+use Illuminate\Http\Request;
+use App\Models\SentenceAttachment;
+use App\Http\Resources\SentenceAttachmentResource;
+
+class SentenceAttachmentController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        switch ($request->view) {
+            case 'sentence':
+                $table = SentenceAttachment::where('sentence_id', $request->get('id'));
+                break;
+            default:
+                return $this->error('known view');
+                break;
+        }
+
+        $table->orderBy($request->get('order', 'updated_at'), $request->get('dir', 'desc'));
+        $count = $table->count();
+        $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+        return $this->ok([
+            "rows" => SentenceAttachmentResource::collection($result),
+            "count" => $count
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreSentenceAttachmentRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\SentenceAttachment  $sentenceAttachment
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $sentenceAttachment)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateSentenceAttachmentRequest  $request
+     * @param  \App\Models\SentenceAttachment  $sentenceAttachment
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, SentenceAttachment $sentenceAttachment)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\SentenceAttachment  $sentenceAttachment
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(SentenceAttachment $sentenceAttachment)
+    {
+        //
+    }
+}

+ 9 - 7
api-v8/app/Http/Controllers/SentenceController.php

@@ -302,13 +302,15 @@ class SentenceController extends Controller
                 $power = ShareApi::getResPower($user["user_uid"], $channel->uid, 2);
                 if ($power < 20) {
                     //判断token
-                    if (isset($sent['access_token'])) {
-                        $key = AccessToken::where('res_id', $channelId)->value('token');
-                        $jwt = JWT::decode($sent['access_token'], new Key($key, 'HS512'));
-                        if ($jwt->book !== $sent['book_id']) {
-                            continue;
-                        }
-                    } else {
+                    if (!isset($sent['access_token'])) {
+                        Log::error('no access token');
+                        continue;
+                    }
+                    $key = AccessToken::where('res_id', $channelId)->value('token');
+                    $jwt = JWT::decode($sent['access_token'], new Key($key, 'HS512'));
+                    Log::debug('access token', ['jwt' => $jwt]);
+                    if ($jwt->book !== $sent['book_id']) {
+                        Log::error('access token error');
                         continue;
                     }
                 }

+ 1 - 0
api-v8/app/Http/Controllers/TaskGroupController.php

@@ -76,6 +76,7 @@ class TaskGroupController extends Controller
                     'old_id' => $task['id'],
                     'title' => $task['title'],
                     'type' => 'instance',
+                    'category' => $task['category'],
                     'status' => $task['status'],
                     'description' => $task['description'],
                     'order' => $task['order'],

+ 30 - 0
api-v8/app/Http/Requests/StoreAttachmentMapRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreAttachmentMapRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return false;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            //
+        ];
+    }
+}

+ 30 - 0
api-v8/app/Http/Requests/StoreSentenceAttachmentRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreSentenceAttachmentRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return false;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            //
+        ];
+    }
+}

+ 30 - 0
api-v8/app/Http/Requests/UpdateAttachmentMapRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class UpdateAttachmentMapRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return false;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            //
+        ];
+    }
+}

+ 30 - 0
api-v8/app/Http/Requests/UpdateSentenceAttachmentRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class UpdateSentenceAttachmentRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return false;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            //
+        ];
+    }
+}

+ 9 - 9
api-v8/app/Http/Resources/AttachmentResource.php

@@ -16,7 +16,7 @@ class AttachmentResource extends JsonResource
      */
     public function toArray($request)
     {
-        $filename = $this->bucket.'/'.$this->name;
+        $filename = $this->bucket . '/' . $this->name;
         $data = [
             "id" => $this->id,
             "user_uid" => $this->user_uid,
@@ -32,21 +32,21 @@ class AttachmentResource extends JsonResource
 
         if (App::environment('local')) {
             $data['url'] = Storage::url($filename);
-        }else{
+        } else {
             $data['url'] = Storage::temporaryUrl($filename, now()->addDays(2));
         }
 
-        $type = explode('/',$this->content_type);
-        if($type[0] === 'image' || $type[0] === 'video') {
+        $type = explode('/', $this->content_type);
+        if ($type[0] === 'image' || $type[0] === 'video') {
             $path_parts = pathinfo($this->name);
-            $small = $this->bucket.'/'.$path_parts['filename'] . '_s.jpg';
-            $middle = $this->bucket.'/'.$path_parts['filename'] . '_m.jpg';
+            $small = $this->bucket . '/' . $path_parts['filename'] . '_s.jpg';
+            $middle = $this->bucket . '/' . $path_parts['filename'] . '_m.jpg';
             if (App::environment('local')) {
                 $data['thumbnail'] = [
-                    'small'=>Storage::url($small),
-                    'middle'=>Storage::url($middle),
+                    'small' => Storage::url($small),
+                    'middle' => Storage::url($middle),
                 ];
-            }else{
+            } else {
                 $data['thumbnail'] = [
                     'small' => Storage::temporaryUrl($small, now()->addDays(2)),
                     'middle' => Storage::temporaryUrl($middle, now()->addDays(2)),

+ 3 - 1
api-v8/app/Http/Resources/ProjectResource.php

@@ -21,6 +21,7 @@ class ProjectResource extends JsonResource
             'id' => $this->uid,
             'title' => $this->title,
             'type' => $this->type,
+            'weight' => $this->weight,
             'description' => $this->description,
             'executors_id' => json_decode($this->executors_id),
             'parent_id' => $this->parent_id,
@@ -28,7 +29,8 @@ class ProjectResource extends JsonResource
             'path' => ProjectApi::getListByIds(json_decode($this->path)),
             'description' => $this->description,
             "owner" => StudioApi::getById($this->owner_id),
-            "editor" => UserApi::getIdByUuid($this->editor_id),
+            "editor" => UserApi::getByUuid($this->editor_id),
+            'privacy' => $this->privacy,
             'created_at' => $this->created_at,
             'updated_at' => $this->updated_at,
         ];

+ 43 - 0
api-v8/app/Http/Resources/SentenceAttachmentResource.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use App\Models\Attachment;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\App;
+
+class SentenceAttachmentResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     */
+    public function toArray($request)
+    {
+        $url = config('app.url') . '/api/v2/attachment/' . $this->attachment_id;
+        Log::info($url);
+        //$response = Http::get($url);
+
+
+        $data = [
+            'uid' => $this->uid,
+            'sentence_id' => $this->sentence_id,
+            'attachment_id' => $this->attachment_id,
+            'attachment' => [],
+            'editor_id' => $this->editor_id,
+        ];
+        $res = Attachment::find($this->attachment_id);
+        $filename = $res->bucket . '/' . $res->name;
+        if (App::environment('local')) {
+            $data['attachment']['url'] = Storage::url($filename);
+        } else {
+            $data['attachment']['url'] = Storage::temporaryUrl($filename, now()->addDays(2));
+        }
+        return $data;
+    }
+}

+ 1 - 0
api-v8/app/Models/AccessToken.php

@@ -8,4 +8,5 @@ use Illuminate\Database\Eloquent\Model;
 class AccessToken extends Model
 {
     use HasFactory;
+    protected $fillable = ['res_type', 'res_id', 'token'];
 }

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

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

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

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

+ 36 - 0
api-v8/database/migrations/2025_03_15_112630_create_attachment_maps_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateAttachmentMapsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('attachment_maps', function (Blueprint $table) {
+            $table->id();
+            $table->uuid('uid')->unique();
+            $table->string('res_type', 64)->index();
+            $table->uuid('res_id')->index();
+            $table->uuid('attachment_id')->index();
+            $table->uuid('editor_id')->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('attachment_maps');
+    }
+}

+ 35 - 0
api-v8/database/migrations/2025_03_16_113614_add_privacy_in_projects.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddPrivacyInProjects extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('projects', function (Blueprint $table) {
+            //
+            $table->string('privacy', 32)->index()->default('private')
+                ->comment('隐私性:private|public');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('projects', function (Blueprint $table) {
+            //
+            $table->dropColumn('privacy');
+        });
+    }
+}

+ 74 - 0
api-v8/resources/views/book.blade.php

@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
+    <title>Universal Viewer</title>
+    <link
+        rel="stylesheet"
+        href="https://cdn.jsdelivr.net/npm/universalviewer@4.0.0/dist/uv.css" />
+    <script
+        type="application/javascript"
+        src="https://cdn.jsdelivr.net/npm/universalviewer@4.0.0/dist/umd/UV.js"></script>
+    <style>
+        #uv {
+            width: 100%;
+            height: 668px;
+        }
+
+        /* 自定义按钮样式 */
+        .custom-menu-button {
+            padding: 5px 10px;
+            margin: 0 5px;
+            cursor: pointer;
+        }
+    </style>
+</head>
+
+<body>
+    <div class="uv" id="uv"></div>
+    <div id="custom-menu">
+        <button id="get-page-id" onclick="getPageId()">获取当前页面 ID</button>
+    </div>
+    <script>
+        const data = {
+            manifest: "https://wellcomelibrary.org/iiif/b18035723/manifest",
+            embedded: true // needed for codesandbox frame
+        };
+
+        uv = UV.init("uv", data);
+
+        // 监听 Universal Viewer 初始化完成事件
+        uv.on('initialized', function() {
+            // 创建自定义按钮
+            var customButton = document.createElement('button');
+            customButton.textContent = '获取当前页面 ID';
+            customButton.className = 'custom-menu-button';
+
+            // 为自定义按钮添加点击事件监听器
+            customButton.addEventListener('click', function() {
+                // 获取当前页面的索引
+                var currentCanvas = uv.extension.getState().canvasIndex;
+                // 获取当前页面的 ID
+                var canvasId = uv.extension.getContent().canvases[currentCanvas].id;
+                // 弹出提示框显示当前页面 ID
+                alert('当前页面 ID: ' + canvasId);
+                console.info('当前页面 ID: ', canvasId)
+            });
+
+            // 获取 Universal Viewer 的菜单容器
+            var menu = document.querySelector('.options');
+            // 将自定义按钮添加到菜单容器中
+            menu.appendChild(customButton);
+        });
+
+        function getPageId() {
+            var canvas = uv.extension.helper.getCurrentCanvas();
+            console.log("当前页面 Canvas ID:", canvas.id);
+        }
+    </script>
+</body>
+
+</html>

+ 2 - 0
api-v8/routes/api.php

@@ -114,6 +114,7 @@ use App\Http\Controllers\SearchWordSliceController;
 use App\Http\Controllers\AiModelController;
 use App\Http\Controllers\AiAssistantController;
 use App\Http\Controllers\ModelLogController;
+use App\Http\Controllers\SentenceAttachmentController;
 use App\Http\Controllers\EmailCertificationController;
 
 
@@ -286,5 +287,6 @@ Route::group(['prefix' => 'v2'], function () {
     Route::apiResource('ai-model', AiModelController::class);
     Route::apiResource('ai-assistant', AiAssistantController::class);
     Route::apiResource('model-log', ModelLogController::class);
+    Route::apiResource('sentence-attachment', SentenceAttachmentController::class);
     Route::apiResource('email-certification', EmailCertificationController::class);
 });

+ 13 - 10
api-v8/routes/web.php

@@ -19,20 +19,23 @@ use App\Http\Controllers\AssetsController;
 Route::redirect('/app', '/app/pcdl/index.php');
 Route::redirect('/app/pcdl', '/app/pcdl/index.php');
 
-Route::get('/', [PageIndexController::class,'index']);
+Route::get('/', [PageIndexController::class, 'index']);
 
-Route::get('/api/sentence/progress/image', [SentenceInfoController::class,'showprogress']);
-Route::get('/api/sentence/progress/daily/image', [SentenceInfoController::class,'showprogressdaily']);
-Route::get('/wbwanalyses', [WbwAnalysisController::class,'index']);
-Route::get('/attachments/{bucket}/{name}',[AssetsController::class,'show']);
+Route::get('/api/sentence/progress/image', [SentenceInfoController::class, 'showprogress']);
+Route::get('/api/sentence/progress/daily/image', [SentenceInfoController::class, 'showprogressdaily']);
+Route::get('/wbwanalyses', [WbwAnalysisController::class, 'index']);
+Route::get('/attachments/{bucket}/{name}', [AssetsController::class, 'show']);
 
-Route::get('/export/wbw', function (){
-    return view('export_wbw',['sentences' => []]);
+Route::get('/export/wbw', function () {
+    return view('export_wbw', ['sentences' => []]);
 });
 
-Route::get('/privacy/{file}', function ($file){
+Route::get('/privacy/{file}', function ($file) {
     $content = file_get_contents(base_path("/documents/mobile/privacy/{$file}.md"));
-    return view('privacy',['content' => $content]);
+    return view('privacy', ['content' => $content]);
 });
-Route::redirect('/privacy', '/privacy/index');
 
+Route::get('/book/{id}', function ($id) {
+    return view('book', ['id' => $id]);
+});
+Route::redirect('/privacy', '/privacy/index');

+ 8 - 1
dashboard-v4/dashboard/src/Router.tsx

@@ -150,6 +150,8 @@ 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 StudioTaskProjectEdit from "./pages/studio/task/project-edit";
+import StudioTaskWorkflow from "./pages/studio/task/workflow";
 
 import StudioAi from "./pages/studio/ai";
 import StudioAiModes from "./pages/studio/ai/models";
@@ -195,8 +197,8 @@ const Widget = () => {
         <Route path="anonymous" element={<Anonymous />}>
           <Route path="users">
             <Route path="sign-in" element={<NutUsersSignIn />} />
+            <Route path="sign-up" element={<UsersSignUp />} />
             <Route path="sign-up/:token" element={<NutUsersSignUp />} />
-
             <Route path="unlock">
               <Route path="new" element={<NutUsersUnlockNew />} />
               <Route path="verify/:token" element={<NutUsersUnlockVerify />} />
@@ -346,6 +348,11 @@ const Widget = () => {
             <Route path="list" element={<StudioTaskList />} />
             <Route path="projects" element={<StudioTaskProjects />} />
             <Route path="project/:projectId" element={<StudioTaskProject />} />
+            <Route
+              path="project/:projectId/edit"
+              element={<StudioTaskProjectEdit />}
+            />
+            <Route path="workflows" element={<StudioTaskWorkflow />} />
           </Route>
 
           <Route path="dict" element={<StudioDict />}>

+ 4 - 4
dashboard-v4/dashboard/src/components/ai/AiAssistantSelect.tsx

@@ -46,10 +46,10 @@ const UserSelectWidget = ({
         console.log("keyWord", keyWords);
 
         if (typeof keyWords === "string") {
-          const json = await get<IUserListResponse>(
-            `/v2/ai-assistant?keyword=${keyWords}`
-          );
-          console.info("api response user select", json);
+          const url = `/v2/ai-assistant?keyword=${keyWords}`;
+          console.info("ai assistant api request", url);
+          const json = await get<IUserListResponse>(url);
+          console.info("ai assistant api response ", json);
           const userList: RequestOptionsType[] = json.data.rows.map((item) => {
             return {
               value: item.id,

+ 12 - 0
dashboard-v4/dashboard/src/components/api/Attachments.ts

@@ -24,3 +24,15 @@ export interface IAttachmentListResponse {
   message: string;
   data: { rows: IAttachmentRequest[]; count: number };
 }
+
+export interface IResAttachmentData {
+  uid: string;
+  sentence_id: string;
+  attachment_id: string;
+  attachment: IAttachmentRequest;
+}
+export interface IResAttachmentListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IResAttachmentData[]; count: number };
+}

+ 1 - 1
dashboard-v4/dashboard/src/components/api/ai.ts

@@ -1,7 +1,7 @@
 import { IStudio } from "../auth/Studio";
 import { IUser } from "../auth/User";
 
-export type TPrivacy = "private" | "public";
+export type TPrivacy = "private" | "public" | "disable";
 
 export interface IKimiResponse {
   id: string;

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

@@ -14,6 +14,7 @@
 
 import { IStudio } from "../auth/Studio";
 import { IUser } from "../auth/User";
+import { TPrivacy } from "./ai";
 
 export type TTaskStatus =
   | "pending"
@@ -31,6 +32,7 @@ export interface IProject {
   id: string;
   title: string;
   description: string | null;
+  weight: number;
 }
 
 export type TTaskCategory =
@@ -141,6 +143,7 @@ export interface IProjectData {
   id: string;
   title: string;
   type: TProjectType;
+  weight: number;
   description: string | null;
   parent?: IProjectData | null;
   parent_id?: string | null;
@@ -150,6 +153,7 @@ export interface IProjectData {
   owner: IStudio;
   editor: IUser;
   status: ITaskStatusInProject[];
+  privacy: TPrivacy;
   created_at: string;
   updated_at: string;
   deleted_at?: string | null;
@@ -163,6 +167,8 @@ export interface IProjectUpdateRequest {
   studio_name?: string;
   title: string;
   type: TProjectType;
+  privacy?: TPrivacy;
+  weight?: number;
   description?: string | null;
   parent_id?: string | null;
   res_id?: string;
@@ -222,6 +228,7 @@ export interface ITaskGroupResponse {
 export interface IProjectTreeInsertRequest {
   studio_name: string;
   parent_id?: string | null;
+  title?: string;
   data: IProjectUpdateRequest[];
 }
 

+ 3 - 0
dashboard-v4/dashboard/src/components/api/token.ts

@@ -4,8 +4,11 @@ export interface IPayload {
   book?: number;
   para_start?: number;
   para_end?: number;
+  power: TPower;
 }
 
+export type TPower = "readonly" | "edit";
+
 export interface ITokenCreate {
   payload: IPayload[];
 }

+ 92 - 0
dashboard-v4/dashboard/src/components/article/Token.tsx

@@ -0,0 +1,92 @@
+import { Button, message, Segmented, Typography } from "antd";
+import { SegmentedValue } from "antd/lib/segmented";
+import { useEffect, useState } from "react";
+import { CopyOutlined } from "@ant-design/icons";
+
+import { useIntl } from "react-intl";
+import { ArticleType } from "./Article";
+import { post } from "../../request";
+import {
+  IPayload,
+  ITokenCreate,
+  ITokenCreateResponse,
+  TPower,
+} from "../api/token";
+const { Text } = Typography;
+
+interface IWidget {
+  channelId?: string;
+  articleId?: string;
+  type?: ArticleType;
+}
+const DictInfoCopyRef = ({ channelId, articleId, type }: IWidget) => {
+  const [text, setText] = useState("");
+  const [power, setPower] = useState<TPower>("readonly");
+  const intl = useIntl();
+
+  useEffect(() => {
+    if (!channelId || !articleId || !type) {
+      console.error("token", channelId, articleId, type);
+      return;
+    }
+    const id = articleId.split("-");
+    if (!channelId || !id || id.length < 2) {
+      console.error("channels or book or para is undefined", channelId, id);
+      return;
+    }
+    const _book = id[0];
+    const _para = id[1];
+    let payload: IPayload[] = [];
+    payload.push({
+      res_id: channelId,
+      res_type: "channel",
+      book: parseInt(_book),
+      para_start: parseInt(_para),
+      para_end: parseInt(_para) + 100,
+      power: power,
+    });
+    const url = "/v2/access-token";
+    const values = { payload: payload };
+    console.info("token api request", url, values);
+    post<ITokenCreate, ITokenCreateResponse>(url, values).then((json) => {
+      console.info("token api response", json);
+      if (json.ok) {
+        setText(json.data.rows[0].token);
+      }
+    });
+  }, [articleId, channelId, power, type]);
+  return (
+    <div>
+      <div style={{ textAlign: "center", padding: 20 }}>
+        <Segmented
+          options={["readonly", "edit"]}
+          onChange={(value: SegmentedValue) => {
+            setPower(value as TPower);
+          }}
+        />
+      </div>
+      <div>
+        <Text>{text}</Text>
+      </div>
+
+      <div style={{ textAlign: "center", padding: 20 }}>
+        <Button
+          type="primary"
+          style={{ width: 200 }}
+          icon={<CopyOutlined />}
+          onClick={() => {
+            navigator.clipboard.writeText(text).then(() => {
+              message.success("链接地址已经拷贝到剪贴板");
+            });
+          }}
+        >
+          {intl.formatMessage({
+            id: "buttons.copy",
+          })}
+        </Button>
+      </div>
+    </div>
+  );
+};
+
+export default DictInfoCopyRef;

+ 60 - 0
dashboard-v4/dashboard/src/components/article/TokenModal.tsx

@@ -0,0 +1,60 @@
+import { useEffect, useState } from "react";
+import { Modal } from "antd";
+import { ArticleType } from "./Article";
+import Token from "./Token";
+
+interface IWidget {
+  channelId?: string;
+  articleId?: string;
+  type?: ArticleType;
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: Function;
+}
+const TokenModal = ({
+  channelId,
+  articleId,
+  type,
+  trigger,
+  open = false,
+  onClose,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+
+  useEffect(() => setIsModalOpen(open), [open]);
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={500}
+        title="token"
+        footer={false}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Token channelId={channelId} articleId={articleId} type={type} />
+      </Modal>
+    </>
+  );
+};
+
+export default TokenModal;

+ 1 - 42
dashboard-v4/dashboard/src/components/article/TypePali.tsx

@@ -26,6 +26,7 @@ import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import { ArticleTplModal } from "../template/Builder/ArticleTpl";
 import { IPayload, ITokenCreate, ITokenCreateResponse } from "../api/token";
+import TokenModal from "./TokenModal";
 
 interface IWidget {
   type?: ArticleType;
@@ -72,7 +73,6 @@ const TypePaliWidget = ({
   const [tplOpen, setTplOpen] = useState(false);
   const user = useAppSelector(currentUser);
   const channels = channelId?.split("_");
-  const _id = articleId?.split("-");
 
   const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
 
@@ -238,37 +238,6 @@ const TypePaliWidget = ({
     }
   }
 
-  const getAccessToken = async () => {
-    if (!channels || !_id || _id.length < 2) {
-      console.error(
-        "channels or book or para is undefined",
-        channels,
-        book,
-        para
-      );
-      return null;
-    }
-    const _book = _id[0];
-    const _para = _id[1];
-    let payload: IPayload[] = [];
-    payload.push({
-      res_id: channels[0],
-      res_type: "channel",
-      book: parseInt(_book),
-      para_start: parseInt(_para),
-      para_end: parseInt(_para) + 100,
-    });
-    const url = "/v2/access-token";
-    const values = { payload: payload };
-    console.info("token api request", url, values);
-    const res = await post<ITokenCreate, ITokenCreateResponse>(url, values);
-    console.info("token api response", res);
-    if (res.ok) {
-      return res.data.rows[0].token;
-    } else {
-      return null;
-    }
-  };
   return (
     <div>
       {loading ? (
@@ -305,10 +274,6 @@ const TypePaliWidget = ({
                     key: "task",
                     label: "生成任务",
                   },
-                  {
-                    key: "token",
-                    label: "获取访问密钥",
-                  },
                 ],
                 onClick: ({ key }) => {
                   switch (key) {
@@ -318,12 +283,6 @@ const TypePaliWidget = ({
                     case "tpl":
                       setTplOpen(true);
                       break;
-                    case "token":
-                      const token = getAccessToken();
-                      if (typeof token === "string") {
-                        alert(token);
-                      }
-                      break;
                   }
                 },
               }}

+ 6 - 8
dashboard-v4/dashboard/src/components/auth/Avatar.tsx

@@ -16,6 +16,7 @@ import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { TooltipPlacement } from "antd/lib/tooltip";
 import SettingModal from "./setting/SettingModal";
+import LoginButton from "./LoginButton";
 
 const { Title } = Typography;
 
@@ -82,16 +83,13 @@ const AvatarWidget = ({ style, placement = "bottomRight" }: IWidget) => {
       </ProCard>
     );
   };
-  const Login = () => (
-    <Link to="/anonymous/users/sign-in">
-      {intl.formatMessage({
-        id: "nut.users.sign-in-up.title",
-      })}
-    </Link>
-  );
+
   return (
     <>
-      <Popover content={user ? <UserCard /> : <Login />} placement={placement}>
+      <Popover
+        content={user ? <UserCard /> : <LoginButton />}
+        placement={placement}
+      >
         <span style={style}>
           <Avatar
             style={{ backgroundColor: user ? "#87d068" : "gray" }}

+ 2 - 7
dashboard-v4/dashboard/src/components/auth/LoginAlert.tsx

@@ -4,6 +4,7 @@ import { Alert } from "antd";
 
 import { useAppSelector } from "../../hooks";
 import { isGuest } from "../../reducers/current-user";
+import LoginButton from "./LoginButton";
 
 const LoginAlertWidget = () => {
   const intl = useIntl();
@@ -16,13 +17,7 @@ const LoginAlertWidget = () => {
       })}
       type="warning"
       closable
-      action={
-        <Link to="/anonymous/users/sign-in">
-          {intl.formatMessage({
-            id: "buttons.sign-in",
-          })}
-        </Link>
-      }
+      action={<LoginButton />}
     />
   ) : (
     <></>

+ 20 - 0
dashboard-v4/dashboard/src/components/auth/LoginButton.tsx

@@ -0,0 +1,20 @@
+import { useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+
+interface IWidget {
+  target?: React.HTMLAttributeAnchorTarget;
+}
+const LoginButton = ({ target }: IWidget) => {
+  const intl = useIntl();
+  const url = btoa(window.location.href);
+
+  return (
+    <Link to={`/anonymous/users/sign-in?url=${url}`} target={target}>
+      {intl.formatMessage({
+        id: "nut.users.sign-in-up.title",
+      })}
+    </Link>
+  );
+};
+
+export default LoginButton;

+ 2 - 7
dashboard-v4/dashboard/src/components/auth/SignInAvatar.tsx

@@ -23,6 +23,7 @@ import { AdminIcon } from "../../assets/icon";
 import User from "./User";
 import { fullUrl } from "../../utils";
 import Studio from "./Studio";
+import LoginButton from "./LoginButton";
 
 const { Title, Paragraph, Text } = Typography;
 
@@ -45,13 +46,7 @@ const SignInAvatarWidget = ({ style, placement = "bottomRight" }: IWidget) => {
     user?.roles?.includes("root") || user?.roles?.includes("administrator");
 
   if (typeof user === "undefined") {
-    return (
-      <Link to="/anonymous/users/sign-in">
-        {intl.formatMessage({
-          id: "nut.users.sign-in-up.title",
-        })}
-      </Link>
-    );
+    return <LoginButton />;
   } else {
     const welcome = (
       <Paragraph>

+ 29 - 0
dashboard-v4/dashboard/src/components/channel/ChannelMy.tsx

@@ -36,6 +36,7 @@ import { IChannel } from "./Channel";
 import CopyToModal from "./CopyToModal";
 import { ArticleType } from "../article/Article";
 import { ChannelInfoModal } from "./ChannelInfo";
+import TokenModal from "../article/TokenModal";
 
 const { Search } = Input;
 
@@ -50,6 +51,12 @@ export const getSentIdInArticle = () => {
   return sentList;
 };
 
+interface IToken {
+  channelId?: string;
+  articleId?: string;
+  type?: ArticleType;
+}
+
 interface ChannelTreeNode {
   key: string;
   title: string | React.ReactNode;
@@ -87,6 +94,8 @@ const ChannelMy = ({
   const [statistic, setStatistic] = useState<IItem>();
   const [sentenceCount, setSentenceCount] = useState<number>(0);
   const [sentencesId, setSentencesId] = useState<string[]>();
+  const [token, SetToken] = useState<IToken>();
+  const [tokenOpen, setTokenOpen] = useState(false);
 
   console.debug("ChannelMy render", type, articleId);
 
@@ -250,6 +259,11 @@ const ChannelMy = ({
 
   return (
     <div style={style}>
+      <TokenModal
+        {...token}
+        open={tokenOpen}
+        onClose={() => setTokenOpen(false)}
+      />
       <Card
         size="small"
         title={
@@ -464,6 +478,13 @@ const ChannelMy = ({
                               }),
                               icon: <InfoCircleOutlined />,
                             },
+                            {
+                              key: "token",
+                              label: intl.formatMessage({
+                                id: "buttons.access-token.get",
+                              }),
+                              icon: <InfoCircleOutlined />,
+                            },
                           ],
                           onClick: (e) => {
                             switch (e.key) {
@@ -479,6 +500,14 @@ const ChannelMy = ({
                                 setInfoOpen(true);
                                 setStatistic(node.channel);
                                 break;
+                              case "token":
+                                SetToken({
+                                  channelId: node.channel.uid,
+                                  type: type as ArticleType,
+                                  articleId: articleId,
+                                });
+                                setTokenOpen(true);
+                                break;
                               default:
                                 break;
                             }

+ 89 - 0
dashboard-v4/dashboard/src/components/channel/ChannelSelectWithToken.tsx

@@ -0,0 +1,89 @@
+import { ProFormSelect } from "@ant-design/pro-components";
+import { Space } from "antd";
+import { IApiResponseChannelList } from "../api/Channel";
+import { get } from "../../request";
+import { useState } from "react";
+
+interface IWidget {
+  channelsId?: string[];
+  type?: string;
+  onChange?: (channel?: string | null) => void;
+}
+const ChannelSelectWithToken = ({ channelsId, type, onChange }: IWidget) => {
+  const [channel, setChannel] = useState<string>("");
+  const [power, setPower] = useState<string>();
+  return (
+    <Space>
+      <ProFormSelect
+        options={[]}
+        initialValue="translation"
+        width="md"
+        name="channel"
+        allowClear={true}
+        label={false}
+        placeholder={"选择一个channel"}
+        onChange={(value: string) => {
+          console.debug(value);
+          setChannel(value);
+          let output = value;
+          if (value) {
+            if (power) {
+              output += "@" + power;
+            }
+          }
+          onChange && onChange(output);
+        }}
+        request={async ({ keyWords }) => {
+          if (!channelsId) {
+            return [];
+          }
+
+          const url = `/v2/channel?view=id&id=` + channelsId?.join(",");
+          console.info("api request", url);
+          const json = await get<IApiResponseChannelList>(url);
+          console.info("api response", json, type);
+          const textbookList = json.data.rows.map((item) => {
+            return {
+              value: item.uid,
+              label: item.name,
+            };
+          });
+          const current = json.data.rows.filter((value) => {
+            if (type) {
+              return value.type === type;
+            } else {
+              return true;
+            }
+          });
+          console.log("json", textbookList);
+          return textbookList;
+        }}
+      />
+      <ProFormSelect
+        options={[
+          { value: "readonly", label: "readonly" },
+          { value: "edit", label: "edit" },
+        ]}
+        initialValue="null"
+        width="xs"
+        name="power"
+        allowClear={true}
+        label={false}
+        placeholder={"选择访问权限"}
+        onChange={(value: string) => {
+          console.debug(value);
+          setPower(value);
+          let output = channel;
+          if (channel) {
+            if (value) {
+              output += "@" + value;
+            }
+          }
+          onChange && onChange(output);
+        }}
+      />
+    </Space>
+  );
+};
+
+export default ChannelSelectWithToken;

+ 2 - 5
dashboard-v4/dashboard/src/components/course/Status.tsx

@@ -20,6 +20,7 @@ import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import UserAction from "./UserAction";
 import { getStatusColor, getStudentActionsByStatus } from "./RolePower";
+import LoginButton from "../auth/LoginButton";
 
 const { Paragraph, Text } = Typography;
 
@@ -113,11 +114,7 @@ const StatusWidget = ({ data }: IWidget) => {
   } else {
     //未登录
     labelStatus = "未登录";
-    operation = (
-      <Link to="/anonymous/users/sign-in" target="_blank">
-        {"登录"}
-      </Link>
-    );
+    operation = <LoginButton target="_blank" />;
   }
 
   return data?.id ? (

+ 1 - 1
dashboard-v4/dashboard/src/components/nut/users/NonSignInSharedLinks.tsx

@@ -9,7 +9,7 @@ const Widget = () => {
         <FormattedMessage id="nut.users.sign-in.title" />
       </Link>
       <Divider type="vertical" />
-      <Link to="/users/sign-up">
+      <Link to="/anonymous/users/sign-up">
         <FormattedMessage id="nut.users.sign-up.title" />
       </Link>
       <Divider type="vertical" />

+ 2 - 9
dashboard-v4/dashboard/src/components/nut/users/ResetPassword.tsx

@@ -12,6 +12,7 @@ import { useRef, useState } from "react";
 import { Link } from "react-router-dom";
 import { RuleObject } from "antd/lib/form";
 import { StoreValue } from "antd/lib/form/interface";
+import LoginButton from "../../auth/LoginButton";
 
 interface IFormData {
   username: string;
@@ -58,15 +59,7 @@ const Widget = ({ token }: IWidget) => {
           message={notify}
           type={type}
           showIcon
-          action={
-            ok ? (
-              <Link to={"/anonymous/users/sign-in"}>
-                {intl.formatMessage({
-                  id: "buttons.sign-in",
-                })}
-              </Link>
-            ) : undefined
-          }
+          action={ok ? <LoginButton /> : undefined}
         />
       ) : (
         <></>

+ 13 - 2
dashboard-v4/dashboard/src/components/nut/users/SignIn.tsx

@@ -1,7 +1,7 @@
 import { useIntl } from "react-intl";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
 import { Alert, message } from "antd";
-import { useNavigate } from "react-router-dom";
+import { useNavigate, useParams, useSearchParams } from "react-router-dom";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
 
 import { useAppDispatch } from "../../../hooks";
@@ -32,6 +32,7 @@ const Widget = () => {
   const dispatch = useAppDispatch();
   const navigate = useNavigate();
   const [error, setError] = useState<string>();
+  const [searchParams] = useSearchParams();
 
   return (
     <>
@@ -52,7 +53,17 @@ const Widget = () => {
             get<IUserResponse>("/v2/auth/current").then((json) => {
               if (json.ok) {
                 dispatch(signIn([json.data, res.data]));
-                navigate(TO_HOME);
+                let url: string | null = null;
+                searchParams.forEach((value, key) => {
+                  if (key === "url") {
+                    url = value;
+                  }
+                });
+                if (url) {
+                  window.location.href = atob(url);
+                } else {
+                  navigate(TO_HOME);
+                }
               } else {
                 setError("用户名或密码错误");
                 console.error(json.message);

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

@@ -134,6 +134,16 @@ const LeftSiderWidget = ({ selectedKeys = "", openKeys }: IWidgetHeadBar) => {
               ),
               key: "task_projects",
             },
+            {
+              label: (
+                <Link to={`${urlBase}/task/workflows`}>
+                  {intl.formatMessage({
+                    id: "labels.task.workflows",
+                  })}
+                </Link>
+              ),
+              key: "task_workflows",
+            },
           ],
         },
         {

+ 53 - 28
dashboard-v4/dashboard/src/components/task/Filter.tsx

@@ -1,4 +1,4 @@
-import { Button, Popover, Select, Space, Typography } from "antd";
+import { Button, Popover, Typography } from "antd";
 import { IFilter } from "./TaskList";
 import { useRef, useState } from "react";
 import { useIntl } from "react-intl";
@@ -21,7 +21,6 @@ const FilterItem = ({ item, sn, onRemove }: IProps) => {
   const intl = useIntl();
   return (
     <ProForm.Group>
-      <Text>{sn === 0 ? "当" : "且"}</Text>
       <ProFormSelect
         initialValue={item.field}
         name={`field_${sn}`}
@@ -46,15 +45,18 @@ const FilterItem = ({ item, sn, onRemove }: IProps) => {
         name={`operator_${sn}`}
         style={{ width: 120 }}
         options={[
-          {
-            value: "includes",
-            label: "包含",
-          },
-          {
-            value: "not-includes",
-            label: "不包含",
-          },
-        ]}
+          "includes",
+          "not-includes",
+          "equal",
+          "not-equal",
+          "null",
+          "not-null",
+        ].map((item) => {
+          return {
+            value: item,
+            label: intl.formatMessage({ id: `labels.filters.${item}` }),
+          };
+        })}
       />
       <UserSelect
         name={"value_" + sn}
@@ -83,7 +85,7 @@ const Filter = ({ initValue, onChange }: IWidget) => {
       arrowPointAtCenter
       title={intl.formatMessage({ id: "labels.filter" })}
       content={
-        <div style={{ width: 750 }}>
+        <div style={{ width: 780 }}>
           <ProForm
             formRef={formRef}
             submitter={{
@@ -133,22 +135,45 @@ const Filter = ({ initValue, onChange }: IWidget) => {
               }
             }}
           >
-            {filterList.map((item, id) => {
-              return (
-                <FilterItem
-                  item={item}
-                  key={id}
-                  sn={id}
-                  onRemove={() => {
-                    setFilterList((origin) => {
-                      return origin.filter(
-                        (value, index: number) => index !== id
-                      );
-                    });
-                  }}
-                />
-              );
-            })}
+            <ProForm.Group>
+              <Text type="secondary">{"满足以下"}</Text>
+              <ProFormSelect
+                name={"operator"}
+                initialValue={"and"}
+                style={{ width: 120 }}
+                options={["and", "or"].map((item) => {
+                  return {
+                    value: item,
+                    label: intl.formatMessage({ id: `labels.filters.${item}` }),
+                  };
+                })}
+              />
+            </ProForm.Group>
+            <div
+              style={{
+                border: "1px solid rgba(128, 128, 128, 0.5)",
+                borderRadius: 6,
+                marginBottom: 8,
+                padding: "8px 0 8px 8px",
+              }}
+            >
+              {filterList.map((item, id) => {
+                return (
+                  <FilterItem
+                    item={item}
+                    key={id}
+                    sn={id}
+                    onRemove={() => {
+                      setFilterList((origin) => {
+                        return origin.filter(
+                          (value, index: number) => index !== id
+                        );
+                      });
+                    }}
+                  />
+                );
+              })}
+            </div>
           </ProForm>
         </div>
       }

+ 21 - 18
dashboard-v4/dashboard/src/components/task/ProjectEdit.tsx

@@ -1,25 +1,17 @@
 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 { message } from "antd";
+import {
+  IProjectData,
+  IProjectResponse,
+  IProjectUpdateRequest,
+} from "../api/task";
+import { get, patch } 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);
-  });
-};
+import Publicity from "../studio/Publicity";
 
 interface IWidget {
   projectId?: string;
@@ -31,8 +23,13 @@ const ProjectEdit = ({ projectId }: IWidget) => {
   return (
     <ProForm<IProjectData>
       onFinish={async (values) => {
-        await waitTime(2000);
-        console.log(values);
+        const url = `/v2/project/${projectId}`;
+        console.info("api request", url, values);
+        const res = await patch<IProjectUpdateRequest, IProjectResponse>(
+          url,
+          values
+        );
+        console.log("api response", res);
         message.success("提交成功");
       }}
       params={{}}
@@ -63,6 +60,12 @@ const ProjectEdit = ({ projectId }: IWidget) => {
           readonly
         />
       </ProForm.Group>
+      <ProForm.Group>
+        <Publicity
+          name="privacy"
+          disable={["disable", "public_no_list", "blocked"]}
+        />
+      </ProForm.Group>
       <ProForm.Group>
         <ProFormTextArea
           width="md"

+ 1 - 6
dashboard-v4/dashboard/src/components/task/ProjectEditDrawer.tsx

@@ -1,4 +1,4 @@
-import { Button, Drawer, Space } from "antd";
+import { Drawer } from "antd";
 import { useEffect, useState } from "react";
 
 import ProjectEdit from "./ProjectEdit";
@@ -37,11 +37,6 @@ const ProjectEditDrawer = ({
         onClose={onCloseDrawer}
         open={open}
         destroyOnClose
-        extra={
-          <Space>
-            <Button type="primary">从模版创建任务</Button>
-          </Space>
-        }
       >
         <ProjectEdit studioName={studioName} projectId={projectId} />
       </Drawer>

+ 89 - 149
dashboard-v4/dashboard/src/components/task/ProjectList.tsx

@@ -1,22 +1,21 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import { ActionType, ProList } from "@ant-design/pro-components";
 import { useIntl } from "react-intl";
 import { Link } from "react-router-dom";
-import { Button, message, Modal, Popover } from "antd";
-import { Dropdown } from "antd";
-import {
-  ExclamationCircleOutlined,
-  DeleteOutlined,
-  PlusOutlined,
-} from "@ant-design/icons";
+import { Button, message, Modal, Popover, Space } from "antd";
+
+import { ExclamationCircleOutlined, PlusOutlined } from "@ant-design/icons";
 
 import { delete_, get } from "../../request";
-import { PublicityValueEnum } from "../studio/table";
+
 import { IDeleteResponse } from "../api/Article";
 import { getSorterUrl } from "../../utils";
-import { TransferOutLinedIcon } from "../../assets/icon";
+
 import { IProjectData, IProjectListResponse, TProjectType } from "../api/task";
 import ProjectCreate from "./ProjectCreate";
+import ProjectEditDrawer from "./ProjectEditDrawer";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
 
 export interface IResNumberResponse {
   ok: boolean;
@@ -26,22 +25,26 @@ export interface IResNumberResponse {
     collaboration: number;
   };
 }
-
+export type TView = "current" | "studio" | "shared" | "community";
 interface IWidget {
   studioName?: string;
   type?: TProjectType;
+  view?: TView;
   readonly?: boolean;
   onSelect?: (data: IProjectData) => void;
 }
 
 const ProjectListWidget = ({
   studioName,
+  view = "studio",
   type = "instance",
   readonly = false,
   onSelect,
 }: IWidget) => {
   const intl = useIntl();
   const [openCreate, setOpenCreate] = useState(false);
+  const [editId, setEditId] = useState<string>();
+  const [open, setOpen] = useState(false);
   const showDeleteConfirm = (id: string, title: string) => {
     Modal.confirm({
       icon: <ExclamationCircleOutlined />,
@@ -81,6 +84,9 @@ const ProjectListWidget = ({
 
   const ref = useRef<ActionType>();
 
+  useEffect(() => {
+    ref.current?.reload();
+  }, [view]);
   return (
     <>
       <ProList<IProjectData>
@@ -98,13 +104,45 @@ const ProjectListWidget = ({
             },
           },
           description: {
-            dataIndex: "summary",
+            dataIndex: "description",
+            render(dom, entity, index, action, schema) {
+              return (
+                <Space>
+                  <User {...entity.editor} showAvatar={false} />
+                  <TimeShow
+                    createdAt={entity.created_at}
+                    updatedAt={entity.updated_at}
+                  />
+                </Space>
+              );
+            },
+          },
+          content: {
+            dataIndex: "description",
           },
           subTitle: {
             render: (text, row, index, action) => {
               return <></>;
             },
           },
+          actions: {
+            render: (text, row) => [
+              <Button
+                size="small"
+                type="link"
+                key="edit"
+                onClick={() => {
+                  setEditId(row.id);
+                  setOpen(true);
+                }}
+              >
+                编辑
+              </Button>,
+              <Button size="small" type="link" key="clone">
+                克隆
+              </Button>,
+            ],
+          },
         }}
         onRow={(record) => {
           return {
@@ -114,116 +152,10 @@ const ProjectListWidget = ({
             },
           };
         }}
-        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=${type}`;
+          let url = `/v2/project?view=${view}&type=${type}`;
+          url += `&studio=${studioName}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);
@@ -231,7 +163,7 @@ const ProjectListWidget = ({
 
           url += params.keyword ? "&keyword=" + params.keyword : "";
           url += getSorterUrl(sorter);
-          console.log("project list api request", url);
+          console.info("project list api request", url);
           const res = await get<IProjectListResponse>(url);
           console.info("project list api response", res);
           return {
@@ -250,34 +182,42 @@ const ProjectListWidget = ({
         options={{
           search: true,
         }}
-        toolBarRender={() => [
-          readonly ? (
-            <></>
-          ) : (
-            <Popover
-              content={
-                <ProjectCreate
-                  studio={studioName}
-                  type={type}
-                  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={{
+          actions: [
+            view === "studio" ? (
+              <Popover
+                content={
+                  <ProjectCreate
+                    studio={studioName}
+                    type={"workflow"}
+                    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>
+            ) : (
+              <></>
+            ),
+          ],
+        }}
+      />
+      <ProjectEditDrawer
+        studioName={studioName}
+        projectId={editId}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
       />
     </>
   );

+ 118 - 39
dashboard-v4/dashboard/src/components/task/TaskBuilderChapter.tsx

@@ -1,4 +1,13 @@
-import { Button, Divider, message, Modal, Steps } from "antd";
+import {
+  Button,
+  Divider,
+  Input,
+  message,
+  Modal,
+  Space,
+  Steps,
+  Typography,
+} from "antd";
 
 import { useState } from "react";
 import Workflow from "./Workflow";
@@ -19,7 +28,9 @@ import {
   ITokenCreate,
   ITokenCreateResponse,
   ITokenData,
+  TPower,
 } from "../api/token";
+const { Text, Paragraph } = Typography;
 
 interface IModal {
   studioName?: string;
@@ -81,40 +92,65 @@ const TaskBuilderChapter = ({
   const [tokens, setTokens] = useState<ITokenData[]>();
   const [messages, setMessages] = useState<string[]>([]);
   const [prop, setProp] = useState<IProp[]>();
+  const [title, setTitle] = useState<string>();
+  const [loading, setLoading] = useState(false);
 
   const steps = [
     {
       title: "章节选择",
       content: (
-        <ChapterToc
-          book={book}
-          para={para}
-          onData={(data: IChapterToc[]) => {
-            setChapter(data);
-            //获取channel token
-            let payload: IPayload[] = [];
-            channels?.forEach((channel) => {
-              data.forEach((chapter) => {
-                payload.push({
-                  res_id: channel,
-                  res_type: "channel",
-                  book: chapter.book,
-                  para_start: chapter.paragraph,
-                  para_end: chapter.paragraph + chapter.chapter_len,
+        <div style={{ padding: 8 }}>
+          <Space key={1}>
+            <Text type="secondary">{"任务组标题"}</Text>
+            <Input
+              value={title}
+              onChange={(e) => {
+                setTitle(e.target.value);
+              }}
+            />
+          </Space>
+          <ChapterToc
+            key={2}
+            book={book}
+            para={para}
+            onData={(data: IChapterToc[]) => {
+              setChapter(data);
+              if (data.length > 0) {
+                if (!title && data[0].text) {
+                  setTitle(data[0].text);
+                }
+              }
+              //获取channel token
+              let payload: IPayload[] = [];
+              channels?.forEach((channel) => {
+                data.forEach((chapter) => {
+                  const power: TPower[] = ["readonly", "edit"];
+                  payload = payload.concat(
+                    power.map((item) => {
+                      return {
+                        res_id: channel,
+                        res_type: "channel",
+                        book: chapter.book,
+                        para_start: chapter.paragraph,
+                        para_end: chapter.paragraph + chapter.chapter_len,
+                        power: item,
+                      };
+                    })
+                  );
                 });
               });
-            });
-            const url = "/v2/access-token";
-            const values = { payload: payload };
-            console.info("api request", url, values);
-            post<ITokenCreate, ITokenCreateResponse>(url, values).then(
-              (json) => {
-                console.info("api response", json);
-                setTokens(json.data.rows);
-              }
-            );
-          }}
-        />
+              const url = "/v2/access-token";
+              const values = { payload: payload };
+              console.info("api request", url, values);
+              post<ITokenCreate, ITokenCreateResponse>(url, values).then(
+                (json) => {
+                  console.info("api response", json);
+                  setTokens(json.data.rows);
+                }
+              );
+            }}
+          />
+        </div>
       ),
     },
     {
@@ -132,7 +168,11 @@ const TaskBuilderChapter = ({
         <div>
           <TaskBuilderProp
             workflow={workflow}
-            onChange={(data: IProp[] | undefined) => setProp(data)}
+            channelsId={channels}
+            onChange={(data: IProp[] | undefined) => {
+              console.info("prop value", data);
+              setProp(data);
+            }}
           />
         </div>
       ),
@@ -140,10 +180,33 @@ const TaskBuilderChapter = ({
     {
       title: "生成",
       content: (
-        <div>
-          {messages?.map((item, id) => {
-            return <div key={id}>{item}</div>;
-          })}
+        <div style={{ padding: 8 }}>
+          <div>
+            <Space>
+              <Text type="secondary">title</Text>
+              <Text>{title}</Text>
+            </Space>
+          </div>
+          <div>
+            <Space>
+              <Text type="secondary">新增任务组</Text>
+              <Text>{chapter?.length}</Text>
+            </Space>
+          </div>
+          <div>
+            <Space>
+              <Text type="secondary">每个任务组任务数量</Text>
+              <Text>{workflow?.length}</Text>
+            </Space>
+          </div>
+          <div>
+            <Paragraph>点击生成按钮生成</Paragraph>
+          </div>
+          <div>
+            {messages?.map((item, id) => {
+              return <div key={id}>{item}</div>;
+            })}
+          </div>
         </div>
       ),
     },
@@ -181,11 +244,15 @@ const TaskBuilderChapter = ({
         )}
         {current === steps.length - 1 && (
           <Button
+            loading={loading}
+            disabled={loading}
             type="primary"
             onClick={async () => {
               if (!studioName || !chapter) {
+                console.error("缺少参数", studioName, chapter);
                 return;
               }
+              setLoading(true);
               //生成projects
               setMessages((origin) => [...origin, "正在生成任务组……"]);
               const url = "/v2/project-tree";
@@ -194,8 +261,9 @@ const TaskBuilderChapter = ({
                 data: chapter.map((item, id) => {
                   return {
                     id: item.paragraph.toString(),
-                    title: item.text ?? "",
+                    title: id === 0 && title ? title : item.text ?? "",
                     type: "instance",
+                    weight: item.chapter_strlen,
                     parent_id: item.parent.toString(),
                     res_id: `${item.book}-${item.paragraph}`,
                   };
@@ -219,6 +287,7 @@ const TaskBuilderChapter = ({
               if (!workflow) {
                 return;
               }
+
               let taskData: ITaskGroupInsertData[] = res.data.rows
                 .filter((value) => value.isLeaf)
                 .map((project, pId) => {
@@ -239,7 +308,7 @@ const TaskBuilderChapter = ({
                               searchValue,
                               replaceValue
                             );
-                          } else if (value.type === "string") {
+                          } else {
                             //替换book
                             if (project.resId) {
                               const [book, paragraph] =
@@ -252,18 +321,23 @@ const TaskBuilderChapter = ({
                                 "paragraphs=#",
                                 `paragraphs=${paragraph}`
                               );
-                              //查找token
+                              //替换channel
+                              //查找toke
+
+                              const [channel, power] = value.value.split("@");
                               const mToken = tokens?.find(
                                 (token) =>
                                   token.payload.book?.toString() === book &&
                                   token.payload.para_start?.toString() ===
                                     paragraph &&
-                                  token.payload.res_id === value.value
+                                  token.payload.res_id === channel &&
+                                  (power && power.length > 0
+                                    ? token.payload.power === power
+                                    : true)
                               );
                               newContent = newContent?.replace(
                                 value.key,
-                                value.value +
-                                  (mToken ? "@" + mToken?.token : "")
+                                channel + (mToken ? "@" + mToken?.token : "")
                               );
                             }
                           }
@@ -295,7 +369,12 @@ const TaskBuilderChapter = ({
                   ...origin,
                   "生成任务关联" + taskRes.data.taskRelationCount,
                 ]);
+                setMessages((origin) => [
+                  ...origin,
+                  "打开译经楼-我的任务查看已经生成的任务",
+                ]);
               }
+              setLoading(false);
             }}
           >
             Done

+ 18 - 3
dashboard-v4/dashboard/src/components/task/TaskBuilderProjects.tsx

@@ -4,6 +4,7 @@ import {
   Input,
   message,
   Modal,
+  Space,
   Steps,
   Tag,
   Typography,
@@ -24,7 +25,7 @@ import {
 import { post } from "../../request";
 import TaskBuilderProp, { IParam, IProp } from "./TaskBuilderProp";
 
-const { Paragraph } = Typography;
+const { Paragraph, Text } = Typography;
 
 interface IBuildProjects {
   onChange?: (titles: string[]) => void;
@@ -163,7 +164,7 @@ const TaskBuilderProjects = ({
   const [workflow, setWorkflow] = useState<ITaskData[]>();
   const [projectsTitle, setProjectsTitle] = useState<string[]>();
   const [prop, setProp] = useState<IProp[]>();
-
+  const [loading, setLoading] = useState(false);
   const [messages, setMessages] = useState<string[]>([]);
   const steps = [
     {
@@ -195,7 +196,13 @@ const TaskBuilderProjects = ({
       content: (
         <div>
           <div>
-            <Paragraph>新增任务组:{projectsTitle?.length}</Paragraph>
+            <Space>
+              <Text type="secondary">title</Text>
+              <Text>{projectsTitle}</Text>
+            </Space>
+          </div>
+          <div>
+            <Paragraph>新增任务组:{projectsTitle}</Paragraph>
             <Paragraph>每个任务组任务数量:{workflow?.length}</Paragraph>
             <Paragraph>点击生成按钮生成</Paragraph>
           </div>
@@ -242,11 +249,14 @@ const TaskBuilderProjects = ({
         {current === steps.length - 1 && (
           <Button
             type="primary"
+            loading={loading}
+            disabled={loading}
             onClick={async () => {
               if (!studioName || !parentId || !projectsTitle) {
                 console.error("缺少参数", studioName, parentId, projectsTitle);
                 return;
               }
+              setLoading(true);
               //生成projects
               setMessages((origin) => [...origin, "正在生成任务组……"]);
               const url = "/v2/project-tree";
@@ -329,6 +339,10 @@ const TaskBuilderProjects = ({
                   ...origin,
                   "生成任务关联" + taskRes.data.taskRelationCount,
                 ]);
+                setMessages((origin) => [
+                  ...origin,
+                  "打开译经楼-我的任务查看已经生成的任务",
+                ]);
                 onDone && onDone();
               } else {
                 setMessages((origin) => [
@@ -336,6 +350,7 @@ const TaskBuilderProjects = ({
                   "生成任务失败。错误信息:" + taskRes.data,
                 ]);
               }
+              setLoading(false);
             }}
           >
             Done

+ 77 - 60
dashboard-v4/dashboard/src/components/task/TaskBuilderProp.tsx

@@ -3,10 +3,16 @@ import { Divider, Input, InputNumber } from "antd";
 import { ITaskData } from "../api/task";
 import "../article/article.css";
 import { useEffect, useState } from "react";
+import ChannelSelectWithToken from "../channel/ChannelSelectWithToken";
 
-type TParamType = "number" | "string";
+type TParamType =
+  | "number"
+  | "string"
+  | "channel:translation"
+  | "channel:nissaya";
 export interface IParam {
   key: string;
+  label: string;
   value: string;
   type: TParamType;
   initValue: number;
@@ -20,9 +26,10 @@ export interface IProp {
 
 interface IWidget {
   workflow?: ITaskData[];
+  channelsId?: string[];
   onChange?: (data: IProp[] | undefined) => void;
 }
-const TaskBuilderProp = ({ workflow, onChange }: IWidget) => {
+const TaskBuilderProp = ({ workflow, channelsId, onChange }: IWidget) => {
   //console.debug("TaskBuilderProp render");
   const [prop, setProp] = useState<IProp[]>();
   useEffect(() => {
@@ -35,6 +42,7 @@ const TaskBuilderProp = ({ workflow, onChange }: IWidget) => {
           const [k, v] = item.split("=");
           const value: IParam = {
             key: k,
+            label: k,
             value: v,
             type: "number",
             initValue: 1,
@@ -48,10 +56,15 @@ const TaskBuilderProp = ({ workflow, onChange }: IWidget) => {
         .filter((value) => value.includes("=%"))
         .map((item) => {
           const [k, v] = item.split("=");
+          const paramKey = v.split("@");
           const value: IParam = {
             key: v,
+            label: paramKey[0],
             value: "",
-            type: "string",
+            type:
+              paramKey.length > 1 && paramKey[1]
+                ? (paramKey[1] as TParamType)
+                : "string",
             initValue: 0,
             step: 0,
           };
@@ -97,6 +110,64 @@ const TaskBuilderProp = ({ workflow, onChange }: IWidget) => {
     console.debug("newData", newData);
     onChange && onChange(newData);
   };
+
+  const Value = (item: IParam, taskId: number, paramId: number) => {
+    let channelType: string | undefined;
+    if (item.key.includes("@channel")) {
+      const [_, channel] = item.key.split("@");
+      if (channel.includes(":")) {
+        channelType = channel.split(":")[1].replaceAll("%", "");
+      }
+    }
+    return item.type === "number" ? (
+      <InputNumber
+        defaultValue={item.initValue}
+        value={item.initValue}
+        onChange={(e) => {
+          if (e) {
+            change(taskId, paramId, item.value, e, item.step);
+          }
+        }}
+      />
+    ) : item.type === "string" ? (
+      <Input
+        defaultValue={item.value}
+        value={item.value}
+        onChange={(e) => {
+          if (e) {
+            change(taskId, paramId, e.target.value, item.initValue, item.step);
+          }
+        }}
+      />
+    ) : (
+      <ChannelSelectWithToken
+        channelsId={channelsId}
+        type={channelType}
+        onChange={(e) => {
+          console.debug("channel select onChange", e);
+          change(taskId, paramId, e ?? "", item.initValue, item.step);
+        }}
+      />
+    );
+  };
+
+  const Step = (item: IParam, taskId: number, paramId: number) => {
+    return item.type === "string" ? (
+      <>{"无"}</>
+    ) : item.value === "?" ? (
+      <>{"无"}</>
+    ) : (
+      <InputNumber
+        defaultValue={item.step}
+        readOnly={item.value === "?++"}
+        onChange={(e) => {
+          if (e) {
+            change(taskId, paramId, item.value, item.initValue, e);
+          }
+        }}
+      />
+    );
+  };
   return (
     <>
       {prop?.map((item, taskId) => {
@@ -115,63 +186,9 @@ const TaskBuilderProp = ({ workflow, onChange }: IWidget) => {
                 {item.param?.map((item, paramId) => {
                   return (
                     <tr key={paramId}>
-                      <td key={1}>{item.key}</td>
-                      <td key={2}>
-                        {item.type === "number" ? (
-                          <InputNumber
-                            defaultValue={item.initValue}
-                            value={item.initValue}
-                            onChange={(e) => {
-                              if (e) {
-                                change(
-                                  taskId,
-                                  paramId,
-                                  item.value,
-                                  e,
-                                  item.step
-                                );
-                              }
-                            }}
-                          />
-                        ) : (
-                          <Input
-                            defaultValue={item.value}
-                            value={item.value}
-                            onChange={(e) => {
-                              if (e) {
-                                change(
-                                  taskId,
-                                  paramId,
-                                  e.target.value,
-                                  item.initValue,
-                                  item.step
-                                );
-                              }
-                            }}
-                          />
-                        )}
-                      </td>
-                      <td key={3}>
-                        {item.value === "?" ? (
-                          <>{"无"}</>
-                        ) : (
-                          <InputNumber
-                            defaultValue={item.step}
-                            readOnly={item.value === "?++"}
-                            onChange={(e) => {
-                              if (e) {
-                                change(
-                                  taskId,
-                                  paramId,
-                                  item.value,
-                                  item.initValue,
-                                  e
-                                );
-                              }
-                            }}
-                          />
-                        )}
-                      </td>
+                      <td key={1}>{item.label}</td>
+                      <td key={2}>{Value(item, taskId, paramId)}</td>
+                      <td key={3}>{Step(item, taskId, paramId)}</td>
                     </tr>
                   );
                 })}

+ 2 - 4
dashboard-v4/dashboard/src/components/task/TaskList.tsx

@@ -63,10 +63,8 @@ export interface IFilter {
     | "not-includes"
     | "equals"
     | "not-equals"
-    | ">="
-    | "<="
-    | ">"
-    | "<"
+    | "null"
+    | "not-null"
     | null;
   value: string | string[] | null;
 }

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

@@ -219,6 +219,7 @@ const ProjectListWidget = ({
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);
           let url = `/v2/project?view=studio&type=${activeKey}`;
+          url += `&studio=${studioName}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);

+ 7 - 1
dashboard-v4/dashboard/src/components/task/TaskTable.tsx

@@ -98,7 +98,12 @@ const TaskTable = ({ tasks }: IWidget) => {
           {tasksTitle?.map((row, level) => {
             return (
               <tr>
-                {level === 0 ? <th rowSpan={2}>project</th> : undefined}
+                {level === 0 ? (
+                  <>
+                    <th rowSpan={2}>project</th>
+                    <th>weight</th>
+                  </>
+                ) : undefined}
                 {row.map((task, index) => {
                   return (
                     <th
@@ -118,6 +123,7 @@ const TaskTable = ({ tasks }: IWidget) => {
           {projects?.map((row, index) => (
             <tr key={index}>
               <td>{row.title}</td>
+              <td>{row.weight}</td>
               {dataHeading?.map((task, id) => {
                 const taskData = tasks?.find(
                   (value: ITaskData) =>

+ 62 - 16
dashboard-v4/dashboard/src/components/task/Workflow.tsx

@@ -1,8 +1,10 @@
 import React, { useState } from "react";
 import { IProjectData, ITaskData } from "../api/task";
-import ProjectList from "./ProjectList";
+import ProjectList, { TView } from "./ProjectList";
 import ProjectTask from "./ProjectTask";
-import { Modal } from "antd";
+import { Button, Card, Modal, Tree } from "antd";
+import { ArrowLeftOutlined } from "@ant-design/icons";
+import { Key } from "antd/es/table/interface";
 
 interface IModal {
   tiger?: React.ReactNode;
@@ -55,24 +57,68 @@ interface IWidget {
 }
 
 const Workflow = ({ studioName, onSelect, onData }: IWidget) => {
-  const [projectId, setProjectId] = useState<string>();
+  const [project, setProject] = useState<IProjectData>();
+  const [view, setView] = useState<TView>("studio");
   return (
     <div style={{ display: "flex" }}>
-      <div style={{ minWidth: 300, flex: 1 }}>
-        <ProjectList
-          studioName={studioName}
-          type="workflow"
-          readonly
-          onSelect={(data) => setProjectId(data.id)}
+      <div style={{ minWidth: 200, flex: 1 }}>
+        <Tree
+          multiple={false}
+          defaultSelectedKeys={["studio"]}
+          treeData={[
+            { title: "my", key: "studio" },
+            { title: "shared", key: "shared" },
+            { title: "community", key: "community" },
+            { title: "authors", key: "authors" },
+          ]}
+          onSelect={(selectedKeys: Key[]) => {
+            console.debug("selectedKeys", selectedKeys);
+            if (selectedKeys.length > 0) {
+              setProject(undefined);
+              setView(selectedKeys[0].toString() as TView);
+            }
+          }}
         />
       </div>
-      <div style={{ flex: 3 }}>
-        <ProjectTask
-          studioName={studioName}
-          projectId={projectId}
-          readonly
-          onChange={onData}
-        />
+      <div style={{ flex: 5 }}>
+        <div style={{ display: project ? "block" : "none" }}>
+          <Card
+            title={
+              project ? (
+                <>
+                  <Button
+                    type="link"
+                    icon={<ArrowLeftOutlined />}
+                    onClick={() => setProject(undefined)}
+                  />
+                  {project.title}
+                </>
+              ) : (
+                "请选择一个工作流"
+              )
+            }
+          >
+            {project ? (
+              <ProjectTask
+                studioName={studioName}
+                projectId={project.id}
+                readonly={view !== "studio"}
+                onChange={onData}
+              />
+            ) : (
+              <></>
+            )}
+          </Card>
+        </div>
+        <div style={{ display: project ? "none" : "block" }}>
+          <ProjectList
+            studioName={studioName}
+            view={view}
+            type="workflow"
+            readonly
+            onSelect={(data: IProjectData) => setProject(data)}
+          />
+        </div>
       </div>
     </div>
   );

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

@@ -246,6 +246,7 @@ export const SentEditInner = ({
         simNum={simNum}
         loadedRes={loadedRes}
         wbwData={wbwData}
+        origin={origin}
         magicDictLoading={magicDictLoading}
         compact={isCompact}
         mode={articleMode}

+ 35 - 0
dashboard-v4/dashboard/src/components/template/SentEdit/SentAttachment.tsx

@@ -0,0 +1,35 @@
+import { useEffect, useState } from "react";
+import {
+  IResAttachmentData,
+  IResAttachmentListResponse,
+} from "../../api/Attachments";
+import { get } from "../../../request";
+
+interface IWidget {
+  sentenceId?: string;
+}
+const SentAttachment = ({ sentenceId }: IWidget) => {
+  const [Attachments, setAttachments] = useState<IResAttachmentData[]>();
+  useEffect(() => {
+    if (!sentenceId) {
+      return;
+    }
+    const url = `/v2/sentence-attachment?view=sentence&id=${sentenceId}`;
+    console.debug("api request", url);
+    get<IResAttachmentListResponse>(url).then((json) => {
+      console.debug("api response", json);
+      if (json.ok) {
+        setAttachments(json.data.rows);
+      }
+    });
+  }, [sentenceId]);
+  return (
+    <>
+      {Attachments?.map((item, id) => {
+        return <img key={id} src={item.attachment.url} alt="img" />;
+      })}
+    </>
+  );
+};
+
+export default SentAttachment;

+ 34 - 12
dashboard-v4/dashboard/src/components/template/SentEdit/SentCanRead.tsx

@@ -12,6 +12,7 @@ import SentAdd from "./SentAdd";
 import { useAppSelector } from "../../../hooks";
 import { currentUser as _currentUser } from "../../../reducers/current-user";
 import { IChannel } from "../../channel/Channel";
+import { IWbw } from "../Wbw/WbwWord";
 
 export const toISentence = (item: ISentenceData, channelsId?: string[]) => {
   return {
@@ -40,7 +41,7 @@ interface IWidget {
   wordEnd: number;
   type: TChannelType;
   channelsId?: string[];
-  reload?: boolean;
+  origin?: ISentence[];
   onReload?: Function;
   onCreate?: Function;
 }
@@ -51,7 +52,7 @@ const SentCanReadWidget = ({
   wordEnd,
   type,
   channelsId,
-  reload = false,
+  origin,
   onReload,
   onCreate,
 }: IWidget) => {
@@ -66,7 +67,7 @@ const SentCanReadWidget = ({
     if (type === "commentary" || type === "similar") {
       url += channelsId ? `&channels=${channelsId.join()}` : "";
     }
-    console.log("url", url);
+    console.info("ai request", url);
     get<ISentenceListResponse>(url)
       .then((json) => {
         if (json.ok) {
@@ -85,9 +86,7 @@ const SentCanReadWidget = ({
         }
       })
       .finally(() => {
-        if (reload && typeof onReload !== "undefined") {
-          onReload();
-        }
+        onReload && onReload();
       });
   };
 
@@ -95,12 +94,6 @@ const SentCanReadWidget = ({
     load();
   }, []);
 
-  useEffect(() => {
-    if (reload) {
-      load();
-    }
-  }, [reload]);
-
   return (
     <div>
       <div style={{ display: "flex", justifyContent: "space-between" }}>
@@ -158,11 +151,40 @@ const SentCanReadWidget = ({
       />
 
       {sentData.map((item, id) => {
+        let diffText: string | null = null;
+        if (origin) {
+          diffText = origin[0].html;
+          if (origin[0].contentType === "json" && origin[0].content) {
+            const wbw = JSON.parse(origin[0].content) as IWbw[];
+            console.debug("wbw data", wbw);
+            diffText = wbw
+              .filter((value) => {
+                if (value.style && value.style.value === "note") {
+                  return false;
+                } else if (value.type && value.type.value === ".ctl.") {
+                  return false;
+                } else {
+                  return true;
+                }
+              })
+              .map(
+                (item) =>
+                  `${item.word.value
+                    .replaceAll("{", "**")
+                    .replaceAll("}", "**")}`
+              )
+              .join(" ");
+          }
+          console.debug("origin", origin);
+        }
+
         return (
           <SentCell
             value={item}
             key={id}
             isPr={false}
+            diffText={diffText}
+            showDiff={origin ? true : false}
             editMode={item.openInEditMode}
             onChange={(value: ISentence) => {
               console.debug("onChange", value);

+ 19 - 2
dashboard-v4/dashboard/src/components/template/SentEdit/SentCell.tsx

@@ -1,6 +1,12 @@
 import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
-import { Divider, message as AntdMessage, Modal } from "antd";
+import {
+  Divider,
+  message as AntdMessage,
+  Modal,
+  Collapse,
+  CollapseProps,
+} from "antd";
 import { ExclamationCircleOutlined, LoadingOutlined } from "@ant-design/icons";
 
 import { ISentence } from "../SentEdit";
@@ -30,6 +36,7 @@ import { randomString } from "../../../utils";
 import User from "../../auth/User";
 import { ISentenceListResponse } from "../../api/Corpus";
 import { toISentence } from "./SentCanRead";
+import SentAttachment from "./SentAttachment";
 
 interface ISnowFlakeResponse {
   ok: boolean;
@@ -475,7 +482,7 @@ const SentCellWidget = ({
           </div>
         ) : undefined}
       </SentEditMenu>
-      {compact ? undefined : <Divider style={{ margin: "10px 0" }} />}
+
       <CopyToModal
         important
         sentencesId={[sentId]}
@@ -483,6 +490,16 @@ const SentCellWidget = ({
         open={copyOpen}
         onClose={() => setCopyOpen(false)}
       />
+      <Collapse bordered={false} style={{ backgroundColor: "unset" }}>
+        <Collapse.Panel
+          header={"attachment"}
+          key="parent2"
+          style={{ backgroundColor: "unset" }}
+        >
+          <SentAttachment sentenceId={sentData?.id} />
+        </Collapse.Panel>
+      </Collapse>
+      {compact ? undefined : <Divider style={{ margin: "10px 0" }} />}
     </div>
   );
 };

+ 28 - 25
dashboard-v4/dashboard/src/components/template/SentEdit/SentTab.tsx

@@ -15,7 +15,7 @@ import { IWbw } from "../Wbw/WbwWord";
 import RelaGraphic from "../Wbw/RelaGraphic";
 import SentMenu from "./SentMenu";
 import { ArticleMode } from "../../article/Article";
-import { IResNumber } from "../SentEdit";
+import { IResNumber, ISentence } from "../SentEdit";
 import SentTabCopy from "./SentTabCopy";
 import { fullUrl } from "../../../utils";
 import SentWbw from "./SentWbw";
@@ -42,6 +42,7 @@ interface IWidget {
   compact?: boolean;
   mode?: ArticleMode;
   loadedRes?: IResNumber;
+  origin?: ISentence[];
   onMagicDict?: Function;
   onCompact?: Function;
   onModeChange?: Function;
@@ -65,6 +66,7 @@ const SentTabWidget = ({
   compact = false,
   mode,
   loadedRes,
+  origin,
   onMagicDict,
   onCompact,
   onModeChange,
@@ -148,7 +150,7 @@ const SentTabWidget = ({
             }}
             onMenuClick={(key: string) => {
               switch (key) {
-                case "compact" || "normal":
+                case "compact":
                   if (typeof onCompact !== "undefined") {
                     setIsCompact(true);
                     onCompact(true);
@@ -304,29 +306,30 @@ const SentTabWidget = ({
             />
           ),
         },
-        /*{
-            label: (
-              <SentTabButton
-                icon={<BlockOutlined />}
-                type="original"
-                sentId={id}
-                count={originNum}
-                title={intl.formatMessage({
-                  id: "channel.type.original.label",
-                })}
-              />
-            ),
-            key: "original",
-            children: (
-              <SentCanRead
-                book={parseInt(sId[0])}
-                para={parseInt(sId[1])}
-                wordStart={parseInt(sId[2])}
-                wordEnd={parseInt(sId[3])}
-                type="original"
-              />
-            ),
-          },*/
+        {
+          label: (
+            <SentTabButton
+              icon={<BlockOutlined />}
+              type="original"
+              sentId={id}
+              count={originNum}
+              title={intl.formatMessage({
+                id: "channel.type.original.label",
+              })}
+            />
+          ),
+          key: "original",
+          children: (
+            <SentCanRead
+              book={parseInt(sId[0])}
+              para={parseInt(sId[1])}
+              wordStart={parseInt(sId[2])}
+              wordEnd={parseInt(sId[3])}
+              type="original"
+              origin={origin}
+            />
+          ),
+        },
         {
           label: (
             <SentTabButton

+ 1 - 0
dashboard-v4/dashboard/src/locales/en-US/buttons.ts

@@ -100,6 +100,7 @@ const items = {
   "buttons.task.status.change.to.done": "完成任务",
   "buttons.task.status.change.to.restarted": "重做",
   "buttons.task.status.change.to.requested_restart": "请求重做",
+  "buttons.access-token.get": "access token",
 };
 
 export default items;

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

@@ -76,6 +76,15 @@ const items = {
   "labels.task.category.review": "review",
   "labels.task.category.proofread": "proofread",
   "labels.ai-assistant": "AI Assistant",
+  "labels.filters.includes": "includes",
+  "labels.filters.not-includes": "not includes",
+  "labels.filters.equal": "equal",
+  "labels.filters.not-equal": "not equal",
+  "labels.filters.null": "null",
+  "labels.filters.not-null": "not null",
+  "labels.filters.and": "and",
+  "labels.filters.or": "or",
+  "labels.task.workflows": "Workflows",
 };
 
 export default items;

+ 1 - 0
dashboard-v4/dashboard/src/locales/zh-Hans/buttons.ts

@@ -101,6 +101,7 @@ const items = {
   "buttons.task.status.change.to.done": "完成任务",
   "buttons.task.status.change.to.restarted": "重做",
   "buttons.task.status.change.to.requested_restart": "请求重做",
+  "buttons.access-token.get": "获取访问口令",
 };
 
 export default items;

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

@@ -84,6 +84,15 @@ const items = {
   "labels.task.category.review": "审稿",
   "labels.task.category.proofread": "proofread",
   "labels.ai-assistant": "人工智能助手",
+  "labels.filters.includes": "包含",
+  "labels.filters.not-includes": "不包含",
+  "labels.filters.equal": "等于",
+  "labels.filters.not-equal": "不等于",
+  "labels.filters.null": "为空",
+  "labels.filters.not-null": "不为空",
+  "labels.filters.and": "全部条件",
+  "labels.filters.or": "任一条件",
+  "labels.task.workflows": "工作流",
 };
 
 export default items;

+ 26 - 2
dashboard-v4/dashboard/src/pages/library/article/show.tsx

@@ -32,9 +32,13 @@ import RecentModal from "../../../components/recent/RecentModal";
 import { useAppSelector } from "../../../hooks";
 import { add } from "../../../reducers/inline-dict";
 import { paraParam } from "../../../reducers/para-change";
-import { get } from "../../../request";
+import { get, post } from "../../../request";
 import store from "../../../store";
-import { IRecent } from "../../../components/recent/RecentList";
+import {
+  IRecent,
+  IRecentRequest,
+  IRecentResponse,
+} from "../../../components/recent/RecentList";
 import { fullUrl } from "../../../utils";
 import ThemeSelect from "../../../components/general/ThemeSelect";
 import {
@@ -146,6 +150,26 @@ const Widget = () => {
     store.dispatch(modeChange({ mode: currMode as ArticleMode }));
   }, [currMode]);
 
+  useEffect(() => {
+    if (!type || !id) {
+      return;
+    }
+    const url = "/v2/recent";
+    // 将 Map 转换为普通对象
+    const mapObject = Object.fromEntries(searchParams);
+    // 将普通对象序列化为 JSON 字符串
+    const jsonString = JSON.stringify(mapObject);
+    const data: IRecentRequest = {
+      type: type as ArticleType,
+      article_id: id,
+      param: jsonString,
+    };
+    console.info("recent scan api request", url, data);
+    post<IRecentRequest, IRecentResponse>(url, data).then((json) => {
+      console.info("recent scan api response", json);
+    });
+  }, [id, searchParams, type]);
+
   console.log(anchorNavOpen, anchorNavShow);
 
   return (

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

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

+ 11 - 0
dashboard-v4/dashboard/src/pages/studio/task/workflow.tsx

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