feat: 下载dicom

This commit is contained in:
mozzie 2023-09-05 15:07:15 +08:00
parent d63eb1a5af
commit c6834cb4c4
13 changed files with 351 additions and 64 deletions

View File

@ -1,4 +1,4 @@
import { Apis, ExistInPacsDTO } from "@@/infra/api"; import { Apis, DownloadArchiveDTO, ExistInPacsDTO } from "@@/infra/api";
export class DicomRepository { export class DicomRepository {
async upload2Pacs(dcmFile: File) { async upload2Pacs(dcmFile: File) {
@ -10,4 +10,8 @@ export class DicomRepository {
async existInPacs(p: ExistInPacsDTO) { async existInPacs(p: ExistInPacsDTO) {
return Apis.existInPacs(p); return Apis.existInPacs(p);
} }
async downloadDicom(p: DownloadArchiveDTO) {
return Apis.downloadDicom(p);
}
} }

View File

@ -1,4 +1,4 @@
import { ExistInPacsDTO } from "@@/infra/api"; import { DownloadArchiveDTO, ExistInPacsDTO } from "@@/infra/api";
import { DicomRepository } from "./DicomRepository"; import { DicomRepository } from "./DicomRepository";
export class DicomService { export class DicomService {
@ -17,4 +17,8 @@ export class DicomService {
async existInPacs(p: ExistInPacsDTO) { async existInPacs(p: ExistInPacsDTO) {
return await this.dicomRepository.existInPacs(p); return await this.dicomRepository.existInPacs(p);
} }
async downloadDicom(p: DownloadArchiveDTO) {
return await this.dicomRepository.downloadDicom(p);
}
} }

View File

@ -1,6 +1,8 @@
import { User } from "@@/domain/User/entities/User"; import { User } from "@@/domain/User/entities/User";
import { Request } from "./Request"; import { Request } from "./Request";
import { Study } from "@/modules/Admin/Dicom/Upload/DicomUploader/util"; import { Study } from "@/modules/Admin/Dicom/Upload/DicomUploader/util";
import axios from "axios";
import { saveAs } from "file-saver";
const PREFIX = "/api/dmp"; const PREFIX = "/api/dmp";
const PREFIX_CERT = "/cert"; const PREFIX_CERT = "/cert";
@ -31,6 +33,11 @@ export type ArchiveTaskCreateDto = {
study: Study[]; study: Study[];
}; };
export type DownloadArchiveDTO = {
ID: string;
Type: "study" | "series" | "Study" | "Series";
};
export const Apis = { export const Apis = {
/** /**
* *
@ -80,4 +87,20 @@ export const Apis = {
Request.get(PREFIX + "/annotator/find/archiveTask"), Request.get(PREFIX + "/annotator/find/archiveTask"),
findDicoms: (): ResponseType => Request.get(PREFIX + "/admin/find/dicom/all"), findDicoms: (): ResponseType => Request.get(PREFIX + "/admin/find/dicom/all"),
/**
* dicom zip
*
*/
downloadDicom: (p: DownloadArchiveDTO) =>
axios
.post(PREFIX + `/dicom/download`, p, { responseType: "blob" })
.then((response) => {
const filename = response.headers["content-name"];
const blob = new Blob([response.data], { type: "application/zip" });
saveAs(blob, filename);
})
.catch((error) => {
console.error("Download error:", error);
}),
}; };

View File

@ -22,7 +22,8 @@
"@ant-design/icons": "5.2.5", "@ant-design/icons": "5.2.5",
"dicom-parser": "1.8.21", "dicom-parser": "1.8.21",
"path-to-regexp": "6.2.1", "path-to-regexp": "6.2.1",
"@tavi/util": "workspace:*" "@tavi/util": "workspace:*",
"file-saver": "2.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.2.14", "@types/react": "18.2.14",
@ -36,6 +37,7 @@
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1", "eslint-plugin-react-refresh": "^0.4.1",
"typescript": "^5.0.2", "typescript": "^5.0.2",
"vite": "^4.4.0" "vite": "^4.4.0",
"@types/file-saver": "2.0.5"
} }
} }

View File

