mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-10-13 10:14:26 +00:00
feat(web): tree batch auto-expand with depth limit and view icons
勾选文件树文件夹时自动展开并懒加载子节点,按最大深度(默认 4,勾选文件夹为第 0 层)递归收集可下载文件;触及上限时中文提示部分文件已跳过。树侧栏顶部增加显眼的批量下载入口。Home/ShowList 窗格与文件树切换增加 Font Awesome 图标。 Co-authored-by: qaiu <[email protected]>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 文件树批量勾选:按深度递归展开并收集可下载文件。
|
||||
*
|
||||
* Depth convention (checked folder = depth 0):
|
||||
* - 用户勾选的文件夹为第 0 层。
|
||||
* - 其直接子项为第 1 层。
|
||||
* - 当 depth < maxDepth 时继续展开子文件夹;默认 maxDepth = 4,
|
||||
* 即最多再向下展开 4 层(第 0/1/2/3 层会 load,第 4 层文件夹不再展开)。
|
||||
* - 触及上限时若仍有未加载/剩余子文件夹,标记 depthExceeded,由 UI 提示跳过。
|
||||
*/
|
||||
|
||||
const DEFAULT_BATCH_MAX_DEPTH = 4
|
||||
|
||||
function getTreeNodeId(item) {
|
||||
if (!item) return ''
|
||||
if (item.id) return String(item.id)
|
||||
if (item.fileId) return `fid:${item.fileId}`
|
||||
if (item.parserUrl) return `url:${item.parserUrl}`
|
||||
return `name:${item.fileName || ''}:${item.fileType || ''}`
|
||||
}
|
||||
|
||||
function normalizeTreeItem(item) {
|
||||
if (!item || typeof item !== 'object') return item
|
||||
return {
|
||||
...item,
|
||||
id: getTreeNodeId(item),
|
||||
isLeaf: item.fileType !== 'folder'
|
||||
}
|
||||
}
|
||||
|
||||
function fileIdentity(file) {
|
||||
return `${file?.parserUrl || ''}::${file?.fileName || ''}::${file?.fileId || file?.id || ''}`
|
||||
}
|
||||
|
||||
function uniqueFiles(files) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const file of files || []) {
|
||||
const key = fileIdentity(file)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(file)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isFolderNode(data) {
|
||||
return !!(data && data.fileType === 'folder')
|
||||
}
|
||||
|
||||
function collectAlreadyLoadedFiles(node, isDownloadable, ctx) {
|
||||
for (const child of node.childNodes || []) {
|
||||
if (isDownloadable(child.data)) {
|
||||
ctx.files.push(child.data)
|
||||
} else if (isFolderNode(child.data) && child.loaded) {
|
||||
collectAlreadyLoadedFiles(child, isDownloadable, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnexploredFolders(node) {
|
||||
if (!node) return false
|
||||
if (isFolderNode(node.data) && !node.loaded) return true
|
||||
return (node.childNodes || []).some((child) => {
|
||||
if (!isFolderNode(child.data)) return false
|
||||
if (!child.loaded) return true
|
||||
return hasUnexploredFolders(child)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归展开文件夹并收集可下载文件(对接 el-tree node / loadNode)。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.folderData 勾选的文件夹 data(depth 从 0 计)
|
||||
* @param {number} [opts.depth=0]
|
||||
* @param {number} opts.maxDepth
|
||||
* @param {(data: object) => any} opts.getNode el-tree getNode
|
||||
* @param {(node: any) => Promise<any>} opts.ensureLoaded 展开并触发懒加载
|
||||
* @param {(data: object) => boolean} opts.isDownloadable
|
||||
* @param {Set<string>} [opts.visitedKeys]
|
||||
* @param {(data: object) => string} [opts.getKey]
|
||||
* @param {{ files: object[], depthExceeded: boolean }} [opts.ctx]
|
||||
*/
|
||||
async function collectFolderFiles(opts) {
|
||||
const {
|
||||
folderData,
|
||||
depth = 0,
|
||||
maxDepth,
|
||||
getNode,
|
||||
ensureLoaded,
|
||||
isDownloadable,
|
||||
getKey = getTreeNodeId,
|
||||
visitedKeys = new Set(),
|
||||
ctx = { files: [], depthExceeded: false }
|
||||
} = opts
|
||||
|
||||
if (!folderData || typeof maxDepth !== 'number') return ctx
|
||||
|
||||
const key = getKey(folderData)
|
||||
if (key) visitedKeys.add(key)
|
||||
|
||||
const node = getNode(folderData)
|
||||
if (!node) return ctx
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
collectAlreadyLoadedFiles(node, isDownloadable, ctx)
|
||||
if (hasUnexploredFolders(node)) {
|
||||
ctx.depthExceeded = true
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureLoaded(node)
|
||||
} catch {
|
||||
// 单层加载失败:跳过该分支,不中断整次收集
|
||||
return ctx
|
||||
}
|
||||
|
||||
for (const child of node.childNodes || []) {
|
||||
const data = child.data
|
||||
if (!data) continue
|
||||
if (isDownloadable(data)) {
|
||||
ctx.files.push(data)
|
||||
} else if (isFolderNode(data)) {
|
||||
await collectFolderFiles({
|
||||
...opts,
|
||||
folderData: data,
|
||||
depth: depth + 1,
|
||||
visitedKeys,
|
||||
ctx
|
||||
})
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_BATCH_MAX_DEPTH,
|
||||
getTreeNodeId,
|
||||
normalizeTreeItem,
|
||||
fileIdentity,
|
||||
uniqueFiles,
|
||||
isFolderNode,
|
||||
collectFolderFiles
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
DEFAULT_BATCH_MAX_DEPTH,
|
||||
getTreeNodeId,
|
||||
normalizeTreeItem,
|
||||
fileIdentity,
|
||||
uniqueFiles,
|
||||
collectFolderFiles
|
||||
} = require('./batchTreeCollect.js')
|
||||
|
||||
function file(name, extra = {}) {
|
||||
return {
|
||||
fileName: name,
|
||||
fileType: 'file',
|
||||
parserUrl: `/v2/redirectUrl/demo/${name}`,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function folder(name, extra = {}) {
|
||||
return {
|
||||
fileName: name,
|
||||
fileType: 'folder',
|
||||
parserUrl: `/v2/getFileList?dir=${name}`,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(data, children = [], { loaded = true, expanded = true, failLoad = false } = {}) {
|
||||
const node = {
|
||||
data,
|
||||
loaded,
|
||||
expanded,
|
||||
childNodes: [],
|
||||
expand(cb) {
|
||||
if (failLoad) throw new Error('load failed')
|
||||
this.loaded = true
|
||||
this.expanded = true
|
||||
if (cb) cb()
|
||||
}
|
||||
}
|
||||
node.childNodes = children.map((child) => (child.data ? child : makeNode(child)))
|
||||
return node
|
||||
}
|
||||
|
||||
function createStore(rootNode) {
|
||||
const map = new Map()
|
||||
const walk = (node) => {
|
||||
map.set(getTreeNodeId(node.data), node)
|
||||
for (const child of node.childNodes || []) walk(child)
|
||||
}
|
||||
walk(rootNode)
|
||||
return {
|
||||
getNode(data) {
|
||||
return map.get(getTreeNodeId(data)) || null
|
||||
},
|
||||
async ensureLoaded(node) {
|
||||
if (node.loaded && node.expanded) return node
|
||||
await new Promise((resolve) => node.expand(resolve))
|
||||
for (const child of node.childNodes || []) {
|
||||
map.set(getTreeNodeId(child.data), child)
|
||||
}
|
||||
return node
|
||||
},
|
||||
isDownloadable(data) {
|
||||
return !!(data && data.parserUrl && data.fileType !== 'folder' && data.fileType !== 'url')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('batchTreeCollect helpers', () => {
|
||||
it('uses checked folder as depth 0 and defaults max depth to 4', () => {
|
||||
assert.equal(DEFAULT_BATCH_MAX_DEPTH, 4)
|
||||
assert.equal(getTreeNodeId({ id: 'root' }), 'root')
|
||||
assert.equal(normalizeTreeItem({ fileId: 'abc', fileType: 'folder' }).id, 'fid:abc')
|
||||
assert.equal(normalizeTreeItem({ fileId: 'abc', fileType: 'folder' }).isLeaf, false)
|
||||
})
|
||||
|
||||
it('deduplicates files by parserUrl + name + id', () => {
|
||||
const a = file('a.txt', { fileId: '1' })
|
||||
const dup = file('a.txt', { fileId: '1' })
|
||||
const b = file('b.txt', { fileId: '2' })
|
||||
assert.equal(fileIdentity(a), fileIdentity(dup))
|
||||
assert.deepEqual(uniqueFiles([a, dup, b]).map((f) => f.fileName), ['a.txt', 'b.txt'])
|
||||
})
|
||||
|
||||
it('collects current-level files and recurses into folders', async () => {
|
||||
const leafA = file('a.txt')
|
||||
const leafB = file('b.txt')
|
||||
const nested = folder('nested')
|
||||
const root = folder('root')
|
||||
const store = createStore(makeNode(root, [
|
||||
makeNode(leafA),
|
||||
makeNode(nested, [makeNode(leafB)])
|
||||
]))
|
||||
|
||||
const ctx = await collectFolderFiles({
|
||||
folderData: root,
|
||||
maxDepth: 4,
|
||||
...store
|
||||
})
|
||||
assert.equal(ctx.depthExceeded, false)
|
||||
assert.deepEqual(ctx.files.map((f) => f.fileName).sort(), ['a.txt', 'b.txt'])
|
||||
})
|
||||
|
||||
it('stops expanding at maxDepth and warns when remaining folders exist', async () => {
|
||||
// depth 0: root
|
||||
// depth 1: d1
|
||||
// depth 2: d2
|
||||
// depth 3: d3
|
||||
// depth 4: d4 (cap) — still has child folder d5
|
||||
const deepFile = file('deep.txt')
|
||||
const skipped = file('skipped.txt')
|
||||
const d5 = folder('d5')
|
||||
const d4 = folder('d4')
|
||||
const d3 = folder('d3')
|
||||
const d2 = folder('d2')
|
||||
const d1 = folder('d1')
|
||||
const root = folder('root')
|
||||
|
||||
const d5Node = makeNode(d5, [makeNode(skipped)], { loaded: false, expanded: false })
|
||||
const d4Node = makeNode(d4, [makeNode(deepFile), d5Node], { loaded: true })
|
||||
const tree = makeNode(root, [
|
||||
makeNode(d1, [
|
||||
makeNode(d2, [
|
||||
makeNode(d3, [d4Node])
|
||||
])
|
||||
])
|
||||
])
|
||||
const store = createStore(tree)
|
||||
|
||||
let loadedIds = []
|
||||
const ctx = await collectFolderFiles({
|
||||
folderData: root,
|
||||
maxDepth: 4,
|
||||
getNode: store.getNode,
|
||||
ensureLoaded: async (node) => {
|
||||
loadedIds.push(node.data.fileName)
|
||||
return store.ensureLoaded(node)
|
||||
},
|
||||
isDownloadable: store.isDownloadable
|
||||
})
|
||||
|
||||
assert.equal(ctx.depthExceeded, true)
|
||||
assert.deepEqual(ctx.files.map((f) => f.fileName), ['deep.txt'])
|
||||
assert.ok(!ctx.files.some((f) => f.fileName === 'skipped.txt'))
|
||||
assert.ok(!loadedIds.includes('d4'), 'depth-4 folder must not be expanded')
|
||||
assert.ok(loadedIds.includes('root'))
|
||||
assert.ok(loadedIds.includes('d3'))
|
||||
})
|
||||
|
||||
it('collects already-loaded files at the depth cap without expanding further', async () => {
|
||||
const capFile = file('cap.txt')
|
||||
const inner = folder('inner')
|
||||
const cap = folder('cap')
|
||||
const root = folder('root')
|
||||
const tree = makeNode(root, [
|
||||
makeNode(cap, [
|
||||
makeNode(capFile),
|
||||
makeNode(inner, [makeNode(file('too-deep.txt'))], { loaded: false })
|
||||
])
|
||||
])
|
||||
const store = createStore(tree)
|
||||
const ctx = await collectFolderFiles({
|
||||
folderData: root,
|
||||
maxDepth: 1,
|
||||
...store
|
||||
})
|
||||
assert.equal(ctx.depthExceeded, true)
|
||||
assert.deepEqual(ctx.files.map((f) => f.fileName), ['cap.txt'])
|
||||
})
|
||||
|
||||
it('skips a branch when ensureLoaded throws', async () => {
|
||||
const ok = file('ok.txt')
|
||||
const badFolder = folder('bad')
|
||||
const root = folder('root')
|
||||
const badNode = makeNode(badFolder, [makeNode(file('lost.txt'))], { loaded: false, failLoad: true })
|
||||
const tree = makeNode(root, [makeNode(ok), badNode])
|
||||
const store = createStore(tree)
|
||||
const ctx = await collectFolderFiles({
|
||||
folderData: root,
|
||||
maxDepth: 4,
|
||||
getNode: store.getNode,
|
||||
ensureLoaded: async (node) => {
|
||||
if (node.data.fileName === 'bad') throw new Error('network')
|
||||
return store.ensureLoaded(node)
|
||||
},
|
||||
isDownloadable: store.isDownloadable
|
||||
})
|
||||
assert.deepEqual(ctx.files.map((f) => f.fileName), ['ok.txt'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user