mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 20:46:37 +08:00
fix: improve cloud mount scanning feedback
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user