Przeglądaj źródła

:construction: create

visuddhinanda 3 lat temu
rodzic
commit
1c57100a9a

+ 59 - 0
dashboard/src/components/admin/HeadBar.tsx

@@ -0,0 +1,59 @@
+import { Link } from "react-router-dom";
+import { Col, Row, Input, Layout, Space } from "antd";
+
+import img_banner from "../../assets/studio/images/wikipali_banner.svg";
+import UiLangSelect from "../general/UiLangSelect";
+import SignInAvatar from "../auth/SignInAvatar";
+import ToLibaray from "../auth/ToLibaray";
+import ThemeSelect from "../general/ThemeSelect";
+
+const { Search } = Input;
+const { Header } = Layout;
+
+const onSearch = (value: string) => console.log(value);
+
+const Widget = () => {
+  return (
+    <Header
+      className="header"
+      style={{
+        lineHeight: "44px",
+        height: 44,
+        paddingLeft: 10,
+        paddingRight: 10,
+      }}
+    >
+      <div
+        style={{
+          display: "flex",
+          width: "100%",
+          justifyContent: "space-between",
+        }}
+      >
+        <div style={{ width: 80 }}>
+          <Link to="/">
+            <img alt="code" style={{ height: 36 }} src={img_banner} />
+          </Link>
+        </div>
+        <div style={{ width: 500, lineHeight: 44 }}>
+          <Search
+            disabled
+            placeholder="input search text"
+            onSearch={onSearch}
+            style={{ width: "100%" }}
+          />
+        </div>
+        <div>
+          <Space>
+            <ToLibaray />
+            <SignInAvatar />
+            <UiLangSelect />
+            <ThemeSelect />
+          </Space>
+        </div>
+      </div>
+    </Header>
+  );
+};
+
+export default Widget;

+ 57 - 0
dashboard/src/components/admin/LeftSider.tsx

@@ -0,0 +1,57 @@
+import { Link } from "react-router-dom";
+import type { MenuProps } from "antd";
+import { Affix, Layout } from "antd";
+import { Menu } from "antd";
+import { AppstoreOutlined, HomeOutlined } from "@ant-design/icons";
+
+const { Sider } = Layout;
+
+const onClick: MenuProps["onClick"] = (e) => {
+  console.log("click ", e);
+};
+
+type IWidgetHeadBar = {
+  selectedKeys?: string;
+};
+const Widget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
+  const items: MenuProps["items"] = [
+    {
+      label: "管理",
+      key: "manage",
+      icon: <HomeOutlined />,
+      children: [
+        {
+          label: <Link to="/admin/relation/list">Relation</Link>,
+          key: "relation",
+        },
+        {
+          label: <Link to="/admin/nissaya-ending/list">nissaya-ending</Link>,
+          key: "nissaya-ending",
+        },
+      ],
+    },
+    {
+      label: "统计",
+      key: "advance",
+      icon: <AppstoreOutlined />,
+      children: [],
+    },
+  ];
+
+  return (
+    <Affix offsetTop={0}>
+      <Sider width={200} breakpoint="lg" className="site-layout-background">
+        <Menu
+          theme="light"
+          onClick={onClick}
+          defaultSelectedKeys={[selectedKeys]}
+          defaultOpenKeys={["basic", "advance", "collaboration"]}
+          mode="inline"
+          items={items}
+        />
+      </Sider>
+    </Affix>
+  );
+};
+
+export default Widget;

+ 107 - 0
dashboard/src/components/admin/relation/NissayaEndingEdit.tsx

