ソースを参照

Merge pull request #1943 from visuddhinanda/agile

add StudioAttachment
visuddhinanda 2 年 前
コミット
528be225dc
28 ファイル変更887 行追加159 行削除
  1. 7 0
      dashboard/src/Router.tsx
  2. 24 25
      dashboard/src/components/anthology/EditableTocTree.tsx
  3. 11 0
      dashboard/src/components/api/Attachments.ts
  4. 7 0
      dashboard/src/components/article/Article.tsx
  5. 29 2
      dashboard/src/components/article/ArticleDrawer.tsx
  6. 2 2
      dashboard/src/components/article/ArticleEdit.tsx
  7. 5 9
      dashboard/src/components/article/EditableTree.tsx
  8. 24 35
      dashboard/src/components/article/EditableTreeNode.tsx
  9. 1 1
      dashboard/src/components/article/RightPanel.tsx
  10. 13 2
      dashboard/src/components/article/TypeArticle.tsx
  11. 29 10
      dashboard/src/components/article/TypeArticleReaderToolbar.tsx
  12. 105 0
      dashboard/src/components/attachment/AttachmentImport.tsx
  13. 399 0
      dashboard/src/components/attachment/AttachmentList.tsx
  14. 31 3
      dashboard/src/components/dict/DictContent.tsx
  15. 39 0
      dashboard/src/components/dict/DictGroupTitle.tsx
  16. 2 2
      dashboard/src/components/dict/MyCreate.tsx
  17. 48 35
      dashboard/src/components/dict/WordCard.tsx
  18. 10 8
      dashboard/src/components/dict/WordCardByDict.tsx
  19. 24 0
      dashboard/src/components/general/FileSize.tsx
  20. 23 10
      dashboard/src/components/general/VideoModal.tsx
  21. 12 14
      dashboard/src/components/studio/LeftSider.tsx
  22. 2 0
      dashboard/src/locales/en-US/buttons.ts
  23. 1 0
      dashboard/src/locales/en-US/index.ts
  24. 2 0
      dashboard/src/locales/zh-Hans/buttons.ts
  25. 1 0
      dashboard/src/locales/zh-Hans/index.ts
  26. 6 1
      dashboard/src/locales/zh-Hans/label.ts
  27. 20 0
      dashboard/src/pages/studio/attachment/index.tsx
  28. 10 0
      dashboard/src/pages/studio/attachment/list.tsx

+ 7 - 0
dashboard/src/Router.tsx

@@ -118,6 +118,9 @@ import StudioAnthology from "./pages/studio/anthology";
 import StudioAnthologyList from "./pages/studio/anthology/list";
 import StudioAnthologyEdit from "./pages/studio/anthology/edit";
 
+import StudioAttachment from "./pages/studio/attachment";
+import StudioAttachmentList from "./pages/studio/attachment/list";
+
 import StudioSetting from "./pages/studio/setting";
 
 import StudioAnalysis from "./pages/studio/analysis";
