[Node.js,typescript] 后端服务导出CSV数据流给前端下载

前端时间使用Java做了此功能,另一个使用Node.js开发的服务也需要此功能,所以使用TypeScript做了类似的封装,后来发现,TS做这些功能,代码看起来更简洁

CsvUtils.ts

import { Response } from "express";
import { DateUtils, FXResponse } from "nodejs-fx";
import { GenderType } from "../model/GenderType";

const uuid = require('node-uuid');
const _reg1: RegExp = new RegExp("\"", 'g');
const _reg2: RegExp = new RegExp("\\\"", 'g');

/**
 * CSV 下载辅助类
 */
export class CsvUtils {
    private static charset: String = "utf-8";

    /**
     * 导出 CSV
     * @param res Http请求Response
     * @param fileName 可选,文件名,用户下载的文件名
     * @param onLoadData 获取分页数据
     */
    static async writeCsv<T>(res: Response,
        _constructor: { new (...args: Array<any>): T },
        onLoadData: (page: number) => Promise<PageDTO<T>>,
        fileName: string = undefined
    ): Promise<any> {
        try {
            let cls = (new _constructor()).constructor.name;
            let items: T[] = [];
            let pageIndex: number = 1;
            let count: number = undefined;
            while (true) {
                let result:PageDTO<T> = await onLoadData(pageIndex);
                if (!result || !result.items || result.items.length == 0)
                    break;
                if (pageIndex == 1) {
                    count = result.count;
                }
                if (pageIndex == 1 && count != undefined && count == result.items.length) {
                    return await this.writeCsvByItems(res, result.items, fileName, cls);
                }
                // items.push(...result.items);
                result.items.forEach(item => {
                    items.push(item);
                });
                pageIndex++;
                if (result.hasNext === true)
                    continue;
                if (result.hasNext === false)
                    break;
                if (count != undefined && items.length >= count)
                    break;
            }
            return await this.writeCsvByItems(res, items, fileName, cls);
        } catch (e) {
            return e;
        }
    }

    /**
     * 导出列表 CSV
     * @param res Http请求Response
     * @param items 数据列表
     * @param fileName 可选,文件名,用户下载的文件名
     */
    static async writeCsvByItems<T>(res: Response, items: Array<T>, fileName: string, className: string): Promise<any> {
        this.setHttpHeader(res, fileName);
        if (!items || items.length == 0)
            return "";

        // 筛选出拥有注解的字段
        let fields = new Array<any>();
        for (var o in items[0]) {
            let rKey = className + "." + o.toLowerCase();
            let reg = this.regMap.get(rKey);
            if (reg && reg.ingore === true)
                continue;
            if (!reg || !reg.name) {
                fields.push({v: o, t: o, conv: undefined});
            } else {
                fields.push({v: o, t: reg.name, conv: reg.converter})
            }
        }

        if (fields.length == 0)
            return "";

        let result: string = "";
        // 写入utf-8 BOM \0xef\0xbb\0xbf
        result += "\uFEFF";

        // 写入标题行
        let strs = new Array<string>();
        fields.forEach(v => {
            strs.push(JSON.stringify(v.t));
        });
        let text = this.stringToCsvLines(strs) + "\n";
        result += text;

        // 写入内容
        items.forEach(item => {
            text = this.itemToString(item, fields);
            if (!text) return;
            result += text + "\n";
        });

        return result;
    }

    /** 设置下载用的 Http 响应头部 */
    private static setHttpHeader(res: Response, fileName: string) {
        if (!fileName) fileName = this.generateRandomFileName() + ".csv";
        res.set({
            "Content-Type": "application/octet-stream; charset=" + this.charset,
            "Content-Disposition": "attachment;filename=" + encodeURIComponent(fileName),
            "Pragma": "no-cache",
            "Expires": 0
        });
    }

    private static itemToString(item: any, fields: Array<any>): string {
        let result = new Array<string>();
        fields.forEach(data => {
            let v = undefined;
            if (data.conv) {
                data.conv.data = item;
                v = data.conv.execute(item[data.v]);
            } else
                v = item[data.v];
            if (v == undefined || v === "") {
                result.push("");
            } else {
                let txt = JSON.stringify(v);
                if (txt.startsWith("{") || txt.startsWith("[")) {
                    txt = "\"" + txt.replace(_reg1, "\"\"") + "\"";
                }
                result.push(txt);
            }
        });
        return this.stringToCsvLines(result);
    }

    private static generateRandomFileName(): string {
        return uuid.v4().replace(new RegExp("-", 'g'), '');
    }

    private static stringToCsvLines(strs: Array<string>): string {
        if (!strs || strs.length == 0) return "";
        return strs.join(",");
    }

    // 注册的注解参数
    static regMap: Map<string, CsvParams> = new Map<string, CsvParams>();
}

export class PageDTO<T> {
    count: number = 0;
    hasNext: boolean = true;
    items: T[];

    static load<T>(data: FXResponse<T[]>, pageSize: number) {
        let result = new PageDTO<T>();
        if (data && data.code == 0 && data.data) {
            if (Array.isArray(data.data)) {
                result.items = data.data;
            } else if (data.data.list && Array.isArray(data.data.list)) {
                result.items = data.data.list;
            } else if (data.data.items && Array.isArray(data.data.items)) {
                result.items = data.data.items;
            }
            if (result.items)
                result.hasNext = result.items.length >= pageSize;
            else
                result.hasNext = false;
        } else
            throw data;
        return result;
    }
}

/**
 * csv 注解
 * @param name 字段名称(导出后显示的名称)
 * @param ingore 是否忽略这个字段
 * @param _constructor 转换器
 * @param args 转换器构造参数(依次写)
 */
