Browse Source

Merge pull request #1179 from visuddhinanda/agile

术语气泡fetch
visuddhinanda 2 years ago
parent
commit
2558bf0fc6

+ 8 - 1
dashboard/src/Router.tsx

@@ -99,6 +99,10 @@ import StudioSetting from "./pages/studio/setting";
 
 import StudioAnalysis from "./pages/studio/analysis";
 import StudioAnalysisList from "./pages/studio/analysis/list";
+
+import StudioInvite from "./pages/studio/invite";
+import StudioInviteList from "./pages/studio/invite/list";
+
 import { ConfigProvider } from "antd";
 import { useAppSelector } from "./hooks";
 import { currTheme } from "./reducers/theme";
@@ -123,7 +127,7 @@ const Widget = () => {
         <Route path="anonymous" element={<Anonymous />}>
           <Route path="users">
             <Route path="sign-in" element={<NutUsersSignIn />} />
-            <Route path="sign-up" element={<NutUsersSignUp />} />
+            <Route path="sign-up/:token" element={<NutUsersSignUp />} />
 
             <Route path="unlock">
               <Route path="new" element={<NutUsersUnlockNew />} />
@@ -275,6 +279,9 @@ const Widget = () => {
           <Route path="exp" element={<StudioAnalysis />}>
             <Route path="list" element={<StudioAnalysisList />} />
           </Route>
+          <Route path="invite" element={<StudioInvite />}>
+            <Route path="list" element={<StudioInviteList />} />
+          </Route>
         </Route>
       </Routes>
     </ConfigProvider>

+ 101 - 0
dashboard/src/assets/icon/index.tsx

@@ -179,6 +179,95 @@ const ProgressOutlined = () => (
     ></path>
   </svg>
 );
+
+const StartUp = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="1562"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M920.216 373.282s-155.09-84.012-266.406-44.552c-68.234 24.182-103.976 99.07-79.888 167.336 24.276 68.172 99.198 103.82 167.462 79.638 111.286-39.46 178.832-202.422 178.832-202.422z"
+      fill="#A0D468"
+      p-id="1563"
+    ></path>
+    <path
+      d="M766.408 401.338a21.246 21.246 0 0 0-7.308 0c-2.812 0.468-69.172 12.09-142.282 51.52-31.43 16.902-59.392 36.49-83.48 58.33v-160.932h-42.662V557.52c-27.556 36.21-46.504 76.952-56.378 121.472l41.646 9.216c8.904-40.118 26.354-76.764 51.956-109.13h5.438v-6.654a305.138 305.138 0 0 1 5.544-6.406c25.666-28.774 58.346-54.018 97.088-75.044 67.078-36.398 129.688-47.488 130.312-47.614 8.624-1.5 15.778-8.186 17.402-17.278 2.064-11.624-5.684-22.684-17.276-24.744z"
+      fill="#8CC153"
+      p-id="1564"
+    ></path>
+    <path
+      d="M216.568 812.554S372.19 556.082 512 554.646c139.81-1.468 295.432 257.908 295.432 257.908H216.568z"
+      fill="#434A54"
+      p-id="1565"
+    ></path>
+    <path
+      d="M901.69 735.26c-6.684-0.532-14.028-0.782-22.086-0.782-52.488 0-134.22 11.186-246.226 30.932-13.934-39.742-102.476-57.176-102.476-57.176s-191.096-10.402-313.068-17.056c-7.78-0.438-15.31-0.624-22.558-0.624-161.572-0.032-195.158 100.82-195.158 100.82v104.476c0.062 59.89 184.66 89.26 185.286 89.354 146.638 28.962 326.864 38.68 326.864 38.68s493.87-194.05 506.836-205.39c12.964-11.312 7.276-73.236-117.414-83.234z"
+      fill="#EAC6BB"
+      p-id="1566"
+    ></path>
+    <path
+      d="M771.784 743.446c-38.804 5.156-84.042 12.404-135.75 21.496a1341.336 1341.336 0 0 1-20.34 8.904C480.164 831.676 384.312 843.8 327.778 843.8c-11.778 0-21.324 9.558-21.324 21.338s9.544 21.308 21.324 21.308c88.198 0 190.924-24.776 305.32-73.64 69.17-29.588 120.782-58.832 138.686-69.36z"
+      fill="#DBADA2"
+      p-id="1567"
+    ></path>
+    <path
+      d="M646.904 149.394C622.944 98.968 571.548 64.102 512 64.102s-110.958 34.868-134.922 85.292h-14.372v63.986c0 82.45 66.844 149.308 149.294 149.308 82.448 0 149.308-66.86 149.308-149.308V149.394h-14.404z"
+      fill="#F6BB42"
+      p-id="1568"
+    ></path>
+    <path
+      d="M661.308 149.394c0 82.45-66.862 149.31-149.308 149.31-82.45 0-149.294-66.862-149.294-149.31S429.55 0.118 512 0.118c82.448 0 149.308 66.828 149.308 149.276z"
+      fill="#FFCE54"
+      p-id="1569"
+    ></path>
+    <path
+      d="M533.338 106.748h-0.032c0-11.56-9.202-21.056-20.806-21.338-11.78-0.25-21.542 9.06-21.824 20.838 0 0.156 0.016 0.312 0.016 0.5h-0.016v85.324h0.016c0 11.53 9.202 21.028 20.808 21.308 11.778 0.282 21.542-9.06 21.822-20.84 0-0.156-0.016-0.312-0.016-0.468h0.032V106.748z"
+      fill="#E8AA3D"
+      p-id="1570"
+    ></path>
+  </svg>
+);
+
+const TermOutlined = () => (
+  <svg
+    viewBox="0 0 1346 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="4153"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M1320.940453 319.434073L724.857384 13.866954a123.249191 123.249191 0 0 0-113.593725 0L23.70012 319.718057a44.017568 44.017568 0 0 0 0 78.095685l56.796862 28.398431v289.663998a72.415999 72.415999 0 1 0 70.428109 0v-252.462053l156.191371 80.935529v316.926491a87.183183 87.183183 0 0 0 61.056627 85.195293l186.577693 59.352721a393.034286 393.034286 0 0 0 117.001536 18.174996 378.267102 378.267102 0 0 0 112.173803-16.755074l200.492923-61.056627a87.467168 87.467168 0 0 0 62.192564-83.491388V539.521914L1320.940453 397.813742a44.017568 44.017568 0 0 0 23.854682-39.189835 44.585537 44.585537 0 0 0-23.854682-39.189834zM975.61553 860.992154a17.607027 17.607027 0 0 1-12.495309 16.755074L762.627297 937.383933a312.382742 312.382742 0 0 1-187.713629 0l-186.293708-59.068736a17.607027 17.607027 0 0 1-12.211326-16.755075v-280.860483l235.422994 122.397238a125.237081 125.237081 0 0 0 56.796862 13.915231 122.681222 122.681222 0 0 0 56.796862-13.631247l251.6101-129.212862z m-283.984311-220.087841a52.537098 52.537098 0 0 1-48.277332 0L101.227837 358.339923l543.261987-281.996421a51.969129 51.969129 0 0 1 48.277333 0l550.361594 281.996421z"
+      p-id="4154"
+    ></path>
+  </svg>
+);
+
+const Term = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="5672"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M929.2 228.4L523.5 34.9c-11.9-8.1-27.8-9.7-41.6-2.8L60.6 244.2c-0.5 0.2-0.9 0.5-1.4 0.7-8.2 3.9-15.1 10.5-19.3 19.3-9.7 20.4-1.1 44.9 19.3 54.6l412 196.5c2 0.9 4 1.7 6 2.3 9.9 3.3 21.2 2.8 31.2-2.3l346-174.2v261c0 22.6 18.3 41 41 41 22.6 0 41-18.3 41-41v-303c8.4-6.4 13.7-15.6 15.4-25.6 3.6-18-5.3-36.8-22.6-45.1z"
+      p-id="5673"
+    ></path>
+    <path
+      d="M818.1 458.3c-7.5-13.3-22.8-20.8-38.4-20.2h-1.3c-6.5 0.4-12.6 2.2-17.9 5L489.3 565.7 255.5 465.6c-5.8-2.8-12.3-4.3-19.3-4.3-22.6 0-41 16.5-41 36.7v219.4c0 0.6 0 1.3 0.1 1.9 2.3 99.8 141.8 180.3 313.4 180.3 170.8 0 309.7-79.7 313.4-178.7 0.1-0.8 0.1-1.5 0.1-2.3V478.4c0.8-6.7-0.5-13.7-4.1-20.1z"
+      fill="#242424"
+      p-id="5674"
+    ></path>
+  </svg>
+);
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -221,3 +310,15 @@ export const ParagraphOutlinedIcon = (
 export const ProgressOutlinedIcon = (
   props: Partial<CustomIconComponentProps>
 ) => <Icon component={ProgressOutlined} {...props} />;
+
+export const StartUpIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={StartUp} {...props} />
+);
+
+export const TermOutlinedIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={TermOutlined} {...props} />
+);
+
+export const TermIcon2 = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={Term} {...props} />
+);

