白嫖小程序云存儲空間努释,手擼你的專屬云盤

前言

之前在開發(fā)微信小程序的時候贝椿,發(fā)現(xiàn)官方給每個小程序分配了5g的免費云存儲空間和每個月5g的cdn流量(免費版):

image

在小程序的開發(fā)后臺可以查看云存儲上的文件蜈块,文件本質(zhì)上是存在cdn上的蔑穴,每個文件都提供了專屬的downLoad url,靠著這個url我們就可以下載部署在云端的文件忠寻,也就是說上傳的文件自帶cdn加速。
image

5G的空間不算少存和,自己的小程序用不到額外的云存儲資源奕剃,這個資源拿來給自己搭建一個私有云盤豈不美哉?以后自己的一些小文件就可以放在上面捐腿,方便存儲和下載纵朋。諸位如果沒有開發(fā)過小程序也沒有關(guān)系,在微信公眾平臺上隨便申請個工具人小程序茄袖,然后開啟云開發(fā)即可操软,我們只是白嫖云存儲空間。項目地址見文末宪祥。

需求分析

要完成我們的設(shè)想聂薪,我們先羅列下我們需要哪些功能:

  • 文件本地上傳到云存儲
  • 當(dāng)前文件列表的展示
  • 已上傳文件的下載和刪除
  • 簡單的登錄和api操作鑒權(quán)
  • 具有良好的交互,包括進度條等功能

小程序云存儲的相關(guān)api支持服務(wù)器端調(diào)用品山,不支持瀏覽器直接調(diào)用胆建,所以為了操作云存儲的相關(guān)api,我們需要開啟一個中繼的node服務(wù)作為服務(wù)器,順便管理我們的文件列表肘交。
整個系統(tǒng)的工作流應(yīng)該是這樣的:在我們的前端服務(wù)通過用戶交互笆载,上傳文件到中繼的node服務(wù)上,node服務(wù)器將接收到的文件上傳給小程序的云存儲空間涯呻,獲取返回的文件的相關(guān)信息(主要是download url),同時在數(shù)據(jù)庫內(nèi)維護文件列表的相關(guān)信息(直接存在小程序?qū)?yīng)的數(shù)據(jù)庫中即可)凉驻。前端服務(wù)會請求后端獲取云存儲中的文件列表,通過用戶的交互可對各個文件進行刪除和下載等操作(實際上是向node服務(wù)器發(fā)送請求复罐,由node服務(wù)器調(diào)用官方的各種api來對云端的數(shù)據(jù)進行處理)涝登。
在工具鏈的選擇上,采取react + antd + typescript的技術(shù)方案,后端服務(wù)使用node + express效诅。

核心功能實現(xiàn)

文件上傳

上傳邏輯前端部分

首先我們從數(shù)據(jù)流的源頭開始胀滚,開始搭建文件核心上傳部分index.tsx

import React, { useState, useEffect, useReducer } from 'react';
import * as s from './color.css';
import withStyles from 'isomorphic-style-loader/withStyles';
import { Layout, Upload, Card, Button, message, Table, Progress, Spin } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { upload } from '@utils/upload';
import { UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { fileObj, parseList, columns, FileListAction, ProgressObj, ProgressAction } from './accessory';
const { Header, Content, Footer } = Layout;
//  省略部分依賴

function ShowComponent() {
    //  文件上傳列表的hooks
    const [fileList, setFList] = useReducer(listReducer, []);
    //  省略無關(guān)代碼
    //  ......
    async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
        const { fileList: newFileList, file } = info;
        //  上傳文件的核心邏輯
        const ans = await upload(info);
        const { fileData = {} } = ans;
        if (fileData.fileName) {
            setFList({ type: 'update', payload: Object.assign(fileData, { key: fileData._id }) });
            message.success(`${info.file.name} 上傳成功。`);
        } else {
          message.error(`${info.file.name} 上傳失敗乱投。`);
          return;
        }
      }

    return (
        <Layout className={s.layout}>
            <Header>
                <div className={s.title}>自己的網(wǎng)盤</div>
            </Header>
            <Content style={{ padding: '50px 50px' }}>
                <div className={s.siteLayoutContent}>
                    <Upload
                        customRequest={() => {}}
                        onChange={handleChange}
                        showUploadList={false}
                        multiple={true}
                    >
                        <Button>
                            <UploadOutlined /> Click to Upload
                        </Button>
                    </Upload>
                </div>
            </Content>
        </Layout>
    )
}
export default withStyles(s)(ShowComponent);

