Переглянути джерело

feat[pc-components]: 新增显示下载进度条

chenlei 1 рік тому
батько
коміт
36df25a15d

+ 8 - 0
packages/backend-cli/template/CHANGELOG.md

@@ -0,0 +1,8 @@
+# @dage/backend-template
+
+## 1.0.2
+
+### Patch Changes
+
+- Updated dependencies
+  - @dage/pc-components@1.2.2

+ 1 - 1
packages/backend-cli/template/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@dage/backend-template",
-  "version": "1.0.1",
+  "version": "1.0.2",
   "private": true,
   "dependencies": {
     "@ant-design/icons": "^5.1.4",

+ 12 - 3
packages/docs/docs/components/FileCheckbox/index.md

@@ -47,12 +47,21 @@ import {
 const FILES = [
   {
     creatorName: "",
-    fileName: "h16.4dage",
-    filePath: "https://sit-shgybwg.4dage.com/goods/model/h16.4dage",
+    fileName: "shgy05.4dage",
+    filePath: "https://sit-shgybwg.4dage.com/goods/model/shgy05.4dage",
     id: 111,
     moduleName: "goods",
     type: "model",
   },
+  {
+    creatorName: "",
+    fileName: "shgy05.zip",
+    filePath:
+      "https://sit-shgybwg.4dage.com/goods/model_mobile/20230829_1633142351.zip",
+    id: 112,
+    moduleName: "goods",
+    type: "model_mobile",
+  },
 ];
 
 export default () => {
@@ -146,7 +155,7 @@ const FILES = [
   {
     fileName: "cat.jpg",
     filePath:
-      "https://sit-shgybwg.4dage.com/goods/img/20230731_15280861529.png",
+      "https://sit-shgybwg.4dage.com/goods/img/20230829_12173720283.jpg",
     id: 2,
     moduleName: "goods",
     type: "img",

+ 6 - 0
packages/docs/docs/log/PC-COMPONENTS_CHANGELOG.md

@@ -1,5 +1,11 @@
 # @dage/pc-components
 
+## 1.2.2
+
+### Patch Changes
+
+- 新增显示下载进度条
+
 ## 1.2.1
 
 ### Patch Changes

+ 6 - 0
packages/pc-components/CHANGELOG.md

@@ -1,5 +1,11 @@
 # @dage/pc-components
 
+## 1.2.2
+
+### Patch Changes
+
+- 新增显示下载进度条
+
 ## 1.2.1
 
 ### Patch Changes

+ 1 - 1
packages/pc-components/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@dage/pc-components",
-  "version": "1.2.1",
+  "version": "1.2.2",
   "description": "PC 端组件库",
   "module": "dist/index.js",
   "main": "dist/index.js",

+ 112 - 12
packages/pc-components/src/components/DageUpload/ItemActions.tsx

@@ -1,23 +1,30 @@
-import { FC, ReactNode } from 'react';
-import { UploadFile } from 'antd/es/upload/interface';
-import { DeleteOutlined, DownloadOutlined, EyeOutlined } from '@ant-design/icons';
-import { Button } from 'antd';
+import { FC, ReactNode, useMemo, useRef, useState } from "react";
+import { UploadFile } from "antd/es/upload/interface";
 import {
+  DeleteOutlined,
+  DownloadOutlined,
+  EyeOutlined,
+} from "@ant-design/icons";
+import { Button, Progress, message } from "antd";
+import {
+  DownloadCancelBtn,
+  DownloadProgress,
   UploadFileItem,
   UploadFileItemActions,
   UploadPictureItem,
   UploadPictureItemActions,
-} from './style';
+} from "./style";
+import { requestWithPercent } from "../../utils";
 
 export interface DageUploadItemActionsProps {
   file: UploadFile;
   actions: {
-    download: () => void;
     preview: () => void;
     remove: () => void;
   };
   isPictureCard?: boolean;
   disabled?: boolean;
+  downloadErrorMessage: string;
   children: ReactNode;
 }
 
@@ -27,26 +34,119 @@ export const DageUploadItemActions: FC<DageUploadItemActionsProps> = ({
   file,
   isPictureCard,
   actions,
+  downloadErrorMessage,
 }) => {
-  const showDownload = (file.url || '').indexOf('http') > -1;
+  const controller = useRef<AbortController | null>(null);
+  const [downloading, setDownloading] = useState(false);
+  /** 下载进度百分比 */
+  const [downloadPercent, setDownloadPercent] = useState(0);
+  /** 显示下载按钮 */
+  const showDownload = (file.url || "").indexOf("http") > -1;
+  /** 显示下载进度条 */
+  const showDownloadProgress = useMemo(
+    () => !isPictureCard && downloading,
+    [isPictureCard, downloading]
+  );
   const UploadItem = isPictureCard ? UploadPictureItem : UploadFileItem;
-  const UploadItemActions = isPictureCard ? UploadPictureItemActions : UploadFileItemActions;
+  const UploadItemActions = isPictureCard
+    ? UploadPictureItemActions
+    : UploadFileItemActions;
+
+  const handleDownload = async () => {
+    if (!file.url) return;
+
+    try {
+      setDownloading(true);
+      controller.current = new AbortController();
+      const res = await requestWithPercent({
+        url: file.url,
+        option: {
+          signal: controller.current?.signal,
+        },
+        onProcess: (now, all) => {
+          setDownloadPercent(Math.round((now / all) * 100));
+        },
+      });
+
+      // @ts-ignore
+      const blob = new Blob([res]);
+      const url = URL.createObjectURL(blob);
+
+      const link = document.createElement("a");
+      link.href = url;
+      link.target = "_blank";
+      link.download = file.name;
+      link.style.display = "none";
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+    } catch (err) {
+      if (err instanceof Error && err.name !== "AbortError") {
+        message.error(downloadErrorMessage);
+      }
+    } finally {
+      // 等待进度条动画完成
+      setTimeout(() => {
+        setDownloading(false);
+        setDownloadPercent(0);
+      }, 300);
+    }
+  };
+
+  const handleDownloadCancel = () => {
+    controller.current?.abort();
+  };
 
   return (
-    <UploadItem className="dage-upload__item">
+    <UploadItem
+      className="dage-upload__item"
+      showdownloadprogress={showDownloadProgress.toString()}
+    >
       {children}
 
       <UploadItemActions className="dage-upload__item__actions">
         {isPictureCard && (
-          <Button type="text" size="small" icon={<EyeOutlined />} onClick={actions.preview} />
+          <Button
+            type="text"
+            size="small"
+            icon={<EyeOutlined />}
+            onClick={actions.preview}
+          />
         )}
         {showDownload && (
-          <Button type="text" size="small" icon={<DownloadOutlined />} onClick={actions.download} />
+          <Button
+            type="text"
+            size="small"
+            loading={downloading}
+            disabled={downloading}
+            icon={<DownloadOutlined />}
+            onClick={handleDownload}
+          />
         )}
         {!disabled && (
-          <Button type="text" size="small" icon={<DeleteOutlined />} onClick={actions.remove} />
+          <Button
+            type="text"
+            size="small"
+            icon={<DeleteOutlined />}
+            onClick={actions.remove}
+          />
         )}
       </UploadItemActions>
+
+      {/* 下载进度条 */}
+      {showDownloadProgress && (
+        <DownloadProgress className="dage-upload__item__progress">
+          <Progress
+            size="small"
+            percent={downloadPercent}
+            style={{ margin: 0 }}
+          />
+
+          {downloadPercent < 100 && (
+            <DownloadCancelBtn onClick={handleDownloadCancel} />
+          )}
+        </DownloadProgress>
+      )}
     </UploadItem>
   );
 };

+ 3 - 30
packages/pc-components/src/components/DageUpload/index.tsx

@@ -6,7 +6,7 @@ import {
   UploadOutlined,
 } from "@ant-design/icons";
 import { FC, useContext, useMemo, useState } from "react";
-import { requestByGet, requestByPost } from "@dage/service";
+import { requestByPost } from "@dage/service";
 import { RcFile, UploadFile, UploadProps } from "antd/es/upload";
 import {
   DageFileAPIResponseType,
@@ -41,6 +41,7 @@ export const DageUpload: FC<DageUploadProps> = ({
   tips,
   disabled,
   name = "file",
+  downloadErrorMessage = "下载失败",
   onChange,
   ...rest
 }) => {
@@ -127,14 +128,7 @@ export const DageUpload: FC<DageUploadProps> = ({
       });
 
     requestByPost<DageFileAPIResponseType>(action, formData, {
-      // @ts-ignore
-      withCredentials: option.withCredentials,
       headers,
-      // @ts-ignore
-      onUploadProgress: ({ total, loaded }) => {
-        if (!total || !loaded) return;
-        option.onProgress?.({ percent: Math.round((loaded / total) * 100) });
-      },
     })
       .then((data) => {
         option.onSuccess?.(data);
@@ -189,27 +183,6 @@ export const DageUpload: FC<DageUploadProps> = ({
     );
   };
 
-  const handleDownload = async (file: UploadFile) => {
-    if (!file.url) return;
-
-    const res: BlobPart = await requestByGet(file.url, "", {
-      meta: {
-        responseType: "arrayBuffer",
-      },
-    });
-    const blob = new Blob([res]);
-    const url = URL.createObjectURL(blob);
-
-    const link = document.createElement("a");
-    link.href = url;
-    link.target = "_blank";
-    link.download = file.name;
-    link.style.display = "none";
-    document.body.appendChild(link);
-    link.click();
-    document.body.removeChild(link);
-  };
-
   const props: UploadProps = {
     fileList: value,
     withCredentials: true,
@@ -224,6 +197,7 @@ export const DageUpload: FC<DageUploadProps> = ({
         disabled={disabled}
         file={file}
         actions={actions}
+        downloadErrorMessage={downloadErrorMessage}
       >
         {node}
       </DageUploadItemActions>
@@ -237,7 +211,6 @@ export const DageUpload: FC<DageUploadProps> = ({
     onPreview: handlePreview,
     beforeUpload: beforeUpload,
     onChange: handleChange,
-    onDownload: handleDownload,
     ...rest,
   };
 

+ 34 - 4
packages/pc-components/src/components/DageUpload/style.ts

@@ -1,6 +1,7 @@
+import { CloseOutlined } from "@ant-design/icons";
 import { Upload, UploadProps } from "antd";
 import { FC } from "react";
-import { css, styled } from "styled-components";
+import { css, keyframes, styled } from "styled-components";
 
 const { Dragger } = Upload;
 
@@ -33,18 +34,25 @@ export const AntdDraggerText = styled.p`
   margin: 14px 0;
 `;
 
-export const UploadFileItem = styled.div`
-  margin-top: 8px;
+export const UploadFileItem = styled.div<{
+  showdownloadprogress: string;
+}>`
   display: flex;
   align-items: center;
+  position: relative;
+  margin-top: 8px;
   height: 24px;
-  overflow: hidden;
   border-radius: 4px;
   transition: all 0.3s;
 
   &:hover {
     background-color: rgba(0, 0, 0, 0.04);
   }
+  ${({ showdownloadprogress }) =>
+    showdownloadprogress === "true" &&
+    css`
+      margin-bottom: 10px;
+    `}
   .ant-upload-list-item {
     flex: 1;
     width: 0;
@@ -114,3 +122,25 @@ export const UploadPictureItemActions = styled.div`
     }
   }
 `;
+
+const fadeInAnimation = keyframes`
+  0% { opacity: 0; }
+  100% { opacity: 1; }
+`;
+
+export const DownloadProgress = styled.div`
+  display: flex;
+  align-items: center;
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: -15px;
+  animation: ${fadeInAnimation} 0.2s ease-in-out;
+`;
+export const DownloadCancelBtn = styled(CloseOutlined)`
+  position: relative;
+  top: 2px;
+  margin-left: 5px;
+  color: #999;
+  cursor: pointer;
+`;

+ 26 - 20
packages/pc-components/src/components/DageUpload/types.ts

@@ -1,23 +1,24 @@
-import { UploadFile, UploadProps } from 'antd';
+import { UploadFile, UploadProps } from "antd";
 
 export enum DageUploadType {
   /** 缩略图 */
-  THUMB = 'thumb',
+  THUMB = "thumb",
   /** PC 模型 */
-  MODEL = 'model',
+  MODEL = "model",
   /** Mobile 模型 */
-  MOBILE_MODEL = 'model_mobile',
+  MOBILE_MODEL = "model_mobile",
   /** 视频 */
-  VIDEO = 'video',
+  VIDEO = "video",
   /** 图片 */
-  IMG = 'img',
+  IMG = "img",
   /** 音频 */
-  AUDIO = 'audio',
+  AUDIO = "audio",
   /** 文档 */
-  DOC = 'doc',
+  DOC = "doc",
 }
 
-export interface DageFileResponseType<T = DageFileAPIResponseType> extends UploadFile<T> {
+export interface DageFileResponseType<T = DageFileAPIResponseType>
+  extends UploadFile<T> {
   dType: DageUploadType;
   imgAttrs?: {
     width: number;
@@ -28,16 +29,16 @@ export interface DageFileResponseType<T = DageFileAPIResponseType> extends Uploa
 export interface DageUploadProps<T = DageFileAPIResponseType>
   extends Pick<
     UploadProps<T>,
-    | 'customRequest'
-    | 'directory'
-    | 'maxCount'
-    | 'disabled'
-    | 'onDrop'
-    | 'onDownload'
-    | 'onRemove'
-    | 'headers'
-    | 'data'
-    | 'name'
+    | "customRequest"
+    | "directory"
+    | "maxCount"
+    | "disabled"
+    | "onDrop"
+    | "onDownload"
+    | "onRemove"
+    | "headers"
+    | "data"
+    | "name"
   > {
   /**
    * 上传地址
@@ -62,6 +63,11 @@ export interface DageUploadProps<T = DageFileAPIResponseType>
    */
   tips?: string;
   /**
+   * 下载报错提示语
+   * @default 下载失败
+   */
+  downloadErrorMessage?: string;
+  /**
    * 上传文件改变时的回调
    */
   onChange?(list: DageFileResponseType[], file: DageFileResponseType): void;
@@ -78,4 +84,4 @@ export type DageFileAPIResponseType = {
   type: DageUploadType;
 };
 
-export type HandleUploadingFileNumType = 'add' | 'uploaded';
+export type HandleUploadingFileNumType = "add" | "uploaded";

+ 1 - 0
packages/pc-components/src/utils/index.ts

@@ -1,2 +1,3 @@
 export * from "./storage";
 export * from "./encrypt";
+export * from "./request";

+ 50 - 0
packages/pc-components/src/utils/request.test.ts

@@ -0,0 +1,50 @@
+import { requestWithPercent } from "./request";
+
+describe("requestWithPercent", () => {
+  const mockFetch = jest.fn();
+
+  beforeAll(() => {
+    mockFetch.mockResolvedValue({
+      headers: {
+        get: () => "100",
+      },
+      body: {
+        getReader: jest.fn().mockReturnValue({
+          read: jest
+            .fn()
+            .mockResolvedValueOnce({
+              done: false,
+              value: new Uint8Array([1, 2, 3]),
+            })
+            .mockResolvedValueOnce({
+              done: false,
+              value: new Uint8Array([4, 5, 6]),
+            })
+            .mockResolvedValueOnce({ done: true }),
+        }),
+      },
+    });
+
+    global.fetch = mockFetch;
+  });
+
+  afterAll(() => {
+    // @ts-ignore
+    delete global.fetch;
+    mockFetch.mockRestore();
+  });
+
+  test("数据返回 ArrayBuffer", async () => {
+    const onProcessMock = jest.fn();
+
+    const result = await requestWithPercent({
+      url: "https://example.com/data",
+      onProcess: onProcessMock,
+    });
+
+    expect(onProcessMock).toHaveBeenCalledTimes(2);
+    expect(result).toBeInstanceOf(Uint8Array);
+    expect(result.length).toBe(6);
+    expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6]));
+  });
+});

+ 61 - 0
packages/pc-components/src/utils/request.ts

@@ -0,0 +1,61 @@
+export interface RequestWithPercentProps {
+  url: string;
+  option?: RequestInit;
+  /**
+   * 进度回调
+   * @param now 已下载 buffer 数量
+   * @param all 总长度
+   * @returns
+   */
+  onProcess: (now: number, all: number) => void;
+}
+
+/**
+ * 文件下载并获取下载进度
+ */
+export const requestWithPercent = async ({
+  url = "",
+  option = {},
+  onProcess,
+}: RequestWithPercentProps) => {
+  const res = await fetch(url, {
+    mode: "cors",
+    headers: {
+      responseType: "arraybuffer",
+      Accept: "application/json, text/plain, */*",
+      "Cache-Control": "no-cache",
+    },
+    ...option,
+  });
+  const reader = res.body?.getReader();
+  /** 文件总长度 */
+  const contentLength = Number(res.headers.get("content-length"));
+
+  const chunks = [];
+  let receivedLength = 0;
+
+  if (!reader) return Promise.reject(new Error("无法解析请求体"));
+
+  while (true) {
+    const { done, value } = await reader.read();
+
+    if (done) {
+      break;
+    }
+
+    chunks.push(value);
+    receivedLength += value.length;
+
+    onProcess(receivedLength, contentLength);
+  }
+
+  const chunksAll = new Uint8Array(receivedLength);
+  let position = 0;
+
+  for (const chunk of chunks) {
+    chunksAll.set(chunk, position);
+    position += chunk.length;
+  }
+
+  return chunksAll;
+};