解压缩
这里使用较为常用的 7z 来处理压缩包,它可以解开常见的压缩包格式
Unpacking only: APFS, AR, ARJ, CAB, CHM, CPIO, CramFS, DMG, EXT, FAT, GPT, HFS, IHEX, ISO, LZH, LZMA, MBR, MSI, NSIS, NTFS, QCOW2, RAR, RPM, SquashFS, UDF, UEFI, VDI, VHD, VHDX, VMDK, XAR and Z.
开发
预下载 mac 与 linux 版本的 7z 二进制文件,放置于 explorer-manage/src/7zip/linux 与 /mac 目录内。可前往 7z 官方进行下载,下载链接。
也可以使用 7zip-bin 这个依赖,内部包含所有环境可运行的二进制文件。由于项目是由镜像进行运行,使用全环境的包会加大镜像的体积。所以这里单独下载特定环境的下二进制文件,可能版本会比较旧,最近更新为 2022/5/16 。目前最新的 2023/06/20@23.01 版本。
使用 node-7z 这个依赖处理 7z 的输入输出
安装依赖
pnpm i node-7z
运行文件
// https://laysent.com/til/2019-12-02_7zip-bin-in-alpine-docker
// https://www.npmjs.com/package/node-7z
// https://www.7-zip.org/download.html
// import sevenBin from '7zip-bin'
import node7z from 'node-7z'
import { parseFilePath } from './parse-path.mjs'
import path from 'path'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { formatPath } from '../../lib/format-path.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
/**
* @type {import('node-7z').SevenZipOptions}
*/
const base_option = {
$bin: process.platform === 'darwin' ? path.join(__dirname, './mac/7zz') : path.join(__dirname, './linux/7zzs'),
recursive: true,
exclude: ['!__MACOSX/*', '!.DS_store'],
latestTimeStamp: false,
}
/**
* @param path {string}
* @param out_path {string|undefined}
* @param pwd {string | number | undefined}
* @returns {import('node-7z').ZipStream}
*/
export const node7zaUnpackAction = (path, out_path = '', pwd = 'pwd') => {
const join_path = formatPath(path)
const { file_dir_path } = parseFilePath(join_path)
return node7z.extractFull(join_path, formatPath(out_path) || `${file_dir_path}/`, {
...base_option,
password: pwd,
})
}
/**
* @param path {string}
* @param pwd {string | number | undefined}
* @returns {import('node-7z').ZipStream}
*/
export const node7zListAction = (path, pwd = 'pwd') => {
const join_path = formatPath(path)
return node7z.list(join_path, { ...base_option, password: pwd })
}
简单封装下 node7zaUnpackAction 与 node7zListAction 方法
- node7zaUnpackAction:解压缩方法
- node7zListAction:查看当前压缩包内容
explorer 客户端展示
大致设计为弹窗模式,提供一个解压缩位置,默认当前压缩包位置。再提供一个密码输入栏,用于带密码的压缩包解压。
解压缩一个超大包时,可能会超过 http 的请求超时时间,浏览器会主动关闭这次请求。导致压缩包没有解压缩完毕,请求就已经关闭了。虽然 node 还在后台进行解压缩。但是客户端无法知道是否解压缩完毕。
可通过延长 http 的请求超时时间。也可使用 stream 逐步输出内容的方式避免超时,客户端部分可以实时看到当前解压缩的进度。类似像 AI 机器人提问时,文字逐字出现的效果。
查看压缩包内容
直接使用 server action 调用 node7zListAction 方法即可
解压缩
使用 node-7z 的输出流逐步输出到浏览器
封装一个 post api 接口。
- 监听 node-7z 返回的数据流
.on('data')
事件。 - 对数据流做
encoder.encode(JSON.stringify(value) + ‘, ’)
格式化操作。方便客户端读取数据流。 - 每秒往客户端输出一个时间戳避免请求超时
stream.push({ loading: Date.now() })
- 10 分钟后关闭 2 的定时输出,让其自然超时。
- 客户端通过 fetch 获取数据流,具体可以看 unpack 方法
接口 api
import { NextRequest, NextResponse } from 'next/server'
import { node7zaUnpackAction } from '@/explorer-manager/src/7zip/7zip.mjs'
import { nodeStreamToIterator } from '@/explorer-manager/src/main.mjs'
const encoder = new TextEncoder()
const iteratorToStream = (iterator: AsyncGenerator) => {
return new ReadableStream({
async pull(controller) {
const { value, done } = await iterator.next()
if (done) {
controller.close()
} else {
controller.enqueue(encoder.encode(JSON.stringify(value) + ', '))
}
},
})
}
export const POST = async (req: NextRequest) => {
const { path, out_path, pwd } = await req.json()
try {
const stream = node7zaUnpackAction(path, out_path, pwd)
stream.on('data', (item) => {
console.log('data', item.file)
})
const interval = setInterval(() => {
console.log('interval', stream.info)
stream.push({ loading: Date.now() })
}, 1000)
const timeout = setTimeout(
() => {
clearInterval(interval)
},
60 * 10 * 1000,
)
stream.on('end', () => {
console.log('end', stream.info)
stream.push({
done: JSON.stringify(Object.fromEntries(stream.info), null, 2),
})
clearTimeout(timeout)
clearInterval(interval)
stream.push(null)
})
return new NextResponse(iteratorToStream(nodeStreamToIterator(stream)), {
headers: {
'Content-Type': 'application/octet-stream',
},
})
} catch (e) {
return NextResponse.json({ ret: -1, err_msg: e })
}
}
客户端弹窗组件
'use client'
import React, { useState } from 'react'
import { Card, Modal, Space, Table } from 'antd'
import UnpackForm from '@/components/unpack-modal/unpack-form'
import { isEmpty } from 'lodash'
import { useRequest } from 'ahooks'
import Bit from '@/components/bit'
import DateFormat from '@/components/date-format'
import { UnpackItemType } from '@/explorer-manager/src/7zip/types'
import { useUnpackPathDispatch, useUnpackPathStore } from '@/components/unpack-modal/unpack-path-context'
import { useUpdateReaddirList } from '@/app/path/readdir-context'
import { unpackListAction } from '@/components/unpack-modal/action'
let pack_list_path = ''
const UnpackModal: React.FC = () => {
const unpack_path = useUnpackPathStore()
const changeUnpackPath = useUnpackPathDispatch()
const [unpack_list, changeUnpackList] = useState<UnpackItemType['list']>([])
const { update } = useUpdateReaddirList()
const packList = useRequest(
async (form_val) => {
pack_list_path = unpack_path
const { pwd } = await form_val
return unpackListAction(unpack_path, pwd)
},
{
manual: true,
},
)
const unpack = useRequest(
async (form_val) => {
pack_list_path = unpack_path
unpack_list.length = 0
const { out_path, pwd } = await form_val
const res = await fetch('/path/api/unpack', {
method: 'post',
body: JSON.stringify({ path: unpack_path, out_path, pwd: pwd }),
})
if (res.body) {
const reader = res.body.getReader()
const decode = new TextDecoder()
while (1) {
const { done, value } = await reader.read()
const decode_value = decode
.decode(value)
.split(', ')
.filter((text) => Boolean(String(text).trim()))
.map((value) => {
try {
return value ? JSON.parse(value) : { value }
} catch (e) {
return { value }
}
})
.filter((item) => !item.loading)
.reverse()
!isEmpty(decode_value) && changeUnpackList((unpack_list) => decode_value.concat(unpack_list))
if (done) {
break
}
}
}
return Promise.resolve().then(update)
},
{
manual: true,
},
)
return (
<Modal
title="解压缩"
open={!isEmpty(unpack_path)}
width={1000}
onCancel={() => changeUnpackPath('')}
footer={false}
destroyOnClose={true}
>
<UnpackForm packList={packList} unpack={unpack} />
<Space direction="vertical" style={{ width: '100%' }}>
{pack_list_path === unpack_path && !isEmpty(unpack_list) && (
<Card
title="unpack"
bodyStyle={{
maxHeight: '300px',
overflowY: 'scroll',
paddingTop: 20,
overscrollBehavior: 'contain',
}}
>
{unpack_list.map(({ file, done }) => (
<pre key={file || done}>{file || done}</pre>
))}
</Card>
)}
{pack_list_path === unpack_path && !isEmpty(packList.data) && (
<Card title="压缩包内容">
{!isEmpty(packList.data?.data) && (
<Table
scroll={{ x: true }}
rowKey={({ file }) => file}
columns={[
{ key: 'file', dataIndex: 'file', title: 'file' },
{
key: 'size',
dataIndex: 'size',
title: 'size',
width: 100,
render: (size) => {
return <Bit>{size}</Bit>
},
},
{
key: 'sizeCompressed',
dataIndex: 'sizeCompressed',
title: 'sizeCompressed',
width: 150,
render: (size) => {
return <Bit>{size}</Bit>
},
},
{
key: 'datetime',
dataIndex: 'datetime',
title: 'datetime',
width: 180,
render: (date) => <DateFormat>{new Date(date).getTime()}</DateFormat>,
},
]}
dataSource={packList.data?.data}
/>
)}
{packList.data?.message && <p>{packList.data?.message}</p>}
</Card>
)}
</Space>
</Modal>
)
}
export default UnpackModal
测试用逐字输出
每秒往客户端输出当前时间。持续 10 分钟。
import { iteratorToStream, nodeStreamToIterator } from '@/explorer-manager/src/main.mjs'
function sleep(time: number) {
return new Promise((resolve) => {
setTimeout(resolve, time)
})
}
const encoder = new TextEncoder()
async function* makeIterator() {
let length = 0
while (length > 60 * 10) {
await sleep(1000)
yield encoder.encode(`<p>${length} ${new Date().toLocaleString()}</p>`)
length += 1
}
}
export async function POST() {
return new Response(iteratorToStream(nodeStreamToIterator(makeIterator())), {
headers: { 'Content-Type': 'application/octet-stream' },
})
}
export async function GET() {
return new Response(iteratorToStream(nodeStreamToIterator(makeIterator())), {
headers: { 'Content-Type': 'html' },
})
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。