Bläddra i källkod

Merge branch 'development' of github.com:visuddhinanda/mint into development

visuddhinanda 1 år sedan
förälder
incheckning
d60c79161c
30 ändrade filer med 726 tillägg och 226 borttagningar
  1. 100 0
      api-v8/app/Http/Controllers/EmailCertificationController.php
  2. 41 30
      api-v8/app/Http/Controllers/InviteController.php
  3. 46 0
      api-v8/app/Mail/EmailCertif.php
  4. 7 9
      api-v8/app/Mail/InviteMail.php
  5. 5 0
      api-v8/app/Models/Invite.php
  6. 74 0
      api-v8/resources/views/book.blade.php
  7. 15 0
      api-v8/resources/views/emails/certification/en-US.blade.php
  8. 15 0
      api-v8/resources/views/emails/certification/en.blade.php
  9. 18 0
      api-v8/resources/views/emails/certification/zh-Hans.blade.php
  10. 15 0
      api-v8/resources/views/emails/certification/zh-Hant.blade.php
  11. 2 0
      api-v8/routes/api.php
  12. 13 10
      api-v8/routes/web.php
  13. 1 1
      dashboard-v4/dashboard/src/Router.tsx
  14. 6 0
      dashboard-v4/dashboard/src/components/api/Auth.ts
  15. 6 8
      dashboard-v4/dashboard/src/components/auth/Avatar.tsx
  16. 2 7
      dashboard-v4/dashboard/src/components/auth/LoginAlert.tsx
  17. 20 0
      dashboard-v4/dashboard/src/components/auth/LoginButton.tsx
  18. 2 7
      dashboard-v4/dashboard/src/components/auth/SignInAvatar.tsx
  19. 2 5
      dashboard-v4/dashboard/src/components/course/Status.tsx
  20. 1 1
      dashboard-v4/dashboard/src/components/nut/users/NonSignInSharedLinks.tsx
  21. 2 9
      dashboard-v4/dashboard/src/components/nut/users/ResetPassword.tsx
  22. 13 2
      dashboard-v4/dashboard/src/components/nut/users/SignIn.tsx
  23. 113 81
      dashboard-v4/dashboard/src/components/nut/users/SignUp.tsx
  24. 53 28
      dashboard-v4/dashboard/src/components/task/Filter.tsx
  25. 135 27
      dashboard-v4/dashboard/src/components/users/SignUp.tsx
  26. 1 0
      dashboard-v4/dashboard/src/locales/en-US/auth/index.ts
  27. 8 0
      dashboard-v4/dashboard/src/locales/en-US/label.ts
  28. 1 0
      dashboard-v4/dashboard/src/locales/zh-Hans/auth/index.ts
  29. 8 0
      dashboard-v4/dashboard/src/locales/zh-Hans/label.ts
  30. 1 1
      dashboard-v4/dashboard/src/pages/nut/users/sign-in.tsx

