mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
Fix organize targets and cloud transfer controls
This commit is contained in:
@@ -36,13 +36,14 @@ export interface CloudUploadResult {
|
||||
source_path: string
|
||||
dest_path: string
|
||||
uploaded: number
|
||||
moved?: number
|
||||
skipped: number
|
||||
bytes: number
|
||||
errors?: string[]
|
||||
items?: Array<{
|
||||
source: string
|
||||
target: string
|
||||
action: 'upload' | 'skip' | 'error'
|
||||
action: 'upload' | 'move' | 'skip' | 'error'
|
||||
size?: number
|
||||
reason?: string
|
||||
}>
|
||||
@@ -103,6 +104,7 @@ export const storageAPI = {
|
||||
recursive: boolean
|
||||
include_sidecars: boolean
|
||||
overwrite: boolean
|
||||
transfer_mode?: 'copy' | 'move'
|
||||
},
|
||||
) =>
|
||||
api
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface BackgroundTask {
|
||||
dest_path?: string
|
||||
message?: string
|
||||
error?: string
|
||||
details?: string[]
|
||||
metrics?: Record<string, number>
|
||||
started_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -107,6 +107,10 @@ function settingOn(value: string): boolean {
|
||||
return ['1', 'true', 'yes', 'on', 'enabled', '启用', '开启'].includes(value.trim().toLowerCase())
|
||||
}
|
||||
|
||||
function isCloudLibraryPath(value: string): boolean {
|
||||
return value.trim().toLowerCase().startsWith('cloud://')
|
||||
}
|
||||
|
||||
// FileManagerPage provides a focused local storage view:
|
||||
// browse allowed roots, optionally recurse, and perform safe local operations.
|
||||
export function FileManagerPage() {
|
||||
@@ -145,6 +149,10 @@ export function FileManagerPage() {
|
||||
if (data?.path) return data.path
|
||||
return path
|
||||
}, [data?.path, path])
|
||||
const localLibraries = useMemo(
|
||||
() => libraries.filter((library) => !isCloudLibraryPath(library.path)),
|
||||
[libraries],
|
||||
)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
@@ -193,11 +201,15 @@ export function FileManagerPage() {
|
||||
}, [path])
|
||||
|
||||
useEffect(() => {
|
||||
const lib = libraries.find((item) => item.id === organizeLibraryID)
|
||||
if (!lib) return
|
||||
if (!organizeLibraryID) return
|
||||
const lib = localLibraries.find((item) => item.id === organizeLibraryID)
|
||||
if (!lib) {
|
||||
setOrganizeLibraryID('')
|
||||
return
|
||||
}
|
||||
setOrganizeDestPath(lib.path)
|
||||
setOrganizeMediaType(lib.type || 'auto')
|
||||
}, [libraries, organizeLibraryID])
|
||||
}, [localLibraries, organizeLibraryID])
|
||||
|
||||
const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => {
|
||||
setAutoConfig((current) => ({ ...current, [key]: value }))
|
||||
@@ -551,12 +563,15 @@ export function FileManagerPage() {
|
||||
onChange={(event) => setOrganizeLibraryID(event.target.value)}
|
||||
>
|
||||
<option value="">手动填写目的路径</option>
|
||||
{libraries.map((library) => (
|
||||
{localLibraries.map((library) => (
|
||||
<option key={library.id} value={library.id}>
|
||||
{library.name}({library.type})— {library.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[11px] text-sand-500">
|
||||
手动整理只写入本地可写媒体库;网盘请到“外部存储”中挂载、扫描或转存。
|
||||
</span>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">目的路径</span>
|
||||
|
||||
@@ -325,7 +325,7 @@ const GROUPS: SettingGroup[] = [
|
||||
key: 'cloud.upload_auto_enabled',
|
||||
label: '启用自动转存',
|
||||
type: 'toggle',
|
||||
hint: '开启后后台会按间隔扫描本地源目录,把视频、NFO、海报、字幕复制到目标存储;不会删除本地源文件。',
|
||||
hint: '开启后后台会按间隔扫描本地源目录,把视频、NFO、海报、字幕转存到目标存储;还需要在外部存储页开启该目标的“允许转存写入”。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
@@ -373,6 +373,17 @@ const GROUPS: SettingGroup[] = [
|
||||
type: 'toggle',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_transfer_mode',
|
||||
label: '自动转存方式',
|
||||
type: 'select',
|
||||
defaultValue: 'copy',
|
||||
hint: '复制会保留本地源文件;移动只在上传成功后删除本地文件。',
|
||||
options: [
|
||||
{ value: 'copy', label: '复制' },
|
||||
{ value: 'move', label: '移动' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_interval_seconds',
|
||||
label: '自动转存间隔秒数',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Cloud, FileVideo, Folder, Loader2, LogOut, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react'
|
||||
import { Cloud, Copy, FileVideo, Folder, Loader2, LogOut, Move, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
@@ -26,6 +26,7 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
clouddrive2: 'CloudDrive2',
|
||||
}
|
||||
const PATH_BASED_CLOUD = new Set<StorageType>(['openlist', 'clouddrive2'])
|
||||
const TRANSFER_SUPPORTED_TYPES = new Set<StorageType>(['alist', 'openlist', 'webdav', 'clouddrive2'])
|
||||
|
||||
function normalizeCloudDisplayPath(value: string) {
|
||||
let text = value.trim()
|
||||
@@ -151,6 +152,7 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
|
||||
|
||||
function StorageForm({ type }: { type: StorageType }) {
|
||||
const fields = useMemo(() => FIELD_DEFS[type], [type])
|
||||
const transferSupported = TRANSFER_SUPPORTED_TYPES.has(type)
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -163,6 +165,9 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
try {
|
||||
const r = await storageAPI.get(type)
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(r.config ?? {})) {
|
||||
next[key] = value === '********' ? '' : String(value ?? '')
|
||||
}
|
||||
for (const f of fields) {
|
||||
const v = r.config?.[f.key]
|
||||
// List() redacts secrets to "********"; show empty so the user
|
||||
@@ -221,14 +226,14 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
const onLogout = async () => {
|
||||
const ok = await confirmAction({
|
||||
title: '退出云盘登录',
|
||||
message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储;不会删除网盘文件,也不会删除已挂载媒体库。`,
|
||||
message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储,同时移除本项目中的对应网盘挂载和媒体记录;不会删除网盘里的真实文件。`,
|
||||
confirmText: '退出登录',
|
||||
})
|
||||
if (!ok) return
|
||||
setLoggingOut(true)
|
||||
try {
|
||||
await storageAPI.logout(type)
|
||||
toast.success('已退出云盘登录并停用该存储')
|
||||
toast.success('已退出云盘登录、停用该存储并清理本项目挂载')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
@@ -280,6 +285,39 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
{transferSupported && (
|
||||
<div className="rounded-xl border border-blue-100 bg-blue-50/60 p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-ink-600">转存写入权限</div>
|
||||
<p className="mb-3 text-xs text-ink-50">
|
||||
挂载/扫描只需要读取;只有手动开启这里,才允许把本地文件转存写入到该外部存储。可随时关闭。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-ink-100">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={(config.transfer_enabled ?? '').toLowerCase() === 'true'}
|
||||
onChange={(event) => setConfig((current) => ({ ...current, transfer_enabled: event.target.checked ? 'true' : 'false' }))}
|
||||
/>
|
||||
允许转存写入
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>转存方式</span>
|
||||
<select
|
||||
className="input-base w-32"
|
||||
value={config.transfer_mode === 'move' ? 'move' : 'copy'}
|
||||
onChange={(event) => setConfig((current) => ({ ...current, transfer_mode: event.target.value }))}
|
||||
>
|
||||
<option value="copy">复制</option>
|
||||
<option value="move">移动</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-ink-50">
|
||||
复制保留本地源文件;移动只在上传成功后删除本地文件。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{isCloud(type) && (
|
||||
<button
|
||||
@@ -306,13 +344,17 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<StorageUploadPanel type={type} />
|
||||
<StorageUploadPanel
|
||||
type={type}
|
||||
transferEnabled={(config.transfer_enabled ?? '').toLowerCase() === 'true'}
|
||||
transferMode={config.transfer_mode === 'move' ? 'move' : 'copy'}
|
||||
/>
|
||||
{isCloud(type) && <CloudBrowser type={type} />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function StorageUploadPanel({ type }: { type: StorageType }) {
|
||||
function StorageUploadPanel({ type, transferEnabled, transferMode }: { type: StorageType; transferEnabled: boolean; transferMode: 'copy' | 'move' }) {
|
||||
const [sourcePath, setSourcePath] = useState('')
|
||||
const [destPath, setDestPath] = useState('/MediaStationGo')
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
@@ -320,7 +362,7 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const supported = type === 'alist' || type === 'openlist' || type === 'webdav' || type === 'clouddrive2'
|
||||
const supported = TRANSFER_SUPPORTED_TYPES.has(type)
|
||||
|
||||
const submit = async () => {
|
||||
if (!supported) {
|
||||
@@ -331,6 +373,10 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
|
||||
toast.error('请填写本地源目录或文件路径')
|
||||
return
|
||||
}
|
||||
if (!transferEnabled) {
|
||||
toast.error('请先开启“允许转存写入”并保存该外部存储配置')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const { result, error } = await storageAPI.uploadLocal(type, {
|
||||
@@ -339,9 +385,11 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
|
||||
recursive,
|
||||
include_sidecars: includeSidecars,
|
||||
overwrite,
|
||||
transfer_mode: transferMode,
|
||||
})
|
||||
const errText = error || (result.errors && result.errors.length > 0 ? ` · 错误 ${result.errors.length}` : '')
|
||||
toast.success(`转存完成:上传 ${result.uploaded} · 跳过 ${result.skipped} · ${fmtBytes(result.bytes)}${errText}`)
|
||||
const movedText = result.moved ? ` · 移动 ${result.moved}` : ''
|
||||
toast.success(`转存完成:上传 ${result.uploaded}${movedText} · 跳过 ${result.skipped} · ${fmtBytes(result.bytes)}${errText}`)
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转存失败')
|
||||
} finally {
|
||||
@@ -354,10 +402,12 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 font-display text-base font-semibold text-ink-600">
|
||||
<Upload size={16} /> 本地媒体转存到此存储
|
||||
{transferMode === 'move' ? <Move size={16} /> : <Copy size={16} />} 本地媒体转存到此存储
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-ink-50">
|
||||
复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。
|
||||
{transferMode === 'move'
|
||||
? '移动模式会先上传到外部存储,上传成功后才删除本地源文件;远端已存在且未覆盖时不会删除本地。'
|
||||
: '复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。'}
|
||||
</p>
|
||||
</div>
|
||||
{!supported && (
|
||||
@@ -414,9 +464,9 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={overwrite} onChange={(event) => setOverwrite(event.target.checked)} />
|
||||
覆盖远端同名文件
|
||||
</label>
|
||||
<button type="button" className="neon-button ml-auto" disabled={busy || !supported} onClick={submit}>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
|
||||
{busy ? '转存中…' : '开始转存'}
|
||||
<button type="button" className="neon-button ml-auto" disabled={busy || !supported || !transferEnabled} onClick={submit}>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : transferMode === 'move' ? <Move size={16} /> : <Upload size={16} />}
|
||||
{busy ? '转存中…' : transferMode === 'move' ? '开始移动转存' : '开始复制转存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,6 +94,15 @@ function BackgroundTaskTable({ tasks, empty }: { tasks: BackgroundTask[]; empty:
|
||||
{formatMetrics(task.metrics) && (
|
||||
<div className="mt-1 text-xs text-sand-500">{formatMetrics(task.metrics)}</div>
|
||||
)}
|
||||
{task.details && task.details.length > 0 && (
|
||||
<div className="mt-2 max-h-28 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 font-mono text-[11px] text-sand-600">
|
||||
{task.details.map((line, index) => (
|
||||
<div key={`${task.id}-detail-${index}`} className="break-all">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-ink-100">
|
||||
{new Date(task.finished_at || task.updated_at || task.started_at).toLocaleTimeString()}
|
||||
|
||||
Reference in New Issue
Block a user