@@ -0,0 +1,107 @@
+import { ModalForm, ProForm, ProFormText } from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  INissayaEnding,
+  INissayaEndingRequest,
+  INissayaEndingResponse,
+} from "../../../pages/admin/nissaya-ending/list";
+import { get, post, put } from "../../../request";
+import LangSelect from "../../general/LangSelect";
+
+interface IWidget {
+  trigger?: JSX.Element;
+  id?: string;
+  onSuccess?: Function;
+}
+const Widget = ({ trigger = <>{"trigger"}</>, id, onSuccess }: IWidget) => {
+  const [title, setTitle] = useState<string | undefined>(id ? "" : "新建");
+  const [form] = Form.useForm<INissayaEnding>();
+  const intl = useIntl();
+  return (
+    <ModalForm<INissayaEnding>
+      title={title}
+      trigger={trigger}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        console.log(values.ending);
+        let res: INissayaEndingResponse;
+        if (typeof id === "undefined") {
+          res = await post<INissayaEndingRequest, INissayaEndingResponse>(
+            `/v2/nissaya-ending`,
+            values
+          );
+        } else {
+          res = await put<INissayaEndingRequest, INissayaEndingResponse>(
+            `/v2/nissaya-ending/${id}`,
+            values
+          );
+        }
+        console.log(res);
+        if (res.ok) {
+          message.success("提交成功");
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+      request={
+        id
+          ? async () => {
+              const res = await get<INissayaEndingResponse>(
+                `/v2/nissaya-ending/${id}`
+              );
+              console.log("nissaya-ending get", res);
+              if (res.ok) {
+                setTitle(res.data.ending);
+
+                return {
+                  id: id,
+                  ending: res.data.ending,
+                  relation: res.data.relation,
+                  lang: res.data.lang,
+                };
+              } else {
+                return {
+                  id: undefined,
+                  ending: "",
+                  relation: "",
+                  lang: "",
+                };
+              }
+            }
+          : undefined
+      }
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="ending"
+          label={intl.formatMessage({ id: "forms.fields.ending.label" })}
+          tooltip="最长为 24 位"
+        />
+
+        <ProFormText
+          width="md"
+          name="relation"
+          label={intl.formatMessage({ id: "forms.fields.relation.label" })}
+        />
+        <LangSelect width="md" />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default Widget;

+ 144 - 0
dashboard/src/components/admin/relation/RelationEdit.tsx

@@ -0,0 +1,144 @@
+import {
+  ModalForm,
+  ProForm,
+  ProFormInstance,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Form, message } from "antd";
+
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  IRelation,
+  IRelationRequest,
+  IRelationResponse,
+} from "../../../pages/admin/relation/list";
+import { get, post, put } from "../../../request";
+
+interface IWidget {
+  trigger?: JSX.Element;
+  id?: string;
+  onSuccess?: Function;
+}
+const Widget = ({ trigger = <>{"trigger"}</>, id, onSuccess }: IWidget) => {
+  const [title, setTitle] = useState<string | undefined>(id ? "" : "新建");
+  const [form] = Form.useForm<IRelation>();
+  const formRef = useRef<ProFormInstance>();
+  const intl = useIntl();
+  const _case = ["nom", "acc", "gen", "dat", "inst", "abl", "loc"];
+  const caseOptions = _case.map((item) => {
+    return {
+      value: item,
+      label: intl.formatMessage({
+        id: `dict.fields.type.${item}.label`,
+      }),
+    };
+  });
+
+  const _verb = ["pass", "v", "caus", "abs"];
+  const verbOptions = _verb.map((item) => {
+    return {
+      value: item,
+      label: intl.formatMessage({
+        id: `dict.fields.type.${item}.label`,
+      }),
+    };
+  });
+  return (
+    <ModalForm<IRelation>
+      title={title}
+      trigger={trigger}
+      formRef={formRef}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnClose: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        console.log("submit", values);
+        let res: IRelationResponse;
+        if (typeof id === "undefined") {
+          res = await post<IRelationRequest, IRelationResponse>(
+            `/v2/relation`,
+            values
+          );
+        } else {
+          res = await put<IRelationRequest, IRelationResponse>(
+            `/v2/relation/${id}`,
+            values
+          );
+        }
+        console.log(res);
+        if (res.ok) {
+          message.success("提交成功");
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+          }
+        } else {
+          message.error(res.message);
+        }
+
+        return true;
+      }}
+      request={
+        id
+          ? async () => {
+              const res = await get<IRelationResponse>(`/v2/relation/${id}`);
+              console.log("relation get", res);
+              if (res.ok) {
+                setTitle(res.data.name);
+
+                return {
+                  id: id,
+                  name: res.data.name,
+                  case: res.data.case,
+                  to: res.data.to,
+                };
+              } else {
+                return {
+                  id: undefined,
+                  name: "",
+                };
+              }
+            }
+          : undefined
+      }
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          options={caseOptions}
+          fieldProps={{
+            mode: "tags",
+          }}
+          width="md"
+          name="case"
+          allowClear={false}
+          label={intl.formatMessage({ id: "forms.fields.case.label" })}
+        />
+
+        <ProFormSelect
+          options={verbOptions}
+          fieldProps={{
+            mode: "tags",
+          }}
+          width="md"
+          name="to"
+          allowClear={false}
+          label={intl.formatMessage({ id: "forms.fields.case.label" })}
+        />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default Widget;

+ 61 - 0
dashboard/src/components/general/NissayaCard.tsx

@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import { Modal } from "antd";
+
+import { get } from "../../request";
+import { get as getLang } from "../../locales";
+import { IGuideResponse } from "../api/Guide";
+import Marked from "../general/Marked";
+
+interface INissayaCardModal {
+  text?: string;
+  trigger?: JSX.Element;
+}
+export const NissayaCardModal = ({ text, trigger }: INissayaCardModal) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        title="缅文语尾"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Widget text={text} />
+      </Modal>
+    </>
+  );
+};
+
+interface IWidget {
+  text?: string;
+}
+const Widget = ({ text }: IWidget) => {
+  const [guide, setGuide] = useState("Loading");
+  useEffect(() => {
+    const uiLang = getLang();
+    const url = `/v2/nissaya-ending-card?lang=${uiLang}&ending=${text}`;
+    get<IGuideResponse>(url).then((json) => {
+      if (json.ok) {
+        setGuide(json.data);
+      }
+    });
+  }, [text]);
+
+  return <Marked text={guide} />;
+};
+
+export default Widget;

