31 |
32 |
33 | -
34 |
35 | JavaScript SDK 基于 h5 file api 开发,可以上传文件至七牛云存储。
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
48 |
49 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/src/api/index.test.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_CHUNK_SIZE, Config } from '../upload'
2 | import { region } from '../config'
3 |
4 | import { getUploadUrl } from '.'
5 |
6 | jest.mock('../utils', () => ({
7 | ...jest.requireActual('../utils') as any,
8 |
9 | request: () => Promise.resolve({
10 | data: {
11 | up: {
12 | acc: {
13 | main: ['mock.qiniu.com']
14 | }
15 | }
16 | }
17 | }),
18 | getPutPolicy: () => ({
19 | ak: 'ak',
20 | bucket: 'bucket'
21 | })
22 | }))
23 |
24 | describe('api function test', () => {
25 | test('getUploadUrl', async () => {
26 | const config: Config = {
27 | useCdnDomain: true,
28 | disableStatisticsReport: false,
29 | retryCount: 3,
30 | checkByMD5: false,
31 | uphost: '',
32 | upprotocol: 'https',
33 | forceDirect: false,
34 | chunkSize: DEFAULT_CHUNK_SIZE,
35 | concurrentRequestLimit: 3
36 | }
37 |
38 | let url: string
39 | const token = 'token'
40 |
41 | url = await getUploadUrl(config, token)
42 | expect(url).toBe('https://mock.qiniu.com')
43 |
44 | config.region = region.z0
45 | url = await getUploadUrl(config, token)
46 | expect(url).toBe('https://upload.qiniup.com')
47 |
48 | config.upprotocol = 'https'
49 | url = await getUploadUrl(config, token)
50 | expect(url).toBe('https://upload.qiniup.com')
51 |
52 | config.upprotocol = 'http'
53 | url = await getUploadUrl(config, token)
54 | expect(url).toBe('http://upload.qiniup.com')
55 |
56 | config.upprotocol = 'https:'
57 | url = await getUploadUrl(config, token)
58 | expect(url).toBe('https://upload.qiniup.com')
59 |
60 | config.upprotocol = 'http:'
61 | url = await getUploadUrl(config, token)
62 | expect(url).toBe('http://upload.qiniup.com')
63 |
64 | config.uphost = 'qiniu.com'
65 | url = await getUploadUrl(config, token)
66 | expect(url).toBe('http://qiniu.com')
67 | })
68 | })
69 |
--------------------------------------------------------------------------------
/src/logger/index.ts:
--------------------------------------------------------------------------------
1 | import { reportV3, V3LogInfo } from './report-v3'
2 |
3 | export type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'OFF'
4 |
5 | export default class Logger {
6 | private static id = 0
7 |
8 | // 为每个类分配一个 id
9 | // 用以区分不同的上传任务
10 | private id = ++Logger.id
11 |
12 | constructor(
13 | private token: string,
14 | private disableReport = true,
15 | private level: LogLevel = 'OFF',
16 | private prefix = 'UPLOAD'
17 | ) { }
18 |
19 | private getPrintPrefix(level: LogLevel) {
20 | return `Qiniu-JS-SDK [${level}][${this.prefix}#${this.id}]:`
21 | }
22 |
23 | /**
24 | * @param {V3LogInfo} data 上报的数据。
25 | * @param {boolean} retry 重试次数,可选,默认为 3。
26 | * @description 向服务端上报统计信息。
27 | */
28 | report(data: V3LogInfo, retry?: number) {
29 | if (this.disableReport) return
30 | try {
31 | reportV3(this.token, data, retry)
32 | } catch (error) {
33 | this.warn(error)
34 | }
35 | }
36 |
37 | /**
38 | * @param {unknown[]} ...args
39 | * @description 输出 info 级别的调试信息。
40 | */
41 | info(...args: unknown[]) {
42 | const allowLevel: LogLevel[] = ['INFO']
43 | if (allowLevel.includes(this.level)) {
44 | // eslint-disable-next-line no-console
45 | console.log(this.getPrintPrefix('INFO'), ...args)
46 | }
47 | }
48 |
49 | /**
50 | * @param {unknown[]} ...args
51 | * @description 输出 warn 级别的调试信息。
52 | */
53 | warn(...args: unknown[]) {
54 | const allowLevel: LogLevel[] = ['INFO', 'WARN']
55 | if (allowLevel.includes(this.level)) {
56 | // eslint-disable-next-line no-console
57 | console.warn(this.getPrintPrefix('WARN'), ...args)
58 | }
59 | }
60 |
61 | /**
62 | * @param {unknown[]} ...args
63 | * @description 输出 error 级别的调试信息。
64 | */
65 | error(...args: unknown[]) {
66 | const allowLevel: LogLevel[] = ['INFO', 'WARN', 'ERROR']
67 | if (allowLevel.includes(this.level)) {
68 | // eslint-disable-next-line no-console
69 | console.error(this.getPrintPrefix('ERROR'), ...args)
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/test/demo3/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
七牛云 - JavaScript SDK
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | -
17 |
18 | JavaScript SDK 基于 h5 file api 开发,可以上传文件至七牛云存储。
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
![]()
46 |
47 |
48 |
49 |
![]()
50 |
51 |
52 |
53 |
54 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/test/demo1/component/ui.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | var init = function(obj) {
3 | var li_children =
4 | "
" +
5 | "
" +
6 | "
" +
7 | "
";
8 | var li = document.createElement("li");
9 | $(li).addClass("fragment");
10 | $(li).html(li_children);
11 | obj.node.append(li);
12 | };
13 | widget.register("li", {
14 | init: init
15 | });
16 | })();
17 |
18 | (function() {
19 | var init = function(obj) {
20 | var data = obj.data;
21 | var name = data.name;
22 | var size = data.size;
23 | var parent =
24 | "
" +
25 | name +
26 | "" +
27 | " | " +
28 | "
" +
29 | size +
30 | " | " +
31 | "
" +
32 | " " +
33 | " " +
34 | " " +
35 | " " +
36 | '' +
37 | " " +
38 | "" +
39 | " | ";
41 | var tr = document.createElement("tr");
42 | $(tr).html(parent);
43 | obj.node.append(tr);
44 | for (var i = 0; i < data.num; i++) {
45 | widget.add("li", {
46 | data: "",
47 | node: $(tr).find(".fragment-group")
48 | });
49 | }
50 | $(tr)
51 | .find(".resume")
52 | .on("click", function() {
53 | var ulDom = $(tr).find(".fragment-group");
54 | if (ulDom.hasClass("hide")) {
55 | ulDom.removeClass("hide");
56 | } else {
57 | ulDom.addClass("hide");
58 | }
59 | });
60 | return tr;
61 | };
62 | widget.register("tr", {
63 | init: init
64 | });
65 | })();
66 |
--------------------------------------------------------------------------------
/test/demo1/style/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: rgb(249, 249, 249);
3 | }
4 | .navbar {
5 | background-color: #fff;
6 | }
7 | .mainContainer {
8 | position: relative;
9 | top: 52px;
10 | }
11 | .mainContainer {
12 | width: 1170px;
13 | margin: 0 auto;
14 | padding: 15px 15px;
15 | }
16 | .mainContainer .row .tip li {
17 | list-style: none;
18 | }
19 | .mainContainer .nav-box ul li a {
20 | color: #777;
21 | }
22 | #box,
23 | #box2 {
24 | margin-top: 20px;
25 | height: 46px;
26 | }
27 | .fragment-group {
28 | overflow: hidden;
29 | padding-left: 0;
30 | }
31 | .hide {
32 | visibility: hidden;
33 | }
34 | .fragment-group .fragment {
35 | float: left;
36 | width: 30%;
37 | padding-right: 10px;
38 | list-style: none;
39 | margin-top: 10px;
40 | }
41 | .file-input {
42 | display: inline-block;
43 | box-sizing: border-box;
44 | width: 130px;
45 | height: 46px;
46 | opacity: 0;
47 | cursor: pointer;
48 | }
49 |
50 | .mainContainer .select-button {
51 | position: absolute;
52 | background-color: #00b7ee;
53 | color: #fff;
54 | font-size: 18px;
55 | padding: 0 30px;
56 | line-height: 44px;
57 | font-weight: 100;
58 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
59 | }
60 | .speed {
61 | margin-top: 15px;
62 | }
63 | .control-upload {
64 | line-height: 14px;
65 | margin-left: 10px;
66 | }
67 | .control-container {
68 | float: left;
69 | width: 20%;
70 | }
71 | #totalBar {
72 | margin-bottom: 40px;
73 | float: left;
74 | width: 80%;
75 | height: 30px;
76 | border: 1px solid;
77 | border-radius: 3px;
78 | }
79 | .linkWrapper img {
80 | width: 100px;
81 | height: 100px;
82 | }
83 | .modal-body {
84 | text-align: center;
85 | }
86 | .buttonList a {
87 | padding: 5px 10px;
88 | background: #fff;
89 | border-radius: 5px;
90 | color: #000;
91 | margin-left: 10px;
92 | cursor: pointer;
93 | }
94 | .buttonList ul {
95 | text-align: left;
96 | }
97 | .buttonList li {
98 | list-style: none;
99 | margin-top: 15px;
100 | }
101 | .disabled {
102 | background: #ccc;
103 | }
104 | .display a {
105 | display: block;
106 | background: #fff;
107 | }
108 |
--------------------------------------------------------------------------------
/test/demo1/scripts/uploadWithForm.js:
--------------------------------------------------------------------------------
1 | // 实现form直传无刷新并解决跨域问题
2 | function uploadWithForm(token, putExtra, config) {
3 | controlTabDisplay("form");
4 | // 获得上传地址
5 | qiniu.getUploadUrl(config, token).then(function(res){
6 | var uploadUrl = res;
7 | document.getElementsByName("token")[0].value = token;
8 | document.getElementsByName("url")[0].value = uploadUrl;
9 | // 当选择文件后执行的操作
10 | $("#select3").unbind("change").bind("change",function(){
11 | var iframe = createIframe();
12 | disableButtonOfSelect();
13 | var key = this.files[0].name;
14 | // 添加上传dom面板
15 | var board = addUploadBoard(this.files[0], config, key, "3");
16 | window.showRes = function(res){
17 | $(board)
18 | .find(".control-container")
19 | .html(
20 | "
Hash:" +
21 | res.hash +
22 | "
" +
23 | "
Bucket:" +
24 | res.bucket +
25 | "
"
26 | );
27 | }
28 | $(board)
29 | .find("#totalBar")
30 | .addClass("hide");
31 | $(board)
32 | .find(".control-upload")
33 | .on("click", function() {
34 | enableButtonOfSelect();
35 | // 把action地址指向我们的 node sdk 后端服务,通过后端来实现跨域访问
36 | $("#uploadForm").attr("target", iframe.name);
37 | $("#uploadForm")
38 | .attr("action", "/api/transfer")
39 | .submit();
40 | $(this).text("上传中...");
41 | $(this).attr("disabled", "disabled");
42 | $(this).css("backgroundColor", "#aaaaaa");
43 | });
44 | })
45 | });
46 | }
47 |
48 | function createIframe() {
49 | var iframe = document.createElement("iframe");
50 | iframe.name = "iframe" + Math.random();
51 | $("#directForm").append(iframe);
52 | iframe.style.display = "none";
53 | return iframe;
54 | }
55 |
56 | function enableButtonOfSelect() {
57 | $("#select3").removeAttr("disabled", "disabled");
58 | $("#directForm")
59 | .find("button")
60 | .css("backgroundColor", "#00b7ee");
61 | }
62 |
63 | function disableButtonOfSelect() {
64 | $("#select3").attr("disabled", "disabled");
65 | $("#directForm")
66 | .find("button")
67 | .css("backgroundColor", "#aaaaaa");
68 | }
69 |
70 |
--------------------------------------------------------------------------------
/site/src/components/Settings/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import * as utils from '../../utils'
4 |
5 | import { Input } from '../Input'
6 | import classnames from './style.less'
7 |
8 | interface IProps { }
9 |
10 | export function Settings(props: IProps) {
11 | const setting = React.useMemo(() => utils.loadSetting(), [])
12 | const [deadline, setDeadline] = React.useState
(0)
13 | const [uphost, seUphost] = React.useState(setting.uphost || '')
14 | const [assessKey, setAssessKey] = React.useState(setting.assessKey || '')
15 | const [secretKey, setSecretKey] = React.useState(setting.secretKey || '')
16 | const [bucketName, setBucketName] = React.useState(setting.bucketName || '')
17 |
18 | React.useEffect(() => {
19 | utils.saveSetting({
20 | assessKey,
21 | secretKey,
22 | bucketName,
23 | deadline,
24 | uphost
25 | })
26 | }, [assessKey, secretKey, bucketName, deadline, uphost])
27 |
28 | React.useEffect(() => {
29 | if (deadline > 0) return
30 | // 基于当前时间加上 3600s
31 | setDeadline(Math.floor(Date.now() / 1000) + 3600)
32 | }, [deadline])
33 |
34 | return (
35 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/site/src/components/Layout/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/site/src/components/SelectFile/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import folderIcon from './assets/folder.svg'
4 | import classnames from './style.less'
5 |
6 | export interface UniqueFile {
7 | key: string
8 | file: File
9 | }
10 |
11 | interface IProps {
12 | onFile(file: UniqueFile): void
13 | }
14 |
15 | enum State {
16 | Drop,
17 | Over,
18 | Leave
19 | }
20 |
21 | export function SelectFile(props: IProps): React.ReactElement {
22 | const [state, setState] = React.useState(null)
23 | const inputRef = React.useRef(null)
24 |
25 | const onClick = () => {
26 | if (!inputRef.current) return
27 | inputRef.current.value = ''
28 | inputRef.current.click()
29 | }
30 |
31 | const onDrop = (event: React.DragEvent) => {
32 | event.preventDefault()
33 | if (!event || !event.dataTransfer || !event.dataTransfer.files) return
34 | Array.from(event.dataTransfer.files).forEach(file => props.onFile({
35 | key: Date.now() + file.name,
36 | file
37 | }))
38 | setState(State.Drop)
39 | }
40 |
41 | const onChange = (event: React.ChangeEvent) => {
42 | if (!inputRef || !event || !event.target || !event.target.files) return
43 | Array.from(event.target.files).forEach(file => props.onFile({
44 | key: Date.now() + file.name,
45 | file
46 | }))
47 | }
48 |
49 | // 阻止默认的拖入文件处理
50 | React.useEffect(() => {
51 | const handler = (e: any) => e.preventDefault()
52 | document.addEventListener('dragover', handler)
53 | return () => {
54 | document.removeEventListener('dragover', handler)
55 | }
56 | }, [])
57 |
58 | return (
59 | setState(State.Over)}
64 | onDragLeave={() => setState(State.Leave)}
65 | >
66 |
67 |
68 |

69 |
70 | {State.Over === state && (
松开释放文件
)}
71 | {(state == null || State.Over !== state) && (
72 |
点击选择 OR 拖入文件
73 | )}
74 |
75 |
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/src/upload/direct.ts:
--------------------------------------------------------------------------------
1 | import { CRC32 } from '../utils/crc32'
2 |
3 | import { direct } from '../api'
4 |
5 | import Base from './base'
6 |
7 | export default class Direct extends Base {
8 |
9 | protected async run() {
10 | this.logger.info('start run Direct.')
11 |
12 | const formData = new FormData()
13 | formData.append('file', this.file)
14 | formData.append('token', this.token)
15 | if (this.key != null) {
16 | formData.append('key', this.key)
17 | }
18 | formData.append('fname', this.putExtra.fname)
19 |
20 | if (this.config.checkByServer) {
21 | const crcSign = await CRC32.file(this.file)
22 | formData.append('crc32', crcSign.toString())
23 | }
24 |
25 | if (this.putExtra.customVars) {
26 | this.logger.info('init customVars.')
27 | const { customVars } = this.putExtra
28 | Object.keys(customVars).forEach(key => formData.append(key, customVars[key].toString()))
29 | this.logger.info('customVars inited.')
30 | }
31 |
32 | if (this.putExtra.metadata) {
33 | this.logger.info('init metadata.')
34 | const { metadata } = this.putExtra
35 | Object.keys(metadata).forEach(key => formData.append(key, metadata[key].toString()))
36 | }
37 |
38 | this.logger.info('formData inited.')
39 | const result = await direct(this.uploadHost!.getUrl(), formData, {
40 | onProgress: data => {
41 | this.updateDirectProgress(data.loaded, data.total)
42 | },
43 | onCreate: xhr => this.addXhr(xhr)
44 | })
45 |
46 | this.logger.info('Direct progress finish.')
47 | this.finishDirectProgress()
48 | return result
49 | }
50 |
51 | private updateDirectProgress(loaded: number, total: number) {
52 | // 当请求未完成时可能进度会达到100,所以total + 1来防止这种情况出现
53 | this.progress = { total: this.getProgressInfoItem(loaded, total + 1) }
54 | this.onData(this.progress)
55 | }
56 |
57 | private finishDirectProgress() {
58 | // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null,这里 fake 下
59 | if (!this.progress) {
60 | this.logger.warn('progress is null.')
61 | this.progress = { total: this.getProgressInfoItem(this.file.size, this.file.size) }
62 | this.onData(this.progress)
63 | return
64 | }
65 |
66 | const { total } = this.progress
67 | this.progress = { total: this.getProgressInfoItem(total.loaded + 1, total.size) }
68 | this.onData(this.progress)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/utils/crc32.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-bitwise */
2 |
3 | import { MB } from './helper'
4 |
5 | /**
6 | * 以下 class 实现参考
7 | * https://github.com/Stuk/jszip/blob/d4702a70834bd953d4c2d0bc155fad795076631a/lib/crc32.js
8 | * 该实现主要针对大文件优化、对计算的值进行了 `>>> 0` 运算(为与服务端保持一致)
9 | */
10 | export class CRC32 {
11 | private crc = -1
12 | private table = this.makeTable()
13 |
14 | private makeTable() {
15 | const table = new Array()
16 | for (let i = 0; i < 256; i++) {
17 | let t = i
18 | for (let j = 0; j < 8; j++) {
19 | if (t & 1) {
20 | // IEEE 标准
21 | t = (t >>> 1) ^ 0xEDB88320
22 | } else {
23 | t >>>= 1
24 | }
25 | }
26 | table[i] = t
27 | }
28 |
29 | return table
30 | }
31 |
32 | private append(data: Uint8Array) {
33 | let crc = this.crc
34 | for (let offset = 0; offset < data.byteLength; offset++) {
35 | crc = (crc >>> 8) ^ this.table[(crc ^ data[offset]) & 0xFF]
36 | }
37 | this.crc = crc
38 | }
39 |
40 | private compute() {
41 | return (this.crc ^ -1) >>> 0
42 | }
43 |
44 | private async readAsUint8Array(file: File | Blob): Promise {
45 | if (typeof file.arrayBuffer === 'function') {
46 | return new Uint8Array(await file.arrayBuffer())
47 | }
48 |
49 | return new Promise((resolve, reject) => {
50 | const reader = new FileReader()
51 | reader.onload = () => {
52 | if (reader.result == null) {
53 | reject()
54 | return
55 | }
56 |
57 | if (typeof reader.result === 'string') {
58 | reject()
59 | return
60 | }
61 |
62 | resolve(new Uint8Array(reader.result))
63 | }
64 | reader.readAsArrayBuffer(file)
65 | })
66 | }
67 |
68 | async file(file: File): Promise {
69 | if (file.size <= MB) {
70 | this.append(await this.readAsUint8Array(file))
71 | return this.compute()
72 | }
73 |
74 | const count = Math.ceil(file.size / MB)
75 | for (let index = 0; index < count; index++) {
76 | const start = index * MB
77 | const end = index === (count - 1) ? file.size : start + MB
78 | // eslint-disable-next-line no-await-in-loop
79 | const chuck = await this.readAsUint8Array(file.slice(start, end))
80 | this.append(new Uint8Array(chuck))
81 | }
82 |
83 | return this.compute()
84 | }
85 |
86 | static file(file: File): Promise {
87 | const crc = new CRC32()
88 | return crc.file(file)
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/upload/index.ts:
--------------------------------------------------------------------------------
1 | import Resume from './resume'
2 | import Direct from './direct'
3 | import Logger from '../logger'
4 | import { UploadCompleteData } from '../api'
5 | import { Observable, IObserver, MB, normalizeUploadConfig } from '../utils'
6 | import { QiniuError, QiniuNetworkError, QiniuRequestError } from '../errors'
7 | import { Extra, UploadOptions, UploadHandlers, UploadProgress, Config } from './base'
8 | import { HostPool } from './hosts'
9 |
10 | export * from './base'
11 | export * from './resume'
12 |
13 | export function createUploadManager(
14 | options: UploadOptions,
15 | handlers: UploadHandlers,
16 | hostPool: HostPool,
17 | logger: Logger
18 | ) {
19 | if (options.config && options.config.forceDirect) {
20 | logger.info('ues forceDirect mode.')
21 | return new Direct(options, handlers, hostPool, logger)
22 | }
23 |
24 | if (options.file.size > 4 * MB) {
25 | logger.info('file size over 4M, use Resume.')
26 | return new Resume(options, handlers, hostPool, logger)
27 | }
28 |
29 | logger.info('file size less or equal than 4M, use Direct.')
30 | return new Direct(options, handlers, hostPool, logger)
31 | }
32 |
33 | /**
34 | * @param file 上传文件
35 | * @param key 目标文件名
36 | * @param token 上传凭证
37 | * @param putExtra 上传文件的相关资源信息配置
38 | * @param config 上传任务的配置
39 | * @returns 返回用于上传任务的可观察对象
40 | */
41 | export default function upload(
42 | file: File,
43 | key: string | null | undefined,
44 | token: string,
45 | putExtra?: Partial,
46 | config?: Config
47 | ): Observable {
48 |
49 | // 为每个任务创建单独的 Logger
50 | const logger = new Logger(token, config?.disableStatisticsReport, config?.debugLogLevel, file.name)
51 |
52 | const options: UploadOptions = {
53 | file,
54 | key,
55 | token,
56 | putExtra,
57 | config: normalizeUploadConfig(config, logger)
58 | }
59 |
60 | // 创建 host 池
61 | const hostPool = new HostPool(options.config.uphost)
62 |
63 | return new Observable((observer: IObserver<
64 | UploadProgress,
65 | QiniuError | QiniuRequestError | QiniuNetworkError,
66 | UploadCompleteData
67 | >) => {
68 | const manager = createUploadManager(options, {
69 | onData: (data: UploadProgress) => observer.next(data),
70 | onError: (err: QiniuError) => observer.error(err),
71 | onComplete: (res: any) => observer.complete(res)
72 | }, hostPool, logger)
73 | manager.putFile()
74 | return manager.stop.bind(manager)
75 | })
76 | }
77 |
--------------------------------------------------------------------------------
/test/server.js:
--------------------------------------------------------------------------------
1 | var qiniu = require("qiniu");
2 | var express = require("express");
3 | var util = require("util");
4 | var path = require("path")
5 | var request = require("request");
6 | var app = express();
7 | app.use(express.static(__dirname + "/"));
8 | var multiparty = require("multiparty");
9 |
10 | var fs=require('fs');
11 | var config=JSON.parse(fs.readFileSync(path.resolve(__dirname,"config.json")));
12 |
13 | var mac = new qiniu.auth.digest.Mac(config.AccessKey, config.SecretKey);
14 | var config2 = new qiniu.conf.Config();
15 | // 这里主要是为了用 node sdk 的 form 直传,结合 demo 中 form 方式来实现无刷新上传
16 | config2.zone = qiniu.zone.Zone_z2;
17 | var formUploader = new qiniu.form_up.FormUploader(config2);
18 | var putExtra = new qiniu.form_up.PutExtra();
19 | var options = {
20 | scope: config.Bucket,
21 | // 上传策略设置文件过期时间,正式环境中要谨慎使用,文件在存储空间保存一天后删除
22 | deleteAfterDays: 1,
23 | returnBody:
24 | '{"key":"$(key)","hash":"$(etag)","fsize":$(fsize),"bucket":"$(bucket)","name":"$(x:name)"}'
25 | };
26 |
27 | var putPolicy = new qiniu.rs.PutPolicy(options);
28 | var bucketManager = new qiniu.rs.BucketManager(mac, null);
29 |
30 | app.get("/api/uptoken", function(req, res, next) {
31 | var token = putPolicy.uploadToken(mac);
32 | res.header("Cache-Control", "max-age=0, private, must-revalidate");
33 | res.header("Pragma", "no-cache");
34 | res.header("Expires", 0);
35 | if (token) {
36 | res.json({
37 | uptoken: token,
38 | domain: config.Domain
39 | });
40 | }
41 | });
42 |
43 | app.post("/api/transfer", function(req, res) {
44 | var form = new multiparty.Form();
45 | form.parse(req, function(err, fields, files) {
46 | var path = files.file[0].path;
47 | var token = fields.token[0];
48 | var key = fields.key[0];
49 | formUploader.putFile(token, key, path, putExtra, function(
50 | respErr,
51 | respBody,
52 | respInfo
53 | ) {
54 | if (respErr) {
55 | console.log(respErr);
56 | throw respErr;
57 | }
58 | if (respInfo.statusCode == 200) {
59 | res.send('');
60 | } else {
61 | console.log(respInfo.statusCode);
62 | console.log(respBody);
63 | }
64 | });
65 | });
66 | });
67 |
68 | app.listen(config.Port, function() {
69 | console.log("Listening on port %d\n", config.Port);
70 | console.log(
71 | "▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ Demos ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽"
72 | );
73 | console.log(
74 | "△ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △\n"
75 | );
76 | });
77 |
--------------------------------------------------------------------------------
/site/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const { HotModuleReplacementPlugin } = require('webpack')
3 | const HtmlWebpackPlugin = require('html-webpack-plugin')
4 | const ESLintPlugin = require('eslint-webpack-plugin')
5 | const WebpackBar = require('webpackbar')
6 |
7 | const htmlTemp = `
8 |
9 |
10 |
11 |
12 | 七牛云 - JS SDK 示例 V3
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | `
23 |
24 | module.exports = {
25 | context: path.join(__dirname, 'src'),
26 | devtool: 'source-map',
27 |
28 | resolve: {
29 | extensions: ['.js', '.ts', '.tsx'],
30 | alias: {
31 | buffer: require.resolve("buffer/"),
32 | stream: require.resolve("stream-browserify")
33 | },
34 | },
35 | entry: ['./index.tsx'],
36 |
37 | output: {
38 | filename: 'bundle.js',
39 | path: path.join(__dirname, 'dist')
40 | },
41 |
42 | devServer: {
43 | port: 7777,
44 | inline: true,
45 | host: '0.0.0.0',
46 | stats: 'errors-only',
47 | contentBase: './dist'
48 | },
49 |
50 | module: {
51 | rules: [
52 | {
53 | test: /\.tsx?$/,
54 | exclude: /node_modules/,
55 | loader: 'esbuild-loader',
56 | options: {
57 | loader: 'tsx',
58 | target: 'es2015',
59 | tsconfigRaw: require('./tsconfig.json')
60 | }
61 | },
62 | {
63 | test: /\.less$/,
64 | use: [
65 | "style-loader",
66 | {
67 | loader: 'css-loader',
68 | options: {
69 | modules: {
70 | localIdentName: '[name]@[local]:[hash:base64:5]'
71 | }
72 | }
73 | },
74 | "less-loader"
75 | ]
76 | },
77 | {
78 | test: /\.(png|jpg|gif|svg)$/,
79 | loader: 'file-loader',
80 | options: {
81 | name: 'static/img/[name].[ext]?[hash]',
82 | esModule: false
83 | }
84 | }
85 | ]
86 | },
87 |
88 | plugins: [
89 | new HtmlWebpackPlugin({
90 | templateContent: htmlTemp,
91 | inject: 'head'
92 | }),
93 | new HotModuleReplacementPlugin(),
94 | new ESLintPlugin(),
95 | new WebpackBar()
96 | ]
97 | }
98 |
--------------------------------------------------------------------------------
/src/logger/report-v3.test.ts:
--------------------------------------------------------------------------------
1 | import { reportV3, V3LogInfo } from './report-v3'
2 |
3 | class MockXHR {
4 | sendData: string
5 | openData: string[]
6 | openCount: number
7 | headerData: string[]
8 |
9 | status: number
10 | readyState: number
11 | onreadystatechange() {
12 | // null
13 | }
14 |
15 | clear() {
16 | this.sendData = ''
17 | this.openData = []
18 | this.headerData = []
19 |
20 | this.status = 0
21 | this.readyState = 0
22 | }
23 |
24 | open(...args: string[]) {
25 | this.clear()
26 | this.openCount += 1
27 | this.openData = args
28 | }
29 |
30 | send(args: string) {
31 | this.sendData = args
32 | }
33 |
34 | setRequestHeader(...args: string[]) {
35 | this.headerData.push(...args)
36 | }
37 |
38 | changeStatusAndState(readyState: number, status: number) {
39 | this.status = status
40 | this.readyState = readyState
41 | this.onreadystatechange()
42 | }
43 | }
44 |
45 | const mockXHR = new MockXHR()
46 |
47 | jest.mock('../utils', () => ({
48 | createXHR: () => mockXHR,
49 | getAuthHeaders: (t: string) => t
50 | }))
51 |
52 | describe('test report-v3', () => {
53 | const testData: V3LogInfo = {
54 | code: 200,
55 | reqId: 'reqId',
56 | host: 'host',
57 | remoteIp: 'remoteIp',
58 | port: 'port',
59 | duration: 1,
60 | time: 1,
61 | bytesSent: 1,
62 | upType: 'jssdk-h5',
63 | size: 1
64 | }
65 |
66 | test('stringify send Data', () => {
67 | reportV3('token', testData, 3)
68 | mockXHR.changeStatusAndState(0, 0)
69 | expect(mockXHR.sendData).toBe([
70 | testData.code || '',
71 | testData.reqId || '',
72 | testData.host || '',
73 | testData.remoteIp || '',
74 | testData.port || '',
75 | testData.duration || '',
76 | testData.time || '',
77 | testData.bytesSent || '',
78 | testData.upType || '',
79 | testData.size || ''
80 | ].join(','))
81 | })
82 |
83 | test('retry', () => {
84 | mockXHR.openCount = 0
85 | reportV3('token', testData)
86 | for (let index = 1; index <= 10; index++) {
87 | mockXHR.changeStatusAndState(4, 0)
88 | }
89 | expect(mockXHR.openCount).toBe(4)
90 |
91 | mockXHR.openCount = 0
92 | reportV3('token', testData, 4)
93 | for (let index = 1; index < 10; index++) {
94 | mockXHR.changeStatusAndState(4, 0)
95 | }
96 | expect(mockXHR.openCount).toBe(5)
97 |
98 | mockXHR.openCount = 0
99 | reportV3('token', testData, 0)
100 | for (let index = 1; index < 10; index++) {
101 | mockXHR.changeStatusAndState(4, 0)
102 | }
103 | expect(mockXHR.openCount).toBe(1)
104 | })
105 | })
106 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "qiniu-js",
3 | "jsName": "qiniu",
4 | "version": "3.4.3",
5 | "private": false,
6 | "description": "Javascript SDK for Qiniu Resource (Cloud) Storage AP",
7 | "main": "lib/index.js",
8 | "types": "esm/index.d.ts",
9 | "module": "esm/index.js",
10 | "scripts": {
11 | "test": "jest --coverage",
12 | "clean": "del \"./(lib|dist|esm)\"",
13 | "build": "npm run clean && tsc && babel esm --out-dir lib && webpack --optimize-minimize --config webpack.prod.js",
14 | "dev": "webpack-dev-server --open --config webpack.dev.js",
15 | "lint": "tsc --noEmit && eslint --ext .ts src/",
16 | "server": "node test/server.js"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git://github.com/qiniu/js-sdk.git"
21 | },
22 | "author": "sdk@qiniu.com",
23 | "bugs": {
24 | "url": "https://github.com/qiniu/js-sdk/issues"
25 | },
26 | "contributors": [
27 | {
28 | "name": "jinxinxin",
29 | "email": "jinxinxin@qiniu.com"
30 | },
31 | {
32 | "name": "winddies",
33 | "email": "zhangheng01@qiniu.com"
34 | },
35 | {
36 | "name": "yinxulai",
37 | "email": "yinxulai@qiniu.com"
38 | }
39 | ],
40 | "devDependencies": {
41 | "@babel/cli": "^7.10.1",
42 | "@babel/core": "^7.10.2",
43 | "@babel/plugin-proposal-object-rest-spread": "^7.10.1",
44 | "@babel/plugin-transform-runtime": "^7.10.1",
45 | "@babel/preset-env": "^7.10.2",
46 | "@qiniu/eslint-config": "^0.0.6-beta.7",
47 | "@types/jest": "^26.0.23",
48 | "@types/node": "^15.3.1",
49 | "@types/spark-md5": "^3.0.2",
50 | "@typescript-eslint/eslint-plugin": "~4.10.0",
51 | "@typescript-eslint/parser": "^4.28.4",
52 | "babel-loader": "^8.1.0",
53 | "babel-plugin-syntax-flow": "^6.18.0",
54 | "body-parser": "^1.18.2",
55 | "connect-multiparty": "^2.1.0",
56 | "del-cli": "^3.0.1",
57 | "eslint": "~7.2.0",
58 | "eslint-import-resolver-typescript": "~2.3.0",
59 | "eslint-plugin-import": "~2.22.1",
60 | "eslint-plugin-jsx-a11y": "~6.3.0",
61 | "eslint-plugin-react": "~7.20.0",
62 | "eslint-plugin-react-hooks": "~4.2.0",
63 | "express": "^4.16.2",
64 | "jest": "^26.0.1",
65 | "multiparty": "^4.1.3",
66 | "qiniu": "^7.3.1",
67 | "request": "^2.88.1",
68 | "terser-webpack-plugin": "4.2.3",
69 | "ts-jest": "25.5.1",
70 | "ts-loader": "^6.2.1",
71 | "typedoc": "^0.17.7",
72 | "typescript": "^3.9.5",
73 | "webpack": "^4.41.5",
74 | "webpack-cli": "^3.3.11",
75 | "webpack-dev-server": "^3.11.0",
76 | "webpack-merge": "^4.2.2"
77 | },
78 | "license": "MIT",
79 | "dependencies": {
80 | "@babel/runtime-corejs2": "^7.10.2",
81 | "querystring": "^0.2.1",
82 | "spark-md5": "^3.0.0"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/upload/index.test.ts:
--------------------------------------------------------------------------------
1 | import { ApiName, errorMap, MockApi } from '../api/index.mock'
2 |
3 | const mockApi = new MockApi()
4 | jest.mock('../api', () => mockApi)
5 |
6 | // eslint-disable-next-line import/first
7 | import { MB, Observable } from '../utils'
8 | // eslint-disable-next-line import/first
9 | import upload from '.'
10 |
11 | const testToken = 'lVgtk5xr03Oz_uvkzDtQ8LtpiEUWx5tGEDUZVg1y:rAwZ6rnPQbjyG6Pzkx4PORzn6C8=:eyJyZXR1cm5Cb2R5Ijoie1wia2V5XCI6ICQoa2V5KX0iLCJzY29wZSI6InFpbml1LWRhcnQtc2RrIiwiZGVhZGxpbmUiOjE2MTkzNjA0Mzh9'
12 |
13 | function mockFile(size = 4, name = 'mock.jpg', type = 'image/jpg'): File {
14 | if (size >= 1024) throw new Error('the size is set too large.')
15 |
16 | const blob = new Blob(['1'.repeat(size * MB)], { type })
17 | return new File([blob], name)
18 | }
19 |
20 | function observablePromisify(observable: Observable) {
21 | return new Promise((resolve, reject) => {
22 | observable.subscribe({
23 | error: reject,
24 | complete: resolve
25 | })
26 | })
27 | }
28 |
29 | const File3M = mockFile(3)
30 | const File4M = mockFile(4)
31 | const File5M = mockFile(5)
32 |
33 | describe('test upload', () => {
34 | beforeEach(() => {
35 | localStorage.clear() // 清理缓存
36 | mockApi.clearInterceptor()
37 | })
38 |
39 | test('base Direct.', async () => {
40 | // 文件小于 4M 使用直传
41 | const result1 = await observablePromisify(upload(File3M, null, testToken))
42 | expect(result1).toStrictEqual((await mockApi.direct()).data)
43 |
44 | // 文件等于 4M 使用直传
45 | const result2 = await observablePromisify(upload(File4M, null, testToken))
46 | expect(result2).toStrictEqual((await mockApi.direct()).data)
47 | })
48 |
49 | test('Direct: all api error state.', async () => {
50 | for (const error of Object.values(errorMap)) {
51 | localStorage.clear()
52 | mockApi.clearInterceptor()
53 | mockApi.setInterceptor('direct', () => Promise.reject(error))
54 | // eslint-disable-next-line no-await-in-loop
55 | await expect(observablePromisify(upload(File3M, null, testToken)))
56 | .rejects.toStrictEqual(error)
57 | }
58 | })
59 |
60 | test('Resume: base.', async () => {
61 | // 文件大于 4M 使用分片
62 | const result = await observablePromisify(upload(File5M, null, testToken))
63 | expect(result).toStrictEqual((await mockApi.uploadComplete()).data)
64 | })
65 |
66 | test('Resume: all api error state.', async () => {
67 | const testApiTable: ApiName[] = [
68 | 'getUpHosts', 'initUploadParts',
69 | 'uploadChunk', 'uploadComplete'
70 | ]
71 |
72 | for (const apiName of testApiTable) {
73 | for (const error of Object.values(errorMap)) {
74 | localStorage.clear()
75 | mockApi.clearInterceptor()
76 | mockApi.setInterceptor(apiName, (..._: any[]) => Promise.reject(error))
77 | // eslint-disable-next-line no-await-in-loop
78 | await expect(observablePromisify(upload(File5M, null, testToken)))
79 | .rejects.toStrictEqual(error)
80 | }
81 | }
82 | })
83 | })
84 |
--------------------------------------------------------------------------------
/src/utils/config.test.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_CHUNK_SIZE } from '../upload'
2 | import { normalizeUploadConfig } from './config'
3 | import { region, regionUphostMap } from '../config/region'
4 |
5 | describe('test config ', () => {
6 | test('normalizeUploadConfig', () => {
7 | const config1 = normalizeUploadConfig()
8 | expect(config1).toStrictEqual({
9 | uphost: [],
10 | retryCount: 3,
11 | checkByMD5: false,
12 | checkByServer: true,
13 | forceDirect: false,
14 | useCdnDomain: true,
15 | concurrentRequestLimit: 3,
16 | chunkSize: DEFAULT_CHUNK_SIZE,
17 | upprotocol: 'https',
18 | debugLogLevel: 'OFF',
19 | disableStatisticsReport: false
20 | })
21 |
22 | const config2 = normalizeUploadConfig({ upprotocol: 'https:' })
23 | expect(config2).toStrictEqual({
24 | uphost: [],
25 | retryCount: 3,
26 | checkByMD5: false,
27 | checkByServer: true,
28 | forceDirect: false,
29 | useCdnDomain: true,
30 | concurrentRequestLimit: 3,
31 | chunkSize: DEFAULT_CHUNK_SIZE,
32 | upprotocol: 'https',
33 | debugLogLevel: 'OFF',
34 | disableStatisticsReport: false
35 | })
36 |
37 | const config3 = normalizeUploadConfig({ region: region.z0 })
38 | expect(config3).toStrictEqual({
39 | region: region.z0,
40 | uphost: regionUphostMap[region.z0].cdnUphost,
41 | retryCount: 3,
42 | checkByMD5: false,
43 | checkByServer: true,
44 | forceDirect: false,
45 | useCdnDomain: true,
46 | concurrentRequestLimit: 3,
47 | chunkSize: DEFAULT_CHUNK_SIZE,
48 | upprotocol: 'https',
49 | debugLogLevel: 'OFF',
50 | disableStatisticsReport: false
51 | })
52 |
53 | const config4 = normalizeUploadConfig({ uphost: ['test'] })
54 | expect(config4).toStrictEqual({
55 | uphost: ['test'],
56 | retryCount: 3,
57 | checkByMD5: false,
58 | checkByServer: true,
59 | forceDirect: false,
60 | useCdnDomain: true,
61 | concurrentRequestLimit: 3,
62 | chunkSize: DEFAULT_CHUNK_SIZE,
63 | upprotocol: 'https',
64 | debugLogLevel: 'OFF',
65 | disableStatisticsReport: false
66 | })
67 |
68 | const config5 = normalizeUploadConfig({ uphost: ['test'], region: region.z0 })
69 | expect(config5).toStrictEqual({
70 | region: region.z0,
71 | uphost: ['test'],
72 | retryCount: 3,
73 | checkByMD5: false,
74 | checkByServer: true,
75 | forceDirect: false,
76 | useCdnDomain: true,
77 | concurrentRequestLimit: 3,
78 | chunkSize: DEFAULT_CHUNK_SIZE,
79 | upprotocol: 'https',
80 | debugLogLevel: 'OFF',
81 | disableStatisticsReport: false
82 | })
83 |
84 | const config6 = normalizeUploadConfig({ useCdnDomain: false, region: region.z0 })
85 | expect(config6).toStrictEqual({
86 | region: region.z0,
87 | uphost: regionUphostMap[region.z0].srcUphost,
88 | retryCount: 3,
89 | checkByMD5: false,
90 | checkByServer: true,
91 | forceDirect: false,
92 | useCdnDomain: false,
93 | concurrentRequestLimit: 3,
94 | chunkSize: DEFAULT_CHUNK_SIZE,
95 | upprotocol: 'https',
96 | debugLogLevel: 'OFF',
97 | disableStatisticsReport: false
98 | })
99 | })
100 | })
101 |
--------------------------------------------------------------------------------
/test/demo1/scripts/uploadWithSDK.js:
--------------------------------------------------------------------------------
1 | function uploadWithSDK(token, putExtra, config, domain) {
2 | // 切换tab后进行一些css操作
3 | controlTabDisplay("sdk");
4 | $("#select2").unbind("change").bind("change",function(){
5 | var file = this.files[0];
6 | // eslint-disable-next-line
7 | var finishedAttr = [];
8 | // eslint-disable-next-line
9 | var compareChunks = [];
10 | var observable;
11 | if (file) {
12 | var key = file.name;
13 | // 添加上传dom面板
14 | var board = addUploadBoard(file, config, key, "");
15 | if (!board) {
16 | return;
17 | }
18 | putExtra.customVars["x:name"] = key.split(".")[0];
19 | board.start = true;
20 | var dom_total = $(board)
21 | .find("#totalBar")
22 | .children("#totalBarColor");
23 |
24 | // 设置next,error,complete对应的操作,分别处理相应的进度信息,错误信息,以及完成后的操作
25 | var error = function(err) {
26 | board.start = true;
27 | $(board).find(".control-upload").text("继续上传");
28 | console.log(err);
29 | alert("上传出错")
30 | };
31 |
32 | var complete = function(res) {
33 | $(board)
34 | .find("#totalBar")
35 | .addClass("hide");
36 | $(board)
37 | .find(".control-container")
38 | .html(
39 | "Hash:" +
40 | res.hash +
41 | "
" +
42 | "Bucket:" +
43 | res.bucket +
44 | "
"
45 | );
46 | if (res.key && res.key.match(/\.(jpg|jpeg|png|gif)$/)) {
47 | imageDeal(board, res.key, domain);
48 | }
49 | };
50 |
51 | var next = function(response) {
52 | var chunks = response.chunks||[];
53 | var total = response.total;
54 | // 这里对每个chunk更新进度,并记录已经更新好的避免重复更新,同时对未开始更新的跳过
55 | for (var i = 0; i < chunks.length; i++) {
56 | if (chunks[i].percent === 0 || finishedAttr[i]){
57 | continue;
58 | }
59 | if (compareChunks[i].percent === chunks[i].percent){
60 | continue;
61 | }
62 | if (chunks[i].percent === 100){
63 | finishedAttr[i] = true;
64 | }
65 | $(board)
66 | .find(".fragment-group li")
67 | .eq(i)
68 | .find("#childBarColor")
69 | .css(
70 | "width",
71 | chunks[i].percent + "%"
72 | );
73 | }
74 | $(board)
75 | .find(".speed")
76 | .text("进度:" + total.percent + "% ");
77 | dom_total.css(
78 | "width",
79 | total.percent + "%"
80 | );
81 | compareChunks = chunks;
82 | };
83 |
84 | var subObject = {
85 | next: next,
86 | error: error,
87 | complete: complete
88 | };
89 | var subscription;
90 | // 调用sdk上传接口获得相应的observable,控制上传和暂停
91 | observable = qiniu.upload(file, key, token, putExtra, config);
92 |
93 | $(board)
94 | .find(".control-upload")
95 | .on("click", function() {
96 | if(board.start){
97 | $(this).text("暂停上传");
98 | board.start = false;
99 | subscription = observable.subscribe(subObject);
100 | }else{
101 | board.start = true;
102 | $(this).text("继续上传");
103 | subscription.unsubscribe();
104 | }
105 | });
106 | }
107 | })
108 | }
109 |
--------------------------------------------------------------------------------
/site/src/components/Queue/style.less:
--------------------------------------------------------------------------------
1 | .queue {
2 | width: 400px;
3 | padding-top: 1rem;
4 |
5 | list-style: none;
6 | margin-block-end: 0;
7 | margin-block-start: 0;
8 | padding-inline-start: 0;
9 |
10 | .item {
11 | margin: 10px 0;
12 |
13 | .content {
14 | display: flex;
15 | align-items: center;
16 | flex-direction: column;
17 |
18 | padding: 10px;
19 | border-radius: 14px;
20 | background-color: rgba(255, 255, 255, 0.3);
21 | box-shadow: 0px 5px 20px -5px rgba(0, 0, 0, 0.05);
22 |
23 | .top {
24 | position: relative;
25 |
26 | flex: 1;
27 | width: 100%;
28 | display: flex;
29 | flex-direction: row;
30 | align-items: center;
31 | justify-content: space-between;
32 | }
33 |
34 | .down {
35 | position: relative;
36 |
37 | flex: 1;
38 | width: 100%;
39 | display: flex;
40 | flex-direction: column;
41 | justify-content: center;
42 | align-items: center;
43 | }
44 | }
45 | }
46 | }
47 |
48 | .img {
49 | cursor: pointer;
50 | padding: 0.5rem;
51 | border-radius: 1rem;
52 | border: 1px solid rgba(0, 0, 0, 0.1);
53 | background-color: rgb(255, 255, 255);
54 | transition: .2s;
55 |
56 | &:hover {
57 | border: 1px solid rgba(0, 0, 0, 0.0);
58 | box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.2);
59 | }
60 | }
61 |
62 | .progressBar {
63 | width: 100%;
64 | position: relative;
65 |
66 | display: flex;
67 | flex-wrap: wrap;
68 | flex-direction: row;
69 | align-items: center;
70 |
71 | list-style: none;
72 | padding-inline-start: 0;
73 |
74 | .expanded {
75 | flex: 1;
76 | }
77 |
78 | li {
79 | margin: 1px;
80 | padding: 1px;
81 | min-width: 4px;
82 | border-radius: 2px;
83 | border: #333 solid 1px;
84 |
85 | span {
86 | height: 10px;
87 | display: block;
88 | background-color: rgb(119, 140, 255);
89 | }
90 |
91 | .cachedChunk {
92 | background-color: rgb(53, 66, 139);
93 | }
94 | }
95 | }
96 |
97 | .fileName {
98 | font-size: 14px;
99 | text-overflow: ellipsis;
100 | white-space: nowrap;
101 | overflow: hidden
102 | }
103 |
104 | .speed {
105 | width: 100%;
106 | display: flex;
107 | flex-direction: row;
108 | justify-content: flex-start;
109 |
110 | .speedItem {
111 | padding: 4px;
112 | display: flex;
113 | flex-direction: row;
114 | align-items: center;
115 |
116 | .speedTitle {
117 | color: rgba(51, 51, 51, 0.8);
118 | font-size: 10px;
119 | margin-right: 0.5rem;
120 | }
121 |
122 | .speedValue {
123 | font-size: 14px;
124 | font-weight: bold;
125 | color: rgb(255, 78, 78);
126 | }
127 | }
128 | }
129 |
130 | .complete, .error {
131 | width: 70%;
132 | margin: 0 auto;
133 | color: white;
134 | font-size: 14px;
135 | text-align: center;
136 | padding: 0.2rem 1rem;
137 | border-radius: 0 0 10px 10px;
138 | overflow: hidden;
139 | cursor: pointer;
140 | transition: .2s;
141 | }
142 |
143 | .error {
144 | background-color: rgb(226, 46, 46, 0.5);
145 | }
146 |
147 | .complete {
148 | background-color: rgba(235, 235, 235, 0.788);
149 |
150 | .completeItem {
151 | display: flex;
152 |
153 | .key {
154 | color: #333;
155 | font-size: 10px;
156 | font-weight: bold;
157 | margin-right: 0.5rem;
158 | text-overflow: ellipsis;
159 | white-space: nowrap;
160 | overflow: hidden
161 | }
162 |
163 | .value {
164 | flex: 1;
165 | color: #333;
166 | font-size: 14px;
167 | text-align: left;
168 |
169 | text-overflow: ellipsis;
170 | white-space: nowrap;
171 | overflow: hidden
172 | }
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/src/upload/hosts.test.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/newline-after-import
2 | import { MockApi } from '../api/index.mock'
3 | const mockApi = new MockApi()
4 | jest.mock('../api', () => mockApi)
5 |
6 | // eslint-disable-next-line import/first
7 | import { Host, HostPool } from './hosts'
8 |
9 | function sleep(time = 100) {
10 | return new Promise((resolve, _) => {
11 | setTimeout(resolve, time)
12 | })
13 | }
14 |
15 | describe('test hosts', () => {
16 | const getParams = ['accessKey', 'bucket', 'https'] as const
17 |
18 | test('getUp from api', async () => {
19 | const hostPool = new HostPool()
20 | const apiData = await mockApi.getUpHosts()
21 |
22 | // 无冻结行为每次获取到的都是第一个
23 | const actual1 = await hostPool.getUp(...getParams)
24 | expect(actual1?.host).toStrictEqual(apiData.data.up.acc.main[0])
25 |
26 | const actual2 = await hostPool.getUp(...getParams)
27 | expect(actual2?.host).toStrictEqual(apiData.data.up.acc.main[0])
28 |
29 | const actual3 = await hostPool.getUp(...getParams)
30 | expect(actual3?.host).toStrictEqual(apiData.data.up.acc.main[0])
31 | })
32 |
33 | test('getUp from config', async () => {
34 | const hostPool = new HostPool([
35 | 'host-1',
36 | 'host-2'
37 | ])
38 |
39 | // 无冻结行为每次获取到的都是第一个
40 | const actual1 = await hostPool.getUp(...getParams)
41 | expect(actual1).toStrictEqual(new Host('host-1', 'https'))
42 |
43 | const actual2 = await hostPool.getUp(...getParams)
44 | expect(actual2).toStrictEqual(new Host('host-1', 'https'))
45 |
46 | const actual3 = await hostPool.getUp(...getParams)
47 | expect(actual3).toStrictEqual(new Host('host-1', 'https'))
48 | })
49 |
50 | test('freeze & unfreeze', async () => {
51 | const hostPool = new HostPool([
52 | 'host-1',
53 | 'host-2'
54 | ])
55 |
56 | // 测试冻结第一个
57 | const host1 = await hostPool.getUp(...getParams)
58 | expect(host1).toStrictEqual(new Host('host-1', 'https'))
59 | // eslint-disable-next-line no-unused-expressions
60 | host1?.freeze()
61 | await sleep()
62 |
63 | // 自动切换到了下一个可用的 host-2
64 | const host2 = await hostPool.getUp(...getParams)
65 | expect(host2).toStrictEqual(new Host('host-2', 'https'))
66 | // eslint-disable-next-line no-unused-expressions
67 | host2?.freeze()
68 | await sleep()
69 |
70 | // 以下是都被冻结情况的测试
71 |
72 | // 全部都冻结了,拿到的应该是离解冻时间最近的一个
73 | const actual1 = await hostPool.getUp(...getParams)
74 | expect(actual1).toStrictEqual(new Host('host-1', 'https'))
75 | // eslint-disable-next-line no-unused-expressions
76 | host1?.freeze() // 已经冻结的再次冻结相当于更新解冻时间
77 | await sleep()
78 |
79 | // 因为 host-1 刚更新过冻结时间,所以这个时候解冻时间优先的应该是 host-2
80 | const actual2 = await hostPool.getUp(...getParams)
81 | expect(actual2).toStrictEqual(new Host('host-2', 'https'))
82 | await sleep()
83 |
84 | // 测试解冻 host-2,拿到的应该是 host-2
85 | // eslint-disable-next-line no-unused-expressions
86 | host2?.unfreeze()
87 | const actual3 = await hostPool.getUp(...getParams)
88 | expect(actual3).toStrictEqual(new Host('host-2', 'https'))
89 | // eslint-disable-next-line no-unused-expressions
90 | host2?.freeze() // 测试完再冻结住
91 | await sleep()
92 |
93 | // 本来优先的现在应该是 host-1
94 | // 测试 host-2 冻结时间设置为 0,应该获取到 host-2
95 | // eslint-disable-next-line no-unused-expressions
96 | host2?.freeze(0)
97 | const actual4 = await hostPool.getUp(...getParams)
98 | expect(actual4).toStrictEqual(new Host('host-2', 'https'))
99 | // eslint-disable-next-line no-unused-expressions
100 | host2?.freeze()
101 | await sleep()
102 |
103 | // 测试自定义冻结时间
104 | // eslint-disable-next-line no-unused-expressions
105 | host1?.freeze(200)
106 | // eslint-disable-next-line no-unused-expressions
107 | host2?.freeze(100)
108 | const actual5 = await hostPool.getUp(...getParams)
109 | expect(actual5).toStrictEqual(new Host('host-2', 'https'))
110 | })
111 | })
112 |
--------------------------------------------------------------------------------
/src/upload/hosts.ts:
--------------------------------------------------------------------------------
1 | import { getUpHosts } from '../api'
2 | import { InternalConfig } from './base'
3 |
4 | /**
5 | * @description 解冻时间,key 是 host,value 为解冻时间
6 | */
7 | const unfreezeTimeMap = new Map()
8 |
9 | export class Host {
10 | constructor(public host: string, public protocol: InternalConfig['upprotocol']) { }
11 |
12 | /**
13 | * @description 当前 host 是否为冻结状态
14 | */
15 | isFrozen() {
16 | const currentTime = new Date().getTime()
17 | const unfreezeTime = unfreezeTimeMap.get(this.host)
18 | return unfreezeTime != null && unfreezeTime >= currentTime
19 | }
20 |
21 | /**
22 | * @param {number} time 单位秒,默认 20s
23 | * @description 冻结该 host 对象,该 host 将在指定时间内不可用
24 | */
25 | freeze(time = 20) {
26 | const unfreezeTime = new Date().getTime() + (time * 1000)
27 | unfreezeTimeMap.set(this.host, unfreezeTime)
28 | }
29 |
30 | /**
31 | * @description 解冻该 host
32 | */
33 | unfreeze() {
34 | unfreezeTimeMap.delete(this.host)
35 | }
36 |
37 | /**
38 | * @description 获取当前 host 的完整 url
39 | */
40 | getUrl() {
41 | return `${this.protocol}://${this.host}`
42 | }
43 |
44 | /**
45 | * @description 获取解冻时间
46 | */
47 | getUnfreezeTime() {
48 | return unfreezeTimeMap.get(this.host)
49 | }
50 | }
51 | export class HostPool {
52 | /**
53 | * @description 缓存的 host 表,以 bucket 和 accessKey 作为 key
54 | */
55 | private cachedHostsMap = new Map()
56 |
57 | /**
58 | * @param {string[]} initHosts
59 | * @description 如果在构造时传入 initHosts,则该 host 池始终使用传入的 initHosts 做为可用的数据
60 | */
61 | constructor(private initHosts: string[] = []) { }
62 |
63 | /**
64 | * @param {string} accessKey
65 | * @param {string} bucketName
66 | * @param {string[]} hosts
67 | * @param {InternalConfig['upprotocol']} protocol
68 | * @returns {void}
69 | * @description 注册可用 host
70 | */
71 | private register(accessKey: string, bucketName: string, hosts: string[], protocol: InternalConfig['upprotocol']): void {
72 | this.cachedHostsMap.set(
73 | `${accessKey}@${bucketName}`,
74 | hosts.map(host => new Host(host, protocol))
75 | )
76 | }
77 |
78 | /**
79 | * @param {string} accessKey
80 | * @param {string} bucketName
81 | * @param {InternalConfig['upprotocol']} protocol
82 | * @returns {Promise}
83 | * @description 刷新最新的 host 数据,如果用户在构造时该类时传入了 host 或者已经存在缓存则不会发起请求
84 | */
85 | private async refresh(accessKey: string, bucketName: string, protocol: InternalConfig['upprotocol']): Promise {
86 | const cachedHostList = this.cachedHostsMap.get(`${accessKey}@${bucketName}`) || []
87 | if (cachedHostList.length > 0) return
88 |
89 | if (this.initHosts.length > 0) {
90 | this.register(accessKey, bucketName, this.initHosts, protocol)
91 | return
92 | }
93 |
94 | const response = await getUpHosts(accessKey, bucketName, protocol)
95 | if (response?.data != null) {
96 | const stashHosts: string[] = [
97 | ...(response.data.up?.acc?.main || []),
98 | ...(response.data.up?.acc?.backup || [])
99 | ]
100 | this.register(accessKey, bucketName, stashHosts, protocol)
101 | }
102 | }
103 |
104 | /**
105 | * @param {string} accessKey
106 | * @param {string} bucketName
107 | * @param {InternalConfig['upprotocol']} protocol
108 | * @returns {Promise}
109 | * @description 获取一个可用的上传 Host,排除已冻结的
110 | */
111 | public async getUp(accessKey: string, bucketName: string, protocol: InternalConfig['upprotocol']): Promise {
112 | await this.refresh(accessKey, bucketName, protocol)
113 | const cachedHostList = this.cachedHostsMap.get(`${accessKey}@${bucketName}`) || []
114 |
115 | if (cachedHostList.length === 0) return null
116 | const availableHostList = cachedHostList.filter(host => !host.isFrozen())
117 | if (availableHostList.length > 0) return availableHostList[0]
118 |
119 | // 无可用的,去取离解冻最近的 host
120 | const priorityQueue = cachedHostList
121 | .slice().sort(
122 | (hostA, hostB) => (hostA.getUnfreezeTime() || 0) - (hostB.getUnfreezeTime() || 0)
123 | )
124 |
125 | return priorityQueue[0]
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/site/src/upload.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { upload } from '../../src'
4 | import { UploadProgress } from '../../src/upload'
5 |
6 | import { generateUploadToken, loadSetting } from './utils'
7 |
8 | export enum Status {
9 | Ready, // 准备好了
10 | Processing, // 上传中
11 | Finished // 任务已结束(完成、失败、中断)
12 | }
13 |
14 | // 上传逻辑封装
15 | export function useUpload(file: File) {
16 | const startTimeRef = React.useRef(null)
17 | const [state, setState] = React.useState(null)
18 | const [error, setError] = React.useState(null)
19 | const [token, setToken] = React.useState(null)
20 | const [speedPeak, setSpeedPeak] = React.useState(null)
21 | const [completeInfo, setCompleteInfo] = React.useState(null)
22 | const [progress, setProgress] = React.useState(null)
23 | const [observable, setObservable] = React.useState | null>(null)
24 | const subscribeRef = React.useRef['subscribe']> | null>(null)
25 |
26 | // 开始上传文件
27 | const start = () => {
28 | startTimeRef.current = Date.now()
29 | setCompleteInfo(null)
30 | setProgress(null)
31 | setError(null)
32 |
33 | subscribeRef.current = observable?.subscribe({
34 | error: newError => { setState(Status.Finished); setError(newError) },
35 | next: newProgress => { setState(Status.Processing); setProgress(newProgress) },
36 | complete: newInfo => { setState(Status.Finished); setError(null); setCompleteInfo(newInfo) }
37 | }) || null
38 | }
39 |
40 | // 停止上传文件
41 | const stop = () => {
42 | const subscribe = subscribeRef.current
43 | if (state === Status.Processing && subscribe && !subscribe.closed) {
44 | setState(Status.Finished)
45 | subscribe.unsubscribe()
46 | }
47 | }
48 |
49 | // 获取上传速度
50 | const speed = React.useMemo(() => {
51 | if (progress == null || progress.total == null || progress.total.loaded == null) return 0
52 | const duration = (Date.now() - (startTimeRef.current || 0)) / 1000
53 |
54 | if (Array.isArray(progress.chunks)) {
55 | const size = progress.chunks.reduce(((acc, cur) => (
56 | !cur.fromCache ? cur.loaded + acc : acc
57 | )), 0)
58 |
59 | return size > 0 ? Math.floor(size / duration) : 0
60 | }
61 |
62 | return progress.total.loaded > 0
63 | ? Math.floor(progress.total.loaded / duration)
64 | : 0
65 | }, [progress, startTimeRef])
66 |
67 | // 获取 token
68 | React.useEffect(() => {
69 | const { assessKey, secretKey, bucketName, deadline } = loadSetting()
70 | if (token == null && (!assessKey || !secretKey || !bucketName || !deadline)) {
71 | setError(new Error('请点开设置并输入必要的配置信息'))
72 | return
73 | }
74 |
75 | // 线上应该使用服务端生成 token
76 | if (token != null) return
77 |
78 | // 确保所有必需的参数都存在后再调用 generateUploadToken
79 | if (assessKey && secretKey && bucketName && deadline) {
80 | setToken(generateUploadToken({ assessKey, secretKey, bucketName, deadline }))
81 | }
82 | }, [file, token])
83 |
84 | // 创建上传任务
85 | React.useEffect(() => {
86 | const { uphost } = loadSetting()
87 |
88 | if (token != null) {
89 | setState(Status.Ready)
90 | setObservable(upload(
91 | file,
92 | file.name,
93 | token,
94 | {
95 | metadata: {
96 | 'x-qn-meta-test': 'tt',
97 | 'x-qn-meta-test1': '222',
98 | 'x-qn-meta-test2': '333'
99 | }
100 | },
101 | {
102 | // checkByMD5: true,
103 | checkByServer: true,
104 | chunkSize: 2,
105 | concurrentRequestLimit: 3,
106 | debugLogLevel: 'INFO',
107 | uphost: uphost && uphost.split(',')
108 | }
109 | ))
110 | }
111 | }, [file, token])
112 |
113 | // 计算峰值上传速度
114 | React.useEffect(() => {
115 | if (speed == null) {
116 | setSpeedPeak(0)
117 | return
118 | }
119 |
120 | if (speed > (speedPeak || 0)) {
121 | setSpeedPeak(speed)
122 | }
123 | }, [speed, speedPeak])
124 |
125 | return { start, stop, state, progress, error, completeInfo, speed, speedPeak }
126 | }
127 |
--------------------------------------------------------------------------------
/src/utils/observable.ts:
--------------------------------------------------------------------------------
1 | /** 消费者接口 */
2 | export interface IObserver {
3 | /** 用来接收 Observable 中的 next 类型通知 */
4 | next: (value: T) => void
5 | /** 用来接收 Observable 中的 error 类型通知 */
6 | error: (err: E) => void
7 | /** 用来接收 Observable 中的 complete 类型通知 */
8 | complete: (res: C) => void
9 | }
10 |
11 | export interface NextObserver {
12 | next: (value: T) => void
13 | error?: (err: E) => void
14 | complete?: (res: C) => void
15 | }
16 |
17 | export interface ErrorObserver {
18 | next?: (value: T) => void
19 | error: (err: E) => void
20 | complete?: (res: C) => void
21 | }
22 |
23 | export interface CompletionObserver {
24 | next?: (value: T) => void
25 | error?: (err: E) => void
26 | complete: (res: C) => void
27 | }
28 |
29 | export type PartialObserver = NextObserver | ErrorObserver | CompletionObserver
30 |
31 | export interface IUnsubscribable {
32 | /** 取消 observer 的订阅 */
33 | unsubscribe(): void
34 | }
35 |
36 | /** Subscription 的接口 */
37 | export interface ISubscriptionLike extends IUnsubscribable {
38 | readonly closed: boolean
39 | }
40 |
41 | export type TeardownLogic = () => void
42 |
43 | export interface ISubscribable {
44 | subscribe(
45 | observer?: PartialObserver | ((value: T) => void),
46 | error?: (error: any) => void,
47 | complete?: () => void
48 | ): IUnsubscribable
49 | }
50 |
51 | /** 表示可清理的资源,比如 Observable 的执行 */
52 | class Subscription implements ISubscriptionLike {
53 | /** 用来标示该 Subscription 是否被取消订阅的标示位 */
54 | public closed = false
55 |
56 | /** 清理 subscription 持有的资源 */
57 | private _unsubscribe: TeardownLogic | undefined
58 |
59 | /** 取消 observer 的订阅 */
60 | unsubscribe() {
61 | if (this.closed) {
62 | return
63 | }
64 |
65 | this.closed = true
66 | if (this._unsubscribe) {
67 | this._unsubscribe()
68 | }
69 | }
70 |
71 | /** 添加一个 tear down 在该 Subscription 的 unsubscribe() 期间调用 */
72 | add(teardown: TeardownLogic) {
73 | this._unsubscribe = teardown
74 | }
75 | }
76 |
77 | /**
78 | * 实现 Observer 接口并且继承 Subscription 类,Observer 是消费 Observable 值的公有 API
79 | * 所有 Observers 都转化成了 Subscriber,以便提供类似 Subscription 的能力,比如 unsubscribe
80 | */
81 | export class Subscriber extends Subscription implements IObserver {
82 | protected isStopped = false
83 | protected destination: Partial>
84 |
85 | constructor(
86 | observerOrNext?: PartialObserver | ((value: T) => void) | null,
87 | error?: ((err: E) => void) | null,
88 | complete?: ((res: C) => void) | null
89 | ) {
90 | super()
91 |
92 | if (observerOrNext && typeof observerOrNext === 'object') {
93 | this.destination = observerOrNext
94 | } else {
95 | this.destination = {
96 | ...observerOrNext && { next: observerOrNext },
97 | ...error && { error },
98 | ...complete && { complete }
99 | }
100 | }
101 | }
102 |
103 | unsubscribe(): void {
104 | if (this.closed) {
105 | return
106 | }
107 |
108 | this.isStopped = true
109 | super.unsubscribe()
110 | }
111 |
112 | next(value: T) {
113 | if (!this.isStopped && this.destination.next) {
114 | this.destination.next(value)
115 | }
116 | }
117 |
118 | error(err: E) {
119 | if (!this.isStopped && this.destination.error) {
120 | this.isStopped = true
121 | this.destination.error(err)
122 | }
123 | }
124 |
125 | complete(result: C) {
126 | if (!this.isStopped && this.destination.complete) {
127 | this.isStopped = true
128 | this.destination.complete(result)
129 | }
130 | }
131 | }
132 |
133 | /** 可观察对象,当前的上传事件的集合 */
134 | export class Observable implements ISubscribable {
135 |
136 | constructor(private _subscribe: (subscriber: Subscriber) => TeardownLogic) {}
137 |
138 | subscribe(observer: PartialObserver): Subscription
139 | subscribe(next: null | undefined, error: null | undefined, complete: (res: C) => void): Subscription
140 | subscribe(next: null | undefined, error: (error: E) => void, complete?: (res: C) => void): Subscription
141 | subscribe(next: (value: T) => void, error: null | undefined, complete: (res: C) => void): Subscription
142 | subscribe(
143 | observerOrNext?: PartialObserver | ((value: T) => void) | null,
144 | error?: ((err: E) => void) | null,
145 | complete?: ((res: C) => void) | null
146 | ): Subscription {
147 | const sink = new Subscriber(observerOrNext, error, complete)
148 | sink.add(this._subscribe(sink))
149 | return sink
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/logger/index.test.ts:
--------------------------------------------------------------------------------
1 | import Logger from './index'
2 |
3 | let isCallReport = false
4 |
5 | jest.mock('./report-v3', () => ({
6 | reportV3: () => {
7 | isCallReport = true
8 | }
9 | }))
10 |
11 | // eslint-disable-next-line no-console
12 | const originalLog = console.log
13 | // eslint-disable-next-line no-console
14 | const originalWarn = console.warn
15 | // eslint-disable-next-line no-console
16 | const originalError = console.error
17 |
18 | const logMessage: unknown[] = []
19 | const warnMessage: unknown[] = []
20 | const errorMessage: unknown[] = []
21 |
22 | beforeAll(() => {
23 | // eslint-disable-next-line no-console
24 | console.log = jest.fn((...args: unknown[]) => logMessage.push(...args))
25 | // eslint-disable-next-line no-console
26 | console.warn = jest.fn((...args: unknown[]) => warnMessage.push(...args))
27 | // eslint-disable-next-line no-console
28 | console.error = jest.fn((...args: unknown[]) => errorMessage.push(...args))
29 | })
30 |
31 | afterAll(() => {
32 | // eslint-disable-next-line no-console
33 | console.log = originalLog
34 | // eslint-disable-next-line no-console
35 | console.warn = originalWarn
36 | // eslint-disable-next-line no-console
37 | console.error = originalError
38 | })
39 |
40 | describe('test logger', () => {
41 | test('level', () => {
42 | const infoLogger = new Logger('', true, 'INFO')
43 | infoLogger.info('test1')
44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
45 | // @ts-ignore
46 | expect(logMessage).toStrictEqual([infoLogger.getPrintPrefix('INFO'), 'test1'])
47 | infoLogger.warn('test2')
48 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
49 | // @ts-ignore
50 | expect(warnMessage).toStrictEqual([infoLogger.getPrintPrefix('WARN'), 'test2'])
51 | infoLogger.error('test3')
52 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
53 | // @ts-ignore
54 | expect(errorMessage).toStrictEqual([infoLogger.getPrintPrefix('ERROR'), 'test3'])
55 |
56 | // 清空消息
57 | logMessage.splice(0, logMessage.length)
58 | warnMessage.splice(0, warnMessage.length)
59 | errorMessage.splice(0, errorMessage.length)
60 |
61 | const warnLogger = new Logger('', true, 'WARN')
62 | warnLogger.info('test1')
63 | expect(logMessage).toStrictEqual([])
64 | warnLogger.warn('test2')
65 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
66 | // @ts-ignore
67 | expect(warnMessage).toStrictEqual([warnLogger.getPrintPrefix('WARN'), 'test2'])
68 | warnLogger.error('test3')
69 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
70 | // @ts-ignore
71 | expect(errorMessage).toStrictEqual([warnLogger.getPrintPrefix('ERROR'), 'test3'])
72 |
73 | // 清空消息
74 | logMessage.splice(0, logMessage.length)
75 | warnMessage.splice(0, warnMessage.length)
76 | errorMessage.splice(0, errorMessage.length)
77 |
78 | const errorLogger = new Logger('', true, 'ERROR')
79 | errorLogger.info('test1')
80 | expect(logMessage).toStrictEqual([])
81 | errorLogger.warn('test2')
82 | expect(warnMessage).toStrictEqual([])
83 | errorLogger.error('test3')
84 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
85 | // @ts-ignore
86 | expect(errorMessage).toStrictEqual([errorLogger.getPrintPrefix('ERROR'), 'test3'])
87 |
88 | // 清空消息
89 | logMessage.splice(0, logMessage.length)
90 | warnMessage.splice(0, warnMessage.length)
91 | errorMessage.splice(0, errorMessage.length)
92 |
93 | const offLogger = new Logger('', true, 'OFF')
94 | offLogger.info('test1')
95 | expect(logMessage).toStrictEqual([])
96 | offLogger.warn('test2')
97 | expect(warnMessage).toStrictEqual([])
98 | offLogger.error('test3')
99 | expect(errorMessage).toStrictEqual([])
100 | })
101 |
102 | test('unique id', () => {
103 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
104 | // @ts-ignore
105 | const startId = Logger.id
106 | // eslint-disable-next-line no-new
107 | new Logger('', true, 'OFF')
108 | // eslint-disable-next-line no-new
109 | new Logger('', true, 'OFF')
110 | const last = new Logger('', true, 'OFF')
111 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
112 | // @ts-ignore
113 | expect(last.id).toStrictEqual(startId + 3)
114 | })
115 |
116 | test('report', () => {
117 | const logger1 = new Logger('', false, 'OFF')
118 | logger1.report(null as any)
119 | expect(isCallReport).toBeTruthy()
120 | isCallReport = false
121 | const logger2 = new Logger('', true, 'OFF')
122 | logger2.report(null as any)
123 | expect(isCallReport).toBeFalsy()
124 | })
125 | })
126 |
--------------------------------------------------------------------------------
/site/src/components/Queue/item.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import byteSize from 'byte-size'
3 | import { UploadProgress } from 'qiniu-js/esm/upload'
4 |
5 | import { Status, useUpload } from '../../upload'
6 | import startIcon from './assets/start.svg'
7 | import stopIcon from './assets/stop.svg'
8 | import classnames from './style.less'
9 |
10 | interface IProps {
11 | file: File
12 | }
13 |
14 | export function Item(props: IProps) {
15 | const {
16 | stop, start,
17 | speed, speedPeak,
18 | state, error, progress, completeInfo
19 | } = useUpload(props.file)
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | {(state != null && [Status.Processing].includes(state)) && (
28 |
![]()
stop()}
31 | src={stopIcon}
32 | height="14"
33 | width="14"
34 | />
35 | )}
36 | {(state != null && [Status.Ready, Status.Finished].includes(state)) && (
37 |
![]()
start()}
39 | className={classnames.img}
40 | src={startIcon}
41 | height="14"
42 | width="14"
43 | />
44 | )}
45 |
46 |
47 |
51 |
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | // 文件名
59 | function FileName(prop: { fileName: string }) {
60 | return (
61 |
62 | {prop.fileName}
63 |
64 | )
65 | }
66 |
67 | // 上传速度
68 | function Speed(props: { speed: number | null, peak: number | null }) {
69 | const render = (name: string, value: number) => (
70 |
71 | {name}:
72 |
73 | {byteSize(value || 0, { precision: 2 }).toString()}/s
74 |
75 |
76 | )
77 |
78 | return (
79 |
80 | {render('最大上传速度', props.peak || 0)}
81 | {render('实时平均速度', props.speed || 0)}
82 |
83 | )
84 | }
85 |
86 | // 进度条
87 | function ProgressBar(props: { progress: UploadProgress | null }) {
88 | const chunks = React.useMemo(() => {
89 | // 分片任务使用显示具体的 chunks 进度信息
90 | if (props.progress?.chunks != null) return props.progress?.chunks
91 | // 直传任务直接显示总的进度信息
92 | if (props.progress?.total != null) return [props.progress?.total]
93 | return []
94 | }, [props.progress])
95 |
96 | // 一行以内就需要撑开
97 | const isExpanded = chunks.length < 18
98 |
99 | return (
100 |
101 | {chunks.map((chunk, index) => {
102 | const cacheName = chunk.fromCache ? classnames.cachedChunk : ''
103 | return (
104 | -
105 |
106 |
107 |
108 | )
109 | })}
110 |
111 | )
112 | }
113 |
114 | // 错误信息
115 | function ErrorView(props: { error: any }) {
116 | return (
117 | console.error(props.error)}
121 | style={props.error == null ? { height: 0, padding: 0 } : {}}
122 | >
123 | {props.error?.message || '发生未知错误!'}
124 |
125 | )
126 | }
127 |
128 | // 完成信息
129 | function CompleteView(props: { completeInfo: any }) {
130 | const render = (key: string, value: any) => (
131 |
132 | {key}:
133 | {value}
134 |
135 | )
136 |
137 | return (
138 | console.log(props.completeInfo)}
142 | style={props.completeInfo == null ? { height: 0, padding: 0 } : {}}
143 | >
144 | {Object.entries(props.completeInfo || {}).map(([key, value]) => render(key, value))}
145 |
146 | )
147 | }
148 |
--------------------------------------------------------------------------------
/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import { stringify } from 'querystring'
2 |
3 | import { normalizeUploadConfig } from '../utils'
4 | import { Config, InternalConfig, UploadInfo } from '../upload'
5 | import * as utils from '../utils'
6 |
7 | interface UpHosts {
8 | data: {
9 | up: {
10 | acc: {
11 | main: string[]
12 | backup: string[]
13 | }
14 | }
15 | }
16 | }
17 |
18 | export async function getUpHosts(accessKey: string, bucketName: string, protocol: InternalConfig['upprotocol']): Promise {
19 | const params = stringify({ ak: accessKey, bucket: bucketName })
20 | const url = `${protocol}://api.qiniu.com/v2/query?${params}`
21 | return utils.request(url, { method: 'GET' })
22 | }
23 |
24 | /**
25 | * @param bucket 空间名
26 | * @param key 目标文件名
27 | * @param uploadInfo 上传信息
28 | */
29 | function getBaseUrl(bucket: string, key: string | null | undefined, uploadInfo: UploadInfo) {
30 | const { url, id } = uploadInfo
31 | return `${url}/buckets/${bucket}/objects/${key != null ? utils.urlSafeBase64Encode(key) : '~'}/uploads/${id}`
32 | }
33 |
34 | export interface InitPartsData {
35 | /** 该文件的上传 id, 后续该文件其他各个块的上传,已上传块的废弃,已上传块的合成文件,都需要该 id */
36 | uploadId: string
37 | /** uploadId 的过期时间 */
38 | expireAt: number
39 | }
40 |
41 | /**
42 | * @param token 上传鉴权凭证
43 | * @param bucket 上传空间
44 | * @param key 目标文件名
45 | * @param uploadUrl 上传地址
46 | */
47 | export function initUploadParts(
48 | token: string,
49 | bucket: string,
50 | key: string | null | undefined,
51 | uploadUrl: string
52 | ): utils.Response {
53 | const url = `${uploadUrl}/buckets/${bucket}/objects/${key != null ? utils.urlSafeBase64Encode(key) : '~'}/uploads`
54 | return utils.request(
55 | url,
56 | {
57 | method: 'POST',
58 | headers: utils.getAuthHeaders(token)
59 | }
60 | )
61 | }
62 |
63 | export interface UploadChunkData {
64 | etag: string
65 | md5: string
66 | }
67 |
68 | /**
69 | * @param token 上传鉴权凭证
70 | * @param index 当前 chunk 的索引
71 | * @param uploadInfo 上传信息
72 | * @param options 请求参数
73 | */
74 | export function uploadChunk(
75 | token: string,
76 | key: string | null | undefined,
77 | index: number,
78 | uploadInfo: UploadInfo,
79 | options: Partial
80 | ): utils.Response {
81 | const bucket = utils.getPutPolicy(token).bucketName
82 | const url = getBaseUrl(bucket, key, uploadInfo) + `/${index}`
83 | const headers = utils.getHeadersForChunkUpload(token)
84 | if (options.md5) headers['Content-MD5'] = options.md5
85 |
86 | return utils.request(url, {
87 | ...options,
88 | method: 'PUT',
89 | headers
90 | })
91 | }
92 |
93 | export type UploadCompleteData = any
94 |
95 | /**
96 | * @param token 上传鉴权凭证
97 | * @param key 目标文件名
98 | * @param uploadInfo 上传信息
99 | * @param options 请求参数
100 | */
101 | export function uploadComplete(
102 | token: string,
103 | key: string | null | undefined,
104 | uploadInfo: UploadInfo,
105 | options: Partial
106 | ): utils.Response {
107 | const bucket = utils.getPutPolicy(token).bucketName
108 | const url = getBaseUrl(bucket, key, uploadInfo)
109 | return utils.request(url, {
110 | ...options,
111 | method: 'POST',
112 | headers: utils.getHeadersForMkFile(token)
113 | })
114 | }
115 |
116 | /**
117 | * @param token 上传鉴权凭证
118 | * @param key 目标文件名
119 | * @param uploadInfo 上传信息
120 | */
121 | export function deleteUploadedChunks(
122 | token: string,
123 | key: string | null | undefined,
124 | uploadinfo: UploadInfo
125 | ): utils.Response {
126 | const bucket = utils.getPutPolicy(token).bucketName
127 | const url = getBaseUrl(bucket, key, uploadinfo)
128 | return utils.request(
129 | url,
130 | {
131 | method: 'DELETE',
132 | headers: utils.getAuthHeaders(token)
133 | }
134 | )
135 | }
136 |
137 | /**
138 | * @param {string} url
139 | * @param {FormData} data
140 | * @param {Partial} options
141 | * @returns Promise
142 | * @description 直传接口
143 | */
144 | export function direct(
145 | url: string,
146 | data: FormData,
147 | options: Partial
148 | ): Promise {
149 | return utils.request(url, {
150 | method: 'POST',
151 | body: data,
152 | ...options
153 | })
154 | }
155 |
156 | export type UploadUrlConfig = Partial>
157 |
158 | /**
159 | * @param {UploadUrlConfig} config
160 | * @param {string} token
161 | * @returns Promise
162 | * @description 获取上传 url
163 | */
164 | export async function getUploadUrl(_config: UploadUrlConfig, token: string): Promise {
165 | const config = normalizeUploadConfig(_config)
166 | const protocol = config.upprotocol
167 |
168 | if (config.uphost.length > 0) {
169 | return `${protocol}://${config.uphost[0]}`
170 | }
171 | const putPolicy = utils.getPutPolicy(token)
172 | const res = await getUpHosts(putPolicy.assessKey, putPolicy.bucketName, protocol)
173 | const hosts = res.data.up.acc.main
174 | return `${protocol}://${hosts[0]}`
175 | }
176 |
--------------------------------------------------------------------------------
/src/api/index.mock.ts:
--------------------------------------------------------------------------------
1 | import { QiniuNetworkError, QiniuRequestError } from '../errors'
2 | import * as api from '.'
3 |
4 | export const errorMap = {
5 | networkError: new QiniuNetworkError('mock', 'message'), // 网络错误
6 |
7 | invalidParams: new QiniuRequestError(400, 'mock', 'message'), // 无效的参数
8 | expiredToken: new QiniuRequestError(401, 'mock', 'message'), // token 过期
9 |
10 | gatewayUnavailable: new QiniuRequestError(502, 'mock', 'message'), // 网关不可用
11 | serviceUnavailable: new QiniuRequestError(503, 'mock', 'message'), // 服务不可用
12 | serviceTimeout: new QiniuRequestError(504, 'mock', 'message'), // 服务超时
13 | serviceError: new QiniuRequestError(599, 'mock', 'message'), // 服务错误
14 |
15 | invalidUploadId: new QiniuRequestError(612, 'mock', 'message') // 无效的 upload id
16 | }
17 |
18 | export type ApiName =
19 | | 'direct'
20 | | 'getUpHosts'
21 | | 'uploadChunk'
22 | | 'uploadComplete'
23 | | 'initUploadParts'
24 | | 'deleteUploadedChunks'
25 |
26 | export class MockApi {
27 | constructor() {
28 | this.direct = this.direct.bind(this)
29 | this.getUpHosts = this.getUpHosts.bind(this)
30 | this.uploadChunk = this.uploadChunk.bind(this)
31 | this.uploadComplete = this.uploadComplete.bind(this)
32 | this.initUploadParts = this.initUploadParts.bind(this)
33 | this.deleteUploadedChunks = this.deleteUploadedChunks.bind(this)
34 | }
35 |
36 | private interceptorMap = new Map()
37 | public clearInterceptor() {
38 | this.interceptorMap.clear()
39 | }
40 |
41 | public setInterceptor(name: 'direct', interceptor: typeof api.direct): void
42 | public setInterceptor(name: 'getUpHosts', interceptor: typeof api.getUpHosts): void
43 | public setInterceptor(name: 'uploadChunk', interceptor: typeof api.uploadChunk): void
44 | public setInterceptor(name: 'uploadComplete', interceptor: typeof api.uploadComplete): void
45 | public setInterceptor(name: 'initUploadParts', interceptor: typeof api.initUploadParts): void
46 | public setInterceptor(name: 'deleteUploadedChunks', interceptor: typeof api.deleteUploadedChunks): void
47 | public setInterceptor(name: ApiName, interceptor: any): void
48 | public setInterceptor(name: any, interceptor: any): void {
49 | this.interceptorMap.set(name, interceptor)
50 | }
51 |
52 | private callInterceptor(name: ApiName, defaultValue: any): any {
53 | const interceptor = this.interceptorMap.get(name)
54 | if (interceptor != null) {
55 | return interceptor()
56 | }
57 |
58 | return defaultValue
59 | }
60 |
61 | public direct(): ReturnType {
62 | const defaultData: ReturnType = Promise.resolve({
63 | reqId: 'req-id',
64 | data: {
65 | fsize: 270316,
66 | bucket: 'test2222222222',
67 | hash: 'Fs_k3kh7tT5RaFXVx3z1sfCyoa2Y',
68 | name: '84575bc9e34412d47cf3367b46b23bc7e394912a',
69 | key: '84575bc9e34412d47cf3367b46b23bc7e394912a.html'
70 | }
71 | })
72 |
73 | return this.callInterceptor('direct', defaultData)
74 | }
75 |
76 | public getUpHosts(): ReturnType {
77 | const defaultData: ReturnType = Promise.resolve({
78 | reqId: 'req-id',
79 | data: {
80 | ttl: 86400,
81 | io: { src: { main: ['iovip-z2.qbox.me'] } },
82 | up: {
83 | acc: {
84 | main: ['upload-z2.qiniup.com'],
85 | backup: ['upload-dg.qiniup.com', 'upload-fs.qiniup.com']
86 | },
87 | old_acc: { main: ['upload-z2.qbox.me'], info: 'compatible to non-SNI device' },
88 | old_src: { main: ['up-z2.qbox.me'], info: 'compatible to non-SNI device' },
89 | src: { main: ['up-z2.qiniup.com'], backup: ['up-dg.qiniup.com', 'up-fs.qiniup.com'] }
90 | },
91 | uc: { acc: { main: ['uc.qbox.me'] } },
92 | rs: { acc: { main: ['rs-z2.qbox.me'] } },
93 | rsf: { acc: { main: ['rsf-z2.qbox.me'] } },
94 | api: { acc: { main: ['api-z2.qiniu.com'] } }
95 | }
96 | })
97 |
98 | return this.callInterceptor('getUpHosts', defaultData)
99 | }
100 |
101 | public uploadChunk(): ReturnType {
102 | const defaultData: ReturnType = Promise.resolve({
103 | reqId: 'req-id',
104 | data: {
105 | etag: 'FuYYVJ1gmVCoGk5C5r5ftrLXxE6m',
106 | md5: '491309eddd8e7233e14eaa25216594b4'
107 | }
108 | })
109 |
110 | return this.callInterceptor('uploadChunk', defaultData)
111 | }
112 |
113 | public uploadComplete(): ReturnType {
114 | const defaultData: ReturnType = Promise.resolve({
115 | reqId: 'req-id',
116 | data: {
117 | key: 'test.zip',
118 | hash: 'lsril688bAmXn7kiiOe9fL4mpc39',
119 | fsize: 11009649,
120 | bucket: 'test',
121 | name: 'test'
122 | }
123 | })
124 |
125 | return this.callInterceptor('uploadComplete', defaultData)
126 | }
127 |
128 | public initUploadParts(): ReturnType {
129 | const defaultData: ReturnType = Promise.resolve({
130 | reqId: 'req-id',
131 | data: { uploadId: '60878b9408bc044043f5d74f', expireAt: 1620100628 }
132 | })
133 |
134 | return this.callInterceptor('initUploadParts', defaultData)
135 | }
136 |
137 | public deleteUploadedChunks(): ReturnType {
138 | const defaultData: ReturnType = Promise.resolve({
139 | reqId: 'req-id',
140 | data: undefined
141 | })
142 |
143 | return this.callInterceptor('deleteUploadedChunks', defaultData)
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/site/src/components/Layout/assets/setting.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/site/src/components/Settings/assets/setting.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/src/image/index.ts:
--------------------------------------------------------------------------------
1 | import { request, urlSafeBase64Encode } from '../utils'
2 |
3 | export interface ImageViewOptions {
4 | mode: number
5 | format?: string
6 | w?: number
7 | h?: number
8 | q?: number
9 | }
10 |
11 | export interface ImageWatermark {
12 | image: string
13 | mode: number
14 | fontsize?: number
15 | dissolve?: number
16 | dx?: number
17 | dy?: number
18 | gravity?: string
19 | text?: string
20 | font?: string
21 | fill?: string
22 | }
23 |
24 | export interface ImageMogr2 {
25 | 'auto-orient'?: boolean
26 | strip?: boolean
27 | thumbnail?: number
28 | crop?: number
29 | gravity?: number
30 | format?: number
31 | blur?: number
32 | quality?: number
33 | rotate?: number
34 | }
35 |
36 | type Pipeline =
37 | | (ImageWatermark & { fop: 'watermark' })
38 | | (ImageViewOptions & { fop: 'imageView2' })
39 | | (ImageMogr2 & { fop: 'imageMogr2' })
40 |
41 | export interface Entry {
42 | domain: string
43 | key: string
44 | }
45 |
46 | function getImageUrl(key: string, domain: string) {
47 | key = encodeURIComponent(key)
48 | if (domain.slice(domain.length - 1) !== '/') {
49 | domain += '/'
50 | }
51 |
52 | return domain + key
53 | }
54 |
55 | export function imageView2(op: ImageViewOptions, key?: string, domain?: string) {
56 | if (!/^\d$/.test(String(op.mode))) {
57 | throw 'mode should be number in imageView2'
58 | }
59 |
60 | const { mode, w, h, q, format } = op
61 |
62 | if (!w && !h) {
63 | throw 'param w and h is empty in imageView2'
64 | }
65 |
66 | let imageUrl = 'imageView2/' + encodeURIComponent(mode)
67 | imageUrl += w ? '/w/' + encodeURIComponent(w) : ''
68 | imageUrl += h ? '/h/' + encodeURIComponent(h) : ''
69 | imageUrl += q ? '/q/' + encodeURIComponent(q) : ''
70 | imageUrl += format ? '/format/' + encodeURIComponent(format) : ''
71 | if (key && domain) {
72 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl
73 | }
74 | return imageUrl
75 | }
76 |
77 | // invoke the imageMogr2 api of Qiniu
78 | export function imageMogr2(op: ImageMogr2, key?: string, domain?: string) {
79 | const autoOrient = op['auto-orient']
80 | const { thumbnail, strip, gravity, crop, quality, rotate, format, blur } = op
81 |
82 | let imageUrl = 'imageMogr2'
83 |
84 | imageUrl += autoOrient ? '/auto-orient' : ''
85 | imageUrl += thumbnail ? '/thumbnail/' + encodeURIComponent(thumbnail) : ''
86 | imageUrl += strip ? '/strip' : ''
87 | imageUrl += gravity ? '/gravity/' + encodeURIComponent(gravity) : ''
88 | imageUrl += quality ? '/quality/' + encodeURIComponent(quality) : ''
89 | imageUrl += crop ? '/crop/' + encodeURIComponent(crop) : ''
90 | imageUrl += rotate ? '/rotate/' + encodeURIComponent(rotate) : ''
91 | imageUrl += format ? '/format/' + encodeURIComponent(format) : ''
92 | imageUrl += blur ? '/blur/' + encodeURIComponent(blur) : ''
93 | if (key && domain) {
94 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl
95 | }
96 | return imageUrl
97 | }
98 |
99 | // invoke the watermark api of Qiniu
100 | export function watermark(op: ImageWatermark, key?: string, domain?: string) {
101 | const mode = op.mode
102 | if (!mode) {
103 | throw "mode can't be empty in watermark"
104 | }
105 |
106 | let imageUrl = 'watermark/' + mode
107 | if (mode !== 1 && mode !== 2) {
108 | throw 'mode is wrong'
109 | }
110 |
111 | if (mode === 1) {
112 | const image = op.image
113 | if (!image) {
114 | throw "image can't be empty in watermark"
115 | }
116 | imageUrl += image ? '/image/' + urlSafeBase64Encode(image) : ''
117 | }
118 |
119 | if (mode === 2) {
120 | const { text, font, fontsize, fill } = op
121 | if (!text) {
122 | throw "text can't be empty in watermark"
123 | }
124 | imageUrl += text ? '/text/' + urlSafeBase64Encode(text) : ''
125 | imageUrl += font ? '/font/' + urlSafeBase64Encode(font) : ''
126 | imageUrl += fontsize ? '/fontsize/' + fontsize : ''
127 | imageUrl += fill ? '/fill/' + urlSafeBase64Encode(fill) : ''
128 | }
129 |
130 | const { dissolve, gravity, dx, dy } = op
131 |
132 | imageUrl += dissolve ? '/dissolve/' + encodeURIComponent(dissolve) : ''
133 | imageUrl += gravity ? '/gravity/' + encodeURIComponent(gravity) : ''
134 | imageUrl += dx ? '/dx/' + encodeURIComponent(dx) : ''
135 | imageUrl += dy ? '/dy/' + encodeURIComponent(dy) : ''
136 | if (key && domain) {
137 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl
138 | }
139 | return imageUrl
140 | }
141 |
142 | // invoke the imageInfo api of Qiniu
143 | export function imageInfo(key: string, domain: string) {
144 | const url = getImageUrl(key, domain) + '?imageInfo'
145 | return request(url, { method: 'GET' })
146 | }
147 |
148 | // invoke the exif api of Qiniu
149 | export function exif(key: string, domain: string) {
150 | const url = getImageUrl(key, domain) + '?exif'
151 | return request(url, { method: 'GET' })
152 | }
153 |
154 | export function pipeline(arr: Pipeline[], key?: string, domain?: string) {
155 | const isArray = Object.prototype.toString.call(arr) === '[object Array]'
156 | let option: Pipeline
157 | let errOp = false
158 | let imageUrl = ''
159 | if (isArray) {
160 | for (let i = 0, len = arr.length; i < len; i++) {
161 | option = arr[i]
162 | if (!option.fop) {
163 | throw "fop can't be empty in pipeline"
164 | }
165 | switch (option.fop) {
166 | case 'watermark':
167 | imageUrl += watermark(option) + '|'
168 | break
169 | case 'imageView2':
170 | imageUrl += imageView2(option) + '|'
171 | break
172 | case 'imageMogr2':
173 | imageUrl += imageMogr2(option) + '|'
174 | break
175 | default:
176 | errOp = true
177 | break
178 | }
179 | if (errOp) {
180 | throw 'fop is wrong in pipeline'
181 | }
182 | }
183 |
184 | if (key && domain) {
185 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl
186 | const length = imageUrl.length
187 | if (imageUrl.slice(length - 1) === '|') {
188 | imageUrl = imageUrl.slice(0, length - 1)
189 | }
190 | }
191 | return imageUrl
192 | }
193 |
194 | throw "pipeline's first param should be array"
195 | }
196 |
--------------------------------------------------------------------------------
/src/utils/compress.ts:
--------------------------------------------------------------------------------
1 | import { QiniuErrorName, QiniuError } from '../errors'
2 |
3 | import { createObjectURL } from './helper'
4 |
5 | export interface CompressOptions {
6 | quality?: number
7 | noCompressIfLarger?: boolean
8 | maxWidth?: number
9 | maxHeight?: number
10 | }
11 |
12 | export interface Dimension {
13 | width?: number
14 | height?: number
15 | }
16 |
17 | export interface CompressResult {
18 | dist: Blob | File
19 | width: number
20 | height: number
21 | }
22 |
23 | const mimeTypes = {
24 | PNG: 'image/png',
25 | JPEG: 'image/jpeg',
26 | WEBP: 'image/webp',
27 | BMP: 'image/bmp'
28 | } as const
29 |
30 | const maxSteps = 4
31 | const scaleFactor = Math.log(2)
32 | const supportMimeTypes = Object.keys(mimeTypes).map(type => mimeTypes[type])
33 | const defaultType = mimeTypes.JPEG
34 |
35 | type MimeKey = keyof typeof mimeTypes
36 |
37 | function isSupportedType(type: string): type is typeof mimeTypes[MimeKey] {
38 | return supportMimeTypes.includes(type)
39 | }
40 |
41 | class Compress {
42 | private outputType: string
43 |
44 | constructor(private file: File, private config: CompressOptions) {
45 | this.config = {
46 | quality: 0.92,
47 | noCompressIfLarger: false,
48 | ...this.config
49 | }
50 | }
51 |
52 | async process(): Promise {
53 | this.outputType = this.file.type
54 | const srcDimension: Dimension = {}
55 | if (!isSupportedType(this.file.type)) {
56 | throw new QiniuError(
57 | QiniuErrorName.UnsupportedFileType,
58 | `unsupported file type: ${this.file.type}`
59 | )
60 | }
61 |
62 | const originImage = await this.getOriginImage()
63 | const canvas = await this.getCanvas(originImage)
64 | let scale = 1
65 | if (this.config.maxWidth) {
66 | scale = Math.min(1, this.config.maxWidth / canvas.width)
67 | }
68 | if (this.config.maxHeight) {
69 | scale = Math.min(1, scale, this.config.maxHeight / canvas.height)
70 | }
71 | srcDimension.width = canvas.width
72 | srcDimension.height = canvas.height
73 |
74 | const scaleCanvas = await this.doScale(canvas, scale)
75 | const distBlob = this.toBlob(scaleCanvas)
76 | if (distBlob.size > this.file.size && this.config.noCompressIfLarger) {
77 | return {
78 | dist: this.file,
79 | width: srcDimension.width,
80 | height: srcDimension.height
81 | }
82 | }
83 |
84 | return {
85 | dist: distBlob,
86 | width: scaleCanvas.width,
87 | height: scaleCanvas.height
88 | }
89 | }
90 |
91 | clear(ctx: CanvasRenderingContext2D, width: number, height: number) {
92 | // jpeg 没有 alpha 通道,透明区间会被填充成黑色,这里把透明区间填充为白色
93 | if (this.outputType === defaultType) {
94 | ctx.fillStyle = '#fff'
95 | ctx.fillRect(0, 0, width, height)
96 | } else {
97 | ctx.clearRect(0, 0, width, height)
98 | }
99 | }
100 |
101 | /** 通过 file 初始化 image 对象 */
102 | getOriginImage(): Promise {
103 | return new Promise((resolve, reject) => {
104 | const url = createObjectURL(this.file)
105 | const img = new Image()
106 | img.onload = () => {
107 | resolve(img)
108 | }
109 | img.onerror = () => {
110 | reject('image load error')
111 | }
112 | img.src = url
113 | })
114 | }
115 |
116 | getCanvas(img: HTMLImageElement): Promise {
117 | return new Promise((resolve, reject) => {
118 | const canvas = document.createElement('canvas')
119 | const context = canvas.getContext('2d')
120 |
121 | if (!context) {
122 | reject(new QiniuError(
123 | QiniuErrorName.GetCanvasContextFailed,
124 | 'context is null'
125 | ))
126 | return
127 | }
128 |
129 | const { width, height } = img
130 | canvas.height = height
131 | canvas.width = width
132 |
133 | this.clear(context, width, height)
134 | context.drawImage(img, 0, 0)
135 | resolve(canvas)
136 | })
137 | }
138 |
139 | async doScale(source: HTMLCanvasElement, scale: number) {
140 | if (scale === 1) {
141 | return source
142 | }
143 | // 不要一次性画图,通过设定的 step 次数,渐进式的画图,这样可以增加图片的清晰度,防止一次性画图导致的像素丢失严重
144 | const sctx = source.getContext('2d')
145 | const steps = Math.min(maxSteps, Math.ceil((1 / scale) / scaleFactor))
146 |
147 | const factor = scale ** (1 / steps)
148 |
149 | const mirror = document.createElement('canvas')
150 | const mctx = mirror.getContext('2d')
151 |
152 | let { width, height } = source
153 | const originWidth = width
154 | const originHeight = height
155 | mirror.width = width
156 | mirror.height = height
157 | if (!mctx || !sctx) {
158 | throw new QiniuError(
159 | QiniuErrorName.GetCanvasContextFailed,
160 | "mctx or sctx can't be null"
161 | )
162 | }
163 |
164 | let src!: CanvasImageSource
165 | let context!: CanvasRenderingContext2D
166 | for (let i = 0; i < steps; i++) {
167 |
168 | let dw = width * factor | 0 // eslint-disable-line no-bitwise
169 | let dh = height * factor | 0 // eslint-disable-line no-bitwise
170 | // 到最后一步的时候 dw, dh 用目标缩放尺寸,否则会出现最后尺寸偏小的情况
171 | if (i === steps - 1) {
172 | dw = originWidth * scale
173 | dh = originHeight * scale
174 | }
175 |
176 | if (i % 2 === 0) {
177 | src = source
178 | context = mctx
179 | } else {
180 | src = mirror
181 | context = sctx
182 | }
183 | // 每次画前都清空,避免图像重叠
184 | this.clear(context, width, height)
185 | context.drawImage(src, 0, 0, width, height, 0, 0, dw, dh)
186 | width = dw
187 | height = dh
188 | }
189 |
190 | const canvas = src === source ? mirror : source
191 | // save data
192 | const data = context.getImageData(0, 0, width, height)
193 |
194 | // resize
195 | canvas.width = width
196 | canvas.height = height
197 |
198 | // store image data
199 | context.putImageData(data, 0, 0)
200 |
201 | return canvas
202 | }
203 |
204 | /** 这里把 base64 字符串转为 blob 对象 */
205 | toBlob(result: HTMLCanvasElement) {
206 | const dataURL = result.toDataURL(this.outputType, this.config.quality)
207 | const buffer = atob(dataURL.split(',')[1]).split('').map(char => char.charCodeAt(0))
208 | const blob = new Blob([new Uint8Array(buffer)], { type: this.outputType })
209 | return blob
210 | }
211 | }
212 |
213 | const compressImage = (file: File, options: CompressOptions) => new Compress(file, options).process()
214 |
215 | export default compressImage
216 |
--------------------------------------------------------------------------------
/test/demo1/common/common.js:
--------------------------------------------------------------------------------
1 | var BLOCK_SIZE = 4 * 1024 * 1024;
2 |
3 | function addUploadBoard(file, config, key, type) {
4 | var count = Math.ceil(file.size / BLOCK_SIZE);
5 | var board = widget.add("tr", {
6 | data: { num: count, name: key, size: file.size },
7 | node: $("#fsUploadProgress" + type)
8 | });
9 | if (file.size > 100 * 1024 * 1024) {
10 | $(board).html("本实例最大上传文件100M");
11 | return "";
12 | }
13 | count > 1 && type != "3"
14 | ? ""
15 | : $(board)
16 | .find(".resume")
17 | .addClass("hide");
18 | return board;
19 | }
20 |
21 | function createXHR() {
22 | var xmlhttp = {};
23 | if (window.XMLHttpRequest) {
24 | xmlhttp = new XMLHttpRequest();
25 | } else {
26 | xmlhttp = new ActiveXObject("Microsoft.XMLHTTP");
27 | }
28 | return xmlhttp;
29 | }
30 |
31 | function getBoardWidth(board) {
32 | var total_width = $(board)
33 | .find("#totalBar")
34 | .outerWidth();
35 | $(board)
36 | .find(".fragment-group")
37 | .removeClass("hide");
38 | var child_width = $(board)
39 | .find(".fragment-group li")
40 | .children("#childBar")
41 | .outerWidth();
42 | $(board)
43 | .find(".fragment-group")
44 | .addClass("hide");
45 | return { totalWidth: total_width, childWidth: child_width };
46 | }
47 |
48 | function controlTabDisplay(type) {
49 | switch (type) {
50 | case "sdk":
51 | document.getElementById("box2").className = "";
52 | document.getElementById("box").className = "hide";
53 | break;
54 | case "others":
55 | document.getElementById("box2").className = "hide";
56 | document.getElementById("box").className = "";
57 | break;
58 | case "form":
59 | document.getElementById("box").className = "hide";
60 | document.getElementById("box2").className = "hide";
61 | break;
62 | }
63 | }
64 |
65 | var getRotate = function(url) {
66 | if (!url) {
67 | return 0;
68 | }
69 | var arr = url.split("/");
70 | for (var i = 0, len = arr.length; i < len; i++) {
71 | if (arr[i] === "rotate") {
72 | return parseInt(arr[i + 1], 10);
73 | }
74 | }
75 | return 0;
76 | };
77 |
78 | function imageControl(domain) {
79 | $(".modal-body")
80 | .find(".buttonList a")
81 | .on("click", function() {
82 | var img = document.getElementById("imgContainer").getElementsByTagName("img")[0]
83 | var oldUrl = img.src;
84 | var key = img.key;
85 | var originHeight = img.h;
86 | var fopArr = [];
87 | var rotate = getRotate(oldUrl);
88 | if (!$(this).hasClass("no-disable-click")) {
89 | $(this)
90 | .addClass("disabled")
91 | .siblings()
92 | .removeClass("disabled");
93 | if ($(this).data("imagemogr") !== "no-rotate") {
94 | fopArr.push({
95 | fop: "imageMogr2",
96 | "auto-orient": true,
97 | strip: true,
98 | rotate: rotate
99 | });
100 | }
101 | } else {
102 | $(this)
103 | .siblings()
104 | .removeClass("disabled");
105 | var imageMogr = $(this).data("imagemogr");
106 | if (imageMogr === "left") {
107 | rotate = rotate - 90 < 0 ? rotate + 270 : rotate - 90;
108 | } else if (imageMogr === "right") {
109 | rotate = rotate + 90 > 360 ? rotate - 270 : rotate + 90;
110 | }
111 | fopArr.push({
112 | fop: "imageMogr2",
113 | "auto-orient": true,
114 | strip: true,
115 | rotate: rotate
116 | });
117 | }
118 | $(".modal-body")
119 | .find("a.disabled")
120 | .each(function() {
121 | var watermark = $(this).data("watermark");
122 | var imageView = $(this).data("imageview");
123 | var imageMogr = $(this).data("imagemogr");
124 |
125 | if (watermark) {
126 | fopArr.push({
127 | fop: "watermark",
128 | mode: 1,
129 | image: "http://www.b1.qiniudn.com/images/logo-2.png",
130 | dissolve: 100,
131 | gravity: watermark,
132 | dx: 100,
133 | dy: 100
134 | });
135 | }
136 | if (imageView) {
137 | var height;
138 | switch (imageView) {
139 | case "large":
140 | height = originHeight;
141 | break;
142 | case "middle":
143 | height = originHeight * 0.5;
144 | break;
145 | case "small":
146 | height = originHeight * 0.1;
147 | break;
148 | default:
149 | height = originHeight;
150 | break;
151 | }
152 | fopArr.push({
153 | fop: "imageView2",
154 | mode: 3,
155 | h: parseInt(height, 10),
156 | q: 100
157 | });
158 | }
159 |
160 | if (imageMogr === "no-rotate") {
161 | fopArr.push({
162 | fop: "imageMogr2",
163 | "auto-orient": true,
164 | strip: true,
165 | rotate: 0
166 | });
167 | }
168 | });
169 | var newUrl = qiniu.pipeline(fopArr, key, domain);
170 |
171 | var newImg = new Image();
172 | img.src = "images/loading.gif"
173 | newImg.onload = function() {
174 | img.src = newUrl
175 | document.getElementById("imgContainer").href = newUrl
176 | };
177 | newImg.src = newUrl;
178 | return false;
179 | });
180 | }
181 |
182 | function imageDeal(board, key, domain) {
183 | var fopArr = [];
184 | //var img = $(".modal-body").find(".display img");
185 | var img = document.getElementById("imgContainer").getElementsByTagName("img")[0];
186 | img.key = key
187 | fopArr.push({
188 | fop: "watermark",
189 | mode: 1,
190 | image: "http://www.b1.qiniudn.com/images/logo-2.png",
191 | dissolve: 100,
192 | gravity: "NorthWest",
193 | ws: 0.8,
194 | dx: 100,
195 | dy: 100
196 | });
197 | fopArr.push({
198 | fop: "imageView2",
199 | mode: 2,
200 | h: 450,
201 | q: 100
202 | });
203 | var newUrl = qiniu.pipeline(fopArr, key, domain);
204 | $(board)
205 | .find(".wraper a")
206 | .html(
207 | '
' +
212 | '查看处理效果'
213 | );
214 | var newImg = new Image();
215 | img.src = "images/loading.gif"
216 | newImg.onload = function() {
217 | img.src = newUrl
218 | img.h = 450
219 | document.getElementById("imgContainer").href = newUrl
220 | };
221 | newImg.src = newUrl;
222 | }
223 |
--------------------------------------------------------------------------------
/src/utils/base64.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | // https://github.com/locutusjs/locutus/blob/master/src/php/xml/utf8_encode.js
4 | function utf8Encode(argString: string) {
5 | // http://kevin.vanzonneveld.net
6 | // + original by: Webtoolkit.info (http://www.webtoolkit.info/)
7 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
8 | // + improved by: sowberry
9 | // + tweaked by: Jack
10 | // + bugfixed by: Onno Marsman
11 | // + improved by: Yves Sucaet
12 | // + bugfixed by: Onno Marsman
13 | // + bugfixed by: Ulrich
14 | // + bugfixed by: Rafal Kukawski
15 | // + improved by: kirilloid
16 | // + bugfixed by: kirilloid
17 | // * example 1: this.utf8Encode('Kevin van Zonneveld')
18 | // * returns 1: 'Kevin van Zonneveld'
19 |
20 | if (argString === null || typeof argString === 'undefined') {
21 | return ''
22 | }
23 |
24 | let string = argString + '' // .replace(/\r\n/g, '\n').replace(/\r/g, '\n')
25 | let utftext = '',
26 | start,
27 | end,
28 | stringl = 0
29 |
30 | start = end = 0
31 | stringl = string.length
32 | for (let n = 0; n < stringl; n++) {
33 | let c1 = string.charCodeAt(n)
34 | let enc = null
35 |
36 | if (c1 < 128) {
37 | end++
38 | } else if (c1 > 127 && c1 < 2048) {
39 | enc = String.fromCharCode((c1 >> 6) | 192, (c1 & 63) | 128)
40 | } else if ((c1 & 0xf800 ^ 0xd800) > 0) {
41 | enc = String.fromCharCode(
42 | (c1 >> 12) | 224,
43 | ((c1 >> 6) & 63) | 128,
44 | (c1 & 63) | 128
45 | )
46 | } else {
47 | // surrogate pairs
48 | if ((c1 & 0xfc00 ^ 0xd800) > 0) {
49 | throw new RangeError('Unmatched trail surrogate at ' + n)
50 | }
51 | let c2 = string.charCodeAt(++n)
52 | if ((c2 & 0xfc00 ^ 0xdc00) > 0) {
53 | throw new RangeError('Unmatched lead surrogate at ' + (n - 1))
54 | }
55 | c1 = ((c1 & 0x3ff) << 10) + (c2 & 0x3ff) + 0x10000
56 | enc = String.fromCharCode(
57 | (c1 >> 18) | 240,
58 | ((c1 >> 12) & 63) | 128,
59 | ((c1 >> 6) & 63) | 128,
60 | (c1 & 63) | 128
61 | )
62 | }
63 | if (enc !== null) {
64 | if (end > start) {
65 | utftext += string.slice(start, end)
66 | }
67 | utftext += enc
68 | start = end = n + 1
69 | }
70 | }
71 |
72 | if (end > start) {
73 | utftext += string.slice(start, stringl)
74 | }
75 |
76 | return utftext
77 | }
78 |
79 | // https://github.com/locutusjs/locutus/blob/master/src/php/xml/utf8_decode.js
80 | function utf8Decode(strData: string) {
81 | // eslint-disable-line camelcase
82 | // discuss at: https://locutus.io/php/utf8_decode/
83 | // original by: Webtoolkit.info (https://www.webtoolkit.info/)
84 | // input by: Aman Gupta
85 | // input by: Brett Zamir (https://brett-zamir.me)
86 | // improved by: Kevin van Zonneveld (https://kvz.io)
87 | // improved by: Norman "zEh" Fuchs
88 | // bugfixed by: hitwork
89 | // bugfixed by: Onno Marsman (https://twitter.com/onnomarsman)
90 | // bugfixed by: Kevin van Zonneveld (https://kvz.io)
91 | // bugfixed by: kirilloid
92 | // bugfixed by: w35l3y (https://www.wesley.eti.br)
93 | // example 1: utf8_decode('Kevin van Zonneveld')
94 | // returns 1: 'Kevin van Zonneveld'
95 |
96 | const tmpArr = []
97 | let i = 0
98 | let c1 = 0
99 | let seqlen = 0
100 |
101 | strData += ''
102 |
103 | while (i < strData.length) {
104 | c1 = strData.charCodeAt(i) & 0xFF
105 | seqlen = 0
106 |
107 | // https://en.wikipedia.org/wiki/UTF-8#Codepage_layout
108 | if (c1 <= 0xBF) {
109 | c1 = (c1 & 0x7F)
110 | seqlen = 1
111 | } else if (c1 <= 0xDF) {
112 | c1 = (c1 & 0x1F)
113 | seqlen = 2
114 | } else if (c1 <= 0xEF) {
115 | c1 = (c1 & 0x0F)
116 | seqlen = 3
117 | } else {
118 | c1 = (c1 & 0x07)
119 | seqlen = 4
120 | }
121 |
122 | for (let ai = 1; ai < seqlen; ++ai) {
123 | c1 = ((c1 << 0x06) | (strData.charCodeAt(ai + i) & 0x3F))
124 | }
125 |
126 | if (seqlen === 4) {
127 | c1 -= 0x10000
128 | tmpArr.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF)))
129 | tmpArr.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF)))
130 | } else {
131 | tmpArr.push(String.fromCharCode(c1))
132 | }
133 |
134 | i += seqlen
135 | }
136 |
137 | return tmpArr.join('')
138 | }
139 |
140 | function base64Encode(data: any) {
141 | // http://kevin.vanzonneveld.net
142 | // + original by: Tyler Akins (http://rumkin.com)
143 | // + improved by: Bayron Guevara
144 | // + improved by: Thunder.m
145 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
146 | // + bugfixed by: Pellentesque Malesuada
147 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
148 | // - depends on: this.utf8Encode
149 | // * example 1: this.base64Encode('Kevin van Zonneveld')
150 | // * returns 1: 'S2V2aW4gdmFuIFpvbm5ldmVsZA=='
151 | // mozilla has this native
152 | // - but breaks in 2.0.0.12!
153 | // if (typeof this.window['atob'] == 'function') {
154 | // return atob(data)
155 | // }
156 | let b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
157 | let o1,
158 | o2,
159 | o3,
160 | h1,
161 | h2,
162 | h3,
163 | h4,
164 | bits,
165 | i = 0,
166 | ac = 0,
167 | enc = '',
168 | tmp_arr = []
169 |
170 | if (!data) {
171 | return data
172 | }
173 |
174 | data = utf8Encode(data + '')
175 |
176 | do {
177 | // pack three octets into four hexets
178 | o1 = data.charCodeAt(i++)
179 | o2 = data.charCodeAt(i++)
180 | o3 = data.charCodeAt(i++)
181 |
182 | bits = (o1 << 16) | (o2 << 8) | o3
183 |
184 | h1 = (bits >> 18) & 0x3f
185 | h2 = (bits >> 12) & 0x3f
186 | h3 = (bits >> 6) & 0x3f
187 | h4 = bits & 0x3f
188 |
189 | // use hexets to index into b64, and append result to encoded string
190 | tmp_arr[ac++] =
191 | b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4)
192 | } while (i < data.length)
193 |
194 | enc = tmp_arr.join('')
195 |
196 | switch (data.length % 3) {
197 | case 1:
198 | enc = enc.slice(0, -2) + '=='
199 | break
200 | case 2:
201 | enc = enc.slice(0, -1) + '='
202 | break
203 | }
204 |
205 | return enc
206 | }
207 |
208 | function base64Decode(data: string) {
209 | // http://kevin.vanzonneveld.net
210 | // + original by: Tyler Akins (http://rumkin.com)
211 | // + improved by: Thunder.m
212 | // + input by: Aman Gupta
213 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
214 | // + bugfixed by: Onno Marsman
215 | // + bugfixed by: Pellentesque Malesuada
216 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
217 | // + input by: Brett Zamir (http://brett-zamir.me)
218 | // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
219 | // * example 1: base64_decode('S2V2aW4gdmFuIFpvbm5ldmVsZA==')
220 | // * returns 1: 'Kevin van Zonneveld'
221 | // mozilla has this native
222 | // - but breaks in 2.0.0.12!
223 | // if (typeof this.window['atob'] == 'function') {
224 | // return atob(data)
225 | // }
226 | let b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
227 | let o1, o2, o3, h1, h2, h3, h4, bits, i = 0,
228 | ac = 0,
229 | dec = '',
230 | tmp_arr = []
231 |
232 | if (!data) {
233 | return data
234 | }
235 |
236 | data += ''
237 |
238 | do { // unpack four hexets into three octets using index points in b64
239 | h1 = b64.indexOf(data.charAt(i++))
240 | h2 = b64.indexOf(data.charAt(i++))
241 | h3 = b64.indexOf(data.charAt(i++))
242 | h4 = b64.indexOf(data.charAt(i++))
243 |
244 | bits = h1 << 18 | h2 << 12 | h3 << 6 | h4
245 |
246 | o1 = bits >> 16 & 0xff
247 | o2 = bits >> 8 & 0xff
248 | o3 = bits & 0xff
249 |
250 | if (h3 === 64) {
251 | tmp_arr[ac++] = String.fromCharCode(o1)
252 | } else if (h4 === 64) {
253 | tmp_arr[ac++] = String.fromCharCode(o1, o2)
254 | } else {
255 | tmp_arr[ac++] = String.fromCharCode(o1, o2, o3)
256 | }
257 | } while (i < data.length)
258 |
259 | dec = tmp_arr.join('')
260 |
261 | return utf8Decode(dec)
262 | }
263 |
264 | export function urlSafeBase64Encode(v: any) {
265 | v = base64Encode(v)
266 |
267 | // 参考 https://tools.ietf.org/html/rfc4648#section-5
268 | return v.replace(/\//g, '_').replace(/\+/g, '-')
269 | }
270 |
271 | export function urlSafeBase64Decode(v: any) {
272 | v = v.replace(/_/g, '/').replace(/-/g, '+')
273 | return base64Decode(v)
274 | }
275 |
--------------------------------------------------------------------------------
/test/demo1/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 七牛云 - JavaScript SDK
7 |
8 |
9 |
10 |
11 |
12 |
13 |
30 |
31 |
32 |
33 | -
34 |
35 | JavaScript SDK 基于 h5 file api 开发,可以上传文件至七牛云存储。
36 |
37 |
38 | -
39 | 临时上传的空间不定时清空,请勿保存重要文件。
40 |
41 | -
42 | H5模式大于4M文件采用分块上传。
43 |
44 | -
45 | 上传图片可查看处理效果。
46 |
47 | -
48 | 本示例限制最大上传文件100M。
49 |
50 |
51 |
52 |
62 |
63 |
76 |
77 |
78 |
79 |
80 |
81 | | Filename |
82 | Size |
83 | Detail |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | | Filename |
96 | Size |
97 | Detail |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
126 |
127 |
128 |
129 |
130 |
202 |
203 |
205 |
208 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
--------------------------------------------------------------------------------
/src/upload/resume.ts:
--------------------------------------------------------------------------------
1 | import { uploadChunk, uploadComplete, initUploadParts, UploadChunkData } from '../api'
2 | import { QiniuError, QiniuErrorName, QiniuRequestError } from '../errors'
3 | import * as utils from '../utils'
4 |
5 | import Base, { Progress, UploadInfo, Extra } from './base'
6 |
7 | export interface UploadedChunkStorage extends UploadChunkData {
8 | size: number
9 | }
10 |
11 | export interface ChunkLoaded {
12 | mkFileProgress: 0 | 1
13 | chunks: number[]
14 | }
15 |
16 | export interface ChunkInfo {
17 | chunk: Blob
18 | index: number
19 | }
20 |
21 | export interface LocalInfo {
22 | data: UploadedChunkStorage[]
23 | id: string
24 | }
25 |
26 | export interface ChunkPart {
27 | etag: string
28 | partNumber: number
29 | }
30 |
31 | export interface UploadChunkBody extends Extra {
32 | parts: ChunkPart[]
33 | }
34 |
35 | /** 是否为正整数 */
36 | function isPositiveInteger(n: number) {
37 | const re = /^[1-9]\d*$/
38 | return re.test(String(n))
39 | }
40 |
41 | export default class Resume extends Base {
42 | /**
43 | * @description 文件的分片 chunks
44 | */
45 | private chunks: Blob[]
46 |
47 | /**
48 | * @description 使用缓存的 chunks
49 | */
50 | private usedCacheList: boolean[]
51 |
52 | /**
53 | * @description 来自缓存的上传信息
54 | */
55 | private cachedUploadedList: UploadedChunkStorage[]
56 |
57 | /**
58 | * @description 当前上传过程中已完成的上传信息
59 | */
60 | private uploadedList: UploadedChunkStorage[]
61 |
62 | /**
63 | * @description 当前上传片进度信息
64 | */
65 | private loaded: ChunkLoaded
66 |
67 | /**
68 | * @description 当前上传任务的 id
69 | */
70 | private uploadId: string
71 |
72 | /**
73 | * @returns {Promise>}
74 | * @description 实现了 Base 的 run 接口,处理具体的分片上传事务,并抛出过程中的异常。
75 | */
76 | protected async run() {
77 | this.logger.info('start run Resume.')
78 | if (!this.config.chunkSize || !isPositiveInteger(this.config.chunkSize)) {
79 | throw new QiniuError(
80 | QiniuErrorName.InvalidChunkSize,
81 | 'chunkSize must be a positive integer'
82 | )
83 | }
84 |
85 | if (this.config.chunkSize > 1024) {
86 | throw new QiniuError(
87 | QiniuErrorName.InvalidChunkSize,
88 | 'chunkSize maximum value is 1024'
89 | )
90 | }
91 |
92 | await this.initBeforeUploadChunks()
93 |
94 | const pool = new utils.Pool(
95 | async (chunkInfo: ChunkInfo) => {
96 | if (this.aborted) {
97 | pool.abort()
98 | throw new Error('pool is aborted')
99 | }
100 |
101 | await this.uploadChunk(chunkInfo)
102 | },
103 | this.config.concurrentRequestLimit
104 | )
105 |
106 | let mkFileResponse = null
107 | const localKey = this.getLocalKey()
108 | const uploadChunks = this.chunks.map((chunk, index) => pool.enqueue({ chunk, index }))
109 |
110 | try {
111 | await Promise.all(uploadChunks)
112 | mkFileResponse = await this.mkFileReq()
113 | } catch (error) {
114 | // uploadId 无效,上传参数有误(多由于本地存储信息的 uploadId 失效)
115 | if (error instanceof QiniuRequestError && (error.code === 612 || error.code === 400)) {
116 | utils.removeLocalFileInfo(localKey, this.logger)
117 | }
118 |
119 | throw error
120 | }
121 |
122 | // 上传成功,清理本地缓存数据
123 | utils.removeLocalFileInfo(localKey, this.logger)
124 | return mkFileResponse
125 | }
126 |
127 | private async uploadChunk(chunkInfo: ChunkInfo) {
128 | const { index, chunk } = chunkInfo
129 | const cachedInfo = this.cachedUploadedList[index]
130 | this.logger.info(`upload part ${index}, cache:`, cachedInfo)
131 |
132 | const shouldCheckMD5 = this.config.checkByMD5
133 | const reuseSaved = () => {
134 | this.usedCacheList[index] = true
135 | this.updateChunkProgress(chunk.size, index)
136 | this.uploadedList[index] = cachedInfo
137 | this.updateLocalCache()
138 | }
139 |
140 | // FIXME: 至少判断一下 size
141 | if (cachedInfo && !shouldCheckMD5) {
142 | reuseSaved()
143 | return
144 | }
145 |
146 | const md5 = await utils.computeMd5(chunk)
147 | this.logger.info('computed part md5.', md5)
148 |
149 | if (cachedInfo && md5 === cachedInfo.md5) {
150 | reuseSaved()
151 | return
152 | }
153 |
154 | // 没有使用缓存设置标记为 false
155 | this.usedCacheList[index] = false
156 |
157 | const onProgress = (data: Progress) => {
158 | this.updateChunkProgress(data.loaded, index)
159 | }
160 |
161 | const requestOptions = {
162 | body: chunk,
163 | md5: this.config.checkByServer ? md5 : undefined,
164 | onProgress,
165 | onCreate: (xhr: XMLHttpRequest) => this.addXhr(xhr)
166 | }
167 |
168 | this.logger.info(`part ${index} start uploading.`)
169 | const response = await uploadChunk(
170 | this.token,
171 | this.key,
172 | chunkInfo.index + 1,
173 | this.getUploadInfo(),
174 | requestOptions
175 | )
176 | this.logger.info(`part ${index} upload completed.`)
177 |
178 | // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null,这里在每次分片上传完成后都手动更新下 progress
179 | onProgress({
180 | loaded: chunk.size,
181 | total: chunk.size
182 | })
183 |
184 | this.uploadedList[index] = {
185 | etag: response.data.etag,
186 | md5: response.data.md5,
187 | size: chunk.size
188 | }
189 |
190 | this.updateLocalCache()
191 | }
192 |
193 | private async mkFileReq() {
194 | const data: UploadChunkBody = {
195 | parts: this.uploadedList.map((value, index) => ({
196 | etag: value.etag,
197 | // 接口要求 index 需要从 1 开始,所以需要整体 + 1
198 | partNumber: index + 1
199 | })),
200 | fname: this.putExtra.fname,
201 | ...this.putExtra.mimeType && { mimeType: this.putExtra.mimeType },
202 | ...this.putExtra.customVars && { customVars: this.putExtra.customVars },
203 | ...this.putExtra.metadata && { metadata: this.putExtra.metadata }
204 | }
205 |
206 | this.logger.info('parts upload completed, make file.', data)
207 | const result = await uploadComplete(
208 | this.token,
209 | this.key,
210 | this.getUploadInfo(),
211 | {
212 | onCreate: xhr => this.addXhr(xhr),
213 | body: JSON.stringify(data)
214 | }
215 | )
216 |
217 | this.logger.info('finish Resume Progress.')
218 | this.updateMkFileProgress(1)
219 | return result
220 | }
221 |
222 | private async initBeforeUploadChunks() {
223 | this.uploadedList = []
224 | this.usedCacheList = []
225 | const cachedInfo = utils.getLocalFileInfo(this.getLocalKey(), this.logger)
226 |
227 | // 分片必须和当时使用的 uploadId 配套,所以断点续传需要把本地存储的 uploadId 拿出来
228 | // 假如没有 cachedInfo 本地信息并重新获取 uploadId
229 | if (!cachedInfo) {
230 | this.logger.info('init upload parts from api.')
231 | const res = await initUploadParts(
232 | this.token,
233 | this.bucketName,
234 | this.key,
235 | this.uploadHost!.getUrl()
236 | )
237 | this.logger.info(`initd upload parts of id: ${res.data.uploadId}.`)
238 | this.uploadId = res.data.uploadId
239 | this.cachedUploadedList = []
240 | } else {
241 | const infoMessage = [
242 | 'resume upload parts from local cache,',
243 | `total ${cachedInfo.data.length} part,`,
244 | `id is ${cachedInfo.id}.`
245 | ]
246 |
247 | this.logger.info(infoMessage.join(' '))
248 | this.cachedUploadedList = cachedInfo.data
249 | this.uploadId = cachedInfo.id
250 | }
251 |
252 | this.chunks = utils.getChunks(this.file, this.config.chunkSize)
253 | this.loaded = {
254 | mkFileProgress: 0,
255 | chunks: this.chunks.map(_ => 0)
256 | }
257 | this.notifyResumeProgress()
258 | }
259 |
260 | private getUploadInfo(): UploadInfo {
261 | return {
262 | id: this.uploadId,
263 | url: this.uploadHost!.getUrl()
264 | }
265 | }
266 |
267 | private getLocalKey() {
268 | return utils.createLocalKey(this.file.name, this.key, this.file.size)
269 | }
270 |
271 | private updateLocalCache() {
272 | utils.setLocalFileInfo(this.getLocalKey(), {
273 | id: this.uploadId,
274 | data: this.uploadedList
275 | }, this.logger)
276 | }
277 |
278 | private updateChunkProgress(loaded: number, index: number) {
279 | this.loaded.chunks[index] = loaded
280 | this.notifyResumeProgress()
281 | }
282 |
283 | private updateMkFileProgress(progress: 0 | 1) {
284 | this.loaded.mkFileProgress = progress
285 | this.notifyResumeProgress()
286 | }
287 |
288 | private notifyResumeProgress() {
289 | this.progress = {
290 | total: this.getProgressInfoItem(
291 | utils.sum(this.loaded.chunks) + this.loaded.mkFileProgress,
292 | // FIXME: 不准确的 fileSize
293 | this.file.size + 1 // 防止在 complete 未调用的时候进度显示 100%
294 | ),
295 | chunks: this.chunks.map((chunk, index) => {
296 | const fromCache = this.usedCacheList[index]
297 | return this.getProgressInfoItem(this.loaded.chunks[index], chunk.size, fromCache)
298 | }),
299 | uploadInfo: {
300 | id: this.uploadId,
301 | url: this.uploadHost!.getUrl()
302 | }
303 | }
304 | this.onData(this.progress)
305 | }
306 | }
307 |
--------------------------------------------------------------------------------
/src/upload/base.ts:
--------------------------------------------------------------------------------
1 | import { QiniuErrorName, QiniuError, QiniuRequestError } from '../errors'
2 | import Logger, { LogLevel } from '../logger'
3 | import { region } from '../config'
4 | import * as utils from '../utils'
5 |
6 | import { Host, HostPool } from './hosts'
7 |
8 | export const DEFAULT_CHUNK_SIZE = 4 // 单位 MB
9 |
10 | // code 信息地址 https://developer.qiniu.com/kodo/3928/error-responses
11 | export const FREEZE_CODE_LIST = [0, 502, 503, 504, 599] // 将会冻结当前 host 的 code
12 | export const RETRY_CODE_LIST = [...FREEZE_CODE_LIST, 406] // 会进行重试的 code
13 |
14 | /** 上传文件的资源信息配置 */
15 | export interface Extra {
16 | /** 文件原文件名 */
17 | fname: string
18 | /** 用来放置自定义变量 */
19 | customVars?: { [key: string]: string }
20 | /** 自定义元信息 */
21 | metadata?: { [key: string]: string }
22 | /** 文件类型设置 */
23 | mimeType?: string //
24 | }
25 |
26 | export interface InternalConfig {
27 | /** 是否开启 cdn 加速 */
28 | useCdnDomain: boolean
29 | /** 是否开启服务端校验 */
30 | checkByServer: boolean
31 | /** 是否对分片进行 md5校验 */
32 | checkByMD5: boolean
33 | /** 强制直传 */
34 | forceDirect: boolean
35 | /** 上传失败后重试次数 */
36 | retryCount: number
37 | /** 自定义上传域名 */
38 | uphost: string[]
39 | /** 自定义分片上传并发请求量 */
40 | concurrentRequestLimit: number
41 | /** 分片大小,单位为 MB */
42 | chunkSize: number
43 | /** 上传域名协议 */
44 | upprotocol: 'https' | 'http'
45 | /** 上传区域 */
46 | region?: typeof region[keyof typeof region]
47 | /** 是否禁止统计日志上报 */
48 | disableStatisticsReport: boolean
49 | /** 设置调试日志输出模式,默认 `OFF`,不输出任何日志 */
50 | debugLogLevel?: LogLevel
51 | }
52 |
53 | /** 上传任务的配置信息 */
54 | export interface Config extends Partial> {
55 | /** 上传域名协议 */
56 | upprotocol?: InternalConfig['upprotocol'] | 'https:' | 'http:'
57 | /** 自定义上传域名 */
58 | uphost?: InternalConfig['uphost'] | string
59 | }
60 |
61 | export interface UploadOptions {
62 | file: File
63 | key: string | null | undefined
64 | token: string
65 | config: InternalConfig
66 | putExtra?: Partial
67 | }
68 |
69 | export interface UploadInfo {
70 | id: string
71 | url: string
72 | }
73 |
74 | /** 传递给外部的上传进度信息 */
75 | export interface UploadProgress {
76 | total: ProgressCompose
77 | uploadInfo?: UploadInfo
78 | chunks?: ProgressCompose[]
79 | }
80 |
81 | export interface UploadHandlers {
82 | onData: (data: UploadProgress) => void
83 | onError: (err: QiniuError) => void
84 | onComplete: (res: any) => void
85 | }
86 |
87 | export interface Progress {
88 | total: number
89 | loaded: number
90 | }
91 |
92 | export interface ProgressCompose {
93 | size: number
94 | loaded: number
95 | percent: number
96 | fromCache?: boolean
97 | }
98 |
99 | export type XHRHandler = (xhr: XMLHttpRequest) => void
100 |
101 | const GB = 1024 ** 3
102 |
103 | export default abstract class Base {
104 | protected config: InternalConfig
105 | protected putExtra: Extra
106 |
107 | protected aborted = false
108 | protected retryCount = 0
109 |
110 | protected uploadHost?: Host
111 | protected xhrList: XMLHttpRequest[] = []
112 |
113 | protected file: File
114 | protected key: string | null | undefined
115 |
116 | protected token: string
117 | protected assessKey: string
118 | protected bucketName: string
119 |
120 | protected uploadAt: number
121 | protected progress: UploadProgress
122 |
123 | protected onData: (data: UploadProgress) => void
124 | protected onError: (err: QiniuError) => void
125 | protected onComplete: (res: any) => void
126 |
127 | /**
128 | * @returns utils.Response
129 | * @description 子类通过该方法实现具体的任务处理
130 | */
131 | protected abstract run(): utils.Response
132 |
133 | constructor(
134 | options: UploadOptions,
135 | handlers: UploadHandlers,
136 | protected hostPool: HostPool,
137 | protected logger: Logger
138 | ) {
139 |
140 | this.config = options.config
141 | logger.info('config inited.', this.config)
142 |
143 | this.putExtra = {
144 | fname: '',
145 | ...options.putExtra
146 | }
147 |
148 | logger.info('putExtra inited.', this.putExtra)
149 |
150 | this.key = options.key
151 | this.file = options.file
152 | this.token = options.token
153 |
154 | this.onData = handlers.onData
155 | this.onError = handlers.onError
156 | this.onComplete = handlers.onComplete
157 |
158 | try {
159 | const putPolicy = utils.getPutPolicy(this.token)
160 | this.bucketName = putPolicy.bucketName
161 | this.assessKey = putPolicy.assessKey
162 | } catch (error) {
163 | logger.error('get putPolicy from token failed.', error)
164 | this.onError(error)
165 | }
166 | }
167 |
168 | // 检查并更新 upload host
169 | protected async checkAndUpdateUploadHost() {
170 | // 从 hostPool 中获取一个可用的 host 挂载在 this
171 | this.logger.info('get available upload host.')
172 | const newHost = await this.hostPool.getUp(
173 | this.assessKey,
174 | this.bucketName,
175 | this.config.upprotocol
176 | )
177 |
178 | if (newHost == null) {
179 | throw new QiniuError(
180 | QiniuErrorName.NotAvailableUploadHost,
181 | 'no available upload host.'
182 | )
183 | }
184 |
185 | if (this.uploadHost != null && this.uploadHost.host !== newHost.host) {
186 | this.logger.warn(`host switches from ${this.uploadHost.host} to ${newHost.host}.`)
187 | } else {
188 | this.logger.info(`use host ${newHost.host}.`)
189 | }
190 |
191 | this.uploadHost = newHost
192 | }
193 |
194 | // 检查并解冻当前的 host
195 | protected checkAndUnfreezeHost() {
196 | this.logger.info('check unfreeze host.')
197 | if (this.uploadHost != null && this.uploadHost.isFrozen()) {
198 | this.logger.warn(`${this.uploadHost.host} will be unfrozen.`)
199 | this.uploadHost.unfreeze()
200 | }
201 | }
202 |
203 | // 检查并更新冻结当前的 host
204 | private checkAndFreezeHost(error: QiniuError) {
205 | this.logger.info('check freeze host.')
206 | if (error instanceof QiniuRequestError && this.uploadHost != null) {
207 | if (FREEZE_CODE_LIST.includes(error.code)) {
208 | this.logger.warn(`${this.uploadHost.host} will be temporarily frozen.`)
209 | this.uploadHost.freeze()
210 | }
211 | }
212 | }
213 |
214 | private handleError(error: QiniuError) {
215 | this.logger.error(error.message)
216 | this.onError(error)
217 | }
218 |
219 | /**
220 | * @returns Promise 返回结果与上传最终状态无关,状态信息请通过 [Subscriber] 获取。
221 | * @description 上传文件,状态信息请通过 [Subscriber] 获取。
222 | */
223 | public async putFile(): Promise {
224 | this.aborted = false
225 | if (!this.putExtra.fname) {
226 | this.logger.info('use file.name as fname.')
227 | this.putExtra.fname = this.file.name
228 | }
229 |
230 | if (this.file.size > 10000 * GB) {
231 | this.handleError(new QiniuError(
232 | QiniuErrorName.InvalidFile,
233 | 'file size exceed maximum value 10000G'
234 | ))
235 | return
236 | }
237 |
238 | if (this.putExtra.customVars) {
239 | if (!utils.isCustomVarsValid(this.putExtra.customVars)) {
240 | this.handleError(new QiniuError(
241 | QiniuErrorName.InvalidCustomVars,
242 | // FIXME: width => with
243 | 'customVars key should start width x:'
244 | ))
245 | return
246 | }
247 | }
248 |
249 | if (this.putExtra.metadata) {
250 | if (!utils.isMetaDataValid(this.putExtra.metadata)) {
251 | this.handleError(new QiniuError(
252 | QiniuErrorName.InvalidMetadata,
253 | 'metadata key should start with x-qn-meta-'
254 | ))
255 | return
256 | }
257 | }
258 |
259 | try {
260 | this.uploadAt = new Date().getTime()
261 | await this.checkAndUpdateUploadHost()
262 | const result = await this.run()
263 | this.onComplete(result.data)
264 | this.checkAndUnfreezeHost()
265 | this.sendLog(result.reqId, 200)
266 | return
267 | } catch (err) {
268 | if (this.aborted) {
269 | this.logger.warn('upload is aborted.')
270 | this.sendLog('', -2)
271 | return
272 | }
273 |
274 | this.clear()
275 | this.logger.error(err)
276 | if (err instanceof QiniuRequestError) {
277 | this.sendLog(err.reqId, err.code)
278 |
279 | // 检查并冻结当前的 host
280 | this.checkAndFreezeHost(err)
281 |
282 | const notReachRetryCount = ++this.retryCount <= this.config.retryCount
283 | const needRetry = RETRY_CODE_LIST.includes(err.code)
284 |
285 | // 以下条件满足其中之一则会进行重新上传:
286 | // 1. 满足 needRetry 的条件且 retryCount 不为 0
287 | // 2. uploadId 无效时在 resume 里会清除本地数据,并且这里触发重新上传
288 | if (needRetry && notReachRetryCount) {
289 | this.logger.warn(`error auto retry: ${this.retryCount}/${this.config.retryCount}.`)
290 | this.putFile()
291 | return
292 | }
293 | }
294 |
295 | this.onError(err)
296 | }
297 | }
298 |
299 | private clear() {
300 | this.xhrList.forEach(xhr => {
301 | xhr.onreadystatechange = null
302 | xhr.abort()
303 | })
304 | this.xhrList = []
305 | this.logger.info('cleanup uploading xhr.')
306 | }
307 |
308 | public stop() {
309 | this.logger.info('aborted.')
310 | this.clear()
311 | this.aborted = true
312 | }
313 |
314 | public addXhr(xhr: XMLHttpRequest) {
315 | this.xhrList.push(xhr)
316 | }
317 |
318 | private sendLog(reqId: string, code: number) {
319 | this.logger.report({
320 | code,
321 | reqId,
322 | remoteIp: '',
323 | upType: 'jssdk-h5',
324 | size: this.file.size,
325 | time: Math.floor(this.uploadAt / 1000),
326 | port: utils.getPortFromUrl(this.uploadHost?.getUrl()),
327 | host: utils.getDomainFromUrl(this.uploadHost?.getUrl()),
328 | bytesSent: this.progress ? this.progress.total.loaded : 0,
329 | duration: Math.floor((new Date().getTime() - this.uploadAt) / 1000)
330 | })
331 | }
332 |
333 | public getProgressInfoItem(loaded: number, size: number, fromCache?: boolean): ProgressCompose {
334 | return {
335 | size,
336 | loaded,
337 | percent: loaded / size * 100,
338 | ...(fromCache == null ? {} : { fromCache })
339 | }
340 | }
341 | }
342 |
--------------------------------------------------------------------------------
/src/utils/helper.ts:
--------------------------------------------------------------------------------
1 | import SparkMD5 from 'spark-md5'
2 |
3 | import { QiniuErrorName, QiniuError, QiniuRequestError, QiniuNetworkError } from '../errors'
4 | import { Progress, LocalInfo } from '../upload'
5 | import Logger from '../logger'
6 |
7 | import { urlSafeBase64Decode } from './base64'
8 |
9 | export const MB = 1024 ** 2
10 |
11 | // 文件分块
12 | export function getChunks(file: File, blockSize: number): Blob[] {
13 |
14 | let chunkByteSize = blockSize * MB // 转换为字节
15 | // 如果 chunkByteSize 比文件大,则直接取文件的大小
16 | if (chunkByteSize > file.size) {
17 | chunkByteSize = file.size
18 | } else {
19 | // 因为最多 10000 chunk,所以如果 chunkSize 不符合则把每片 chunk 大小扩大两倍
20 | while (file.size > chunkByteSize * 10000) {
21 | chunkByteSize *= 2
22 | }
23 | }
24 |
25 | const chunks: Blob[] = []
26 | const count = Math.ceil(file.size / chunkByteSize)
27 | for (let i = 0; i < count; i++) {
28 | const chunk = file.slice(
29 | chunkByteSize * i,
30 | i === count - 1 ? file.size : chunkByteSize * (i + 1)
31 | )
32 | chunks.push(chunk)
33 | }
34 | return chunks
35 | }
36 |
37 | export function isMetaDataValid(params: { [key: string]: string }) {
38 | return Object.keys(params).every(key => key.indexOf('x-qn-meta-') === 0)
39 | }
40 |
41 | export function isCustomVarsValid(params: { [key: string]: string }) {
42 | return Object.keys(params).every(key => key.indexOf('x:') === 0)
43 | }
44 |
45 | export function sum(list: number[]) {
46 | return list.reduce((data, loaded) => data + loaded, 0)
47 | }
48 |
49 | export function setLocalFileInfo(localKey: string, info: LocalInfo, logger: Logger) {
50 | try {
51 | localStorage.setItem(localKey, JSON.stringify(info))
52 | } catch (err) {
53 | logger.warn(new QiniuError(
54 | QiniuErrorName.WriteCacheFailed,
55 | `setLocalFileInfo failed: ${localKey}`
56 | ))
57 | }
58 | }
59 |
60 | export function createLocalKey(name: string, key: string | null | undefined, size: number): string {
61 | const localKey = key == null ? '_' : `_key_${key}_`
62 | return `qiniu_js_sdk_upload_file_name_${name}${localKey}size_${size}`
63 | }
64 |
65 | export function removeLocalFileInfo(localKey: string, logger: Logger) {
66 | try {
67 | localStorage.removeItem(localKey)
68 | } catch (err) {
69 | logger.warn(new QiniuError(
70 | QiniuErrorName.RemoveCacheFailed,
71 | `removeLocalFileInfo failed. key: ${localKey}`
72 | ))
73 | }
74 | }
75 |
76 | export function getLocalFileInfo(localKey: string, logger: Logger): LocalInfo | null {
77 | let localInfoString: string | null = null
78 | try {
79 | localInfoString = localStorage.getItem(localKey)
80 | } catch {
81 | logger.warn(new QiniuError(
82 | QiniuErrorName.ReadCacheFailed,
83 | `getLocalFileInfo failed. key: ${localKey}`
84 | ))
85 | }
86 |
87 | if (localInfoString == null) {
88 | return null
89 | }
90 |
91 | let localInfo: LocalInfo | null = null
92 | try {
93 | localInfo = JSON.parse(localInfoString)
94 | } catch {
95 | // 本地信息已被破坏,直接删除
96 | removeLocalFileInfo(localKey, logger)
97 | logger.warn(new QiniuError(
98 | QiniuErrorName.InvalidCacheData,
99 | `getLocalFileInfo failed to parse. key: ${localKey}`
100 | ))
101 | }
102 |
103 | return localInfo
104 | }
105 |
106 | export function getAuthHeaders(token: string) {
107 | const auth = 'UpToken ' + token
108 | return { Authorization: auth }
109 | }
110 |
111 | export function getHeadersForChunkUpload(token: string) {
112 | const header = getAuthHeaders(token)
113 | return {
114 | 'content-type': 'application/octet-stream',
115 | ...header
116 | }
117 | }
118 |
119 | export function getHeadersForMkFile(token: string) {
120 | const header = getAuthHeaders(token)
121 | return {
122 | 'content-type': 'application/json',
123 | ...header
124 | }
125 | }
126 |
127 | export function createXHR(): XMLHttpRequest {
128 | if (window.XMLHttpRequest) {
129 | return new XMLHttpRequest()
130 | }
131 |
132 | if (window.ActiveXObject) {
133 | return new window.ActiveXObject('Microsoft.XMLHTTP')
134 | }
135 |
136 | throw new QiniuError(
137 | QiniuErrorName.NotAvailableXMLHttpRequest,
138 | 'the current environment does not support.'
139 | )
140 | }
141 |
142 | export async function computeMd5(data: Blob): Promise {
143 | const buffer = await readAsArrayBuffer(data)
144 | const spark = new SparkMD5.ArrayBuffer()
145 | spark.append(buffer)
146 | return spark.end()
147 | }
148 |
149 | export function readAsArrayBuffer(data: Blob): Promise {
150 | return new Promise((resolve, reject) => {
151 | const reader = new FileReader()
152 | // evt 类型目前存在问题 https://github.com/Microsoft/TypeScript/issues/4163
153 | reader.onload = (evt: ProgressEvent) => {
154 | if (evt.target) {
155 | const body = evt.target.result
156 | resolve(body as ArrayBuffer)
157 | } else {
158 | reject(new QiniuError(
159 | QiniuErrorName.InvalidProgressEventTarget,
160 | 'progress event target is undefined'
161 | ))
162 | }
163 | }
164 |
165 | reader.onerror = () => {
166 | reject(new QiniuError(
167 | QiniuErrorName.FileReaderReadFailed,
168 | 'fileReader read failed'
169 | ))
170 | }
171 |
172 | reader.readAsArrayBuffer(data)
173 | })
174 | }
175 |
176 | export interface ResponseSuccess {
177 | data: T
178 | reqId: string
179 | }
180 |
181 | export type XHRHandler = (xhr: XMLHttpRequest) => void
182 |
183 | export interface RequestOptions {
184 | method: string
185 | onProgress?: (data: Progress) => void
186 | onCreate?: XHRHandler
187 | body?: BodyInit | null
188 | headers?: { [key: string]: string }
189 | }
190 |
191 | export type Response = Promise>
192 |
193 | export function request(url: string, options: RequestOptions): Response {
194 | return new Promise((resolve, reject) => {
195 | const xhr = createXHR()
196 | xhr.open(options.method, url)
197 |
198 | if (options.onCreate) {
199 | options.onCreate(xhr)
200 | }
201 |
202 | if (options.headers) {
203 | const headers = options.headers
204 | Object.keys(headers).forEach(k => {
205 | xhr.setRequestHeader(k, headers[k])
206 | })
207 | }
208 |
209 | xhr.upload.addEventListener('progress', (evt: ProgressEvent) => {
210 | if (evt.lengthComputable && options.onProgress) {
211 | options.onProgress({
212 | loaded: evt.loaded,
213 | total: evt.total
214 | })
215 | }
216 | })
217 |
218 | xhr.onreadystatechange = () => {
219 | const responseText = xhr.responseText
220 | if (xhr.readyState !== 4) {
221 | return
222 | }
223 |
224 | const reqId = xhr.getResponseHeader('x-reqId') || ''
225 |
226 | if (xhr.status === 0) {
227 | // 发生 0 基本都是网络错误,常见的比如跨域、断网、host 解析失败、系统拦截等等
228 | reject(new QiniuNetworkError('network error.', reqId))
229 | return
230 | }
231 |
232 | if (xhr.status !== 200) {
233 | let message = `xhr request failed, code: ${xhr.status}`
234 | if (responseText) {
235 | message += ` response: ${responseText}`
236 | }
237 |
238 | let data
239 | try {
240 | data = JSON.parse(responseText)
241 | } catch {
242 | // 无需处理该错误、可能拿到非 json 格式的响应是预期的
243 | }
244 |
245 | reject(new QiniuRequestError(xhr.status, reqId, message, data))
246 | return
247 | }
248 |
249 | try {
250 | resolve({
251 | data: JSON.parse(responseText),
252 | reqId
253 | })
254 | } catch (err) {
255 | reject(err)
256 | }
257 | }
258 |
259 | xhr.send(options.body)
260 | })
261 | }
262 |
263 | export function getPortFromUrl(url: string | undefined) {
264 | if (url && url.match) {
265 | let groups = url.match(/(^https?)/)
266 |
267 | if (!groups) {
268 | return ''
269 | }
270 |
271 | const type = groups[1]
272 | groups = url.match(/^https?:\/\/([^:^/]*):(\d*)/)
273 |
274 | if (groups) {
275 | return groups[2]
276 | }
277 |
278 | if (type === 'http') {
279 | return '80'
280 | }
281 |
282 | return '443'
283 | }
284 |
285 | return ''
286 | }
287 |
288 | export function getDomainFromUrl(url: string | undefined): string {
289 | if (url && url.match) {
290 | const groups = url.match(/^https?:\/\/([^:^/]*)/)
291 | return groups ? groups[1] : ''
292 | }
293 |
294 | return ''
295 | }
296 |
297 | // 非标准的 PutPolicy
298 | interface PutPolicy {
299 | assessKey: string
300 | bucketName: string
301 | scope: string
302 | }
303 |
304 | export function getPutPolicy(token: string): PutPolicy {
305 | if (!token) throw new QiniuError(QiniuErrorName.InvalidToken, 'invalid token.')
306 |
307 | const segments = token.split(':')
308 | if (segments.length === 1) throw new QiniuError(QiniuErrorName.InvalidToken, 'invalid token segments.')
309 |
310 | // token 构造的差异参考:https://github.com/qbox/product/blob/master/kodo/auths/UpToken.md#admin-uptoken-authorization
311 | const assessKey = segments.length > 3 ? segments[1] : segments[0]
312 | if (!assessKey) throw new QiniuError(QiniuErrorName.InvalidToken, 'missing assess key field.')
313 |
314 | let putPolicy: PutPolicy | null = null
315 |
316 | try {
317 | putPolicy = JSON.parse(urlSafeBase64Decode(segments[segments.length - 1]))
318 | } catch (error) {
319 | throw new QiniuError(QiniuErrorName.InvalidToken, 'token parse failed.')
320 | }
321 |
322 | if (putPolicy == null) {
323 | throw new QiniuError(QiniuErrorName.InvalidToken, 'putPolicy is null.')
324 | }
325 |
326 | if (putPolicy.scope == null) {
327 | throw new QiniuError(QiniuErrorName.InvalidToken, 'scope field is null.')
328 | }
329 |
330 | const bucketName = putPolicy.scope.split(':')[0]
331 | if (!bucketName) {
332 | throw new QiniuError(QiniuErrorName.InvalidToken, 'resolve bucketName failed.')
333 | }
334 |
335 | return { assessKey, bucketName, scope: putPolicy.scope }
336 | }
337 |
338 | export function createObjectURL(file: File) {
339 | const URL = window.URL || window.webkitURL || window.mozURL
340 | // FIXME: 需要 revokeObjectURL
341 | return URL.createObjectURL(file)
342 | }
343 |
--------------------------------------------------------------------------------