這部分的邏輯很簡單咽笼,主要是通過react+antd搭建UI,使用antd的Upload控件完成上傳文件的相關(guān)交互戚炫,將獲取到的文件對象傳遞給封裝好的upload函數(shù)剑刑,接下來我們來看看upload.tsx中的邏輯:

import {UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { reqPost, apiMap, request, host } from '@utils/api';
import { ProgressObj, ProgressAction } from '../entry/component/content/accessory';

const SIZE = 1 * 1024 * 1024; // 切片大小

// 生成文件切片
function createFileChunk(file: File | Blob | undefined, size = SIZE) {
    if (!file) {
        return [];
    }
    const fileChunkList = [];
    let cur = 0;
    while (cur < file.size) {
        //  對字節(jié)碼進行切割
        fileChunkList.push({ file: file.slice(cur, cur + size) });
        cur += size;
    }
    return fileChunkList;
}

interface FileObj extends File {
    name: string;
}

//  發(fā)送單個的文件切片
export async function uploadFile(params: FormData, fileName: string) {
    return request(host + apiMap.UPLOAD_FILE_SLICE, {
        method: 'post',
        data: params,
    });
}

//  給服務(wù)器發(fā)送合并切片的邏輯
export async function fileMergeReq(name: string, fileSize: number) {
    return reqPost(apiMap.MERGE_SLICE, { fileName: name, size: SIZE, fileSize: fileSize });
}

export async function upload(info: UploadChangeParam<UploadFile<any>>) {
    //  獲取切片的文件列表
    const fileList = createFileChunk(info.file.originFileObj);
    if (!info.file.originFileObj) {
        return '';
    }
    const { name: filename, size: fileSize } = info.file.originFileObj as FileObj;
    //  生成數(shù)據(jù)包list
    const dataPkg = fileList.map(({ file }, index) => ({
        chunk: file,
        hash: `${filename}-${index}` // 文件名 + 數(shù)組下標
        }));
    //  通過formdata依次發(fā)送數(shù)據(jù)包
    const uploadReqList = dataPkg.map(({ chunk, hash}) => {
        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('hash', hash);
        formData.append('filename', filename);
        return formData
    });
    const promiseArr = uploadReqList.map(item => uploadFile(item, filename));
    await Promise.all(promiseArr);
    //  全部發(fā)送完成后發(fā)送合并切片的請求
    const ans = await fileMergeReq(filename, fileSize);
    callBack({ type: 'delete', fileName: filename });
    return ans;
}

這里的邏輯并不復(fù)雜,核心是思想是將用戶上傳的文件切成每個1M的文件切片双肤,并做好標記施掏,將所有的文件切片送到服務(wù)器钮惠,服務(wù)器接收到所有的切片后告知前端接收完成,前端發(fā)送合并請求七芭,告知服務(wù)器可以將所有的文件切片依據(jù)做好的標記合并成原文件素挽。

上傳邏輯server端部分

接下來我們看看服務(wù)器端與之配合的代碼:

let ownTool = require('xiaohuli-package');
let fs = require('fs');
const request = require('request-promise');
const fse = require('fs-extra');
const path = require('path');
const multiparty = require('multiparty');
const { getToken, verifyToken, apiPrefix, errorSend, loginVerify, ENV_ID } = require('../baseUtil');
const { uploadApi, downLoadApi, queryApi, addApi, updateApi } = require('./apiDomain');

const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲目錄

//  讀取文件流,并將其pipe到寫文件流
const pipeStream = (path, writableStream) => 
    new Promise(resolve => {
        const readStream = fse.createReadStream(path);
        readStream.on('end', () => {
            fse.unlinkSync(path);
            resolve()
        });
        readStream.pipe(writableStream);
    })

//  合并接收到文件chunk
const mergeFileChunk = async (filePath, fileName, size) => {
    const chunkDir = path.resolve(UPLOAD_DIR, fileName);
    const chunkPaths = await fse.readdir(chunkDir);
    chunkPaths.sort((a, b) => a.split('-')[1] - b.split('-')[1]);
    //  對所有的文件切片完成寫文件流操作
    await Promise.all(chunkPaths.map((chunkPath, index) =>
        pipeStream(path.resolve(chunkDir, chunkPath),
            fse.createWriteStream(filePath, { start: index * size, end: (index + 1) * size })
        )
    ));
    //  刪除中間的過渡文件
    try {
        //  反復(fù)改名啥的很奇怪抖苦,但是不這樣就會有報錯毁菱,導(dǎo)致請求返回pending米死,可能是windows下的bug
        //  文件夾的名字和文件名字不能重復(fù)
        await fse.move(filePath, path.resolve(UPLOAD_DIR, `p${fileName}`)).catch(e => {
            console.log(e)
        });
        fse.removeSync(chunkDir);
        await fse.move(path.resolve(UPLOAD_DIR, `p${fileName}`), path.resolve(UPLOAD_DIR, `${fileName}`)).catch(e => {
            console.log(e);
        });
    } catch(e) {
        await fse.move(path.resolve(UPLOAD_DIR, `p${fileName}`), path.resolve(UPLOAD_DIR, `${fileName}`)).catch(e => {
            console.log(e)
        });
    }
}
//  上傳本地合并的文件到云存儲
async function uploadToCloud(filePath, fileName) {
    const wxToken = await getToken();
    const fullPath = path.resolve(filePath, fileName);
    const doamin = uploadApi + wxToken;
    //  獲取圖片上傳相關(guān)信息
    let a = await ownTool.netModel.post(doamin, {
        env: ENV_ID,
        path: fileName
    })
    const { authorization, url, token: newToken, cos_file_id, file_id} = a;
    //  真正上傳圖片
    const option = {
        method: 'POST',
        uri: url,
        formData: {
            "Signature": authorization,
            "key": fileName,
            "x-cos-security-token": newToken,
            "x-cos-meta-fileid": cos_file_id,
            "file": {
                //  讀取文件流锌历,作為屬性值上傳
                value: fs.createReadStream(fullPath),
                options: {
                    filename: 'test',
                    //contentType: file.type
                }
            }
        }
    }
    await request(option);
    //  獲取圖片的下載鏈接
    const getDownDomain = downLoadApi + wxToken;
    let imgInfo = await ownTool.netModel.post(getDownDomain, {
        env: ENV_ID,
        file_list: [{
            fileid: file_id,
            max_age: 7200
        }]
    });
    //  server中轉(zhuǎn)的圖片刪掉
    fs.unlink(fullPath, (e) => {
        if(e) {
            console.log(e);
        }
    })
    return imgInfo;
}

//  更新數(shù)據(jù)庫中的文件列表
async function updateList(fileObj, fileName, size) {
    const { download_url, fileid } = fileObj;
    const dataInfo = {
        fileName,
        downloadUrl: download_url,
        fileId: fileid,
        size,
        timeStamp: Date.now()
    };
    const dataInfoString = JSON.stringify(dataInfo);
    const wxToken = await getToken();
    let fileId = '';
    let isNew = false;
    //  先看有沒有同名文件
    const res = await ownTool.netModel.post(
        queryApi + wxToken, {
        env: ENV_ID,
        //  查詢數(shù)據(jù)
        query: 'db.collection(\"fileList\").where({ fileName: "' + fileName +'"}).get()'
    });

    //  如果已經(jīng)有了,就更新記錄
    if (res.data.length) {
        fileId = JSON.parse(res.data[0])._id;
        const res1 = await ownTool.netModel.post(updateApi + wxToken, {
            env: ENV_ID,
            //  query語句峦筒,功能是給filelist這個集合更新數(shù)據(jù)
            query: 'db.collection(\"fileList\").where({ fileName: "' + fileName + '"}).update({ data: ' + dataInfoString +'})'
        })
    //  否則新建一個
    } else {
        const res2 = await ownTool.netModel.post(addApi + wxToken, {
            env: ENV_ID,
            //  query語句究西,功能是給filelist這個集合添加數(shù)據(jù)
            query: 'db.collection(\"fileList\").add({ data: ' + dataInfoString +'})'
        })
        fileId = res2.id_list[0];
        isNew = true;
    }
    const finalData = Object.assign(dataInfo, { _id: fileId });
    return { fileData: finalData, isNew };
}

function uploadFileApi(app) {
    //  接收上傳的文件片段
    app.post(apiPrefix + '/uploadFile', async function(req, res) {
        //  通過multiparty這個庫解析上傳的form data,并生成本地文件蠢哭
        const multipart = new multiparty.Form();
        multipart.parse(req, async (err, fields, files) => {
            if (err) {
                console.log(err);
                return;
            }
            const [chunk] = files.chunk;
            const [hash] = fields.hash;
            const [filename] = fields.filename;
            const chunkDir = path.resolve(UPLOAD_DIR, filename);
            if (!fse.existsSync(chunkDir)) {
                await fse.mkdirs(chunkDir).catch(e => {
                    console.log(e)
                });
            }
            await fse.move(chunk.path, `${chunkDir}/${hash}`);
            res.end('received file chunk');
        })
    })

    //  合并文件
    app.post(apiPrefix + '/fileMergeReq', async function(req, res) {
        const { fileName, size, fileSize } = req.body;
        const filePath = path.resolve(UPLOAD_DIR, `${fileName}`, `${fileName}`);
        //  合并文件chunk
        await mergeFileChunk(filePath, fileName, size);
        //  上傳文件到云存儲
        const fileInfo = await uploadToCloud(UPLOAD_DIR, `${fileName}`);
        //  更新文件列表
        const dbInfo = await updateList(fileInfo.file_list[0], fileName, fileSize);
        res.send(dbInfo);
    })
}

exports.uploadFileApi = uploadFileApi;

這里涉及到了小程序http api的調(diào)用,調(diào)用前需要獲取調(diào)用token,再配合相關(guān)參數(shù)完成請求物喷,詳情請查閱官方文檔卤材,這里的邏輯與前端一一對應(yīng),首先是接受前端上傳過來的文件切片峦失,將他們解析并保存到臨時目錄扇丛,等到前端發(fā)送過來文件合并的請求后,將先前接受到的文件切片合并成原始文件尉辑。隨后調(diào)用小程序官方api,將本地的文件上傳到云存儲上帆精,根據(jù)返回的fileId,獲取文件部署在cdn上的download url隧魄,并將其返回給前端卓练。

文件列表展示

文件上傳的核心功能完成之后,接下來要處理的是文件列表的展示购啄,這里我們使用react中的hooks來作為狀態(tài)管理的工具襟企。更新index.tsx中的代碼

import { usePageManager, getQueryString, SINGLE_PAGE_SIZE } from '@utils/commonTools';
//  省略部分依賴

function ShowComponent() {
    //  控制頁碼的自定義hook
    const [pageObj, setPage] = usePageManager();
    //  選中的文件的狀態(tài)
    const [chekcList, setCheckList] = useState([]);

    function listReducer(state: Array<fileObj>, action: FileListAction): Array<fileObj> {
        //  文件列表狀態(tài)更新
        const fileUpdate = () => {
            //  找出要更新的文件
            const index = state.findIndex(item => item._id === action.payload._id);
            //  如果找不到,表示是新增
            if (index >= 0) {
                const target = state[index];
                //  修改時間戳
                target.timeStamp = action.payload.timeStamp;
                return [...state.slice(0, index), target, ...state.slice(index + 1)];
            } else {
                //  新增文件
                return (action?.payload ? [action.payload] : []).concat([...state])
            }
        }
        const actionMap = {
          //    初始化內(nèi)容
          init: () => action?.list || [],
          update: fileUpdate,
          //    刪除文件
          delete: () => state.filter(item => action.keys.findIndex(sitem => sitem ===item._id) === -1)
        };
        return actionMap[action.type]();
      }
    //  文件列表的狀態(tài)
    const [fileList, setFList] = useReducer(listReducer, []);
    //  初始化內(nèi)容
    useEffect(() => {
        const initList = async function() {
            //  向后端查詢文件列表內(nèi)容
            const res = await post(apiMap.QUERY_LIST, {
                queryString: getQueryString(1)
            });
            const list = parseList(res);
            //  設(shè)置總頁碼
            setPage({ total: res.pager.Total });
            //  初始化文件列表
            setFList({ type: 'init', list })
        };
        initList();
    }, []);

    async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
        //  省略部分代碼
      }
    //  table點擊下一頁時的回調(diào)
    async function detail(page: number) {
        //  查詢下一頁的內(nèi)容
        const res = await post(apiMap.QUERY_LIST, {
            queryString: getQueryString(page)
        });
        const showList = parseList(res);
        //  設(shè)置頁碼
        setPage({ current: page, total: res.pager.Total });
        //  重置文件列表
        setFList({ type: 'init', list: showList });
    }

    async function deleteFile() {
        const deleteList = fileList.filter(item => chekcList.findIndex(sitem => item._id === sitem) >= 0)
            .map(item => item.fileId);
        await post(apiMap.DELETE_FILE, {
            deleteFileList: deleteList
        });
        setFList({ type: 'delete', keys: chekcList });
    }

    function getNotification() {
        //  省略部分代碼
    }

    const paginaConfig = {
        onChange: detail,
        total: pageObj.total,
        current: pageObj.current,
        pageSize: SINGLE_PAGE_SIZE,
    };
    return (
        <Layout className={s.layout}>
            <Header>
                <div className={s.title}>自己的網(wǎng)盤</div>
            </Header>
            {getNotification()}
            <Content style={{ padding: '50px 50px' }}>
                <div className={s.siteLayoutContent}>
                    <Upload
                        customRequest={() => {}}
                        onChange={handleChange}
                        showUploadList={false}
                        multiple={true}
                    >
                        <Button>
                            <UploadOutlined /> Click to Upload
                        </Button>
                    </Upload>
                    <Button className={s.deleteBtn} onClick={deleteFile} type='dashed'>刪除</Button>
                    <Button className={s.downLBtn} onClick={downloadFile} type='primary'>下載</Button>
                    <Table
                        rowSelection={{
                            type: 'checkbox',
                            onChange: (selectedRowKeys, selectedRows) => {
                                setCheckList(selectedRowKeys);
                            },
                        }}
                        pagination={paginaConfig} columns={columns} dataSource={fileList} />
                </div>
            </Content>
            <Footer style={{ textAlign: 'center' }}>Produced by 廣蘭路地鐵</Footer>
        </Layout>
    )
}