+ 28 - 0
dashboard/src/components/template/Nissaya.tsx

@@ -0,0 +1,28 @@
+import { Space } from "antd";
+import NissayaMeaning from "./Nissaya/NissayaMeaning";
+import PaliText from "./Wbw/PaliText";
+
+interface IWidgetNissayaCtl {
+  pali?: string;
+  meaning?: string;
+  children?: React.ReactNode;
+}
+const NissayaCtl = ({ pali, meaning, children }: IWidgetNissayaCtl) => {
+  return (
+    <Space style={{ marginRight: 10 }}>
+      <PaliText text={pali} code="my" />
+      <NissayaMeaning text={meaning} />
+    </Space>
+  );
+};
+
+interface IWidget {
+  props: string;
+  children?: React.ReactNode;
+}
+const Widget = ({ props, children }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidgetNissayaCtl;
+  return <NissayaCtl {...prop} />;
+};
+
+export default Widget;

+ 199 - 0
dashboard/src/components/template/Nissaya/NissayaMeaning.tsx

@@ -0,0 +1,199 @@
+import GrammarPop from "../../dict/GrammarPop";
+
+interface IWidget {
+  text?: string;
+  code?: string;
+}
+
+interface IEnding {
+  text: string;
+  gid: string;
+}
+//缅文语尾高亮和提示气泡
+function myEndingTooltip(inStr?: string): JSX.Element {
+  if (typeof inStr === "undefined") {
+    return <></>;
+  }
+  let myEnding = [
+    {
+      id: "my_nom1",
+      name: "သည်",
+      tooltip: "主语",
+    },
+    {
+      id: "my_nom2",
+      name: "ကား",
+      tooltip: "主格/主语",
+    },
+    {
+      id: "my_nom3",
+      name: "က",
+      tooltip: "主格/主语",
+    },
+    {
+      id: "my_acc1",
+      name: "ကို",
+      tooltip: "宾格/宾语",
+    },
+    {
+      id: "my_acc2",
+      name: "သို့",
+      tooltip: "宾格/趋向",
+    },
+    {
+      id: "my_inst1",
+      name: "ဖြင့်",
+      tooltip: "具格/用",
+    },
+    {
+      id: "my_inst2",
+      name: "နှင့်",
+      tooltip: "具格/与",
+    },
+    {
+      id: "my_inst2",
+      name: "နှင့်",
+      tooltip: "具格/与",
+    },
+    {
+      id: "my_inst3",
+      name: "ကြောင့်",
+      tooltip: "具格/凭借;从格/原因",
+    },
+    {
+      id: "my_inst3",
+      name: "ကြောင်း",
+      tooltip: "具格/凭借;从格/原因",
+    },
+    {
+      id: "my_dat1",
+      name: "အား",
+      tooltip: "目的格/对象(间接宾语),对……来说",
+    },
+    {
+      id: "my_dat2",
+      name: "ငှာ",
+      tooltip: "目的格/表示目的,为了……",
+    },
+    {
+      id: "my_dat2",
+      name: "အတွက်",
+      tooltip: "目的格/表示目的,为了……",
+    },
+    {
+      id: "my_abl1",
+      name: "မှ",
+      tooltip: "从格/表示来源,从……",
+    },
+    {
+      id: "my_abl2",
+      name: "အောက်",
+      tooltip: "从格/表达比较,比……多",
+    },
+    {
+      id: "my_abl3",
+      name: "ထက်",
+      tooltip: "从格/表达比较,比……少",
+    },
+    {
+      id: "my_gen1",
+      name: "၏",
+      tooltip: "属格/的",
+    },
+    {
+      id: "my_gen2",
+      name: "တွင်",
+      tooltip: "属格/表达范围,……中的",
+    },
+    {
+      id: "my_loc1",
+      name: "၌",
+      tooltip: "处格/处(范围)",
+    },
+    {
+      id: "my_loc2",
+      name: "ကြောင့်",
+      tooltip: "处格/表达动机,因……,旨在……",
+    },
+    {
+      id: "my_abs",
+      name: "၍",
+      tooltip: "连续体",
+    },
+    {
+      id: "my_pl",
+      name: "တို့",
+      tooltip: "复数",
+    },
+    {
+      id: "my_pl",
+      name: "များ",
+      tooltip: "复数",
+    },
+    {
+      id: "my_pl",
+      name: "ကုန်",
+      tooltip: "复数",
+    },
+    {
+      id: "my_pl",
+      name: "ကြ",
+      tooltip: "复数",
+    },
+    {
+      id: "my_time",
+      name: "ပတ်လုံး",
+      tooltip: "时间的整数",
+    },
+    {
+      id: "my_time",
+      name: "လုံလုံး",
+      tooltip: "时间的整数",
+    },
+    {
+      id: "my_length",
+      name: "တိုင်တိုင်",
+      tooltip: "距离,长度的整数",
+    },
+    {
+      id: "my_length",
+      name: "တိုင်အောင်",
+      tooltip: "距离,长度的整数",
+    },
+    {
+      id: "my_def",
+      name: "နေစဉ်",
+      tooltip: "同时发生的时间状语(当……的时候)",
+    },
+    {
+      id: "my_def",
+      name: "လျက်",
+      tooltip: "同时发生的时间状语(当……的时候)",
+    },
+  ];
+
+  let ending: IEnding[] = [];
+  let head: string = inStr;
+  for (const iterator of myEnding) {
+    if (inStr.indexOf(iterator.name) === inStr.length - iterator.name.length) {
+      head = inStr.substring(0, inStr.indexOf(iterator.name));
+      ending.push({ text: iterator.name, gid: iterator.id });
+    }
+  }
+  const eEnding = ending.map((item, id) => {
+    return <GrammarPop text={item.text} key={id} gid={`grammar_${item.gid}`} />;
+  });
+
+  return (
+    <>
+      <span>{head}</span>
+      {eEnding}
+    </>
+  );
+}
+const Widget = ({ text, code = "my" }: IWidget) => {
+  console.log("nissaya-meaning", text);
+  return myEndingTooltip(text);
+};
+
+export default Widget;