@ -1,35 +1,74 @@
import { TableColumnsType } from "antd"; import { TableColumnsType } from "antd";
import { DataItemType } from "."; import { SeriesItemType, StudyItemType } from ".";
export const columnsForStudy: TableColumnsType<DataItemType> = [ export const columnsForStudy: TableColumnsType<StudyItemType> = [
{ title: "PatientID", dataIndex: "PatientID", key: "PatientID" }, { title: "PatientID", dataIndex: "PatientID", key: "PatientID" },
{ title: "患者姓名", dataIndex: "PatientName", key: "PatientName" }, { title: "患者姓名", dataIndex: "PatientName", key: "PatientName" },
{ title: "性别", dataIndex: "PatientSex", key: "PatientSex" }, { title: "性别", dataIndex: "PatientSex", key: "PatientSex" },
{ title: "出生日期", dataIndex: "PatientBirthDate", key: "PatientBirthDate" }, { title: "出生日期", dataIndex: "PatientBirthDate", key: "PatientBirthDate" },
{ title: "病例日期", dataIndex: "StudyDate", key: "StudyDate" }, { title: "病例日期", dataIndex: "StudyDate", key: "StudyDate" },
{ { title: "机构", dataIndex: "InstitutionName", key: "InstitutionName" },
title: "相关序列数",
dataIndex: "NumberPatientRelatedInstances",
key: "NumberPatientRelatedInstances",
},
]; ];
export const columnsForSeries: TableColumnsType<DataItemType> = [ export const columnsForSeries: TableColumnsType<SeriesItemType> = [
{ title: "序列号", dataIndex: "SeriesNumber", key: "SeriesNumber" },
{
title: "成像设备",
dataIndex: "Modality",
key: "Modality",
},
{ {
title: "序列描述", title: "序列描述",
dataIndex: "SeriesDescription", dataIndex: "SeriesDescription",
key: "SeriesDescription", key: "SeriesDescription",
}, },
{
title: (
<div>
<span></span>
</div>
),
key: "PatientPosition",
render: (_, record: SeriesItemType) => {
return <>{record.tags.PatientPosition}</>;
},
},
{
title: "检查区域",
key: "BodyPartExamined",
render: (_, record: SeriesItemType) => {
return <>{record.tags.BodyPartExamined}</>;
},
},
{ {
title: "切片数", title: "切片数",
dataIndex: "NumberSeriesRelatedInstances", dataIndex: "InstanceNumber",
key: "NumberSeriesRelatedInstances", key: "InstanceNumber",
}, },
{
title: "层厚",
key: "SliceThickness",
render: (_, record: SeriesItemType) => {
return <>{record.tags.SliceThickness}</>;
},
},
{
title: "制造商",
dataIndex: "Manufacturer",
key: "Manufacturer",
},
{
title: "成像方式",
dataIndex: "Modality",
key: "Modality",
},
// {
// title: "焦点大小",
// key: "FocalSpots",
// render: (_, record: SeriesItemType) => {
// return <>{record.tags.FocalSpots}</>;
// },
// },
// {
// title: "图像位数",
// key: "BitsStored",
// render: (_, record: SeriesItemType) => {
// return <>{record.tags.BitsStored} bit</>;
// },
// },
]; ];

View File