這里我們使用了三個hook來協(xié)助我們管理狀態(tài)狮含,usePageManager這個自定義hook來控制文件列表的切頁狀態(tài)顽悼,const [chekcList, setCheckList] = useState([]);來控制多個文件的選中態(tài)(下圖中的checkbox):

image

當(dāng)需要對文件進行多選時,通過setCheckList來控制當(dāng)前選中的文件列表几迄,通過文件的唯一_id來標識不同的文件蔚龙。具體可進行下載或者刪除等操作。
const [fileList, setFList] = useReducer(listReducer, []);來控制文件列表狀態(tài)乓旗。useEffect配合空數(shù)組做參數(shù)進行fileList的初始化(可以類比傳統(tǒng)class component的componentDidMount方法)府蛇,向服務(wù)器請求文件列表,將內(nèi)容解析后通過antdtable組件渲染在頁面上屿愚,table切頁時會根據(jù)pagination上注冊的onChange事件根據(jù)當(dāng)前的頁碼去拉取新的內(nèi)容并更新table汇跨。在文件上傳完畢务荆,或者刪除時,都需要更新fileList的狀態(tài)穷遂,此時調(diào)用setFList來更新當(dāng)前的文件列表函匕。

文件下載

這一部分的內(nèi)容相對簡單,這里筆者采取的方案是通過構(gòu)造form表單蚪黑,通過設(shè)置get method然后submit表單來完成文件的下載:

export function downloadUrlFile(url) {
    let tempForm = document.createElement('form')
    tempForm.action = url
    tempForm.method = 'get'
    tempForm.style.display = 'none'
    document.body.appendChild(tempForm)
    tempForm.submit()
    return tempForm
}

實現(xiàn)效果大致這樣:

image

關(guān)于文件盅惜,延伸出來的內(nèi)容不少,我有另外一篇文章進行了比較細致的分析忌穿,感興趣的朋友可以移步關(guān)于點擊下載文件的那些事

增加上傳進度條抒寂,優(yōu)化體驗

作為一個合格的網(wǎng)盤,沒有上傳進度條體驗是很糟糕的掠剑。那么如何實現(xiàn)呢屈芜?顯然,在server端的文件合并朴译,上傳至云盤等步驟是沒有辦法量化的井佑,很難用進度條的形式展示,唯一前端可控的就是文件切片與上傳的過程眠寿。首先我們要定義一個新的列表來標識正在上傳的文件的切片進度:

//  index.tsx
function ShowComponent() {{
    // 省略重復(fù)代碼
    
    //  定義控制文件上傳狀態(tài)的hooks
    const [uploadProgressList, setUploadPL] = useReducer(uploadProFunc, []);

    //  維護上傳列表的進度條
    function uploadProFunc(state: Array<ProgressObj>, action: ProgressAction): Array<ProgressObj> {
        const progressUpdate = () => {
            const index = state.findIndex(item => item.fileName === action.fileName);
            if (index >= 0) {
                const target = state[index];
                target.finishedChunks += action.finishedChunks;
                return [...state.slice(0, index), target, ...state.slice(index + 1)];
            } else {
                return (action?.payload ? [action.payload] : []).concat([...state])
            }
        }
        const actionMap = {
            update: progressUpdate,
            delete: () => state.filter(item => item.fileName !== action.fileName)
        };
        return actionMap[action.type]();
    }
}

我們需要將setUploadPL作為回調(diào)傳入文件上傳的操作中躬翁,在每個切片完成之后更新目標文件的進度:

//  index.tsx
function ShowComponent() {
    //  省略部分內(nèi)容
    async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
        const { fileList: newFileList, file } = info;
        //  console.log(info);
        const ans = await upload(info, setUploadPL);
        //  省略重復(fù)部分
    }
}