+ 16 - 0
dashboard/src/components/blog/TimeLine.tsx

@@ -1,6 +1,7 @@
 import { Timeline } from "antd";
 import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
+import { StartUpIcon, TermIcon2, TermOutlinedIcon } from "../../assets/icon";
 import { get } from "../../request";
 import TimeShow from "../general/TimeShow";
 
@@ -44,9 +45,24 @@ const TimeLineWidget = ({ studioName }: IWidget) => {
     <>
       <Timeline mode="left" style={{ width: "100%" }}>
         {milestone.map((item, id) => {
+          let icon = <></>;
+          switch (item.event) {
+            case "sign-in":
+              icon = (
+                <StartUpIcon style={{ fontSize: "2em", background: "unset" }} />
+              );
+              break;
+            case "first-term":
+              icon = <TermIcon2 style={{ fontSize: "2em" }} />;
+              break;
+            default:
+              break;
+          }
           return (
             <Timeline.Item
+              style={{ backgroundColor: "unset" }}
               key={id}
+              dot={icon}
               label={<TimeShow time={item.date} showIcon={false} />}
             >
               {intl.formatMessage({

+ 5 - 2
dashboard/src/components/general/LangSelect.tsx

@@ -25,8 +25,9 @@ export const LangValueEnum = () => {
 
 interface IWidget {
   width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  label?: string;
 }
-const LangSelectWidget = ({ width }: IWidget) => {
+const LangSelectWidget = ({ width, label }: IWidget) => {
   const intl = useIntl();
 
   const langOptions = [
@@ -55,7 +56,9 @@ const LangSelectWidget = ({ width }: IWidget) => {
       showSearch
       debounceTime={300}
       allowClear={false}
-      label={intl.formatMessage({ id: "forms.fields.lang.label" })}
+      label={
+        label ? label : intl.formatMessage({ id: "forms.fields.lang.label" })
+      }
       rules={[
         {
           required: true,

+ 7 - 3
dashboard/src/components/general/NissayaCard.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from "react";
-import { Modal, Popover, Typography } from "antd";
+import { Modal, Popover, Skeleton, Typography } from "antd";
 
 import { get } from "../../request";
 import { get as getLang } from "../../locales";
@@ -58,7 +58,7 @@ interface IWidget {
   cache?: boolean;
 }
 const NissayaCardWidget = ({ text, cache = false }: IWidget) => {
-  const [guide, setGuide] = useState("Loading");
+  const [guide, setGuide] = useState<string>();
 
   useEffect(() => {
     const uiLang = getLang();
@@ -81,7 +81,11 @@ const NissayaCardWidget = ({ text, cache = false }: IWidget) => {
     });
   }, [cache, text]);
 
-  return <Marked text={guide} />;
+  return guide ? (
+    <Marked text={guide} />
+  ) : (
+    <Skeleton title={{ width: 200 }} paragraph={{ rows: 4 }} active />
+  );
 };
 
 export default NissayaCardWidget;

+ 1 - 1
dashboard/src/components/general/UiLangSelect.tsx

@@ -41,7 +41,7 @@ const UiLangSelectWidget = () => {
 
   return (
     <Dropdown menu={{ items, onClick }} placement="bottomRight">
-      <Button ghost icon={<GlobalOutlined />}>
+      <Button ghost style={{ border: "unset" }} icon={<GlobalOutlined />}>
         {curr}
       </Button>
     </Dropdown>

+ 83 - 0
dashboard/src/components/invite/InviteCreate.tsx

@@ -0,0 +1,83 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { post } from "../../request";
+import { useRef } from "react";
+import { IInviteData } from "../../pages/studio/invite/list";
+import LangSelect from "../general/LangSelect";
+
+interface IInviteRequest {
+  email: string;
+  lang: string;
+  studio: string;
+}
+interface IInviteResponse {
+  ok: boolean;
+  message: string;
+  data: IInviteData;
+}
+interface IFormData {
+  email: string;
+  lang: string;
+}
+
+interface IWidget {
+  studio?: string;
+  onCreate?: Function;
+}
+const InviteCreateWidget = ({ studio, onCreate }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        // TODO
+        if (typeof studio === "undefined") {
+          return;
+        }
+        console.log(values);
+        const res = await post<IInviteRequest, IInviteResponse>(`/v2/invite`, {
+          email: values.email,
+          lang: values.lang,
+          studio: studio,
+        });
+        console.log(res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+            formRef.current?.resetFields();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="email"
+          required
+          label={intl.formatMessage({ id: "forms.fields.email.label" })}
+          rules={[
+            {
+              required: true,
+              type: "email",
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <LangSelect />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default InviteCreateWidget;

+ 14 - 9
dashboard/src/components/nut/users/SignIn.tsx

@@ -2,6 +2,7 @@ import { useIntl } from "react-intl";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
 import { message } from "antd";
 import { useNavigate } from "react-router-dom";
+import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
 
 import { useAppDispatch } from "../../../hooks";
 import { IUser, signIn, TO_HOME } from "../../../reducers/current-user";
@@ -34,18 +35,18 @@ const Widget = () => {
     <ProForm<IFormData>
       onFinish={async (values: IFormData) => {
         const user = {
-          username: values.email,
-          password: values.password,
+          username: values.email.trim(),
+          password: values.password.trim(),
         };
-        const signin = await post<ISignInRequest, ISignInResponse>(
-          "/v2/auth/signin",
+        const res = await post<ISignInRequest, ISignInResponse>(
+          "/v2/sign-in",
           user
         );
-        if (signin.ok) {
-          localStorage.setItem("token", signin.data);
+        if (res.ok) {
+          localStorage.setItem("token", res.data);
           get<IUserResponse>("/v2/auth/current").then((json) => {
             if (json.ok) {
-              dispatch(signIn([json.data, signin.data]));
+              dispatch(signIn([json.data, res.data]));
               navigate(TO_HOME);
             } else {
               console.error(json.message);
@@ -54,7 +55,7 @@ const Widget = () => {
 
           message.success(intl.formatMessage({ id: "flashes.success" }));
         } else {
-          message.error(signin.message);
+          message.error(res.message);
         }
       }}
     >
@@ -70,9 +71,13 @@ const Widget = () => {
         />
       </ProForm.Group>
       <ProForm.Group>
-        <ProFormText
+        <ProFormText.Password
           width="md"
           name="password"
+          fieldProps={{
+            iconRender: (visible) =>
+              visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
+          }}
           required
           label={intl.formatMessage({
             id: "forms.fields.password.label",

+ 189 - 0
dashboard/src/components/nut/users/SignUp.tsx

@@ -0,0 +1,189 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormDependency,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Button, message, Modal, Result } from "antd";
+import { useNavigate } from "react-router-dom";
+import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
+
+import { get, post } from "../../../request";
+import { IInviteResponse } from "../../../pages/studio/invite/list";
+import LangSelect from "../../general/LangSelect";
+import { useState } from "react";
+
+interface IFormData {
+  email: string;
+  username: string;
+  nickname: string;
+  password: string;
+  password2: string;
+  lang: string;
+}
+interface ISignInResponse {
+  ok: boolean;
+  message: string;
+  data: string;
+}
+
+interface ISignUpRequest {
+  token: string;
+  username: string;
+  nickname: string;
+  email: string;
+  password: string;
+  lang: string;
+}
+
+interface IWidget {
+  token?: string;
+}
+const SignUpWidget = ({ token }: IWidget) => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+  const [success, setSuccess] = useState(false);
+  const [nickname, setNickname] = useState<string>();
+  return success ? (
+    <Result
+      status="success"
+      title="注册成功"
+      subTitle={
+        <Button
+          type="primary"
+          onClick={() => navigate("/anonymous/users/sign-in")}
+        >
+          登录
+        </Button>
+      }
+    />
+  ) : (
+    <ProForm<IFormData>
+      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,
+          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 res = await get<IInviteResponse>(`/v2/invite/${token}`);
+        console.log(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>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="username"
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.username.label",
+          })}
+          rules={[{ required: true, max: 255, min: 4 }]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText.Password
+          width="md"
+          name="password"
+          fieldProps={{
+            type: "password",
+
+            iconRender: (visible) =>
+              visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
+          }}
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.password.label",
+          })}
+          rules={[{ required: true, max: 32, min: 4 }]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText.Password
+          width="md"
+          name="password2"
+          fieldProps={{
+            type: "password",
+            iconRender: (visible) =>
+              visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
+          }}
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.confirm-password.label",
+          })}
+          rules={[{ required: true, max: 32, min: 4 }]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormDependency name={["username"]}>
+          {({ username }) => {
+            return (
+              <ProFormText
+                width="md"
+                fieldProps={{
+                  placeholder: username,
+                  value: nickname ? nickname : username,
+                  onChange: (event) => {
+                    setNickname(event.target.value);
+                  },
+                }}
+                name="nickname"
+                required
+                label={intl.formatMessage({
+                  id: "forms.fields.nickname.label",
+                })}
+                rules={[{ required: true, max: 32, min: 4 }]}
+              />
+            );
+          }}
+        </ProFormDependency>
+      </ProForm.Group>
+      <ProForm.Group>
+        <LangSelect label="常用的译文语言" />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default SignUpWidget;

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

@@ -176,6 +176,16 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
           ),
           key: "group",
         },
+        {
+          label: (
+            <Link to={`/studio/${studioname}/invite/list`}>
+              {intl.formatMessage({
+                id: "columns.studio.invite.title",
+              })}
+            </Link>
+          ),
+          key: "invite",
+        },
       ],
     },
   ];

+ 44 - 3
dashboard/src/components/template/Term.tsx

@@ -1,4 +1,4 @@
-import { Button, Popover } from "antd";
+import { Button, Popover, Skeleton } from "antd";
 import { Typography } from "antd";
 import { SearchOutlined, EditOutlined } from "@ant-design/icons";
 import { ProCard } from "@ant-design/pro-components";
@@ -10,9 +10,16 @@ import { useEffect, useState } from "react";
 import { ITermDataResponse } from "../api/Term";
 import { changedTerm, refresh } from "../../reducers/term-change";
 import { useAppSelector } from "../../hooks";
+import { get } from "../../request";
 
 const { Text, Link } = Typography;
 
+interface ITermSummary {
+  ok: boolean;
+  message: string;
+  data: string;
+}
+
 interface IWidgetTermCtl {
   id?: string;
   word?: string;
@@ -22,6 +29,7 @@ interface IWidgetTermCtl {
   parentChannelId?: string;
   parentStudioId?: string;
   summary?: string;
+  isCommunity?: string;
 }
 const TermCtl = ({
   id,
@@ -32,9 +40,12 @@ const TermCtl = ({
   parentChannelId,
   parentStudioId,
   summary,
+  isCommunity,
 }: IWidgetTermCtl) => {
   const [openPopover, setOpenPopover] = useState(false);
   const [termData, setTermData] = useState<ITerm>();
+  const [content, setContent] = useState<string>();
+
   const newTerm: ITermDataResponse | undefined = useAppSelector(changedTerm);
 
   useEffect(() => {
@@ -73,6 +84,26 @@ const TermCtl = ({
           open={openPopover}
           onOpenChange={(visible) => {
             setOpenPopover(visible);
+            if (
+              visible &&
+              typeof content === "undefined" &&
+              typeof id !== "undefined"
+            ) {
+              const value = sessionStorage.getItem(`term/summary/${id}`);
+              if (value !== null) {
+                setContent(value);
+                return;
+              } else {
+                const url = `/v2/term-summary/${id}`;
+                console.log("url", url);
+                get<ITermSummary>(url).then((json) => {
+                  if (json.ok) {
+                    setContent(json.data !== "" ? json.data : " ");
+                    sessionStorage.setItem(`term/summary/${id}`, json.data);
+                  }
+                });
+              }
+            }
           }}
           content={
             <ProCard
@@ -113,12 +144,22 @@ const TermCtl = ({
                 />,
               ]}
             >
-              <div>{termData.summary}</div>
+              <div>
+                {content ? (
+                  content
+                ) : (
+                  <Skeleton
+                    title={{ width: 200 }}
+                    paragraph={{ rows: 4 }}
+                    active
+                  />
+                )}
+              </div>
             </ProCard>
           }
           placement="bottom"
         >
-          <Link>
+          <Link style={{ color: isCommunity ? "green" : undefined }}>
             {termData?.meaning
               ? termData?.meaning
               : termData?.word

+ 3 - 3
dashboard/src/layouts/anonymous/index.tsx

@@ -1,14 +1,14 @@
 import { Outlet } from "react-router-dom";
-import { Col, Layout, Row } from "antd";
+import { Col, Row } from "antd";
 import UiLangSelect from "../../components/general/UiLangSelect";
 import img_banner from "../../assets/library/images/wikipali_logo_library.svg";
 
 const Widget = () => {
   return (
     <>
-      <Layout style={{ textAlign: "right", backgroundColor: "#3e3e3e" }}>
+      <div style={{ textAlign: "right", backgroundColor: "#3e3e3e" }}>
         <UiLangSelect />
-      </Layout>
+      </div>
       <div style={{ paddingTop: "3em", backgroundColor: "#3e3e3e" }}>
         <Row>
           <Col flex="auto"></Col>

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

@@ -51,6 +51,7 @@ const items = {
   "buttons.download": "下载",
   "buttons.download.link": "下载链接",
   "buttons.lookup": "查字典",
+  "buttons.invite": "邀请",
 };
 
 export default items;

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

@@ -2,6 +2,7 @@ const items = {
   "forms.fields.email.label": "电子邮箱",
   "forms.fields.email.or.username.label": "电子邮箱/用户名",
   "forms.fields.password.label": "密码",
+  "forms.fields.confirm-password.label": "确认密码",
   "forms.fields.id.label": "ID",
   "forms.fields.message.label": "消息",
   "forms.fields.created-at.label": "创建时间",
@@ -64,6 +65,8 @@ const items = {
   "forms.message.res.remove": "将此此资源从群中移除吗?",
   "forms.fields.parent2.label": "衍生原型",
   "forms.fields.parent2.tooltip": "最长为 256个字符",
+  "forms.fields.username.label": "用户名(登录名)",
+  "forms.fields.nickname.label": "昵称",
 };
 
 export default items;

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

@@ -45,6 +45,7 @@ const items = {
   "columns.studio.advance.title": "高级",
   "columns.studio.setting.title": "设置",
   "columns.exp.title": "经验",
+  "columns.studio.invite.title": "注册邀请",
   ...buttons,
   ...forms,
   ...tables,

+ 5 - 3
dashboard/src/pages/nut/users/sign-up.tsx

@@ -1,18 +1,20 @@
 import { Card } from "antd";
 import { useIntl } from "react-intl";
+import { useParams } from "react-router-dom";
 import SharedLinks from "../../../components/nut/users/NonSignInSharedLinks";
+import SignUp from "../../../components/nut/users/SignUp";
 
 const Widget = () => {
   const intl = useIntl();
+  const { token } = useParams(); //url 参数
 
   return (
     <Card
       title={intl.formatMessage({
-        id: "nut.users.sign-in.title",
+        id: "nut.users.sign-up.title",
       })}
     >
-      sign up
-      <br />
+      <SignUp token={token} />
       <SharedLinks />
     </Card>
   );

+ 22 - 0
dashboard/src/pages/studio/invite/index.tsx

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

+ 161 - 0
dashboard/src/pages/studio/invite/list.tsx

@@ -0,0 +1,161 @@
+import { useParams } from "react-router-dom";
+import { useIntl } from "react-intl";
+import { Button, Popover } from "antd";
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import { UserAddOutlined } from "@ant-design/icons";
+
+import { get } from "../../../request";
+import { RoleValueEnum } from "../../../components/studio/table";
+
+import { useRef, useState } from "react";
+import InviteCreate from "../../../components/invite/InviteCreate";
+
+export interface IInviteData {
+  id: string;
+  user_uid: string;
+  email: string;
+  status: string;
+  created_at: string;
+  updated_at: string;
+}
+interface IInviteListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IInviteData[];
+    count: number;
+  };
+}
+export interface IInviteResponse {
+  ok: boolean;
+  message: string;
+  data: IInviteData;
+}
+
+interface DataItem {
+  sn: number;
+  id: string;
+  email: string;
+  status: string;
+  createdAt: number;
+}
+
+const Widget = () => {
+  const intl = useIntl(); //i18n
+  const { studioname } = useParams(); //url 参数
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const ref = useRef<ActionType>();
+
+  return (
+    <>
+      <ProTable<DataItem>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.email.label",
+            }),
+            dataIndex: "email",
+            key: "email",
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.status.label",
+            }),
+            dataIndex: "status",
+            key: "status",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: RoleValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.created-at.label",
+            }),
+            key: "created-at",
+            width: 100,
+            search: false,
+            dataIndex: "createdAt",
+            valueType: "date",
+            sorter: (a, b) => a.createdAt - b.createdAt,
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          // TODO
+          console.log(params, sorter, filter);
+          let url = `/v2/invite?view=studio&studio=${studioname}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          console.log(url);
+          const res = await get<IInviteListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            const date = new Date(item.created_at);
+            return {
+              sn: id + 1,
+              id: item.id,
+              email: item.email,
+              status: item.status,
+              createdAt: date.getTime(),
+            };
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Popover
+            content={
+              <InviteCreate
+                studio={studioname}
+                onCreate={() => {
+                  setOpenCreate(false);
+                  ref.current?.reload();
+                }}
+              />
+            }
+            placement="bottomRight"
+            trigger="click"
+            open={openCreate}
+            onOpenChange={(open: boolean) => {
+              setOpenCreate(open);
+            }}
+          >
+            <Button key="button" icon={<UserAddOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.invite" })}
+            </Button>
+          </Popover>,
+        ]}
+      />
+    </>
+  );
+};
+
+export default Widget;