fix: improve cloud mount scanning feedback

This commit is contained in:
ShukeBta
2026-06-10 18:46:30 +08:00
parent 6a892d6549
commit 0bf983cec8
19 changed files with 1414 additions and 119 deletions
+121 -10
View File
@@ -1,7 +1,8 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Upload } from 'lucide-react'
import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Trash2, Upload } from 'lucide-react'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import {
cloudAPI,
storageAPI,
@@ -9,6 +10,8 @@ import {
type QRSession,
type StorageType,
} from '../api/storage_config'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library } from '../types'
const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2', 'openlist']
const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t)
@@ -21,6 +24,46 @@ const TYPE_LABEL: Record<string, string> = {
quark: '夸克网盘',
clouddrive2: 'CloudDrive2',
}
const PATH_BASED_CLOUD = new Set<StorageType>(['openlist', 'clouddrive2'])
function normalizeCloudDisplayPath(value: string) {
let text = value.trim()
try {
text = decodeURIComponent(text)
} catch {
// Keep the original value if it is not URI-encoded.
}
return text.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
}
function cloudLibraryProvider(path: string) {
if (!path.toLowerCase().startsWith('cloud://')) return ''
try {
return new URL(path).host
} catch {
return ''
}
}
function cloudLibraryLabel(path: string) {
try {
const parsed = new URL(path)
const display = normalizeCloudDisplayPath(parsed.pathname)
const scanDir = normalizeCloudDisplayPath(parsed.searchParams.get('dir') ?? '')
return display || scanDir || '根目录'
} catch {
return path
}
}
function cloudMountDisplayPath(type: StorageType, stack: { id: string; name: string }[], child?: CloudEntry) {
if (PATH_BASED_CLOUD.has(type)) {
return normalizeCloudDisplayPath(child?.id ?? stack[stack.length - 1]?.id ?? '')
}
const parts = stack.slice(1).map((item) => item.name).filter(Boolean)
if (child?.name) parts.push(child.name)
return parts.map(normalizeCloudDisplayPath).filter(Boolean).join('/')
}
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
// the import / playback / STRM subsystems. Mirrors the Vue UI's
@@ -433,6 +476,7 @@ function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: str
function CloudBrowser({ type }: { type: StorageType }) {
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
const [items, setItems] = useState<CloudEntry[]>([])
const [mounts, setMounts] = useState<Library[]>([])
const [loading, setLoading] = useState(false)
const [mounting, setMounting] = useState(false)
const [batchMounting, setBatchMounting] = useState(false)
@@ -455,13 +499,39 @@ function CloudBrowser({ type }: { type: StorageType }) {
}
}
const loadMounts = async () => {
const libs = await libraryAPI.list({ includeHidden: true })
setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type))
}
useEffect(() => {
load(cur.id).catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stack.length, type])
useEffect(() => {
loadMounts().catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type])
const enter = (e: CloudEntry) => setStack((s) => [...s, { id: e.id, name: e.name }])
const goTo = (i: number) => setStack((s) => s.slice(0, i + 1))
const currentMountPath = () => cloudMountDisplayPath(type, stack)
const childMountPath = (child: CloudEntry) => cloudMountDisplayPath(type, stack, child)
const handleMountResult = (res: unknown, label: string) => {
const out = res as { already_mounted?: boolean; skipped?: boolean; reason?: string; library?: Library; message?: string; estimate_message?: string }
if (out.skipped) {
toast(`已跳过「${label}」:和已挂载目录重叠`)
return 'skipped'
}
if (out.already_mounted) {
toast(`「${label}」已经挂载,后台会刷新扫描并自动入库`)
return 'mounted'
}
toast.success(`已挂载「${label}」,${out.message ?? '后台会递归扫描并自动加入媒体库'}。${out.estimate_message ?? ''}`)
return 'mounted'
}
const doImport = async (e: CloudEntry) => {
const ref = type === 'cloud115' ? e.pick_code || e.id : e.id
@@ -478,9 +548,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
try {
const label = TYPE_LABEL[type] ?? type
const name = cur.id ? `${label} · ${cur.name}` : label
const res = await cloudAPI.mount(type, cur.id, name, mountMediaType)
const scan = (res as { scan?: { added?: number; updated?: number; removed?: number; visited?: number } }).scan
toast.success(`已挂载为媒体库并扫描:新增 ${scan?.added ?? 0} · 更新 ${scan?.updated ?? 0} · 访问 ${scan?.visited ?? 0}`)
const res = await cloudAPI.mount(type, cur.id, name, mountMediaType, currentMountPath())
handleMountResult(res, cur.name)
await loadMounts()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败')
} finally {
@@ -496,26 +566,64 @@ function CloudBrowser({ type }: { type: StorageType }) {
}
setBatchMounting(true)
let ok = 0
let skipped = 0
let failed = 0
const label = TYPE_LABEL[type] ?? type
for (const dir of dirs) {
try {
await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto')
ok += 1
const result = await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto', childMountPath(dir))
const state = handleMountResult(result, dir.name)
if (state === 'skipped') skipped += 1
else ok += 1
} catch {
failed += 1
}
}
if (failed > 0) {
toast.error(`已挂载 ${ok} 个目录,失败 ${failed} 个;请查看日志或逐个挂载定位`)
toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`)
} else {
toast.success(`已挂载 ${ok} 个目录为媒体库,后台会自动生成 302/STRM 播放入口`)
toast.success(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,后台会自动生成 302/STRM 播放入口`)
}
await loadMounts()
setBatchMounting(false)
}
const removeMount = async (lib: Library) => {
const ok = await confirmAction({
title: '移除网盘挂载',
message: `仅移除「${lib.name}」在本项目中的媒体库和媒体记录,不会删除网盘文件。`,
confirmText: '移除',
})
if (!ok) return
await libraryAPI.remove(lib.id)
toast.success('已移除挂载')
await loadMounts()
}
return (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
{mounts.length > 0 && (
<div className="mb-3 rounded border border-blue-100 bg-blue-50/60 p-2">
<div className="mb-1 text-xs font-semibold text-ink-100">已挂载目录</div>
<div className="space-y-1">
{mounts.map((lib) => (
<div key={lib.id} className="flex items-center gap-2 rounded bg-white/80 px-2 py-1 text-xs">
<span className="min-w-0 flex-1 truncate text-ink-100">
{lib.name} · {cloudLibraryLabel(lib.path)}
</span>
<button
type="button"
className="rounded border border-red-200 px-1.5 py-0.5 text-red-500 hover:bg-red-50"
onClick={() => removeMount(lib)}
title="移除挂载"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
</div>
)}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1 text-xs text-ink-50">
<span className="text-ink-100">网盘资源:</span>
@@ -528,6 +636,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
</span>
))}
</div>
<p className="basis-full text-xs text-ink-50">
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
</p>
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-ink-100"
@@ -547,7 +658,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
disabled={mounting || batchMounting || loading}
onClick={mountCurrent}
>
{mounting ? '挂载扫描中…' : '挂载当前目录为媒体库'}
{mounting ? '挂载中…' : '挂载当前目录为媒体库并递归扫描'}
</button>
<button
type="button"
@@ -555,7 +666,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
disabled={mounting || batchMounting || loading || items.every((item) => !item.is_dir)}
onClick={mountVisibleDirectories}
>
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下文件夹'}
{batchMounting ? '批量挂载中…' : '一键把当前目录下所有文件夹挂载为媒体库'}
</button>
</div>
</div>