繼續(xù)更新upload.tsx:

export async function uploadFile(params: FormData, fileName: string, cb: React.Dispatch<ProgressAction>) {
    return request(host + apiMap.UPLOAD_FILE_SLICE, {
        method: 'post',
        data: params,
    }).then(res => {
        //  追加回調(diào)函數(shù),更新上傳進度
        cb({ type: 'update', fileName, finishedChunks: 1})
    });
}

export async function upload(info: UploadChangeParam<UploadFile<any>>, callBack: React.Dispatch<ProgressAction>) {
    //  省略部分內(nèi)容

    //  創(chuàng)建文件上傳對象
    const initPro = {     
        fileName: filename,
        fullChunks: uploadReqList.length,
        finishedChunks: 0
    } as ProgressObj;
    //  創(chuàng)建一個文件上傳的狀態(tài)
    callBack({ type: 'update', fileName: filename, payload: initPro });
    //  追加回調(diào)
    const promiseArr = uploadReqList.map(item => uploadFile(item, filename, callBack));
    //  省略部分內(nèi)容
}

這里簡單解釋下邏輯盯拱,在每次上傳文件時盒发,計算總計的文件切片數(shù),然后通過調(diào)用傳入的回調(diào)更新文件上傳的狀態(tài)坟乾,在上傳文件切片的過程中迹辐,每個切片上傳完畢后,更新該文件上傳狀態(tài)的finishedChunks屬性,至此甚侣,我們已經(jīng)能夠追蹤文件的上傳態(tài)明吩,接下來要做的就是利用文件上傳的狀態(tài)來繪制進度條,繼續(xù)補充index.tsx中的代碼殷费。