+ 76 - 0
dashboard/src/components/template/SentEdit/SentSim.tsx

@@ -0,0 +1,76 @@
+import { Button, message } from "antd";
+import { useEffect, useState } from "react";
+import { ReloadOutlined } from "@ant-design/icons";
+
+import { get } from "../../../request";
+import { ISentenceSimListResponse } from "../../api/Corpus";
+import { IWidgetSentEditInner, SentEditInner } from "../SentEdit";
+
+interface IWidget {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  limit?: number;
+  reload?: boolean;
+  onReload?: Function;
+}
+const Widget = ({
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  limit,
+  reload = false,
+  onReload,
+}: IWidget) => {
+  const [sentData, setSentData] = useState<IWidgetSentEditInner[]>([]);
+
+  const load = () => {
+    let url = `/v2/sent-sim?view=sentence&book=${book}&paragraph=${para}&start=${wordStart}&end=${wordEnd}&limit=10&mode=edit`;
+    if (typeof limit !== "undefined") {
+      url = url + `&limit=${limit}`;
+    }
+    get<ISentenceSimListResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          console.log("pr load", json.data.rows);
+          setSentData(json.data.rows);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        if (reload && typeof onReload !== "undefined") {
+          onReload();
+        }
+      });
+  };
+  useEffect(() => {
+    load();
+  }, []);
+  useEffect(() => {
+    if (reload) {
+      load();
+    }
+  }, [reload]);
+  return (
+    <>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <span></span>
+        <Button
+          type="link"
+          shape="round"
+          icon={<ReloadOutlined />}
+          onClick={() => load()}
+        />
+      </div>
+
+      {sentData.map((item, id) => {
+        return <SentEditInner {...item} key={id} />;
+      })}
+    </>
+  );
+};
+
+export default Widget;