export function csv<T>(name: string, ingore: boolean = false,
    _constructor: { new (...args: Array<any>): CsvConverterBase } = undefined,
    ...args: any
) {
    return function(target:any, propertyName:string){
        let p = new CsvParams();
        p.name = name;
        p.ingore = ingore;
        if (_constructor) {
            p.converter = new _constructor(...args);
        }
        CsvUtils.regMap.set(target.constructor.name + "." + propertyName.toLowerCase(), p);
    }
}

export class CsvParams {
    /** 字段名称 */
    name: string;
    /** 是否忽略 */
    ingore: boolean;
    /** 转换器 */
    converter: CsvConverterBase;
}

export abstract class CsvConverterBase {
    data: any;
    abstract execute(value: any): string;
}

/**
 * 时间戳转字符串 CSV转换器
 */
export class TimestampCsvConverter extends CsvConverterBase {
    execute(value: any): string {
        if (value == undefined) return "";
        if (!Number.isNaN(value)) {
            return DateUtils.formatDateTime(value);
        } else
            return value;
    }
}

/**
 * 性别类型CSV转换器
 * @description @csv("会员标签", undefined, GenderTypeCsvConverter)
 */
export class GenderTypeCsvConverter extends CsvConverterBase {
    execute(value: GenderType): string {
        if (value == GenderType.female) return "女";
        if (value == GenderType.male) return "男";
        return "未知"
    }
}

/**
 * 字符串数组  CSV转换器
 * @description @csv("会员标签", undefined, StringArrayCsvConverter)
 */
export class  StringArrayCsvConverter extends CsvConverterBase {
    field: string;
    constructor(field: string) {
        super();
        this.field = field;
    }

    execute(value: any): string {
        if (Array.isArray(value) && value.length > 0) {
            if (typeof(value[0]) == 'string')
                return value.join(",");
            if (this.field) {
                let items = [];
                value.forEach(item => items.push(item[this.field]));
                return items.join(",");
            }
        }
        return value;
    }
}

/**
 * 布尔值  CSV转换器
 * @description @csv("允许登录APP", undefined, BoolCsvConverter, "是", "否")
 */
export class BoolCsvConverter extends CsvConverterBase {
    p1: string;
    p2: string;
    p3: string;

    constructor(p1: string, p2: string, p3: string = "") {
        super();
        this.p1 = p1;
        this.p2 = p2;
        this.p3 = p3;
    }

    execute(value: any): string {
        if (value === true)
            return this.p1;
        if (value === false)
            return this.p2;
        return this.p3 == undefined ? "" : this.p3;
    }
}

/**
 * 枚举值 CSV 转换器
 * @description @csv("登录角色", undefined, EnumCsvConverter, {1: "管理员", 2: "普通员工", 3: "创建者"})
 */
export class EnumCsvConverter extends CsvConverterBase {
    enumValue: Object;

    constructor(enumValue: Object) {
        super();
        this.enumValue = enumValue;
    }

    execute(value: any): string {
        if (value == undefined) return "";
        let v = this.enumValue[value];
        return v ? v : "";
    }
}

/**
 * 对象字段值 CSV 转换器
 * @description @csv("图像地址", undefined, ObjectCsvConverter, "url")
 */
export class ObjectCsvConverter extends CsvConverterBase {
    field: string;
    constructor(field: string) {
        super();
        this.field = field;
    }

    execute(value: any): string {
        if (!value || !this.field) return "";
        if (Array.isArray(value)) {
            // 数组取出每项的字段值后,用","分隔连接
            let values = [];
            value.forEach(item => {
                values.push(item[this.field]);
            });
            return values.join(",");
        } else
            return value[this.field];
    }
}

PageDTO 声明, 仅作参考: (主要是作分页用)

export class PageDTO<T> {
    count: number = 0;
    hasNext: boolean = true;
    items: T[];

    static load<T>(data: Response<T[]>, pageSize: number) {
        let result = new PageDTO<T>();
        if (data && data.code == 0 && data.data) {
            if (Array.isArray(data.data)) {
                result.items = data.data;
            } else if (data.data.list && Array.isArray(data.data.list)) {
                result.items = data.data.list;
            } else if (data.data.items && Array.isArray(data.data.items)) {
                result.items = data.data.items;
            }
            if (result.items)
                result.hasNext = result.items.length >= pageSize;
            else
                result.hasNext = false;
        } else
            throw data;
        return result;
    }
}

调用举例:

@get("/list/pc/csv")
    @validate
    async getXXXListCsv(
         @query('a')  a: string,
         @query('b')  b: string,
         @query('c')  c: string
    ) {
        return await CsvUtils.writeCsv(this.res, TestDTO, async (page): Promise<PageDTO<any>> => {
            let data = await this.getList(page, 20, a, b, c);
            return PageDTO.load(data, 20);
        });
    }

TestDTO 声明:

export class TestDTO {
   /**
    * 会员名称
    */
    @csv("会员名称")
    name:string;

    /**
     * 头像
     */
    @csv("", true)
    memberImage:MediaModel;

   /**
    * 性别
    */
    @csv("性别", undefined, GenderTypeCsvConverter)
    gender:GenderType;

    /**
     * 会员标签名称数组
     */
    @csv("会员标签", undefined, StringArrayCsvConverter, "name")
    tags:string[]|TagsDetail[];

    /**
     * 加入时间
     */
    @csv("加入时间")
    jointime?: string;

    /**
     * 会员在该店铺的启用状态
     */
    @csv("启用状态", undefined, BoolCsvConverter, "启用", "未启用")
    enable?: boolean;
}

发表评论

邮箱地址不会被公开。 必填项已用*标注