function ShowComponent() {
    //  省略部分代碼
    function getNotification() {
        const statusList = uploadProgressList.map((item, index) => {
            const { fileName, fullChunks, finishedChunks } = item;
            const percent = finishedChunks / fullChunks;
            return <div key={fileName} className={s.box}>
                <div>正在上傳:{fileName}</div>
                <Progress percent={percent * 100} status="active"/>
                {percent === 1 ? <div className={s.uploading}>
                    <div className={s.loadingW}>正在等待服務(wù)器響應(yīng) </div>
                    <Spin />
                    </div>
                : null}
            </div>
        })
        return (
            <div className={s.boxwrapper}>
                {statusList}
            </div>
        )
    }

    return (
        <Layout className={s.layout}>
            // 省略部分代碼
            {getNotification()}
        </Layout>
}

這里使用了antd中的Progress進度條和Spinloading組件來協(xié)助展示印荔,大致效果如下:

image

簡單鑒權(quán)

既然是專屬云盤,后續(xù)肯定是要部署到公網(wǎng)上的详羡,為了避免被其他人誤操作或刻意破壞仍律,我們有必要加上登錄和鑒權(quán)的機制,在項目的入口文件实柠,我們添加上登錄頁的路由:

import React from 'react';
import ReactDom from 'react-dom';
import Com from './component/content';
import Login from './component/login';
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import StyleContext from 'isomorphic-style-loader/StyleContext';

//  : any[]
const insertCss = (...styles: any[]) => {
    const removeCss = styles.map(style => style._insertCss())
    return () => removeCss.forEach(dispose => dispose())
  }

//  掛載組件
const mountNode = document.getElementById('main');

ReactDom.render(
    <StyleContext.Provider value={{ insertCss }}>
      <Router>
        <Switch>
            //  登錄頁
          <Route path='/cloudDisk/login.html' component={Login} />
            //  內(nèi)容頁
          <Route path='/cloudDisk/disk.html' component={Com} />
          <Route path='/cloudDisk/' component={Login} />  
        </Switch>
      </Router>
    </StyleContext.Provider>,
    mountNode
);

登錄頁login.tsx的實現(xiàn)非常簡單水泉,一個輸入框加摁鈕即可:

import React, { useState } from 'react';
import * as s from './index.css';
import withStyles from 'isomorphic-style-loader/withStyles';
import { Button, Form, Input, message } from 'antd';
import { post, apiMap } from '@utils/api';

const FormItem = Form.Item;

function Login() {
    const [secret, setSecret] = useState('');

    const info = async function(event: React.ChangeEvent<HTMLInputElement>) {
        setSecret(event.target.value);
    }

    async function enter() {
        //  請求接口,驗證身份
        const res = await post(apiMap.LOGIN, {
            password: secret
        });
        //  如果鑒權(quán)成功,在localStorage中設(shè)置token,所有的請求都會帶上token以便server端的校驗
        if (res.verifyResult) {
            localStorage.setItem('tk', res.accessToken);
            window.location.href='/cloudDisk/disk.html';
        } else {
            message.error('密碼錯誤!');
        }
    }

    return (
        <div className={s.bg}>
            <div className={s.title}>歡迎進入DIY云盤</div>
            <div className={s.wrapper}>
                <Form >
                    <FormItem className={s.input}>
                        <Input.Password onChange={info}/>
                    </FormItem>
                </Form>
                <Button className={s.button} onClick={enter} type='primary'>Submit</Button>
            </div>

        </div>
    )
}

export default withStyles(s)(Login);

鑒權(quán)邏輯的核心是在登錄頁設(shè)置校驗草则,登錄成功后后端將返回一個token钢拧,前端將此token存放在localStorage中,之后的所有請求都會帶上這個token以便后端校驗炕横,校驗通過可以進行后續(xù)操作源内,否則返回錯誤碼,前端強制跳轉(zhuǎn)登錄頁份殿。在接口請求側(cè)膜钓,我們統(tǒng)一添加token,相關(guān)邏輯在api.tsx中:

import { extend } from 'umi-request';
/**
 * 配置request請求時的默認參數(shù)
 */
const qulifiedRequest = extend({
    errorHandler, // 默認錯誤處理
    credentials: 'include', // 默認請求是否帶上cookie
});

qulifiedRequest.use(async (ctx, next) => {
    await next();
    const { res } = ctx;
    //  如果是特殊的錯誤碼卿嘲,表示鑒權(quán)失敗直接跳轉(zhuǎn)登錄頁
    if (res?.response?.status === '401') {
        notification.error({
            message: `請求錯誤 鑒權(quán)失敗`,
            description: '鑒權(quán)失敗,請重新登陸',
        });
        setTimeout(() => window.location.href='/cloudDisk/login.html', 2000,);
    }
});

//  帶鑒權(quán)的接口
export const reqPost = (url: string, para: object) =>
    qulifiedRequest(localHost + url, {
        method: 'post',
        //  所有請求都帶上默認token
        data: Object.assign({}, para, {
            token: localStorage.getItem('tk')
        }),
    }
);

在server端颂斜,我們需要對所有的接口請求添加默認的攔截邏輯,首先校驗前端請求的token是否正確腔寡,如果不正確將返回統(tǒng)一的錯誤碼焚鲜,正確將繼續(xù)后續(xù)的處理邏輯。在處理登錄請求時放前,如果密碼正確,server將根據(jù)當(dāng)前的時間戳和秘鑰生成一個token糯彬,并將其返回給前端凭语,相關(guān)內(nèi)容在app.js中:

var express=require('express');
var app =express();
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
const { verifyToken, secret, apiPrefix, errorSend, loginVerify } = require('./baseUtil');

var jwt = require('jwt-simple');

app.use(bodyParser.json());
app.use(cookieParser());
app.use(bodyParser.urlencoded({extended: true}));
//設(shè)置跨域訪問
app.all('*', function(req, res, next) {
    //  省略部分內(nèi)容

    const rawUrl = req.url;
    //  統(tǒng)一處理鑒權(quán)邏輯
    if (!pathNotVerify.includes(rawUrl)) {
        if (verifyToken(req.body)) {
            //  如果通過鑒權(quán),下一步
            next()
        } else {
            //  否則返回特殊錯誤碼
            errorSend(res);
        }
    } else {
        next();
    }
});

//登陸接口 
app.post(apiPrefix + '/login', async function(req,res){
    const { password } = req.body;
    const verifyObj = await loginVerify(password);
    //  如果密碼正確撩扒,返回簽發(fā)的token
    if (verifyObj.verifyResult) {
        res.send({
            verifyResult: true,
            //  用戶請求的鑒權(quán)token似扔,使用jwt-simple這個庫生成
            accessToken: jwt.encode(Object.assign(req.body, { tokenTimeStamp: Date.now() } ), secret)
        })
    } else {
        res.send({
            verifyResult: false,
        });
    }
});

server如何驗證token呢?如果token的簽發(fā)時間在2個小時之內(nèi),我們就認為token有效:

const jwt = require('jwt-simple');

const outOfDatePeriod = 2 * 60 * 60 * 1000;
const verifyToken = ({token = ''}) => {
    //  根據(jù)秘鑰反向解析token搓谆,獲取簽發(fā)時的時間戳
    const res =  token ? jwt.decode(token, secret) : {};
    return (res.tokenTimeStamp + outOfDatePeriod) > Date.now();
}

至此主體工程全部完工炒辉。

部署和持續(xù)集成

部署按照個人的習(xí)慣來就好,直接在云機器上起express也好泉手,用nginx也好黔寇,tomcat亦可,詳細細節(jié)很多斩萌,這里由于篇幅原因不再贅述缝裤,只是推薦筆者之前寫的一篇新手向入門帖docker+nginx+node+jenkins從零開始部署你的前端服務(wù),事無巨細地介紹了從云機器配置到jenkins持續(xù)集成的全流程颊郎。

參考鏈接

項目前端代碼git地址
項目后端代碼git地址
小程序云開發(fā)https api官方文檔
關(guān)于點擊下載文件的那些事
docker+nginx+node+jenkins從零開始部署你的前端服務(wù)
字節(jié)跳動面試官:請你實現(xiàn)一個大文件上傳和斷點續(xù)傳

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末憋飞,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子姆吭,更是在濱河造成了極大的恐慌榛做,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,188評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異检眯,居然都是意外死亡升敲,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,464評論 3 395
  • 文/潘曉璐 我一進店門轰传,熙熙樓的掌柜王于貴愁眉苦臉地迎上來驴党,“玉大人,你說我怎么就攤上這事获茬「圩” “怎么了?”我有些...
    開封第一講書人閱讀 165,562評論 0 356
  • 文/不壞的土叔 我叫張陵恕曲,是天一觀的道長鹏氧。 經(jīng)常有香客問我,道長佩谣,這世上最難降的妖魔是什么把还? 我笑而不...
    開封第一講書人閱讀 58,893評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮茸俭,結(jié)果婚禮上吊履,老公的妹妹穿的比我還像新娘。我一直安慰自己调鬓,他們只是感情好艇炎,可當(dāng)我...
    茶點故事閱讀 67,917評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著腾窝,像睡著了一般缀踪。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上虹脯,一...
    開封第一講書人閱讀 51,708評論 1 305
  • 那天驴娃,我揣著相機與錄音,去河邊找鬼循集。 笑死唇敞,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的暇榴。 我是一名探鬼主播厚棵,決...
    沈念sama閱讀 40,430評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼蔼紧!你這毒婦竟也來了婆硬?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,342評論 0 276
  • 序言:老撾萬榮一對情侶失蹤奸例,失蹤者是張志新(化名)和其女友劉穎彬犯,沒想到半個月后向楼,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,801評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡谐区,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,976評論 3 337
  • 正文 我和宋清朗相戀三年湖蜕,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片宋列。...
    茶點故事閱讀 40,115評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡昭抒,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出炼杖,到底是詐尸還是另有隱情灭返,我是刑警寧澤,帶...
    沈念sama閱讀 35,804評論 5 346
  • 正文 年R本政府宣布坤邪,位于F島的核電站熙含,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏艇纺。R本人自食惡果不足惜怎静,卻給世界環(huán)境...
    茶點故事閱讀 41,458評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望黔衡。 院中可真熱鬧蚓聘,春花似錦、人聲如沸员帮。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,008評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽捞高。三九已至,卻和暖如春渣锦,著一層夾襖步出監(jiān)牢的瞬間硝岗,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,135評論 1 272
  • 我被黑心中介騙來泰國打工袋毙, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留型檀,地道東北人。 一個月前我還...
    沈念sama閱讀 48,365評論 3 373
  • 正文 我出身青樓听盖,卻偏偏與公主長得像胀溺,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子皆看,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,055評論 2 355