+ 45 - 0
dashboard/src/locales/zh-Hans/relation.ts

@@ -0,0 +1,45 @@
+const items = {
+  "relations.iad.label": "同类修饰",
+  "relations.asv.label": "施动者➡动词",
+  "relations.aov.label": "受动者➡动词",
+  "relations.daso-p.label": "主语➡系动词",
+  "relations.daso-s.label": "表语➡系动词",
+  "relations.nio.label": "被描述(主)➡定性(表)",
+  "relations.nid.label": "待命名➡命名",
+  "relations.dasd-p.label": "<命名>主➡系",
+  "relations.dasd-s.label": "<命名>表➡系",
+  "relations.dao-p.label": "被动语态双宾语-首要",
+  "relations.dao-s.label": "被动语态双宾语-次要",
+  "relations.iov.label": "受动者➡动词",
+  "relations.dio-p.label": "双宾语<主要>➡动词",
+  "relations.dio-s.label": "双宾语<次要>➡动词",
+  "relations.dis-p.label": "主➡系(被动)",
+  "relations.dis-s.label": "表➡系(被动)",
+  "relations.stc.label": "时空连续 ➡ 持续动作",
+  "relations.adv.label": "动词修饰词 ➡ 动词",
+  "relations.imp.label": "方式 ➡ 动词",
+  "relations.soe.label": "<带连词>伴随关系",
+  "relations.soi.label": "<无连词>伴随关系",
+  "relations.isv.label": "<非主格>施动者 ➡ 动词",
+  "relations.cau.label": "因 ➡ 果/归因",
+  "relations.iov-c.label": "被使役宾语 ➡ 动词",
+  "relations.adj.label": "名词的形容 ➡ 被形容",
+  "relations.rec.label": "接收者 ➡ 授予",
+  "relations.pur.label": "目的 ➡ 动词",
+  "relations.det.label": "出发地 ➡ 出发",
+  "relations.coc.label": "区分比较",
+  "relations.pos.label": "所有 ➡ 被所有",
+  "relations.coi.label": "包含[全集] ➡ 被包含[子集]元素/集合 ➡ 个体元素",
+  "relations.lov.label": "容器 ➡ 动词/容纳 ➡ 被容纳",
+  "relations.mot.label": "表现 ➡ 有表现",
+  "relations.whp.label": "整体 ➡ 局部",
+  "relations.def.label": "特征限定",
+  "relations.ac.label": "绝对从句",
+  "relations.avc.label": "绝对语态从句",
+  "relations.qus.label": "引号内 ➡ 引号",
+  "relations.qum.label": "引号 ➡ 引号外",
+  "relations.enu.label": "罗列 ➡ 破折号",
+  "relations.enm.label": "破折号 ➡ 被罗列",
+};
+
+export default items;

+ 16 - 0
dashboard/src/pages/admin/nissaya-ending/index.tsx

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

+ 273 - 0
dashboard/src/pages/admin/nissaya-ending/list.tsx