@@ -307,6 +310,10 @@ const Widget = () => {
             <Route path="list" element={<StudioTermList />} />
           </Route>
 
+          <Route path="attachment" element={<StudioAttachment />}>
+            <Route path="list" element={<StudioAttachmentList />} />
+          </Route>
+
           <Route path="article" element={<StudioArticle />}>
             <Route path="list" element={<StudioArticleList />} />
             <Route path="edit/:articleId" element={<StudioArticleEdit />} />

+ 24 - 25
dashboard/src/components/anthology/EditableTocTree.tsx

@@ -19,7 +19,6 @@ import EditableTree, {
   ListNodeData,
   TreeNodeData,
 } from "../article/EditableTree";
-import ArticleEditDrawer from "../article/ArticleEditDrawer";
 import ArticleDrawer from "../article/ArticleDrawer";
 import { fullUrl, randomString } from "../../utils";
 
@@ -35,11 +34,9 @@ const EditableTocTreeWidget = ({
 }: IWidget) => {
   const [tocData, setTocData] = useState<ListNodeData[]>([]);
   const [addArticle, setAddArticle] = useState<TreeNodeData>();
-  const [articleId, setArticleId] = useState<string>();
-  const [openEditor, setOpenEditor] = useState(false);
   const [updatedArticle, setUpdatedArticle] = useState<TreeNodeData>();
   const [openViewer, setOpenViewer] = useState(false);
-  const [viewArticleId, setViewArticleId] = useState<string>();
+  const [viewArticle, setViewArticle] = useState<TreeNodeData>();
 
   const save = (data?: ListNodeData[]) => {
     console.debug("onSave", data);
@@ -163,10 +160,6 @@ const EditableTocTreeWidget = ({
             return;
           }
         }}
-        onNodeEdit={(key: string) => {
-          setArticleId(key);
-          setOpenEditor(true);
-        }}
         onTitleClick={(
           e: React.MouseEvent<HTMLElement, MouseEvent>,
           node: TreeNodeData
@@ -174,33 +167,39 @@ const EditableTocTreeWidget = ({
           if (e.ctrlKey || e.metaKey) {
             window.open(fullUrl(`/article/article/${node.id}`), "_blank");
           } else {
-            setViewArticleId(node.id);
+            setViewArticle(node);
             setOpenViewer(true);
           }
         }}
       />
-      <ArticleEditDrawer
-        anthologyId={anthologyId}
-        articleId={articleId}
-        open={openEditor}
-        onClose={() => setOpenEditor(false)}
-        onChange={(data: IArticleDataResponse) => {
-          console.log("new title", data.title);
+      <ArticleDrawer
+        articleId={viewArticle?.id}
+        type="article"
+        open={openViewer}
+        title={viewArticle?.title_text}
+        onClose={() => setOpenViewer(false)}
+        onArticleEdit={(value: IArticleDataResponse) => {
           setUpdatedArticle({
             key: randomString(),
-            id: data.uid,
-            title: data.title,
-            title_text: data.title_text ? data.title_text : data.title,
+            id: value.uid,
+            title: value.title,
+            title_text: value.title_text,
             level: 0,
             children: [],
           });
         }}
-      />
-      <ArticleDrawer
-        articleId={viewArticleId}
-        type="article"
-        open={openViewer}
-        onClose={() => setOpenViewer(false)}
+        onTitleChange={(value: string) => {
+          if (viewArticle?.id) {
+            setUpdatedArticle({
+              key: randomString(),
+              id: viewArticle?.id,
+              title: value,
+              title_text: value,
+              level: 0,
+              children: [],
+            });
+          }
+        }}
       />
     </div>
   );

+ 11 - 0
dashboard/src/components/api/Attachments.ts

@@ -6,9 +6,20 @@ export interface IAttachmentRequest {
   size: number;
   content_type: string;
   url: string;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface IAttachmentUpdate {
+  title: string;
 }
 export interface IAttachmentResponse {
   ok: boolean;
   message: string;
   data: IAttachmentRequest;
 }
+
+export interface IAttachmentListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IAttachmentRequest[]; count: number };
+}

+ 7 - 0
dashboard/src/components/article/Article.tsx

@@ -61,6 +61,7 @@ interface IWidget {
   onLoad?: Function;
   onAnthologySelect?: Function;
   onTitle?: Function;
+  onArticleEdit?: Function;
 }
 const ArticleWidget = ({
   type,
@@ -80,6 +81,7 @@ const ArticleWidget = ({
   onLoad,
   onAnthologySelect,
   onTitle,
+  onArticleEdit,
 }: IWidget) => {
   const [currId, setCurrId] = useState(articleId);
   useEffect(() => setCurrId(articleId), [articleId]);
@@ -94,6 +96,11 @@ const ArticleWidget = ({
           mode={mode}
           anthologyId={anthologyId}
           active={active}
+          onArticleEdit={(value: IArticleDataResponse) => {
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
           onArticleChange={(type: ArticleType, id: string, target: string) => {
             if (typeof onArticleChange !== "undefined") {
               onArticleChange(type, id, target);

+ 29 - 2
dashboard/src/components/article/ArticleDrawer.tsx

@@ -1,8 +1,10 @@
-import { Button, Drawer, Space } from "antd";
+import { Button, Drawer, Space, Typography } from "antd";
 import React, { useEffect, useState } from "react";
 import { Link } from "react-router-dom";
 
 import Article, { ArticleMode, ArticleType } from "./Article";
+import { IArticleDataResponse } from "../api/Article";
+const { Text } = Typography;
 
 interface IWidget {
   trigger?: React.ReactNode;
@@ -16,6 +18,8 @@ interface IWidget {
   mode?: ArticleMode;
   open?: boolean;
   onClose?: Function;
+  onTitleChange?: Function;
+  onArticleEdit?: Function;
 }
 
 const ArticleDrawerWidget = ({
@@ -30,9 +34,13 @@ const ArticleDrawerWidget = ({
   mode,
   open,
   onClose,
+  onTitleChange,
+  onArticleEdit,
 }: IWidget) => {
   const [openDrawer, setOpenDrawer] = useState(open);
+  const [drawerTitle, setDrawerTitle] = useState(title);
   useEffect(() => setOpenDrawer(open), [open]);
+  useEffect(() => setDrawerTitle(title), [title]);
   const showDrawer = () => {
     setOpenDrawer(true);
   };
@@ -60,7 +68,20 @@ const ArticleDrawerWidget = ({
     <>
       <span onClick={() => showDrawer()}>{trigger}</span>
       <Drawer
-        title={title}
+        title={
+          <Text
+            editable={{
+              onChange: (value: string) => {
+                setDrawerTitle(value);
+                if (typeof onTitleChange !== "undefined") {
+                  onTitleChange(value);
+                }
+              },
+            }}
+          >
+            {drawerTitle}
+          </Text>
+        }
         width={1000}
         placement="right"
         onClose={onDrawerClose}
@@ -85,6 +106,12 @@ const ArticleDrawerWidget = ({
           channelId={channelId}
           articleId={articleId}
           mode={mode}
+          onArticleEdit={(value: IArticleDataResponse) => {
+            setDrawerTitle(value.title_text);
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
         />
       </Drawer>
     </>

+ 2 - 2
dashboard/src/components/article/ArticleEdit.tsx

@@ -96,10 +96,10 @@ const ArticleEditWidget = ({
             anthology_id: anthologyId,
           };
           const url = `/v2/article/${articleId}`;
-          console.log("save", url, request);
+          console.info("save url", url, request);
           put<IArticleDataRequest, IArticleResponse>(url, request)
             .then((res) => {
-              console.log("save response", res);
+              console.debug("save response", res);
               if (res.ok) {
                 if (typeof onChange !== "undefined") {
                   onChange(res.data);

+ 5 - 9
dashboard/src/components/article/EditableTree.tsx

@@ -15,7 +15,7 @@ export interface TreeNodeData {
   key: string;
   id: string;
   title: string | React.ReactNode;
-  title_text?: string | React.ReactNode;
+  title_text?: string;
   icon?: React.ReactNode;
   children: TreeNodeData[];
   deletedAt?: string | null;
@@ -24,7 +24,7 @@ export interface TreeNodeData {
 export type ListNodeData = {
   key: string;
   title: string | React.ReactNode;
-  title_text?: string | React.ReactNode;
+  title_text?: string;
   level: number;
   children?: number;
   deletedAt?: string | null;
@@ -143,7 +143,7 @@ interface IWidget {
   onSave?: Function;
   onAddFile?: Function;
   onAppend?: Function;
-  onNodeEdit?: Function;
+
   onTitleClick?: Function;
 }
 const EditableTreeWidget = ({
@@ -156,7 +156,6 @@ const EditableTreeWidget = ({
   onSave,
   onAddFile,
   onAppend,
-  onNodeEdit,
   onTitleClick,
 }: IWidget) => {
   const intl = useIntl();
@@ -180,6 +179,7 @@ const EditableTreeWidget = ({
       _node.forEach((value, index, array) => {
         if (value.id === updatedNode.id) {
           array[index].title = updatedNode.title;
+          array[index].title_text = updatedNode.title_text;
           console.log("key found");
           return;
         } else {
@@ -387,6 +387,7 @@ const EditableTreeWidget = ({
         rootClassName="draggable-tree"
         draggable
         blockNode
+        selectable={false}
         onDragEnter={onDragEnter}
         onDrop={onDrop}
         onSelect={(selectedKeys: Key[]) => {
@@ -404,11 +405,6 @@ const EditableTreeWidget = ({
           return (
             <EditableTreeNode
               node={node}
-              onEdit={() => {
-                if (typeof onNodeEdit !== "undefined") {
-                  onNodeEdit(node.id);
-                }
-              }}
               onAdd={async () => {
                 if (typeof onAppend !== "undefined") {
                   const newNode = await onAppend(node);

+ 24 - 35
dashboard/src/components/article/EditableTreeNode.tsx

@@ -1,6 +1,6 @@
 import { Button, message, Space, Typography } from "antd";
 import { useState } from "react";
-import { PlusOutlined, EditOutlined } from "@ant-design/icons";
+import { PlusOutlined } from "@ant-design/icons";
 
 import { TreeNodeData } from "./EditableTree";
 const { Text } = Typography;
@@ -8,45 +8,33 @@ const { Text } = Typography;
 interface IWidget {
   node: TreeNodeData;
   onAdd?: Function;
-  onEdit?: Function;
   onTitleClick?: Function;
 }
-const EditableTreeNodeWidget = ({
-  node,
-  onAdd,
-  onEdit,
-  onTitleClick,
-}: IWidget) => {
+const EditableTreeNodeWidget = ({ node, onAdd, onTitleClick }: IWidget) => {
   const [showNodeMenu, setShowNodeMenu] = useState(false);
   const [loading, setLoading] = useState(false);
 
-  const title = node.deletedAt ? (
-    <Text delete disabled>
-      {node.title_text ? node.title_text : node.title}
-    </Text>
-  ) : (
-    <Text
-      onClick={(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
-        if (typeof onTitleClick !== "undefined") {
-          onTitleClick(e);
-        }
-      }}
-    >
-      {node.title_text ? node.title_text : node.title}
-    </Text>
-  );
-  const menu = (
-    <Space style={{ visibility: showNodeMenu ? "visible" : "hidden" }}>
-      <Button
-        size="middle"
-        icon={<EditOutlined />}
-        type="text"
-        onClick={async () => {
-          if (typeof onEdit !== "undefined") {
-            onEdit();
+  const title = node.title_text ? node.title_text : node.title;
+
+  const TitleText = () =>
+    node.deletedAt ? (
+      <Text delete disabled>
+        {title}
+      </Text>
+    ) : (
+      <Text
+        onClick={(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
+          if (typeof onTitleClick !== "undefined") {
+            onTitleClick(e);
           }
         }}
-      />
+      >
+        {title}
+      </Text>
+    );
+
+  const Menu = () => (
+    <Space style={{ visibility: showNodeMenu ? "visible" : "hidden" }}>
       <Button
         loading={loading}
         size="middle"
@@ -65,13 +53,14 @@ const EditableTreeNodeWidget = ({
       />
     </Space>
   );
+
   return (
     <Space
       onMouseEnter={() => setShowNodeMenu(true)}
       onMouseLeave={() => setShowNodeMenu(false)}
     >
-      {title}
-      {menu}
+      <TitleText />
+      <Menu />
     </Space>
   );
 };

+ 1 - 1
dashboard/src/components/article/RightPanel.tsx

@@ -163,7 +163,7 @@ const RightPanelWidget = ({
               }),
               key: "dict",
               children: (
-                <div style={tabInnerStyle}>
+                <div className="dict_component" style={tabInnerStyle}>
                   <DictComponent />
                 </div>
               ),

+ 13 - 2
dashboard/src/components/article/TypeArticle.tsx

@@ -1,5 +1,5 @@
 import { useState } from "react";
-import { Button } from "antd";
+import { Alert, Button } from "antd";
 
 import { IArticleDataResponse } from "../api/Article";
 import { ArticleMode, ArticleType } from "./Article";
@@ -14,6 +14,7 @@ interface IWidget {
   anthologyId?: string | null;
   active?: boolean;
   onArticleChange?: Function;
+  onArticleEdit?: Function;
   onFinal?: Function;
   onLoad?: Function;
   onAnthologySelect?: Function;
@@ -29,6 +30,7 @@ const TypeArticleWidget = ({
   onFinal,
   onLoad,
   onAnthologySelect,
+  onArticleEdit,
 }: IWidget) => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
   const [edit, setEdit] = useState(false);
@@ -36,7 +38,11 @@ const TypeArticleWidget = ({
     <div>
       <div>
         {articleData?.role && articleData?.role !== "reader" && edit ? (
-          <Button onClick={() => setEdit(!edit)}>{"完成"}</Button>
+          <Alert
+            message={"请在提交修改后点完成按钮"}
+            type="info"
+            action={<Button onClick={() => setEdit(!edit)}>{"完成"}</Button>}
+          />
         ) : (
           <></>
         )}
@@ -45,6 +51,11 @@ const TypeArticleWidget = ({
         <ArticleEdit
           anthologyId={anthologyId ? anthologyId : undefined}
           articleId={articleId}
+          onChange={(value: IArticleDataResponse) => {
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
         />
       ) : (
         <TypeArticleReader

+ 29 - 10
dashboard/src/components/article/TypeArticleReaderToolbar.tsx

@@ -1,4 +1,4 @@
-import { Button, Dropdown } from "antd";
+import { Button, Dropdown, Tooltip } from "antd";
 import {
   ReloadOutlined,
   MoreOutlined,
@@ -16,6 +16,7 @@ import { fullUrl } from "../../utils";
 import { ArticleTplModal } from "../template/Builder/ArticleTpl";
 import AnthologiesAtArticle from "./AnthologiesAtArticle";
 import { TRole } from "../api/Auth";
+import { useIntl } from "react-intl";
 
 interface IWidget {
   articleId?: string;
@@ -33,6 +34,7 @@ const TypeArticleReaderToolbarWidget = ({
   onEdit,
   onAnthologySelect,
 }: IWidget) => {
+  const intl = useIntl();
   const user = useAppSelector(currentUser);
   const [addToAnthologyOpen, setAddToAnthologyOpen] = useState(false);
   const [tplOpen, setTplOpen] = useState(false);
@@ -59,29 +61,46 @@ const TypeArticleReaderToolbarWidget = ({
           />
         </div>
         <div>
-          <Button
-            type="link"
-            shape="round"
-            size="small"
-            icon={<ReloadOutlined />}
-          />
+          <Tooltip
+            title={intl.formatMessage({
+              id: "buttons.edit",
+            })}
+          >
+            <Button
+              type="link"
+              size="small"
+              icon={<EditOutlined />}
+              onClick={() => {
+                if (typeof onEdit !== "undefined") {
+                  onEdit();
+                }
+              }}
+            />
+          </Tooltip>
+          <Button type="link" size="small" icon={<ReloadOutlined />} />
           <Dropdown
             menu={{
               items: [
                 {
-                  label: "添加到文集",
+                  label: intl.formatMessage({
+                    id: "buttons.add_to_anthology",
+                  }),
                   key: "add_to_anthology",
                   icon: <InboxOutlined />,
                   disabled: user ? false : true,
                 },
                 {
-                  label: "编辑",
+                  label: intl.formatMessage({
+                    id: "buttons.edit",
+                  }),
                   key: "edit",
                   icon: <EditOutlined />,
                   disabled: !editable,
                 },
                 {
-                  label: "在Studio中打开",
+                  label: intl.formatMessage({
+                    id: "buttons.open.in.studio",
+                  }),
                   key: "open-studio",
                   icon: <EditOutlined />,
                   disabled: user ? false : true,

+ 105 - 0
dashboard/src/components/attachment/AttachmentImport.tsx

@@ -0,0 +1,105 @@
+import { Modal, Upload, UploadProps, message } from "antd";
+import { useEffect, useState } from "react";
+import { InboxOutlined } from "@ant-design/icons";
+import { API_HOST, delete_ } from "../../request";
+
+import { get as getToken } from "../../reducers/current-user";
+import { UploadFile } from "antd/es/upload/interface";
+import { IDeleteResponse } from "../api/Group";
+
+const { Dragger } = Upload;
+
+interface IWidget {
+  replaceId?: string;
+  open?: boolean;
+  onOpenChange?: Function;
+}
+const AttachmentImportWidget = ({
+  replaceId,
+  open = false,
+  onOpenChange,
+}: IWidget) => {
+  const [isOpen, setIsOpen] = useState(open);
+
+  useEffect(() => setIsOpen(open), [open]);
+
+  const props: UploadProps = {
+    name: "file",
+    listType: "picture",
+    multiple: replaceId ? false : true,
+    action: `${API_HOST}/api/v2/attachment`,
+    headers: {
+      Authorization: `Bearer ${getToken()}`,
+    },
+    onChange(info) {
+      const { status } = info.file;
+      if (status !== "uploading") {
+        console.log(info.file, info.fileList);
+      }
+      if (status === "done") {
+        message.success(`${info.file.name} file uploaded successfully.`);
+      } else if (status === "error") {
+        message.error(`${info.file.name} file upload failed.`);
+      }
+    },
+    onDrop(e) {
+      console.log("Dropped files", e.dataTransfer.files);
+    },
+    onRemove: (file: UploadFile<any>): boolean => {
+      console.log("remove", file);
+      const url = `/v2/attachment/${file.response.data.id}`;
+      console.info("avatar delete url", url);
+      delete_<IDeleteResponse>(url)
+        .then((json) => {
+          if (json.ok) {
+            message.success("删除成功");
+          } else {
+            message.error(json.message);
+          }
+        })
+        .catch((e) => console.log("Oops errors!", e));
+      return true;
+    },
+  };
+
+  const handleOk = () => {
+    setIsOpen(false);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(false);
+    }
+  };
+
+  const handleCancel = () => {
+    setIsOpen(false);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(false);
+    }
+  };
+
+  return (
+    <Modal
+      destroyOnClose={true}
+      width={700}
+      title="Upload"
+      footer={false}
+      open={isOpen}
+      onOk={handleOk}
+      onCancel={handleCancel}
+    >
+      <Dragger {...props}>
+        <p className="ant-upload-drag-icon">
+          <InboxOutlined />
+        </p>
+        <p className="ant-upload-text">
+          Click or drag file to this area to upload
+        </p>
+        <p className="ant-upload-hint">
+          Support for a single {replaceId ? "" : "or bulk"} upload. Strictly
+          prohibit from uploading company data or other band files
+        </p>
+      </Dragger>
+    </Modal>
+  );
+};
+
+export default AttachmentImportWidget;

+ 399 - 0
dashboard/src/components/attachment/AttachmentList.tsx

@@ -0,0 +1,399 @@
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Space,
+  Table,
+  Dropdown,
+  message,
+  Modal,
+  Typography,
+  Image,
+} from "antd";
+import {
+  PlusOutlined,
+  ExclamationCircleOutlined,
+  FileOutlined,
+  AudioOutlined,
+  FileImageOutlined,
+  MoreOutlined,
+} from "@ant-design/icons";
+
+import { ActionType, ProList } from "@ant-design/pro-components";
+
+import { IUserDictDeleteRequest } from "../api/Dict";
+import { delete_2, get, put } from "../../request";
+import { useRef, useState } from "react";
+import { IDeleteResponse } from "../api/Article";
+import TimeShow from "../general/TimeShow";
+import { getSorterUrl } from "../../utils";
+import {
+  IAttachmentListResponse,
+  IAttachmentRequest,
+  IAttachmentResponse,
+  IAttachmentUpdate,
+} from "../api/Attachments";
+import { VideoIcon } from "../../assets/icon";
+import AttachmentImport from "./AttachmentImport";
+import VideoModal from "../general/VideoModal";
+import FileSize from "../general/FileSize";
+
+const { Text } = Typography;
+
+export interface IAttachment {
+  id: string;
+  name: string;
+  filename: string;
+  title: string;
+  size: number;
+  content_type: string;
+  url: string;
+}
+interface IParams {
+  content_type?: string;
+}
+interface IWidget {
+  studioName?: string;
+  view?: "studio" | "all";
+}
+const AttachmentWidget = ({ studioName, view = "studio" }: IWidget) => {
+  const intl = useIntl();
+  const [replaceId, setReplaceId] = useState<string>();
+  const [importOpen, setImportOpen] = useState(false);
+  const [imgVisible, setImgVisible] = useState(false);
+  const [imgPrev, setImgPrev] = useState<string>();
+
+  const [videoVisible, setVideoVisible] = useState(false);
+  const [videoUrl, setVideoUrl] = useState<string>();
+
+  const showDeleteConfirm = (id: string[], title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_2<IUserDictDeleteRequest, IDeleteResponse>(
+          `/v2/userdict/${id}`,
+          {
+            id: JSON.stringify(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 (
+    <>
+      <ProList<IAttachmentRequest, IParams>
+        actionRef={ref}
+        editable={{
+          onSave: async (key, record, originRow) => {
+            console.log(key, record, originRow);
+            const url = `/v2/attachment/${key}`;
+            const res = await put<IAttachmentUpdate, IAttachmentResponse>(url, {
+              title: record.title,
+            });
+            return res.ok;
+          },
+        }}
+        metas={{
+          title: {
+            dataIndex: "title",
+            search: false,
+            render: (dom, entity, index, action, schema) => {
+              return (
+                <Button
+                  type="link"
+                  onClick={() => {
+                    const ct = entity.content_type.split("/");
+                    switch (ct[0]) {
+                      case "image":
+                        setImgPrev(entity.url);
+                        setImgVisible(true);
+                        break;
+                      case "video":
+                        setVideoUrl(entity.url);
+                        setVideoVisible(true);
+                        break;
+                      default:
+                        break;
+                    }
+                  }}
+                >
+                  {entity.title}
+                </Button>
+              );
+            },
+          },
+          description: {
+            render: (dom, entity, index, action, schema) => {
+              return (
+                <Text type="secondary">
+                  <Space>
+                    {entity.content_type}
+                    <FileSize size={entity.size} />
+                    <TimeShow
+                      type="secondary"
+                      createdAt={entity.created_at}
+                      updatedAt={entity.updated_at}
+                    />
+                  </Space>
+                </Text>
+              );
+            },
+            editable: false,
+            search: false,
+          },
+          avatar: {
+            editable: false,
+            search: false,
+            render: (dom, entity, index, action, schema) => {
+              const ct = entity.content_type.split("/");
+              let icon = <FileOutlined />;
+              switch (ct[0]) {
+                case "video":
+                  icon = <VideoIcon />;
+                  break;
+                case "audio":
+                  icon = <AudioOutlined />;
+                  break;
+                case "image":
+                  icon = <FileImageOutlined />;
+
+                  break;
+              }
+              return icon;
+            },
+          },
+          actions: {
+            render: (text, row, index, action) => {
+              return [
+                <Button
+                  type="link"
+                  size="small"
+                  onClick={() => {
+                    action?.startEditable(row.id);
+                  }}
+                >
+                  编辑
+                </Button>,
+                <Dropdown
+                  menu={{
+                    items: [
+                      { label: "替换", key: "replace" },
+                      { label: "引用模版", key: "tpl" },
+                    ],
+                    onClick: (e) => {
+                      console.log("click ", e.key);
+                      switch (e.key) {
+                        case "replace":
+                          setReplaceId(row.id);
+                          setImportOpen(true);
+                          break;
+
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                  placement="bottomRight"
+                >
+                  <Button
+                    type="link"
+                    size="small"
+                    icon={<MoreOutlined />}
+                    onClick={(e) => e.preventDefault()}
+                  />
+                </Dropdown>,
+              ];
+            },
+          },
+          content_type: {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            title: "类型",
+            valueType: "select",
+            valueEnum: {
+              all: { text: "全部", status: "Default" },
+              image: {
+                text: "图片",
+                status: "Error",
+              },
+              video: {
+                text: "视频",
+                status: "Success",
+              },
+              audio: {
+                text: "音频",
+                status: "Processing",
+              },
+            },
+          },
+        }}
+        rowSelection={
+          view === "all"
+            ? undefined
+            : {
+                // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                // 注释该行则默认不显示下拉选项
+                selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+              }
+        }
+        tableAlertRender={
+          view === "all"
+            ? undefined
+            : ({ selectedRowKeys, selectedRows, onCleanSelected }) => (
+                <Space size={24}>
+                  <span>
+                    {intl.formatMessage({ id: "buttons.selected" })}
+                    {selectedRowKeys.length}
+                    <Button
+                      type="link"
+                      style={{ marginInlineStart: 8 }}
+                      onClick={onCleanSelected}
+                    >
+                      {intl.formatMessage({ id: "buttons.unselect" })}
+                    </Button>
+                  </span>
+                </Space>
+              )
+        }
+        tableAlertOptionRender={
+          view === "all"
+            ? undefined
+            : ({ intl, selectedRowKeys, selectedRows, onCleanSelected }) => {
+                return (
+                  <Space size={16}>
+                    <Button
+                      type="link"
+                      onClick={() => {
+                        console.log(selectedRowKeys);
+                        showDeleteConfirm(
+                          selectedRowKeys.map((item) => item.toString()),
+                          selectedRowKeys.length + "个单词"
+                        );
+                        onCleanSelected();
+                      }}
+                    >
+                      批量删除
+                    </Button>
+                  </Space>
+                );
+              }
+        }
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+
+          let url = "/v2/attachment?";
+          switch (view) {
+            case "studio":
+              url += `view=studio&studio=${studioName}`;
+              break;
+            case "all":
+              url += `view=all`;
+              break;
+            default:
+              break;
+          }
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+
+          url += params.keyword ? "&search=" + params.keyword : "";
+          if (params.content_type && params.content_type !== "all") {
+            url += "&content_type=" + params.content_type;
+          }
+
+          url += getSorterUrl(sorter);
+
+          console.log(url);
+          const res = await get<IAttachmentListResponse>(url);
+          return {
+            total: res.data.count,
+            success: res.ok,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={{
+          filterType: "light",
+        }}
+        options={{
+          search: true,
+        }}
+        headerTitle=""
+        toolBarRender={() => [
+          <Button
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+            onClick={() => {
+              setReplaceId(undefined);
+              setImportOpen(true);
+            }}
+            disabled={view === "all"}
+          >
+            {intl.formatMessage({ id: "buttons.import" })}
+          </Button>,
+        ]}
+      />
+      <AttachmentImport
+        replaceId={replaceId}
+        open={importOpen}
+        onOpenChange={(open: boolean) => {
+          setImportOpen(open);
+          ref.current?.reload();
+        }}
+      />
+
+      <Image
+        width={200}
+        style={{ display: "none" }}
+        preview={{
+          visible: imgVisible,
+          src: imgPrev,
+          onVisibleChange: (value) => {
+            setImgVisible(value);
+          },
+        }}
+      />
+      <VideoModal
+        src={videoUrl}
+        open={videoVisible}
+        onOpenChange={(open: boolean) => setVideoVisible(open)}
+      />
+    </>
+  );
+};
+
+export default AttachmentWidget;

+ 31 - 3
dashboard/src/components/dict/DictContent.tsx

@@ -1,4 +1,4 @@
-import { Col, Row, Tabs } from "antd";
+import { Button, Col, Divider, Row, Tabs } from "antd";
 
 import type { IAnchorData } from "./DictList";
 import type { IWidgetWordCardData } from "./WordCard";
@@ -9,10 +9,16 @@ import CaseList from "./CaseList";
 import DictList from "./DictList";
 import MyCreate from "./MyCreate";
 import { useIntl } from "react-intl";
+import DictGroupTitle from "./DictGroupTitle";
+
+export interface IDictWords {
+  pass: string;
+  words: IWidgetWordCardData[];
+}
 
 export interface IDictContentData {
   dictlist: IAnchorData[];
-  words: IWidgetWordCardData[];
+  words: IDictWords[];
   caselist: ICaseListData[];
   time?: number;
   count?: number;
@@ -64,7 +70,29 @@ const DictContentWidget = ({ word, data, compact }: IWidget) => {
                     </div>
                     <div>
                       {data.words.map((it, id) => {
-                        return <WordCard key={id} data={it} />;
+                        return (
+                          <div>
+                            <DictGroupTitle
+                              title={
+                                <Button style={{ width: 120 }}>
+                                  {intl.formatMessage({
+                                    id: `labels.dict.pass.${it.pass}`,
+                                  })}
+                                </Button>
+                              }
+                              path={[
+                                intl.formatMessage({
+                                  id: `labels.dict.pass.${it.pass}`,
+                                }),
+                              ]}
+                            />
+                            <div>
+                              {it.words.map((word, index) => (
+                                <WordCard key={index} data={word} />
+                              ))}
+                            </div>
+                          </div>
+                        );
                       })}
                     </div>
                   </div>

+ 39 - 0
dashboard/src/components/dict/DictGroupTitle.tsx

@@ -0,0 +1,39 @@
+import { Affix, Breadcrumb } from "antd";
+import { useState } from "react";
+
+interface IWidget {
+  title: React.ReactNode;
+  path: string[];
+}
+
+const DictGroupTitleWidget = ({ title, path }: IWidget) => {
+  const [fixed, setFixed] = useState<boolean>();
+  return (
+    <Affix
+      offsetTop={0}
+      target={() =>
+        document.getElementsByClassName("dict_component")[0] as HTMLElement
+      }
+      onChange={(affixed) => setFixed(affixed)}
+    >
+      {fixed ? (
+        <Breadcrumb
+          style={{
+            backgroundColor: "white",
+            padding: 4,
+            borderBottom: "1px solid gray",
+          }}
+        >
+          <Breadcrumb.Item key={"top"}>Top</Breadcrumb.Item>
+          {path.map((item, index) => {
+            return <Breadcrumb.Item key={index}>{item}</Breadcrumb.Item>;
+          })}
+        </Breadcrumb>
+      ) : (
+        title
+      )}
+    </Affix>
+  );
+};
+
+export default DictGroupTitleWidget;

+ 2 - 2
dashboard/src/components/dict/MyCreate.tsx

@@ -23,8 +23,8 @@ const MyCreateWidget = ({ word }: IWidget) => {
   const intl = useIntl();
   const [wordSpell, setWordSpell] = useState(word);
   const [editWord, setEditWord] = useState<IWbw>({
-    word: { value: word ? word : "", status: 1 },
-    real: { value: word ? word : "", status: 1 },
+    word: { value: word ? word : "", status: 7 },
+    real: { value: word ? word : "", status: 7 },
     book: 0,
     para: 0,
     sn: [0],

+ 48 - 35
dashboard/src/components/dict/WordCard.tsx

@@ -47,41 +47,54 @@ const WordCardWidget = ({ data }: IWidgetWordCard) => {
       <Title level={4} id={data.anchor}>
         {data.word}
       </Title>
-
-      <div>
-        <Text>{data.grammar.length > 0 ? data.grammar[0].factors : ""}</Text>
-      </div>
-      <div>
-        <Text>{data.parents}</Text>
-      </div>
-      <div>
-        {data.grammar
-          .filter((item) => item.confidence > 0.5)
-          .map((it, id) => {
-            const grammar = it.grammar.split("$");
-            const grammarGuide = grammar.map((item, id) => {
-              const strCase = item.replaceAll(".", "");
-              return (
-                <GrammarPop
-                  key={id}
-                  gid={strCase}
-                  text={intl.formatMessage({
-                    id: `dict.fields.type.${strCase}.label`,
-                    defaultMessage: strCase,
-                  })}
-                />
-              );
-            });
-            return (
-              <div key={id}>
-                <Space>{grammarGuide}</Space>
-              </div>
-            );
-          })}
-      </div>
-      <div>
-        <Text>{caseList}</Text>
-      </div>
+      {data.grammar.length > 0 ? (
+        <WordCardByDict
+          data={{
+            dictname: "语法信息",
+            description: "列出可能的语法信息供参考",
+            anchor: "anchor",
+          }}
+        >
+          <div>
+            <Text>
+              {data.grammar.length > 0 ? data.grammar[0].factors : ""}
+            </Text>
+          </div>
+          <div>
+            <Text>{data.parents}</Text>
+          </div>
+          <div>
+            {data.grammar
+              .filter((item) => item.confidence > 0.5)
+              .map((it, id) => {
+                const grammar = it.grammar.split("$");
+                const grammarGuide = grammar.map((item, id) => {
+                  const strCase = item.replaceAll(".", "");
+                  return (
+                    <GrammarPop
+                      key={id}
+                      gid={strCase}
+                      text={intl.formatMessage({
+                        id: `dict.fields.type.${strCase}.label`,
+                        defaultMessage: strCase,
+                      })}
+                    />
+                  );
+                });
+                return (
+                  <div key={id}>
+                    <Space>{grammarGuide}</Space>
+                  </div>
+                );
+              })}
+          </div>
+          <div>
+            <Text>{caseList}</Text>
+          </div>
+        </WordCardByDict>
+      ) : (
+        <></>
+      )}
       <Community word={data.word} />
       <TermCommunity word={data.word} />
       <div>

+ 10 - 8
dashboard/src/components/dict/WordCardByDict.tsx

@@ -11,24 +11,25 @@ const { Title } = Typography;
 export interface IWordByDict {
   dictname: string;
   description?: string;
-  word: string;
-  note: string;
+  word?: string;
+  note?: string;
   anchor: string;
 }
 interface IWidgetWordCardByDict {
   data: IWordByDict;
+  children?: React.ReactNode;
 }
-const WordCardByDictWidget = (prop: IWidgetWordCardByDict) => {
+const WordCardByDictWidget = ({ data, children }: IWidgetWordCardByDict) => {
   return (
     <Card>
       <Space>
-        <Title level={5} id={prop.data.anchor}>
-          {prop.data.dictname}
+        <Title level={5} id={data.anchor}>
+          {data.dictname}
         </Title>
-        {prop.data.description ? (
+        {data.description ? (
           <Popover
             overlayStyle={{ maxWidth: 600 }}
-            content={<Marked text={prop.data.description} />}
+            content={<Marked text={data.description} />}
             placement="bottom"
           >
             <Button type="link" icon={<InfoCircleOutlined />} />
@@ -36,8 +37,9 @@ const WordCardByDictWidget = (prop: IWidgetWordCardByDict) => {
         ) : undefined}
       </Space>
       <div className="dict_content">
-        <MdView html={prop.data.note} />
+        <MdView html={data.note} />
       </div>
+      {children}
     </Card>
   );
 };

+ 24 - 0
dashboard/src/components/general/FileSize.tsx

@@ -0,0 +1,24 @@
+interface IWidget {
+  size?: number;
+}
+const FileSizeWidget = ({ size = 0 }: IWidget) => {
+  let strSize = 0;
+  let end = "";
+  if (size > Math.pow(1024, 3)) {
+    strSize = size / Math.pow(1024, 3);
+    end = "GB";
+  } else if (size > Math.pow(1024, 2)) {
+    strSize = size / Math.pow(1024, 2);
+    end = "MB";
+  } else if (size > Math.pow(1024, 1)) {
+    strSize = size / Math.pow(1024, 1);
+    end = "KB";
+  } else {
+    strSize = size;
+    end = "B";
+  }
+  const output = strSize.toString().substring(0, 4) + end;
+  return <>{output}</>;
+};
+
+export default FileSizeWidget;

+ 23 - 10
dashboard/src/components/general/VideoModal.tsx

@@ -1,25 +1,38 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { Modal } from "antd";
 import Video from "./Video";
 
 interface IWidget {
   src?: string;
   type?: string;
+  open?: boolean;
   trigger?: JSX.Element;
+  onOpenChange?: Function;
 }
-export const VideoModalWidget = ({ src, type, trigger }: IWidget) => {
-  const [isModalOpen, setIsModalOpen] = useState(false);
+export const VideoModalWidget = ({
+  src,
+  type,
+  open = false,
+  trigger,
+  onOpenChange,
+}: IWidget) => {
+  //TODO 跟video ctl 合并
+  const [isModalOpen, setIsModalOpen] = useState(open);
+
+  useEffect(() => setIsModalOpen(open), [open]);
 
   const showModal = () => {
     setIsModalOpen(true);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(true);
+    }
   };
 
-  const handleOk = () => {
-    setIsModalOpen(false);
-  };
-
-  const handleCancel = () => {
+  const handleClose = () => {
     setIsModalOpen(false);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(false);
+    }
   };
 
   return (
@@ -30,8 +43,8 @@ export const VideoModalWidget = ({ src, type, trigger }: IWidget) => {
         title="Basic Modal"
         footer={false}
         open={isModalOpen}
-        onOk={handleOk}
-        onCancel={handleCancel}
+        onOk={handleClose}
+        onCancel={handleClose}
         width={800}
         destroyOnClose
         maskClosable={false}

+ 12 - 14
dashboard/src/components/studio/LeftSider.tsx

@@ -23,14 +23,12 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
   //Library head bar
   const intl = useIntl(); //i18n
   const { studioname } = useParams();
-  const linkPalicanon = "/studio/" + studioname + "/palicanon";
   const linkRecent = "/studio/" + studioname + "/recent/list";
   const linkChannel = "/studio/" + studioname + "/channel/list";
   const linkGroup = "/studio/" + studioname + "/group/list";
   const linkUserdict = "/studio/" + studioname + "/dict/list";
   const linkTerm = "/studio/" + studioname + "/term/list";
   const linkArticle = "/studio/" + studioname + "/article/list";
-  const linkAnthology = "/studio/" + studioname + "/anthology/list";
   const linkAnalysis = "/studio/" + studioname + "/exp/list";
   const linkCourse = "/studio/" + studioname + "/course/list";
   const linkSetting = "/studio/" + studioname + "/setting";
@@ -43,7 +41,7 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
       children: [
         {
           label: (
-            <Link to={linkPalicanon}>
+            <Link to={`/studio/${studioname}/palicanon`}>
               {intl.formatMessage({
                 id: "columns.studio.palicanon.title",
               })}
@@ -131,7 +129,7 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
         },
         {
           label: (
-            <Link to={linkAnthology}>
+            <Link to={`/studio/${studioname}/anthology/list`}>
               {intl.formatMessage({
                 id: "columns.studio.anthology.title",
               })}
@@ -139,6 +137,16 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
           ),
           key: "anthology",
         },
+        {
+          label: (
+            <Link to={`/studio/${studioname}/attachment/list`}>
+              {intl.formatMessage({
+                id: "columns.studio.attachment.title",
+              })}
+            </Link>
+          ),
+          key: "attachment",
+        },
         {
           label: (
             <Link to={linkSetting}>
@@ -148,16 +156,6 @@ const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
             </Link>
           ),
           key: "setting",
-          children: [
-            {
-              label: "账户",
-              key: "account",
-            },
-            {
-              label: "显示",
-              key: "display",
-            },
-          ],
         },
       ],
     },

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

@@ -82,6 +82,8 @@ const items = {
   "buttons.convert": "convert",
   "buttons.copy.tpl": "copy template",
   "buttons.open.in.new.tab": "Open in New Tab",
+  "buttons.add_to_anthology": "Add to Anthology",
+  "buttons.open.in.studio": "Open in Studio",
 };
 
 export default items;

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

@@ -47,6 +47,7 @@ const items = {
   "columns.exp.title": "Exp",
   "columns.studio.invite.title": "Invite",
   "columns.studio.transfer.title": "Transfer",
+  "columns.studio.attachment.title": "Attachment",
   ...buttons,
   ...forms,
   ...tables,

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

@@ -82,6 +82,8 @@ const items = {
   "buttons.convert": "转换",
   "buttons.copy.tpl": "复制模版",
   "buttons.open.in.new.tab": "在新标签页中打开",
+  "buttons.add_to_anthology": "添加到文集",
+  "buttons.open.in.studio": "在Studio中打开",
 };
 
 export default items;

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

@@ -47,6 +47,7 @@ const items = {
   "columns.exp.title": "经验",
   "columns.studio.invite.title": "注册邀请",
   "columns.studio.transfer.title": "转让",
+  "columns.studio.attachment.title": "网盘",
   ...buttons,
   ...forms,
   ...tables,

+ 6 - 1
dashboard/src/locales/zh-Hans/label.ts

@@ -35,7 +35,12 @@ const items = {
   "labels.page.number.type.c": "章节名称",
   "labels.loading": "载入中",
   "labels.empty": "无内容",
-  "labels.curr.paragraph.cart.tpl": "Add to Cart",
+  "labels.curr.paragraph.cart.tpl": "添加到手推车",
+  "labels.dict.pass.0": "内文查询",
+  "labels.dict.pass.1": "直接查",
+  "labels.dict.pass.2": "查词干",
+  "labels.dict.pass.3": "查衍生",
+  "labels.dict.pass.4": "查二次衍生",
 };
 
 export default items;

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

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

+ 10 - 0
dashboard/src/pages/studio/attachment/list.tsx

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