+ 100 - 0
api-v8/app/Http/Controllers/EmailCertificationController.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\Invite;
+use App\Http\Resources\InviteResource;
+use Illuminate\Support\Str;
+use App\Mail\EmailCertif;
+use Illuminate\Support\Facades\Mail;
+use App\Tools\RedisClusters;
+use App\Models\UserInfo;
+
+class EmailCertificationController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //查询是否重复
+        if (UserInfo::where('email', $request->get('email'))->exists()) {
+            return $this->error('email.exists', 'err.email.exists', 200);
+        }
+        $sender = config("mint.admin.root_uuid");
+
+        $uuid = Str::uuid();
+        $invite = Invite::firstOrNew(
+            ['email' => $request->get('email')],
+            ['id' => $uuid]
+        );
+        $invite->user_uid = $sender;
+        $invite->status = 'invited';
+        $invite->save();
+
+        Mail::to($request->get('email'))
+            ->send(new EmailCertif(
+                $invite->id,
+                $request->get('subject', 'sign up wikipali'),
+                $request->get('lang'),
+            ));
+        if (Mail::failures()) {
+            return $this->error('send email fail', '', 200);
+        }
+
+        return $this->ok(new InviteResource($invite));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $id)
+    {
+        //
+        $code = RedisClusters::get("/email/certification/" . $id);
+        if (empty($code)) {
+            return $this->error('Certification is avalide', 200, 200);
+        }
+        return $this->ok($code);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 41 - 30
api-v8/app/Http/Controllers/InviteController.php

@@ -10,8 +10,8 @@ use App\Http\Api\UserApi;
 use App\Http\Api\StudioApi;
 use App\Http\Resources\InviteResource;
 use Illuminate\Support\Str;
-use Mail;
 use App\Mail\InviteMail;
+use Illuminate\Support\Facades\Mail;
 
 class InviteController extends Controller
 {
@@ -24,41 +24,49 @@ class InviteController extends Controller
     {
         //
         $user = AuthApi::current($request);
-        if(!$user){
+        if (!$user) {
             return $this->error(__('auth.failed'));
         }
-        $table = Invite::select(['id','user_uid','email',
-                                 'status','created_at','updated_at']);
+        $table = Invite::select([
+            'id',
+            'user_uid',
+            'email',
+            'status',
+            'created_at',
+            'updated_at'
+        ]);
         switch ($request->get('view')) {
             case 'studio':
-                if(empty($request->get('studio'))){
+                if (empty($request->get('studio'))) {
                     return $this->error(__('auth.failed'));
                 }
                 //判断当前用户是否有指定的studio的权限
-                if($user['user_uid'] !== StudioApi::getIdByName($request->get('studio'))){
+                if ($user['user_uid'] !== StudioApi::getIdByName($request->get('studio'))) {
                     return $this->error(__('auth.failed'));
                 }
                 $table = $table->where('user_uid', $user["user_uid"]);
                 break;
             case 'all':
                 $user = UserApi::getByUuid($user['user_uid']);
-                if(!$user || !isset($user['roles']) || !in_array('administrator',$user['roles']) ){
+                if (!$user || !isset($user['roles']) || !in_array('administrator', $user['roles'])) {
                     return $this->error(__('auth.failed'));
                 }
                 break;
         }
-        if($request->has('search')){
-            $table = $table->where('email', 'like', '%'.$request->get('search')."%");
+        if ($request->has('search')) {
+            $table = $table->where('email', 'like', '%' . $request->get('search') . "%");
         }
         $count = $table->count();
-        $table = $table->orderBy($request->get('order','updated_at'),
-                                 $request->get('dir','desc'));
+        $table = $table->orderBy(
+            $request->get('order', 'updated_at'),
+            $request->get('dir', 'desc')
+        );
 
-        $table = $table->skip($request->get('offset',0))
-                       ->take($request->get('limit',1000));
+        $table = $table->skip($request->get('offset', 0))
+            ->take($request->get('limit', 1000));
 
         $result = $table->get();
-        return $this->ok(["rows"=>InviteResource::collection($result),"count"=>$count]);
+        return $this->ok(["rows" => InviteResource::collection($result), "count" => $count]);
     }
 
     /**
@@ -71,36 +79,40 @@ class InviteController extends Controller
     {
         //
         $sender = '';
-        if(!empty($request->get('studio'))){
+        if (!empty($request->get('studio'))) {
             $user = AuthApi::current($request);
-            if(!$user){
-                return $this->error(__('auth.failed'),401,401);
+            if (!$user) {
+                return $this->error(__('auth.failed'), 401, 401);
             }
             //判断当前用户是否有指定的studio的权限
             $studio_id = StudioApi::getIdByName($request->get('studio'));
-            if($user['user_uid'] !== $studio_id){
+            if ($user['user_uid'] !== $studio_id) {
                 return $this->error(__('auth.failed'));
             }
             $sender = $studio_id;
-        }else{
+        } else {
             $sender = config("mint.admin.root_uuid");
         }
 
         //查询是否重复
-        if(Invite::where('email',$request->get('email'))->exists() ||
-            UserInfo::where('email',$request->get('email'))->exists()){
-            return $this->error('email.exists',__('validation.exists',['email']),200);
+        if (
+            Invite::where('email', $request->get('email'))->exists() ||
+            UserInfo::where('email', $request->get('email'))->exists()
+        ) {
+            return $this->error('email.exists', __('validation.exists', ['email']), 200);
         }
 
         $uuid = Str::uuid();
         Mail::to($request->get('email'))
-            ->send(new InviteMail($uuid,
-                                $request->get('subject','sign up wikipali'),
-                                $request->get('lang'),
-                                $request->get('dashboard')));
-        if(Mail::failures()){
-            return $this->error('send email fail', '',200);
-        }else{
+            ->send(new InviteMail(
+                $uuid,
+                $request->get('subject', 'sign up wikipali'),
+                $request->get('lang'),
+                $request->get('dashboard')
+            ));
+        if (Mail::failures()) {
+            return $this->error('send email fail', '', 200);
+        } else {
             $invite = new Invite;
             $invite->id = $uuid;
             $invite->email = $request->get('email');
@@ -121,7 +133,6 @@ class InviteController extends Controller
     {
         //
         return $this->ok(new InviteResource($invite));
-
     }
 
     /**

+ 46 - 0
api-v8/app/Mail/EmailCertif.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+use App\Tools\RedisClusters;
+use Illuminate\Support\Facades\Log;
+
+class EmailCertif extends Mailable
+{
+    use Queueable, SerializesModels;
+    protected $uuid;
+    protected $lang;
+    /**
+     * Create a new message instance.
+     *
+     * @return void
+     */
+    public function __construct(string $uuid, string $subject = 'wikipali email certification', string $lang = 'en-US')
+    {
+        //
+        $this->uuid = $uuid;
+        $this->lang = $lang;
+        $this->subject($subject);
+    }
+
+    /**
+     * Build the message.
+     *
+     * @return $this
+     */
+    public function build()
+    {
+        // 生成一个介于 1000 到 9999 之间的随机整数
+        $randomNumber = random_int(1000, 9999);
+        $key = "/email/certification/" . $this->uuid;
+        Log::debug('email certification', ['key' => $key, 'value' => $randomNumber]);
+        RedisClusters::put($key, $randomNumber,  30 * 60);
+        return $this->view('emails.certification.' . $this->lang)
+            ->with([
+                'code' => $randomNumber,
+            ]);
+    }
+}

+ 7 - 9
api-v8/app/Mail/InviteMail.php

@@ -2,9 +2,7 @@
 
 namespace App\Mail;
 
-use App\Models\Invite;
 use Illuminate\Bus\Queueable;
-use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Mail\Mailable;
 use Illuminate\Queue\SerializesModels;
 
@@ -20,15 +18,15 @@ class InviteMail extends Mailable
      *
      * @return void
      */
-    public function __construct(string $uuid,string $subject='wikipali invite email',string $lang='en-US',string $dashboard=null)
+    public function __construct(string $uuid, string $subject = 'wikipali invite email', string $lang = 'en-US', string $dashboard = null)
     {
         //
         $this->uuid = $uuid;
         $this->lang = $lang;
         $this->subject($subject);
-        if($dashboard && !empty($dashboard)){
+        if ($dashboard && !empty($dashboard)) {
             $this->dashboard_url = $dashboard;
-        }else{
+        } else {
             $this->dashboard_url = config('mint.server.dashboard_base_path');
         }
     }
@@ -41,9 +39,9 @@ class InviteMail extends Mailable
     public function build()
     {
 
-        return $this->view('emails.invite.'.$this->lang)
-                    ->with([
-                        'url' => $this->dashboard_url.'/anonymous/users/sign-up/'.$this->uuid,
-                    ]);
+        return $this->view('emails.invite.' . $this->lang)
+            ->with([
+                'url' => $this->dashboard_url . '/anonymous/users/sign-up/' . $this->uuid,
+            ]);
     }
 }

+ 5 - 0
api-v8/app/Models/Invite.php

@@ -8,4 +8,9 @@ use Illuminate\Database\Eloquent\Model;
 class Invite extends Model
 {
     use HasFactory;
+    protected $primaryKey = 'id';
+    protected $casts = [
+        'id' => 'string'
+    ];
+    protected $fillable = ['email', 'id'];
 }

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

+ 15 - 0
api-v8/resources/views/emails/certification/en-US.blade.php

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>email certification</title>
+</head>
+
+<body>
+    <div>wikipali email certification</div>
+    <div>wikipali sign up email certification.</div>
+    <div><b>{{ $code }}</b></div>
+    <div>This email is sent automatically by system, please don't reply.</div>
+</body>
+
+</html>

+ 15 - 0
api-v8/resources/views/emails/certification/en.blade.php

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>email certification</title>
+</head>
+
+<body>
+    <div>wikipali email certification</div>
+    <div>wikipali sign up email certification.</div>
+    <div><b>{{ $code }}</b></div>
+    <div>This email is sent automatically by system, please don't reply.</div>
+</body>
+
+</html>

+ 18 - 0
api-v8/resources/views/emails/certification/zh-Hans.blade.php

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>invite</title>
+</head>
+
+<body>
+    <h2>验证你的电子邮件地址</h2>
+    <div>你好:</div>
+    <div>已收到用此邮箱注册wikipali账号的请求。要完成此流程,请在验证页面输入以下代码:</div>
+    <h3>{{ $code }}</h3>
+    <div>该验证码三十分钟内有效</div>
+    <div>如果你未曾注册wikipali账号,请忽略此邮件。如果你反复收到此邮件,请联系wikipali管理员</div>
+    <div>此邮件为系统自动发送,请勿回复。</div>
+</body>
+
+</html>

+ 15 - 0
api-v8/resources/views/emails/certification/zh-Hant.blade.php

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>invite</title>
+</head>
+
+<body>
+    <div>wikipali 注册邮箱验证码</div>
+    <div>使用下面的邮箱验证码。</div>
+    <div><b>{{ $code }}</b></div>
+    <div>此郵件為係統自動發送,請勿回複。</div>
+</body>
+
+</html>

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

@@ -115,6 +115,7 @@ 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;
 
 
 
@@ -287,4 +288,5 @@ Route::group(['prefix' => 'v2'], function () {
     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');

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

@@ -195,8 +195,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 />} />

+ 6 - 0
dashboard-v4/dashboard/src/components/api/Auth.ts

@@ -115,4 +115,10 @@ export interface IInviteResponse {
   data: IInviteData;
 }
 
+export interface IEmailCertificationResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+
 export type TSoftwareEdition = "basic" | "pro";

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

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

+ 113 - 81
dashboard-v4/dashboard/src/components/nut/users/SignUp.tsx

@@ -19,7 +19,7 @@ import {
   ISignUpRequest,
 } from "../../api/Auth";
 
-interface IFormData {
+export interface IAccountForm {
   email: string;
   username: string;
   nickname: string;
@@ -27,88 +27,31 @@ interface IFormData {
   password2: string;
   lang: string;
 }
-
-interface IWidget {
-  token?: string;
+interface IAccountInfo {
+  email?: boolean;
 }
-const SignUpWidget = ({ token }: IWidget) => {
+export const AccountInfo = ({ email = true }: IAccountInfo) => {
   const intl = useIntl();
-  const navigate = useNavigate();
-  const [success, setSuccess] = useState(false);
   const [nickname, setNickname] = useState<string>();
-  const formRef = useRef<ProFormInstance>();
-  return success ? (
-    <Result
-      status="success"
-      title="注册成功"
-      subTitle={
-        <Button
-          type="primary"
-          onClick={() => navigate("/anonymous/users/sign-in")}
-        >
-          {intl.formatMessage({
-            id: "nut.users.sign-up.title",
-          })}
-        </Button>
-      }
-    />
-  ) : (
-    <ProForm<IFormData>
-      formRef={formRef}
-      onFinish={async (values: IFormData) => {
-        if (typeof token === "undefined") {
-          return;
-        }
-        if (values.password !== values.password2) {
-          Modal.error({ title: "两次密码不同" });
-          return;
-        }
-        const user = {
-          token: token,
-          username: values.username,
-          nickname: values.nickname ? values.nickname : values.username,
-          email: values.email,
-          password: values.password,
-          lang: values.lang,
-        };
-        const signUp = await post<ISignUpRequest, ISignInResponse>(
-          "/v2/sign-up",
-          user
-        );
-        if (signUp.ok) {
-          setSuccess(true);
-        } else {
-          message.error(signUp.message);
-        }
-      }}
-      request={async () => {
-        const url = `/v2/invite/${token}`;
-        console.info("api request", url);
-        const res = await get<IInviteResponse>(url);
-        console.debug("api response", res.data);
-        return {
-          id: res.data.id,
-          username: "",
-          nickname: "",
-          password: "",
-          password2: "",
-          email: res.data.email,
-          lang: "zh-Hans",
-        };
-      }}
-    >
-      <ProForm.Group>
-        <ProFormText
-          width="md"
-          name="email"
-          required
-          label={intl.formatMessage({
-            id: "forms.fields.email.label",
-          })}
-          rules={[{ required: true, max: 255, min: 4 }]}
-          disabled
-        />
-      </ProForm.Group>
+
+  return (
+    <>
+      {email ? (
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="email"
+            required
+            label={intl.formatMessage({
+              id: "forms.fields.email.label",
+            })}
+            rules={[{ required: true, max: 255, min: 4 }]}
+            disabled
+          />
+        </ProForm.Group>
+      ) : (
+        <></>
+      )}
       <ProForm.Group>
         <ProFormText
           width="md"
@@ -188,10 +131,99 @@ const SignUpWidget = ({ token }: IWidget) => {
           }}
         </ProFormDependency>
       </ProForm.Group>
-
       <ProForm.Group>
         <LangSelect label="常用的译文语言" />
       </ProForm.Group>
+    </>
+  );
+};
+
+export const SignUpSuccess = () => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+  return (
+    <Result
+      status="success"
+      title="注册成功"
+      subTitle={
+        <Button
+          type="primary"
+          onClick={() => navigate("/anonymous/users/sign-in")}
+        >
+          {intl.formatMessage({
+            id: "nut.users.sign-in.title",
+          })}
+        </Button>
+      }
+    />
+  );
+};
+export const onSignIn = async (token: string, values: IAccountForm) => {
+  if (values.password !== values.password2) {
+    Modal.error({ title: "两次密码不同" });
+    return false;
+  }
+  const url = "/v2/sign-up";
+  const data = {
+    token: token,
+    username: values.username,
+    nickname:
+      values.nickname && values.nickname.trim() !== ""
+        ? values.nickname
+        : values.username,
+    email: values.email,
+    password: values.password,
+    lang: values.lang,
+  };
+  console.info("api request", url, data);
+  const signUp = await post<ISignUpRequest, ISignInResponse>(
+    "/v2/sign-up",
+    data
+  );
+  console.info("api response", signUp);
+  return signUp;
+};
+interface IWidget {
+  token?: string;
+}
+const SignUpWidget = ({ token }: IWidget) => {
+  const [success, setSuccess] = useState(false);
+  const formRef = useRef<ProFormInstance>();
+  return success ? (
+    <SignUpSuccess />
+  ) : (
+    <ProForm<IAccountForm>
+      formRef={formRef}
+      onFinish={async (values: IAccountForm) => {
+        if (typeof token === "undefined") {
+          return;
+        }
+        const signUp = await onSignIn(token, values);
+        if (signUp) {
+          if (signUp.ok) {
+            setSuccess(true);
+          } else {
+            message.error(signUp.message);
+          }
+        }
+      }}
+      request={async () => {
+        const url = `/v2/invite/${token}`;
+        console.info("api request", url);
+        const res = await get<IInviteResponse>(url);
+        console.debug("api response", res.data);
+        return {
+          id: res.data.id,
+          username: "",
+          nickname: "",
+          password: "",
+          password2: "",
+          email: res.data.email,
+          lang: "zh-Hans",
+        };
+      }}
+    >
+      <AccountInfo />
     </ProForm>
   );
 };

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

+ 135 - 27
dashboard-v4/dashboard/src/components/users/SignUp.tsx

@@ -1,19 +1,33 @@
 import { useRef, useState } from "react";
 import { useIntl } from "react-intl";
-import { Alert, Button, Result, message } from "antd";
+import { Alert, Button, message } from "antd";
 import type { ProFormInstance } from "@ant-design/pro-components";
 import {
   CheckCard,
   ProForm,
+  ProFormCaptcha,
   ProFormCheckbox,
   ProFormText,
   StepsForm,
 } from "@ant-design/pro-components";
 
-import { post } from "../../request";
-import { IInviteRequest, IInviteResponse } from "../api/Auth";
+import { MailOutlined, LockOutlined } from "@ant-design/icons";
+
+import { get, post } from "../../request";
+import {
+  IEmailCertificationResponse,
+  IInviteData,
+  IInviteRequest,
+  IInviteResponse,
+} from "../api/Auth";
 import { dashboardBasePath } from "../../utils";
 import { get as getUiLang } from "../../locales";
+import {
+  AccountInfo,
+  IAccountForm,
+  onSignIn,
+  SignUpSuccess,
+} from "../nut/users/SignUp";
 
 interface IFormData {
   email: string;
@@ -25,6 +39,7 @@ const SingUpWidget = () => {
   const formRef = useRef<ProFormInstance>();
   const [error, setError] = useState<string>();
   const [agree, setAgree] = useState(false);
+  const [invite, setInvite] = useState<IInviteData>();
   return (
     <StepsForm<IFormData>
       formRef={formRef}
@@ -46,7 +61,7 @@ const SingUpWidget = () => {
                 {"下一步"}
               </Button>
             );
-          } else if (props.step === 2) {
+          } else if (props.step === 3) {
             return <></>;
           } else {
             return dom;
@@ -85,8 +100,8 @@ const SingUpWidget = () => {
                 <div>✅经文阅读</div>
                 <div>✅字典</div>
                 <div>✅经文搜索</div>
-                <div>❌课程</div>
                 <div>❌翻译</div>
+                <div>❌参加课程</div>
               </div>
             }
             value="B"
@@ -142,29 +157,31 @@ const SingUpWidget = () => {
       </StepsForm.StepForm>
 
       <StepsForm.StepForm<{
-        checkbox: string;
+        email: string;
+        captcha: number;
       }>
         name="checkbox"
         title={intl.formatMessage({ id: "auth.sign-up.email-certification" })}
         stepProps={{
           description: " ",
         }}
-        onFinish={async () => {
-          const values = formRef.current?.getFieldsValue();
-          const url = `/v2/invite`;
-          const data: IInviteRequest = {
-            email: values.email,
-            lang: getUiLang(),
-            subject: intl.formatMessage({ id: "labels.email.sign-up.subject" }),
-            studio: "",
-            dashboard: dashboardBasePath(),
-          };
-          console.info("api request", values);
+        onFinish={async (value) => {
+          if (!invite) {
+            message.error("无效的id");
+            return false;
+          }
+          const url = `/v2/email-certification/${invite?.id}`;
+          console.info("api request email-certification", url);
           try {
-            const res = await post<IInviteRequest, IInviteResponse>(url, data);
+            const res = await get<IEmailCertificationResponse>(url);
             console.debug("api response", res);
             if (res.ok) {
-              message.success(intl.formatMessage({ id: "flashes.success" }));
+              if (res.data === value.captcha) {
+                message.success(intl.formatMessage({ id: "flashes.success" }));
+              } else {
+                setError("验证码不正确");
+              }
+              //建立账号
             } else {
               setError(intl.formatMessage({ id: `error.${res.message}` }));
             }
@@ -178,10 +195,13 @@ const SingUpWidget = () => {
         {error ? <Alert type="error" message={error} /> : undefined}
         <ProForm.Group>
           <ProFormText
-            width="md"
+            fieldProps={{
+              size: "large",
+              prefix: <MailOutlined />,
+            }}
             name="email"
             required
-            label={intl.formatMessage({ id: "forms.fields.email.label" })}
+            placeholder={intl.formatMessage({ id: "forms.fields.email.label" })}
             rules={[
               {
                 required: true,
@@ -190,17 +210,105 @@ const SingUpWidget = () => {
             ]}
           />
         </ProForm.Group>
+        <ProForm.Group>
+          <ProFormCaptcha
+            fieldProps={{
+              size: "large",
+              prefix: <LockOutlined />,
+            }}
+            captchaProps={{
+              size: "large",
+            }}
+            placeholder={"请输入验证码"}
+            captchaTextRender={(timing, count) => {
+              if (timing) {
+                return `${count} ${"获取验证码"}`;
+              }
+              return "获取验证码";
+            }}
+            name="captcha"
+            rules={[
+              {
+                required: true,
+                message: "请输入验证码!",
+              },
+            ]}
+            onGetCaptcha={async () => {
+              const values = formRef.current?.getFieldsValue();
+              const url = `/v2/email-certification`;
+              const data: IInviteRequest = {
+                email: values.email,
+                lang: getUiLang(),
+                subject: intl.formatMessage({
+                  id: "labels.email.sign-up.subject",
+                }),
+                studio: "",
+                dashboard: dashboardBasePath(),
+              };
+              console.info("api request", values);
+              try {
+                const res = await post<IInviteRequest, IInviteResponse>(
+                  url,
+                  data
+                );
+                console.debug("api response", res);
+                if (res.ok) {
+                  setInvite(res.data);
+                  message.success(
+                    "邮件发送成功,请登录此邮箱查收邮件,并将邮件中的验证码填入。"
+                  );
+                } else {
+                  setError(intl.formatMessage({ id: `error.${res.message}` }));
+                  message.error("邮件发送失败");
+                }
+              } catch (error) {
+                setError(error as string);
+                message.error("邮件发送失败");
+              }
+            }}
+          />
+        </ProForm.Group>
+      </StepsForm.StepForm>
+      <StepsForm.StepForm<IAccountForm>
+        name="info"
+        title={intl.formatMessage({ id: "auth.sign-up.info" })}
+        onFinish={async (values: IAccountForm) => {
+          if (typeof invite === "undefined") {
+            return false;
+          }
+          values.email = invite.email;
+          const signUp = await onSignIn(invite.id, values);
+          if (signUp) {
+            if (signUp.ok) {
+              return true;
+            } else {
+              message.error(signUp.message);
+              return false;
+            }
+          } else {
+            return false;
+          }
+        }}
+        request={async () => {
+          console.debug("account info", invite);
+          return {
+            id: invite ? invite.id : "",
+            username: "",
+            nickname: "",
+            password: "",
+            password2: "",
+            email: invite ? invite.email : "",
+            lang: "zh-Hant",
+          };
+        }}
+      >
+        <AccountInfo email={false} />
       </StepsForm.StepForm>
-
       <StepsForm.StepForm
         name="finish"
         title={intl.formatMessage({ id: "labels.done" })}
       >
-        <Result
-          status="success"
-          title="注册邮件已经成功发送"
-          subTitle="请查收邮件,根据提示完成注册。"
-        />
+        <SignUpSuccess />
       </StepsForm.StepForm>
     </StepsForm>
   );

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

@@ -13,6 +13,7 @@ const items = {
   "auth.type.user": "user",
   "auth.type.group": "group",
   "auth.sign-up.email-certification": "E-Mail certification",
+  "auth.sign-up.info": "完善个人信息",
 };
 
 export default items;

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

@@ -76,6 +76,14 @@ 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",
 };
 
 export default items;

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

@@ -13,6 +13,7 @@ const items = {
   "auth.type.user": "用户",
   "auth.type.group": "群组",
   "auth.sign-up.email-certification": "邮箱验证",
+  "auth.sign-up.info": "完善个人信息",
 };
 
 export default items;

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

@@ -84,6 +84,14 @@ 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": "任一条件",
 };
 
 export default items;

+ 1 - 1
dashboard-v4/dashboard/src/pages/nut/users/sign-in.tsx

@@ -9,7 +9,7 @@ const Widget = () => {
     <div>
       <Card
         title={intl.formatMessage({
-          id: "nut.users.sign-up.title",
+          id: "nut.users.sign-in.title",
         })}
       >
         <Space direction="vertical">