Browse Source

feat: add upload excel for word create

RegMs If 3 years ago
parent
commit
39e5103d24

+ 2 - 1
package.json

@@ -16,7 +16,8 @@
     "qs": "^6.11.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "react-router-dom": "^6.4.1"
+    "react-router-dom": "^6.4.1",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@types/lodash": "^4.14.186",

+ 7 - 7
src/apis/dicts.ts

@@ -14,7 +14,7 @@ export interface DictResult {
   wordCount: number;
 }
 
-interface ListDictsResponse {
+export interface ListDictsResponse {
   total: number;
   list: DictResult[];
 }
@@ -26,16 +26,16 @@ export const useListDicts = () =>
     ),
   );
 
-interface GetDictRequest {
+export interface GetDictRequest {
   id: number;
 }
 
-interface ListWordsResponse {
+export interface ListWordsResponse {
   total: number;
   list: WordResult[];
 }
 
-interface GetDictResponse {
+export interface GetDictResponse {
   dict: DictResult;
   words: ListWordsResponse;
 }
@@ -53,7 +53,7 @@ export const useGetDict = (request: GetDictRequest) =>
     { ready: !!request.id },
   );
 
-interface CreateDictRequest {
+export interface CreateDictRequest {
   name: string;
   valueTitle: string;
   meaningTitle: string;
@@ -70,7 +70,7 @@ export const useCreateDict = () => async (request: CreateDictRequest) =>
     ).data,
   );
 
-interface UpdateDictRequest {
+export interface UpdateDictRequest {
   id: number;
   name: string;
   valueTitle: string;
@@ -88,7 +88,7 @@ export const useUpdateDict = () => async (request: UpdateDictRequest) =>
     ).data,
   );
 
-interface DeleteDictRequest {
+export interface DeleteDictRequest {
   id: number;
 }
 

+ 2 - 2
src/apis/users.ts

@@ -9,7 +9,7 @@ export interface UserResult {
   name: string;
 }
 
-interface RegisterRequest {
+export interface RegisterRequest {
   name: string;
   password: string;
 }
@@ -24,7 +24,7 @@ export const useRegister = () => async (request: RegisterRequest) =>
     ).data,
   );
 
-interface LoginRequest {
+export interface LoginRequest {
   name: string;
   password: string;
 }

+ 3 - 3
src/apis/words.ts

@@ -11,7 +11,7 @@ export interface WordResult {
   star: boolean;
 }
 
-interface CreateWordRequest {
+export interface CreateWordRequest {
   value: string;
   meaning: string;
   extra?: string;
@@ -28,7 +28,7 @@ export const useCreateWord = () => async (request: CreateWordRequest) =>
     ).data,
   );
 
-interface UpdateWordRequest {
+export interface UpdateWordRequest {
   id: number;
   value: string;
   meaning: string;
@@ -46,7 +46,7 @@ export const useUpdateWord = () => async (request: UpdateWordRequest) =>
     ).data,
   );
 
-interface DeleteWordRequest {
+export interface DeleteWordRequest {
   id: number;
 }
 

+ 2 - 1
src/components/DictModal.tsx

@@ -1,6 +1,6 @@
 import React, { useState } from 'react';
-import { Modal, Form, Input, Button, message } from 'antd';
 import type { ModalProps } from 'antd';
+import { Modal, Form, Input, Button, message } from 'antd';
 import type { DictResult } from '../apis/dicts';
 import { useCreateDict, useUpdateDict } from '../apis/dicts';
 
@@ -22,6 +22,7 @@ const DictModal: React.FC<DictModalProps> = ({
 
   return (
     <Modal
+      style={{ top: 64 }}
       title={initialDict ? '更新词库' : '创建新词库'}
       footer={null}
       destroyOnClose

+ 157 - 0
src/components/UploadModal.tsx

@@ -0,0 +1,157 @@
+import React, { useState, useEffect } from 'react';
+import * as XLSX from 'xlsx';
+import type { ModalProps } from 'antd';
+import { Modal, Table, Button, Upload, message } from 'antd';
+import {
+  UploadOutlined,
+  InboxOutlined,
+  DownloadOutlined,
+} from '@ant-design/icons';
+import type { DictResult } from '../apis/dicts';
+import type { CreateWordRequest } from '../apis/words';
+import { useCreateWord } from '../apis/words';
+
+const { Dragger } = Upload;
+
+interface UploadModalProps extends ModalProps {
+  dict: DictResult;
+  refresh: () => void;
+  onCancel: () => void;
+}
+
+const UploadModal: React.FC<UploadModalProps> = ({
+  dict,
+  refresh,
+  ...props
+}) => {
+  const [wordsList, setWordsList] = useState<CreateWordRequest[]>();
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    if (props.open) {
+      setWordsList(undefined);
+    }
+  }, [props.open]);
+
+  const createWord = useCreateWord();
+
+  return (
+    <Modal
+      style={{ top: 64 }}
+      title="上传 Excel"
+      footer={null}
+      destroyOnClose
+      {...props}
+    >
+      {wordsList ? (
+        <>
+          <Table
+            size="small"
+            columns={[
+              { dataIndex: 'value', title: dict.valueTitle },
+              { dataIndex: 'meaning', title: dict.meaningTitle },
+              ...(dict.extraTitle
+                ? [{ dataIndex: 'extra', title: dict.extraTitle }]
+                : []),
+            ]}
+            rowKey={() => Math.random()}
+            dataSource={wordsList}
+          />
+          <Button
+            type="link"
+            icon={<UploadOutlined />}
+            loading={loading}
+            onClick={() =>
+              void (async () => {
+                setLoading(true);
+                const newWordsList: CreateWordRequest[] = [];
+                await Promise.all(
+                  wordsList.map(async record => {
+                    try {
+                      await createWord(record);
+                    } catch {
+                      newWordsList.push(record);
+                    }
+                  }),
+                );
+                if (newWordsList.length) {
+                  void message.warning(
+                    '部分单词上传失败,请确认网络稳定并且单词没有重复',
+                  );
+                  setWordsList(newWordsList);
+                } else {
+                  void message.success('上传成功');
+                  props.onCancel();
+                }
+                refresh();
+                setLoading(false);
+              })()
+            }
+          >
+            确定上传
+          </Button>
+        </>
+      ) : (
+        <>
+          <Dragger
+            accept=".xlsx"
+            beforeUpload={() => false}
+            onChange={({ fileList }) =>
+              void (async () => {
+                const data = await fileList[0]?.originFileObj?.arrayBuffer();
+                if (data) {
+                  const workbook = XLSX.read(data);
+                  const worksheet = workbook.Sheets.Sheet1;
+                  const wordsList: CreateWordRequest[] = [];
+                  XLSX.utils
+                    .sheet_to_json<Partial<Record<string, string | number>>>(
+                      worksheet,
+                    )
+                    .forEach(record => {
+                      const value = record[dict.valueTitle]?.toString();
+                      const meaning = record[dict.meaningTitle]?.toString();
+                      const extra = record[dict.extraTitle]?.toString();
+                      if (value && meaning) {
+                        wordsList.push({
+                          value,
+                          meaning,
+                          extra,
+                          dictID: dict.id,
+                        });
+                      }
+                    });
+                  setWordsList(wordsList);
+                }
+              })()
+            }
+          >
+            <p className="ant-upload-drag-icon">
+              <InboxOutlined />
+            </p>
+            <p className="ant-upload-hint">点击或拖拽文件到此处上传</p>
+          </Dragger>
+          <Button
+            type="link"
+            icon={<DownloadOutlined />}
+            onClick={() => {
+              const workbook = XLSX.utils.book_new();
+              const worksheet = XLSX.utils.aoa_to_sheet([
+                [
+                  dict.valueTitle,
+                  dict.meaningTitle,
+                  ...(dict.extraTitle ? [dict.extraTitle] : []),
+                ],
+              ]);
+              XLSX.utils.book_append_sheet(workbook, worksheet);
+              XLSX.writeFileXLSX(workbook, '模板.xlsx');
+            }}
+          >
+            下载模板
+          </Button>
+        </>
+      )}
+    </Modal>
+  );
+};
+
+export default UploadModal;

+ 2 - 1
src/components/UserModal.tsx

@@ -1,6 +1,6 @@
 import React, { useState } from 'react';
-import { Modal, Form, Input, Button, message } from 'antd';
 import type { ModalProps } from 'antd';
+import { Modal, Form, Input, Button, message } from 'antd';
 import { useAuth } from '../apis/AuthProvider';
 import { useRegister, useLogin } from '../apis/users';
 
@@ -23,6 +23,7 @@ const UserModal: React.FC<UserModalProps> = props => {
 
   return (
     <Modal
+      style={{ top: 64 }}
       title={mode === Mode.Register ? '注册' : '登录'}
       footer={null}
       destroyOnClose

+ 7 - 3
src/components/WordModal.tsx

@@ -1,6 +1,6 @@
 import React, { useState, useEffect } from 'react';
-import { Modal, Form, Input, Button, message } from 'antd';
 import type { ModalProps } from 'antd';
+import { Modal, Form, Input, Button, message } from 'antd';
 import type { DictResult } from '../apis/dicts';
 import type { WordResult } from '../apis/words';
 import { useCreateWord, useUpdateWord } from '../apis/words';
@@ -24,7 +24,11 @@ const WordModal: React.FC<WordModalProps> = ({
 
   useEffect(() => {
     if (props.open) {
-      form.setFieldsValue(initialWord);
+      if (initialWord) {
+        form.setFieldsValue(initialWord);
+      } else {
+        form.resetFields();
+      }
     }
   }, [props.open, form, initialWord]);
 
@@ -33,6 +37,7 @@ const WordModal: React.FC<WordModalProps> = ({
 
   return (
     <Modal
+      style={{ top: 64 }}
       title={initialWord ? '更新单词' : '创建新单词'}
       footer={null}
       destroyOnClose
@@ -43,7 +48,6 @@ const WordModal: React.FC<WordModalProps> = ({
         labelCol={{ span: 6 }}
         wrapperCol={{ span: 18 }}
         form={form}
-        preserve={false}
         onFinish={(values: {
           value: string;
           meaning: string;

+ 32 - 12
src/pages/DictPage.tsx

@@ -13,6 +13,7 @@ import {
 } from 'antd';
 import {
   PlusOutlined,
+  UploadOutlined,
   AimOutlined,
   EditOutlined,
   StarFilled,
@@ -23,6 +24,7 @@ import { useGetDict } from '../apis/dicts';
 import type { WordResult } from '../apis/words';
 import { useUpdateWord, useDeleteWord } from '../apis/words';
 import WordModal from '../components/WordModal';
+import UploadModal from '../components/UploadModal';
 import styles from './DictPage.module.css';
 
 const { Text } = Typography;
@@ -30,7 +32,8 @@ const { Text } = Typography;
 const DictPage: React.FC = () => {
   const [search, setSearch] = useState('');
   const [initialWord, setInitialWord] = useState<WordResult>();
-  const [open, setOpen] = useState(false);
+  const [wordModalOpen, setWordModalOpen] = useState(false);
+  const [uploadModalOpen, setUploadModalOpen] = useState(false);
 
   const { dictID = '' } = useParams();
 
@@ -80,11 +83,18 @@ const DictPage: React.FC = () => {
                   icon={<PlusOutlined />}
                   onClick={() => {
                     setInitialWord(undefined);
-                    setOpen(true);
+                    setWordModalOpen(true);
                   }}
                 >
                   创建新单词
                 </Button>
+                <Button
+                  type="link"
+                  icon={<UploadOutlined />}
+                  onClick={() => setUploadModalOpen(true)}
+                >
+                  上传 Excel
+                </Button>
                 <Link to={`/dicts/${dictID}/memo`}>
                   <Button type="link" icon={<AimOutlined />}>
                     开始复习
@@ -101,7 +111,7 @@ const DictPage: React.FC = () => {
                 word =>
                   word.value.includes(search) ||
                   word.meaning.includes(search) ||
-                  word.extra.includes(search),
+                  (dict?.extraTitle && word.extra.includes(search)),
               )
             : words?.list
         }
@@ -115,7 +125,7 @@ const DictPage: React.FC = () => {
                 icon={<EditOutlined />}
                 onClick={() => {
                   setInitialWord(word);
-                  setOpen(true);
+                  setWordModalOpen(true);
                 }}
               />,
               <Button
@@ -168,7 +178,9 @@ const DictPage: React.FC = () => {
               title={
                 <Space size="middle">
                   {word.value}
-                  <Text type="secondary">{word.extra}</Text>
+                  {dict?.extraTitle && (
+                    <Text type="secondary">{word.extra}</Text>
+                  )}
                 </Space>
               }
               description={word.meaning}
@@ -177,13 +189,21 @@ const DictPage: React.FC = () => {
         )}
       />
       {dict && (
-        <WordModal
-          dict={dict}
-          initialWord={initialWord}
-          refresh={refresh}
-          open={open}
-          onCancel={() => setOpen(false)}
-        />
+        <>
+          <WordModal
+            dict={dict}
+            initialWord={initialWord}
+            refresh={refresh}
+            open={wordModalOpen}
+            onCancel={() => setWordModalOpen(false)}
+          />
+          <UploadModal
+            dict={dict}
+            refresh={refresh}
+            open={uploadModalOpen}
+            onCancel={() => setUploadModalOpen(false)}
+          />
+        </>
       )}
     </div>
   );

+ 58 - 0
yarn.lock

@@ -550,6 +550,11 @@ acorn@^8.8.0:
   resolved "https://registry.npmmirror.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8"
   integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
 
+adler-32@~1.3.0:
+  version "1.3.1"
+  resolved "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2"
+  integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
+
 ahooks-v3-count@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/ahooks-v3-count/-/ahooks-v3-count-1.0.0.tgz#ddeb392e009ad6e748905b3cbf63a9fd8262ca80"
@@ -751,6 +756,14 @@ caniuse-lite@^1.0.30001400:
   resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001414.tgz#5f1715e506e71860b4b07c50060ea6462217611e"
   integrity sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg==
 
+cfb@~1.2.1:
+  version "1.2.2"
+  resolved "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz#94e687628c700e5155436dac05f74e08df23bc44"
+  integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
+  dependencies:
+    adler-32 "~1.3.0"
+    crc-32 "~1.2.0"
+
 chalk@^2.0.0:
   version "2.4.2"
   resolved "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -773,6 +786,11 @@ classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classna
   resolved "https://registry.npmmirror.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
   integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
 
+codepage@~1.15.0:
+  version "1.15.0"
+  resolved "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab"
+  integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
+
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -828,6 +846,11 @@ copy-to-clipboard@^3.2.0:
   dependencies:
     toggle-selection "^1.0.6"
 
+crc-32@~1.2.0, crc-32@~1.2.1:
+  version "1.2.2"
+  resolved "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff"
+  integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
+
 cross-spawn@^7.0.2:
   version "7.0.3"
   resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -1320,6 +1343,11 @@ form-data@^4.0.0:
     combined-stream "^1.0.8"
     mime-types "^2.1.12"
 
+frac@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b"
+  integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -2530,6 +2558,13 @@ sourcemap-codec@^1.4.8:
   resolved "https://registry.npmmirror.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
   integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
 
+ssf@~0.11.2:
+  version "0.11.2"
+  resolved "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c"
+  integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
+  dependencies:
+    frac "~1.1.2"
+
 string-convert@^0.2.0:
   version "0.2.1"
   resolved "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"
@@ -2704,16 +2739,39 @@ which@^2.0.1:
   dependencies:
     isexe "^2.0.0"
 
+wmf@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da"
+  integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
+
 word-wrap@^1.2.3:
   version "1.2.3"
   resolved "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
+word@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.npmmirror.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961"
+  integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
+
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
 
+xlsx@^0.18.5:
+  version "0.18.5"
+  resolved "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz#16711b9113c848076b8a177022799ad356eba7d0"
+  integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
+  dependencies:
+    adler-32 "~1.3.0"
+    cfb "~1.2.1"
+    codepage "~1.15.0"
+    crc-32 "~1.2.1"
+    ssf "~0.11.2"
+    wmf "~1.0.1"
+    word "~0.3.0"
+
 yallist@^4.0.0:
   version "4.0.0"
   resolved "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"