@@ -0,0 +1,273 @@
+import { useIntl } from "react-intl";
+import { Button, Dropdown, Typography, Modal, message } from "antd";
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  ExclamationCircleOutlined,
+} from "@ant-design/icons";
+
+import { delete_, get } from "../../../request";
+import { IDeleteResponse } from "../../../components/api/Article";
+
+import { useRef } from "react";
+
+import { IUser } from "../../../reducers/current-user";
+import NissayaEndingEdit from "../../../components/admin/relation/NissayaEndingEdit";
+import { LangValueEnum } from "../../../components/general/LangSelect";
+import { NissayaCardModal } from "../../../components/general/NissayaCard";
+
+const { Text } = Typography;
+export interface INissayaEndingRequest {
+  id?: string;
+  ending?: string;
+  lang?: string;
+  relation?: string;
+  editor?: IUser;
+  updated_at?: string;
+  created_at?: string;
+}
+export interface INissayaEndingListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: INissayaEndingRequest[];
+    count: number;
+  };
+}
+export interface INissayaEndingResponse {
+  ok: boolean;
+  message: string;
+  data: INissayaEndingRequest;
+}
+export interface INissayaEnding {
+  sn?: number;
+  id?: string;
+  ending?: string;
+  lang?: string;
+  relation?: string;
+  updatedAt?: number;
+  createdAt?: number;
+}
+const Widget = () => {
+  const intl = useIntl(); //i18n
+
+  const showDeleteConfirm = (id?: string, title?: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.sure",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/v2/nissaya-ending/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType>();
+  return (
+    <>
+      <ProTable<INissayaEnding>
+        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.ending.label",
+            }),
+            dataIndex: "ending",
+            key: "ending",
+            render: (text, row, index, action) => {
+              return (
+                <NissayaEndingEdit
+                  id={row.id}
+                  trigger={
+                    <Button type="link" size="small">
+                      {row.ending}
+                    </Button>
+                  }
+                  onSuccess={() => {
+                    ref.current?.reload();
+                  }}
+                />
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.lang.label",
+            }),
+            dataIndex: "lang",
+            key: "lang",
+            filters: true,
+            valueEnum: LangValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.relation.label",
+            }),
+            dataIndex: "relation",
+            key: "relation",
+          },
+          {
+            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 ? a.createdAt - b.createdAt : 0,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (text, row, index, action) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  menu={{
+                    items: [
+                      {
+                        key: "remove",
+                        label: (
+                          <Text type="danger">
+                            {intl.formatMessage({
+                              id: "buttons.delete",
+                            })}
+                          </Text>
+                        ),
+                        icon: (
+                          <Text type="danger">
+                            <DeleteOutlined />
+                          </Text>
+                        ),
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "share":
+                          break;
+                        case "remove":
+                          if (row.id) {
+                            showDeleteConfirm(row.id, row.ending);
+                          }
+
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <NissayaCardModal
+                    key={index}
+                    text={row.ending}
+                    trigger={
+                      <>
+                        {intl.formatMessage({
+                          id: "buttons.preview",
+                        })}
+                      </>
+                    }
+                  />
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/v2/nissaya-ending?view=a`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          if (typeof params.keyword !== "undefined") {
+            url += "&search=" + (params.keyword ? params.keyword : "");
+          }
+
+          const res = await get<INissayaEndingListResponse>(url);
+          const items: INissayaEnding[] = res.data.rows.map((item, id) => {
+            const date = new Date(item.created_at ? item.created_at : 0);
+            const date2 = new Date(item.updated_at ? item.updated_at : 0);
+            return {
+              sn: id + 1,
+              id: item.id,
+              ending: item.ending,
+              lang: item.lang,
+              relation: item.relation,
+              createdAt: date.getTime(),
+              updatedAt: date2.getTime(),
+            };
+          });
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <NissayaEndingEdit
+            trigger={
+              <Button key="button" icon={<PlusOutlined />} type="primary">
+                {intl.formatMessage({ id: "buttons.create" })}
+              </Button>
+            }
+            onSuccess={() => {
+              ref.current?.reload();
+            }}
+          />,
+        ]}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 15 - 0
dashboard/src/pages/admin/relation/index.tsx

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

+ 332 - 0
dashboard/src/pages/admin/relation/list.tsx

@@ -0,0 +1,332 @@
+import { useIntl } from "react-intl";
+import { Button, Dropdown, Typography, Modal, message, Tag } from "antd";
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  ExclamationCircleOutlined,
+} from "@ant-design/icons";
+
+import { delete_, get } from "../../../request";
+import { IDeleteResponse } from "../../../components/api/Article";
+
+import { useRef } from "react";
+
+import { IUser } from "../../../reducers/current-user";
+import RelationEdit from "../../../components/admin/relation/RelationEdit";
+
+const { Text } = Typography;
+
+export const CaseValueEnum = () => {
+  const intl = useIntl();
+  return {
+    all: {
+      text: "all",
+    },
+    nom: {
+      text: intl.formatMessage({
+        id: "dict.fields.type.nom.label",
+      }),
+    },
+    acc: {
+      text: intl.formatMessage({
+        id: "dict.fields.type.acc.label",
+      }),
+    },
+    gen: {
+      text: intl.formatMessage({
+        id: "dict.fields.type.gen.label",
+      }),
+    },
+    dat: {
+      text: intl.formatMessage({
+        id: "dict.fields.type.dat.label",
+      }),
+    },
+    inst: {
+      text: intl.formatMessage({
+        id: "dict.fields.type.inst.label",
+      }),
+    },
+    abl: {
+      text: intl.formatMessage({
+        id: "dict.fields.type.abl.label",
+      }),
+    },
+    loc: {
+      text: intl.formatMessage({
+        id: "dict.fields.type.loc.label",
+      }),
+    },
+  };
+};
+
+export interface IRelationRequest {
+  id?: string;
+  name?: string;
+  case?: string[];
+  to?: string[];
+  editor?: IUser;
+  updated_at?: string;
+  created_at?: string;
+}
+export interface IRelationListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IRelationRequest[];
+    count: number;
+  };
+}
+export interface IRelationResponse {
+  ok: boolean;
+  message: string;
+  data: IRelationRequest;
+}
+export interface IRelation {
+  sn?: number;
+  id?: string;
+  name?: string;
+  case?: string[];
+  to?: string[];
+  updatedAt?: number;
+  createdAt?: number;
+}
+const Widget = () => {
+  const intl = useIntl(); //i18n
+
+  const showDeleteConfirm = (id?: string, title?: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.sure",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/v2/relation/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType>();
+  return (
+    <>
+      <ProTable<IRelation>
+        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.relation.label",
+            }),
+            dataIndex: "relation",
+            key: "relation",
+            render: (text, row, index, action) => {
+              return (
+                <RelationEdit
+                  id={row.id}
+                  trigger={
+                    <Button type="link" size="small">
+                      {row.name}
+                    </Button>
+                  }
+                  onSuccess={() => {
+                    ref.current?.reload();
+                  }}
+                />
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.name.label",
+            }),
+            dataIndex: "name1",
+            key: "name1",
+            render: (text, row, index, action) => {
+              return intl.formatMessage({
+                id: `relations.${row.name}.label`,
+              });
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.case.label",
+            }),
+            dataIndex: "case",
+            key: "case",
+            filters: true,
+            valueEnum: CaseValueEnum(),
+            render: (text, row, index, action) => {
+              return row.case?.map((item, id) => (
+                <Tag key={id}>
+                  {intl.formatMessage({
+                    id: `dict.fields.type.${item}.label`,
+                  })}
+                </Tag>
+              ));
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.to.label",
+            }),
+            dataIndex: "to",
+            key: "to",
+            render: (text, row, index, action) => {
+              return row.to?.join();
+            },
+          },
+
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated-at",
+            width: 100,
+            search: false,
+            dataIndex: "updatedAt",
+            valueType: "date",
+            sorter: (a, b) =>
+              a.updatedAt && b.updatedAt ? a.updatedAt - b.updatedAt : 0,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (text, row, index, action) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  menu={{
+                    items: [
+                      {
+                        key: "remove",
+                        label: (
+                          <Text type="danger">
+                            {intl.formatMessage({
+                              id: "buttons.delete",
+                            })}
+                          </Text>
+                        ),
+                        icon: (
+                          <Text type="danger">
+                            <DeleteOutlined />
+                          </Text>
+                        ),
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "share":
+                          break;
+                        case "remove":
+                          if (row.id) {
+                            showDeleteConfirm(row.id, row.name);
+                          }
+
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <></>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/v2/relation?view=a`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          if (typeof params.keyword !== "undefined") {
+            url += "&search=" + (params.keyword ? params.keyword : "");
+          }
+
+          const res = await get<IRelationListResponse>(url);
+          const items: IRelation[] = res.data.rows.map((item, id) => {
+            const date = new Date(item.created_at ? item.created_at : 0);
+            const date2 = new Date(item.updated_at ? item.updated_at : 0);
+            return {
+              sn: id + 1,
+              id: item.id,
+              name: item.name,
+              case: item.case,
+              to: item.to,
+              createdAt: date.getTime(),
+              updatedAt: date2.getTime(),
+            };
+          });
+          console.log("relation", items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <RelationEdit
+            trigger={
+              <Button key="button" icon={<PlusOutlined />} type="primary">
+                {intl.formatMessage({ id: "buttons.create" })}
+              </Button>
+            }
+            onSuccess={() => {
+              ref.current?.reload();
+            }}
+          />,
+        ]}
+      />
+    </>
+  );
+};
+
+export default Widget;