@ -1,44 +1,62 @@
import { useDomain } from "@/hook/useDomain"; import { useDomain } from "@/hook/useDomain";
import { Button, Space, Table } from "antd"; import { Button, Space, Spin, Table, Tooltip, message } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { columnsForStudy, columnsForSeries } from "./columns"; import { columnsForStudy, columnsForSeries } from "./columns";
import { EyeOutlined } from "@ant-design/icons"; import { DesktopOutlined, FileZipOutlined } from "@ant-design/icons";
import { openOHIFViewer } from "../Upload/util"; import { openOHIFViewer } from "../Upload/util";
interface DicomListProps { interface DicomListProps {
children?: JSX.Element; children?: JSX.Element;
} }
export type DataItemType = { export type SeriesItemType = {
StudyDate: string; BodyPartExamined: string;
StudyTime: string; ContrastBolusAgent: string;
AccessionNumber: string; ID: string;
ImageOrientationPatient: string;
Instances: string[];
Manufacturer: string;
Modality: string; Modality: string;
PatientName: string; OperatorsName: string;
PatientID: string; ProtocolName: string;
PatientBirthDate: string; SeriesDate: string;
PatientSex: string;
StudyInstanceUID: string;
SeriesInstanceUID: string;
CharacterSet: string;
SeriesDescription: string; SeriesDescription: string;
SeriesInstanceUID: string;
SeriesNumber: string;
SeriesTime: string;
StationName: string;
Type: "study";
tags: any;
};
export type StudyItemType = {
AccessionNumber: string;
ID: string;
InstitutionName: string;
PatientBirthDate: string;
PatientID: string;
PatientName: string;
PatientSex: string;
ReferringPhysicianName: string;
StudyDate: string;
StudyID: string; StudyID: string;
SeriesNumber: string | number; StudyInstanceUID: string;
NumberSeriesRelatedInstances: string | number; StudyTime: string;
NumberPatientRelatedInstances: string | number; Type: "series";
NumberPatientRelatedSeries: string | number; subs: SeriesItemType[];
subs: DataItemType[];
}; };
export const DicomList = (props: DicomListProps) => { export const DicomList = (props: DicomListProps) => {
const [dataSource, setDataSource] = useState<DataItemType[]>([]); const [dataSource, setDataSource] = useState<StudyItemType[]>([]);
const [tableLoading, setTableLoading] = useState(false); const [tableLoading, setTableLoading] = useState(false);
const { userDomainService } = useDomain(); const [loading, setLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const { userDomainService, dicomDomainService } = useDomain();
useEffect(() => { useEffect(() => {
setTableLoading(true); setTableLoading(true);
userDomainService.findDicoms().then((result) => { userDomainService.findDicoms().then((result) => {
const { data } = result; const { data } = result;
setDataSource(data as DataItemType[]); setDataSource(data as StudyItemType[]);
setTableLoading(false); setTableLoading(false);
}); });
}, []); }, []);
@ -46,7 +64,7 @@ export const DicomList = (props: DicomListProps) => {
/** /**
* *
*/ */
const expandedRowRenderForSeries = (record: DataItemType) => { const expandedRowRenderForSeries = (record: StudyItemType) => {
return ( return (
<Table <Table
rowKey="SeriesInstanceUID" rowKey="SeriesInstanceUID"
@ -55,16 +73,22 @@ export const DicomList = (props: DicomListProps) => {
{ {
title: "操作", title: "操作",
dataIndex: "operation", dataIndex: "operation",
render: (_: any, recordSeries: DataItemType) => ( render: (_: any, recordSeries: SeriesItemType) => (
<Space> <Space>
<Button <Tooltip title="在线阅片">
type="primary" <Button
size="small" type="text"
icon={<EyeOutlined />} icon={<DesktopOutlined />}
onClick={() => onViewDicom(record, recordSeries)} onClick={() => onViewDicom(record, recordSeries)}
> />
</Tooltip>
</Button> <Tooltip title="下载原始影像 .zip文件">
<Button
type="text"
icon={<FileZipOutlined />}
onClick={() => onDownloadDicom(recordSeries)}
/>
</Tooltip>
</Space> </Space>
), ),
}, },
@ -75,17 +99,61 @@ export const DicomList = (props: DicomListProps) => {
); );
}; };
const onViewDicom = (record: DataItemType, recordSeries: DataItemType) => { const onViewDicom = (record: StudyItemType, recordSeries: SeriesItemType) => {
const { StudyInstanceUID } = record; const { StudyInstanceUID } = record;
const { SeriesInstanceUID } = recordSeries; const { SeriesInstanceUID } = recordSeries;
openOHIFViewer(StudyInstanceUID, SeriesInstanceUID); openOHIFViewer(StudyInstanceUID, SeriesInstanceUID);
}; };
const onDownloadDicom = ({ ID, Type }: SeriesItemType | StudyItemType) => {
setLoading(true);
dicomDomainService.downloadDicom({ ID, Type }).finally(() => {
messageApi.success("数据下载成功");
setLoading(false);
});
};
return ( return (
<div> <div>
{contextHolder}
{loading && (
<div
style={{
position: "fixed",
background: "rgb(255 195 195 / 30%)",
left: 0,
right: 0,
top: 0,
bottom: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 19940121,
}}
>
<Spin size="large" />
</div>
)}
<Table <Table
dataSource={dataSource} dataSource={dataSource}
columns={columnsForStudy} columns={[
...columnsForStudy,
{
title: "操作",
dataIndex: "operation",
render: (_: any, record: StudyItemType) => (
<Space>
<Tooltip title="下载原始影像 .zip文件">
<Button
type="text"
icon={<FileZipOutlined />}
onClick={() => onDownloadDicom(record)}
/>
</Tooltip>
</Space>
),
},
]}
loading={tableLoading} loading={tableLoading}
expandable={{ expandable={{
expandedRowRender: expandedRowRenderForSeries, expandedRowRender: expandedRowRenderForSeries,

View File

@ -7,4 +7,4 @@ NACOS_NAMESPACE=56a3b295-f319-4ced-82b5-0df2e98cc541
NACOS_DATAID='test' NACOS_DATAID='test'
NACOS_GROUP='DEFAULT_GROUP' NACOS_GROUP='DEFAULT_GROUP'
# dicom配置可以后期转移到nacos配置中心 # dicom配置可以后期转移到nacos配置中心
PACS_URL=http://localhost:8042/dicom-web/ PACS_URL=http://localhost:8042

View File

@ -27,6 +27,47 @@ const metaFormatter = (data) => {
}; };
}; };
type StudyResourceType = {
ID: string;
MainDicomTags: {
AccessionNumber: string;
StudyDate: string;
StudyDescription: string;
StudyID: string;
StudyInstanceUID: string;
StudyTime: string;
};
PatientMainDicomTags: {
PatientBirthDate: string;
PatientID: string;
PatientName: string;
PatientSex: string;
};
Series: string[];
Type: 'Study';
};
type SeriesResourceType = {
ExpectedNumberOfInstances: number;
ID: string;
Instances: string[];
MainDicomTags: {
Manufacturer: string;
Modality: string;
NumberOfSlices: string;
ProtocolName: string;
SeriesDate: string;
SeriesDescription: string;
SeriesInstanceUID: string;
SeriesNumber: string;
SeriesTime: string;
StationName: string;
};
ParentStudy: string;
Status: string;
Type: 'Series';
};
@Controller() @Controller()
export class AppController { export class AppController {
constructor( constructor(
@ -38,19 +79,52 @@ export class AppController {
async findDicoms() { async findDicoms() {
const pacsUrl = this.configService.get('PACS_URL'); const pacsUrl = this.configService.get('PACS_URL');
try { try {
const { data: studyData } = await axios.get(`${pacsUrl}/studies`); // 全部的study pacs uuid
const { data: study_uuids } = await axios.get(`${pacsUrl}/studies`);
const result = []; const result = [];
for (const study of studyData) { // study
const studyItem = metaFormatter(study); for (const study_uuid of study_uuids) {
const { data: seriesData } = await axios.get( const studyUrl = `${pacsUrl}/studies/${study_uuid}`;
`${pacsUrl}/studies/${studyItem.StudyInstanceUID}/series`, const { data: studyResource } = await axios.get(studyUrl);
); const { Series, PatientMainDicomTags, MainDicomTags, ID, Type } =
const subs = seriesData.map((s) => metaFormatter(s)); studyResource as StudyResourceType;
result.push({ ...studyItem, subs }); const subs = [];
// series + 第一张 instance 的 tags
for (const series_uuid of Series) {
const seriesUrl = `${pacsUrl}/series/${series_uuid}`;
const { data: seriesResource } = await axios.get(seriesUrl);
const { MainDicomTags, Instances, ID, Type } =
seriesResource as SeriesResourceType;
// 取第一张instance
const InstanceNumber = Instances.length;
const firstInstanceUrl = `${pacsUrl}/instances/${Instances[0]}/simplified-tags`;
const tags =
Instances.length > 0
? await axios.get(firstInstanceUrl).then((d) => d.data)
: {};
subs.push({ ...MainDicomTags, Type, InstanceNumber, ID, tags });
}
result.push({
...PatientMainDicomTags,
...MainDicomTags,
Type,
ID,
subs,
});
} }
return { data: result }; return { data: result };
} catch (error) { } catch (error) {
throw new Error(`Failed to fetch DICOM data: ${error.message}`); throw new Error(`Failed to fetch DICOM data: ${error.message}`);
} }
} }
@EventPattern({ cmd: 'dicom.archive.url' })
async archiveUrl({ ID, Type }: { ID: string; Type: string }) {
const pacsUrl = this.configService.get('PACS_URL');
const mapping = {
study: 'studies',
series: 'series',
};
return `${pacsUrl}/${mapping[Type.toLowerCase()]}/${ID}/archive`;
}
} }

View File

@ -27,7 +27,8 @@
"class-validator": "0.14.0", "class-validator": "0.14.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"uuid": "9.0.0", "uuid": "9.0.0",
"dayjs": "1.11.9" "dayjs": "1.11.9",
"axios": "1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",

View File

@ -9,6 +9,7 @@ import { ForbiddenExceptionFilter } from './filter/forbid.filter';
import { AuthController } from './auth/auth.controller'; import { AuthController } from './auth/auth.controller';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AnnotatorModule } from './annotator/annotator.module'; import { AnnotatorModule } from './annotator/annotator.module';
import { DicomModule } from './dicom/dicom.module';
import * as cookieParser from 'cookie-parser'; import * as cookieParser from 'cookie-parser';
@Module({ @Module({
@ -30,6 +31,7 @@ import * as cookieParser from 'cookie-parser';
]), ]),
AdminModule, AdminModule,
AnnotatorModule, AnnotatorModule,
DicomModule,
], ],
controllers: [AppController, AuthController], controllers: [AppController, AuthController],
providers: [ providers: [

View File

@ -0,0 +1,28 @@
import { Body, Controller, Get, Inject, Param, Post, Res } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { Response } from 'express';
import { firstValueFrom } from 'rxjs';
import axios from 'axios';
@Controller('dicom')
export class DicomController {
constructor(@Inject('Client') private client: ClientProxy) {}
@Post('download')
async downloadDicom(@Body() body, @Res() res: Response) {
const { ID, Type } = body;
const url = await firstValueFrom(
this.client.send({ cmd: 'dicom.archive.url' }, { ID, Type }),
);
try {
const { data: dataStream } = await axios.get(url, {
responseType: 'stream',
});
res.setHeader('Content-Name', `${ID}.zip`);
res.setHeader('Content-Type', 'application/zip');
dataStream.pipe(res);
} catch (error) {
return { code: 1, msg: error };
}
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { DicomController } from './dicom.controller';
import { ClientsModule, Transport } from '@nestjs/microservices';
@Module({
imports: [
ClientsModule.register([
{
name: 'Client',
transport: Transport.NATS,
options: {
servers: ['nats://localhost:4222'],
maxReconnectAttempts: 5,
reconnectTimeWait: 1000,
},
},
]),
],
controllers: [DicomController],
})
export class DicomModule {}

View File

@ -258,6 +258,9 @@ importers:
dicom-parser: dicom-parser:
specifier: 1.8.21 specifier: 1.8.21
version: 1.8.21 version: 1.8.21
file-saver:
specifier: 2.0.5
version: registry.npmmirror.com/file-saver@2.0.5
mobx: mobx:
specifier: 6.9.0 specifier: 6.9.0
version: 6.9.0 version: 6.9.0
@ -283,6 +286,9 @@ importers:
specifier: 6.14.2 specifier: 6.14.2
version: 6.14.2(react-dom@18.2.0)(react@18.2.0) version: 6.14.2(react-dom@18.2.0)(react@18.2.0)
devDependencies: devDependencies:
'@types/file-saver':
specifier: 2.0.5
version: registry.npmmirror.com/@types/file-saver@2.0.5
'@types/react': '@types/react':
specifier: 18.2.14 specifier: 18.2.14
version: 18.2.14 version: 18.2.14
@ -1073,6 +1079,9 @@ importers:
'@nestjs/typeorm': '@nestjs/typeorm':
specifier: 10.0.0 specifier: 10.0.0
version: 10.0.0(@nestjs/common@10.1.0)(@nestjs/core@10.1.0)(reflect-metadata@0.1.13)(rxjs@7.8.1)(typeorm@0.3.17) version: 10.0.0(@nestjs/common@10.1.0)(@nestjs/core@10.1.0)(reflect-metadata@0.1.13)(rxjs@7.8.1)(typeorm@0.3.17)
axios:
specifier: 1.5.0
version: registry.npmmirror.com/axios@1.5.0
class-transformer: class-transformer:
specifier: 0.5.1 specifier: 0.5.1
version: 0.5.1 version: 0.5.1
@ -12778,6 +12787,12 @@ packages:
'@types/serve-static': registry.npmmirror.com/@types/serve-static@1.15.2 '@types/serve-static': registry.npmmirror.com/@types/serve-static@1.15.2
dev: true dev: true
registry.npmmirror.com/@types/file-saver@2.0.5:
resolution: {integrity: sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.5.tgz}
name: '@types/file-saver'
version: 2.0.5
dev: true
registry.npmmirror.com/@types/http-errors@2.0.1: registry.npmmirror.com/@types/http-errors@2.0.1:
resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.1.tgz} resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.1.tgz}
name: '@types/http-errors' name: '@types/http-errors'
@ -14869,6 +14884,12 @@ packages:
flat-cache: registry.npmmirror.com/flat-cache@3.0.4 flat-cache: registry.npmmirror.com/flat-cache@3.0.4
dev: true dev: true
registry.npmmirror.com/file-saver@2.0.5:
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz}
name: file-saver
version: 2.0.5
dev: false
registry.npmmirror.com/fill-range@7.0.1: registry.npmmirror.com/fill-range@7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/fill-range/-/fill-range-7.0.1.tgz} resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/fill-range/-/fill-range-7.0.1.tgz}
name: fill-range name: fill-range