feat(web): 存储设置与文件管理的 Alist 存储切换
- 设置 → 媒体库与目录 → 存储:登记 Alist / OpenList(令牌 / 账号密码 / 访客、路径密码、限速与监控选项), 测试连接显示版本、用户与容量;媒体库目录可选存储 - 文件管理左栏多一组「Alist 存储」,路径显示「存储名:/路径」;移动 / 复制目标可选 Alist 存储; 上传按钮;Alist 根点刷新才让 Alist 刷新目录缓存 - 运行状态的挂载根列出存储实例(令牌失效单独提示) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
c1bc389058
commit
cf364435eb
18 files changed
+618
-41
No files matched your search
@@ -50,7 +50,7 @@ import {
|
|||||||
type TmdbCandidate,
|
type TmdbCandidate,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type { FetcherArg } from "~/lib/api.ts";
|
import { apiData, type FetcherArg } from "~/lib/api.ts";
|
||||||
import { ApiRequestError, apiParsed } from "./parse.ts";
|
import { ApiRequestError, apiParsed } from "./parse.ts";
|
||||||
|
|
||||||
// 文件管理(W5-A:/files*;说明 docs/runbook/files.md,形状取 contracts/files.ts)。
|
// 文件管理(W5-A:/files*;说明 docs/runbook/files.md,形状取 contracts/files.ts)。
|
||||||
@@ -402,6 +402,20 @@ function useFilesMutation<B, T>(fn: (b: B) => Promise<T>, opts: { invalidate?: (
|
|||||||
const notDry = (b: { dryRun?: boolean }) => b.dryRun === false;
|
const notDry = (b: { dryRun?: boolean }) => b.dryRun === false;
|
||||||
|
|
||||||
export const useMkdir = () => useFilesMutation((b: FilesMkdirRequest) => post(FilesMkdirResult, "/files/mkdir", b));
|
export const useMkdir = () => useFilesMutation((b: FilesMkdirRequest) => post(FilesMkdirResult, "/files/mkdir", b));
|
||||||
|
|
||||||
|
/** 上传一个文件到目录(请求体是文件字节;同名不覆盖,Alist 根经服务端转传) */
|
||||||
|
export const useUpload = () =>
|
||||||
|
useFilesMutation((b: { root: string; path: string; file: File }) =>
|
||||||
|
apiData<{ path: string; size: number }>("/files/upload", {
|
||||||
|
method: "PUT",
|
||||||
|
params: { root: b.root, path: b.path, name: b.file.name, idempotencyKey: crypto.randomUUID() },
|
||||||
|
body: b.file,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Alist 根「刷新」:让 Alist 刷新目录缓存(穿透到网盘,只在用户点刷新时做) */
|
||||||
|
export const refreshRemoteDir = (root: string, path: string) =>
|
||||||
|
apiData("/files", { params: { root, path, refresh: "true", limit: 1 } });
|
||||||
export const useRename = () =>
|
export const useRename = () =>
|
||||||
useFilesMutation((b: FilesRenameRequest) => post(FilesRenameResult, "/files/rename", b), { invalidate: notDry });
|
useFilesMutation((b: FilesRenameRequest) => post(FilesRenameResult, "/files/rename", b), { invalidate: notDry });
|
||||||
export const useMove = () =>
|
export const useMove = () =>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import type {
|
|||||||
SiteCredentialsPut,
|
SiteCredentialsPut,
|
||||||
SiteCredentialsResult,
|
SiteCredentialsResult,
|
||||||
SiteUpdate,
|
SiteUpdate,
|
||||||
|
StorageInstance,
|
||||||
|
StorageInstanceCreate,
|
||||||
|
StorageInstanceUpdate,
|
||||||
|
StorageTestResult,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { apiData } from "~/lib/api.ts";
|
import { apiData } from "~/lib/api.ts";
|
||||||
import { type QueryArea, qk } from "~/lib/query-keys.ts";
|
import { type QueryArea, qk } from "~/lib/query-keys.ts";
|
||||||
@@ -71,6 +75,15 @@ function actionHook<R>(area: QueryArea, path: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alist / OpenList 存储实例 */
|
||||||
|
export const storages = {
|
||||||
|
...crudHooks<StorageInstance, StorageInstanceCreate, StorageInstanceUpdate>(
|
||||||
|
"storage-instances",
|
||||||
|
"/storage-instances",
|
||||||
|
),
|
||||||
|
useTest: actionHook<StorageTestResult>("storage-instances", "/storage-instances/{id}/test"),
|
||||||
|
};
|
||||||
|
|
||||||
export const downloaders = {
|
export const downloaders = {
|
||||||
...crudHooks<DownloaderInstance, DownloaderInstanceCreate, DownloaderInstanceUpdate>(
|
...crudHooks<DownloaderInstance, DownloaderInstanceCreate, DownloaderInstanceUpdate>(
|
||||||
"downloader-instances",
|
"downloader-instances",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
Undo2,
|
Undo2,
|
||||||
|
Upload,
|
||||||
Wand2,
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
useScrapeBatch,
|
useScrapeBatch,
|
||||||
useScrapeRecords,
|
useScrapeRecords,
|
||||||
useSuggestName,
|
useSuggestName,
|
||||||
|
useUpload,
|
||||||
} from "~/api/files.ts";
|
} from "~/api/files.ts";
|
||||||
import { useSettings } from "~/api/settings.ts";
|
import { useSettings } from "~/api/settings.ts";
|
||||||
import { FormField } from "~/components/console/form-field.tsx";
|
import { FormField } from "~/components/console/form-field.tsx";
|
||||||
@@ -58,6 +60,7 @@ import { DirPicker } from "./dir-picker.tsx";
|
|||||||
import {
|
import {
|
||||||
baseName,
|
baseName,
|
||||||
deleteBlockText,
|
deleteBlockText,
|
||||||
|
displayPath,
|
||||||
nameProblem,
|
nameProblem,
|
||||||
nameTaken,
|
nameTaken,
|
||||||
newIdempotencyKey,
|
newIdempotencyKey,
|
||||||
@@ -115,6 +118,46 @@ function useDebounced<T>(v: T, ms: number): T {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 上传 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传到当前目录(对照 MP 文件管理的上传;Alist 根经服务端转传):选多个文件逐个上传,同名不覆盖并提示。
|
||||||
|
* 只读目录与下载目录不能上传。
|
||||||
|
*/
|
||||||
|
export function UploadButton({ root, path }: { root: FileRoot; path: string }) {
|
||||||
|
const upload = useUpload();
|
||||||
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const disabled = !root.writable || busy !== null;
|
||||||
|
const run = async (files: FileList | null) => {
|
||||||
|
if (!files?.length) return;
|
||||||
|
let ok = 0;
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
setBusy(file.name);
|
||||||
|
try {
|
||||||
|
await upload.mutateAsync({ root: root.id, path, file });
|
||||||
|
ok++;
|
||||||
|
} catch (e) {
|
||||||
|
failed.push(`${file.name}:${filesErrorReason(e) === "exists" ? "同名已存在,没有覆盖" : filesErrorText(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBusy(null);
|
||||||
|
if (input.current) input.current.value = "";
|
||||||
|
if (ok) toast.success(`已上传 ${ok} 个文件到 ${displayPath(root, path)}`);
|
||||||
|
if (failed.length) toast.error(`${failed.length} 个文件没有上传`, { description: failed.join("\n") });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span title={root.writable ? undefined : "只读目录,不能上传"}>
|
||||||
|
<input ref={input} type="file" multiple hidden onChange={(e) => void run(e.currentTarget.files)} />
|
||||||
|
<Button variant="outline" size="sm" disabled={disabled} onClick={() => input.current?.click()}>
|
||||||
|
{busy ? <LoaderCircle className="animate-spin" /> : <Upload />}
|
||||||
|
{busy ? "上传中" : "上传"}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 新建目录 ───────────────────────────────────────────────────
|
// ─── 新建目录 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MkdirDialog({
|
export function MkdirDialog({
|
||||||
@@ -157,10 +200,7 @@ export function MkdirDialog({
|
|||||||
<form onSubmit={submit} className="grid gap-4">
|
<form onSubmit={submit} className="grid gap-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>新建目录</DialogTitle>
|
<DialogTitle>新建目录</DialogTitle>
|
||||||
<DialogDescription className="font-mono break-all">
|
<DialogDescription className="font-mono break-all">{displayPath(root, path)}/</DialogDescription>
|
||||||
{root.hostPath}
|
|
||||||
{path ? `/${path}` : ""}/
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FormField label="目录名" htmlFor="mkdir-name" error={err}>
|
<FormField label="目录名" htmlFor="mkdir-name" error={err}>
|
||||||
<Input
|
<Input
|
||||||
@@ -589,22 +629,22 @@ export function TransferDialog({
|
|||||||
<DirPicker
|
<DirPicker
|
||||||
roots={roots}
|
roots={roots}
|
||||||
value={target}
|
value={target}
|
||||||
|
includeStorage
|
||||||
onChange={(loc) => {
|
onChange={(loc) => {
|
||||||
setTarget(loc);
|
setTarget(loc);
|
||||||
runPreview(loc, mode);
|
runPreview(loc, mode);
|
||||||
}}
|
}}
|
||||||
className="max-h-72 md:max-h-96"
|
className="max-h-72 md:max-h-96"
|
||||||
/>
|
/>
|
||||||
<p className="text-meta text-muted-foreground">只能选可写媒体库目录下的目录。</p>
|
<p className="text-meta text-muted-foreground">
|
||||||
|
可选可写的媒体库目录或 Alist 存储;本机和 Alist 之间会上传或下载。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-3">
|
<div className="min-w-0 space-y-3">
|
||||||
{target && targetRoot ? (
|
{target && targetRoot ? (
|
||||||
<div className="rounded-xl border border-border px-3 py-2">
|
<div className="rounded-xl border border-border px-3 py-2">
|
||||||
<p className="text-meta text-muted-foreground">目标目录</p>
|
<p className="text-meta text-muted-foreground">目标目录</p>
|
||||||
<p className="font-mono text-caption break-all">
|
<p className="font-mono text-caption break-all">{displayPath(targetRoot, target.path)}</p>
|
||||||
{targetRoot.hostPath}
|
|
||||||
{target.path ? `/${target.path}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Notice tone="neutral">在左侧选择目标目录。</Notice>
|
<Notice tone="neutral">在左侧选择目标目录。</Notice>
|
||||||
@@ -669,8 +709,8 @@ export function TransferDialog({
|
|||||||
<span className="text-meta text-muted-foreground">{formatBytes(p.size, 1)}</span>
|
<span className="text-meta text-muted-foreground">{formatBytes(p.size, 1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<PathPair
|
<PathPair
|
||||||
from={`${sr?.hostPath ?? p.source.root}/${p.source.path}`}
|
from={sr ? displayPath(sr, p.source.path) : `${p.source.root}/${p.source.path}`}
|
||||||
to={`${tr?.hostPath ?? p.target.root}/${p.target.path}`}
|
to={tr ? displayPath(tr, p.target.path) : `${p.target.root}/${p.target.path}`}
|
||||||
tone={p.conflict ? "conflict" : undefined}
|
tone={p.conflict ? "conflict" : undefined}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -947,9 +987,9 @@ export function DeleteDialog({
|
|||||||
|
|
||||||
export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) {
|
export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) {
|
||||||
const stat = useFileStat(root.id, entry.path);
|
const stat = useFileStat(root.id, entry.path);
|
||||||
const local = `${root.hostPath}${entry.path ? `/${entry.path}` : ""}`;
|
const local = displayPath(root, entry.path);
|
||||||
const rows = [
|
const rows = [
|
||||||
{ label: "本机路径", value: local },
|
{ label: root.storage ? "Alist 路径" : "本机路径", value: local },
|
||||||
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
|
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
|
||||||
{ label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
|
{ label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import type { FileRef, FileRoot } from "~/api/files.ts";
|
|||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { DirTree } from "./dir-tree.tsx";
|
import { DirTree } from "./dir-tree.tsx";
|
||||||
|
|
||||||
|
/** 可选的目标根:可写、在线的媒体库;移动 / 复制还可以放进 Alist 存储(includeStorage),整理「指定目录」与还原只能选媒体库 */
|
||||||
|
export function pickableRoots<T extends Pick<FileRoot, "kind" | "writable" | "health">>(
|
||||||
|
roots: T[],
|
||||||
|
includeStorage = false,
|
||||||
|
): T[] {
|
||||||
|
return roots.filter(
|
||||||
|
(r) => (r.kind === "library" || (includeStorage && r.kind === "storage")) && r.writable && r.health !== "offline",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 目录选择器(§14 DirPicker):基于 DirTree,只能选可写库根下的目录。
|
* 目录选择器(§14 DirPicker):基于 DirTree,只能选可写库根下的目录。
|
||||||
* 移动/复制到…、回收站"还原到…"、整理向导"指定目录"共用。
|
* 移动/复制到…、回收站"还原到…"、整理向导"指定目录"共用。
|
||||||
@@ -11,13 +21,15 @@ export function DirPicker({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
|
includeStorage = false,
|
||||||
}: {
|
}: {
|
||||||
roots: FileRoot[];
|
roots: FileRoot[];
|
||||||
value: FileRef | null;
|
value: FileRef | null;
|
||||||
onChange: (ref: FileRef) => void;
|
onChange: (ref: FileRef) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
includeStorage?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const writable = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
|
const writable = pickableRoots(roots, includeStorage);
|
||||||
return (
|
return (
|
||||||
<div className={cn("overflow-y-auto rounded-xl border border-border p-1.5", className)}>
|
<div className={cn("overflow-y-auto rounded-xl border border-border p-1.5", className)}>
|
||||||
{writable.length ? (
|
{writable.length ? (
|
||||||
@@ -30,7 +42,9 @@ export function DirPicker({
|
|||||||
onSelect={onChange}
|
onSelect={onChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="px-2 py-3 text-caption text-muted-foreground">没有可写入的媒体库目录。先在设置里添加。</p>
|
<p className="px-2 py-3 text-caption text-muted-foreground">
|
||||||
|
没有可写入的{includeStorage ? "目录" : "媒体库目录"}。先在设置里添加。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert } from "lucide-react";
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Cloud,
|
||||||
|
Download,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
HardDrive,
|
||||||
|
LoaderCircle,
|
||||||
|
TriangleAlert,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { type FileRoot, useDirChildren } from "~/api/files.ts";
|
import { type FileRoot, useDirChildren } from "~/api/files.ts";
|
||||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||||
import { healthLabel } from "~/lib/labels.ts";
|
import { healthLabel } from "~/lib/labels.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { groupRoots, joinRel, segments } from "./model.ts";
|
import { displayPath, groupRoots, joinRel, segments } from "./model.ts";
|
||||||
|
|
||||||
type Loc = { root: string; path: string };
|
type Loc = { root: string; path: string };
|
||||||
type DropHandler = (target: Loc, e: React.DragEvent) => void;
|
type DropHandler = (target: Loc, e: React.DragEvent) => void;
|
||||||
@@ -115,7 +124,7 @@ function RootNode({
|
|||||||
const isOpen = open.has(k) && !offline;
|
const isOpen = open.has(k) && !offline;
|
||||||
const active = current?.root === root.id && current.path === "";
|
const active = current?.root === root.id && current.path === "";
|
||||||
const h = healthLabel(root.health);
|
const h = healthLabel(root.health);
|
||||||
const Icon = root.kind === "download" ? Download : HardDrive;
|
const Icon = root.kind === "download" ? Download : root.kind === "storage" ? Cloud : HardDrive;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<TreeRow
|
<TreeRow
|
||||||
@@ -131,7 +140,7 @@ function RootNode({
|
|||||||
if (!open.has(k)) toggle(k);
|
if (!open.has(k)) toggle(k);
|
||||||
}}
|
}}
|
||||||
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
|
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
|
||||||
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
|
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : displayPath(root)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
|||||||
import { EntryStatus } from "./file-list.tsx";
|
import { EntryStatus } from "./file-list.tsx";
|
||||||
import { FilePreviewSection } from "./file-preview.tsx";
|
import { FilePreviewSection } from "./file-preview.tsx";
|
||||||
import { IdentifyPanel } from "./identify-panel.tsx";
|
import { IdentifyPanel } from "./identify-panel.tsx";
|
||||||
import { baseName, previewKindOf } from "./model.ts";
|
import { baseName, displayPath, previewKindOf } from "./model.ts";
|
||||||
|
|
||||||
const ACTION_LABEL: Record<string, string> = {
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
copy: "复制",
|
copy: "复制",
|
||||||
@@ -122,8 +122,13 @@ export function FileDetail({
|
|||||||
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
|
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
{ label: "本机路径", value: `${root.hostPath}${e.path ? `/${e.path}` : ""}`, mono: true, wide: true },
|
{
|
||||||
...(stat.data && stat.data.realPath !== `${root.hostPath}/${e.path}`
|
label: root.storage ? "Alist 路径" : "本机路径",
|
||||||
|
value: displayPath(root, e.path),
|
||||||
|
mono: true,
|
||||||
|
wide: true,
|
||||||
|
},
|
||||||
|
...(stat.data && !root.storage && stat.data.realPath !== `${root.hostPath}/${e.path}`
|
||||||
? [{ label: "真实路径", value: stat.data.realPath, mono: true, wide: true }]
|
? [{ label: "真实路径", value: stat.data.realPath, mono: true, wide: true }]
|
||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -524,9 +524,19 @@ export function groupRoots<T extends Pick<FileRoot, "kind">>(
|
|||||||
return [
|
return [
|
||||||
{ title: "下载目录", kind: "download", items: roots.filter((r) => r.kind === "download") },
|
{ title: "下载目录", kind: "download", items: roots.filter((r) => r.kind === "download") },
|
||||||
{ title: "媒体库", kind: "library", items: roots.filter((r) => r.kind === "library") },
|
{ title: "媒体库", kind: "library", items: roots.filter((r) => r.kind === "library") },
|
||||||
|
{ title: "Alist 存储", kind: "storage", items: roots.filter((r) => r.kind === "storage") },
|
||||||
].filter((g) => g.items.length > 0);
|
].filter((g) => g.items.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 根 + 相对路径的显示:本机给本机路径;Alist 给「存储名:/Alist 内路径」(不暴露内部 /@alist 前缀) */
|
||||||
|
export function displayPath(root: Pick<FileRoot, "hostPath" | "storage">, rel = ""): string {
|
||||||
|
const base = root.storage
|
||||||
|
? `${root.storage.name}:${root.storage.path === "/" ? "" : root.storage.path}`
|
||||||
|
: root.hostPath;
|
||||||
|
const joined = `${base}${rel ? `/${rel}` : ""}`;
|
||||||
|
return root.storage && !rel && root.storage.path === "/" ? `${root.storage.name}:/` : joined;
|
||||||
|
}
|
||||||
|
|
||||||
/** 目标名在当前目录已存在(忽略自身;不区分大小写只作提示,服务端仍按字节判断) */
|
/** 目标名在当前目录已存在(忽略自身;不区分大小写只作提示,服务端仍按字节判断) */
|
||||||
export function nameTaken(name: string, self: string, siblings: string[]): boolean {
|
export function nameTaken(name: string, self: string, siblings: string[]): boolean {
|
||||||
const n = name.trim();
|
const n = name.trim();
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pickableRoots } from "./dir-picker.tsx";
|
||||||
|
import { breadcrumbItems, displayPath, groupRoots, initialLocation } from "./model.ts";
|
||||||
|
|
||||||
|
// 文件管理的存储切换(对照 MP 存储切换菜单):Alist 实例是单独一组根,路径显示「存储名:/路径」,
|
||||||
|
// 移动 / 复制的目标可以选 Alist 存储,整理「指定目录」与还原仍只能选媒体库。
|
||||||
|
|
||||||
|
const root = (id: string, kind: "library" | "download" | "storage", extra: Record<string, unknown> = {}) => ({
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
name: id,
|
||||||
|
hostPath: kind === "storage" ? `/@alist/00000000-0000-4000-8000-000000000001${extra.path ?? ""}` : `/data/${id}`,
|
||||||
|
writable: kind !== "download",
|
||||||
|
health: "healthy" as const,
|
||||||
|
storage:
|
||||||
|
kind === "storage"
|
||||||
|
? { id: "00000000-0000-4000-8000-000000000001", name: "OpenList", path: (extra.path as string) ?? "/" }
|
||||||
|
: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("文件管理:存储切换", () => {
|
||||||
|
it("分组顺序:下载目录 → 媒体库 → Alist 存储;没有的组不出现", () => {
|
||||||
|
const g = groupRoots([root("s", "storage"), root("lib", "library"), root("dl", "download")]);
|
||||||
|
expect(g.map((x) => x.title)).toEqual(["下载目录", "媒体库", "Alist 存储"]);
|
||||||
|
expect(groupRoots([root("lib", "library")]).map((x) => x.kind)).toEqual(["library"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("路径显示:本机原样;Alist 显示存储名与 Alist 内路径,不露出内部前缀", () => {
|
||||||
|
expect(displayPath(root("lib", "library"), "电影/A")).toBe("/data/lib/电影/A");
|
||||||
|
expect(displayPath(root("s", "storage", { path: "/115/影视" }), "电影")).toBe("OpenList:/115/影视/电影");
|
||||||
|
expect(displayPath(root("s", "storage"), "")).toBe("OpenList:/");
|
||||||
|
expect(displayPath(root("s", "storage"), "a/b")).toBe("OpenList:/a/b");
|
||||||
|
expect(displayPath(root("s", "storage"), "a")).not.toContain("@alist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("面包屑的组名:Alist 存储", () => {
|
||||||
|
expect(breadcrumbItems(root("s", "storage"), "a/b").group).toBe("Alist 存储");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("目标选择:移动 / 复制可选 Alist 存储;整理指定目录与还原只能选媒体库;离线与只读不可选", () => {
|
||||||
|
const roots = [
|
||||||
|
root("lib", "library"),
|
||||||
|
root("ro", "library", { writable: false }),
|
||||||
|
root("off", "library", { health: "offline" }),
|
||||||
|
root("s", "storage"),
|
||||||
|
root("dl", "download"),
|
||||||
|
];
|
||||||
|
expect(pickableRoots(roots).map((r) => r.id)).toEqual(["lib"]);
|
||||||
|
expect(pickableRoots(roots, true).map((r) => r.id)).toEqual(["lib", "s"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("初始位置:没有下载目录与媒体库时落到 Alist 存储", () => {
|
||||||
|
expect(initialLocation([root("s", "storage")], null)).toEqual({ root: "s", path: "" });
|
||||||
|
expect(initialLocation([root("s", "storage"), root("lib", "library")], null)).toEqual({ root: "lib", path: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,6 +96,7 @@ const NOUN: Record<string, string> = {
|
|||||||
downloader_instance: "下载器",
|
downloader_instance: "下载器",
|
||||||
media_server_instance: "媒体服务器",
|
media_server_instance: "媒体服务器",
|
||||||
path_mapping: "路径映射",
|
path_mapping: "路径映射",
|
||||||
|
storage_instance: "存储",
|
||||||
pi_upstream: "模型服务",
|
pi_upstream: "模型服务",
|
||||||
schedule: "定时任务",
|
schedule: "定时任务",
|
||||||
automation_grant: "持续授权",
|
automation_grant: "持续授权",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { PlugZap } from "lucide-react";
|
import { PlugZap } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { downloaders, libraryRoots, mediaServers } from "~/api/instances.ts";
|
import { downloaders, libraryRoots, mediaServers, storages } from "~/api/instances.ts";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
@@ -360,7 +360,25 @@ const MEDIA_KIND = [
|
|||||||
{ value: "other", label: "其它" },
|
{ value: "other", label: "其它" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 库根路径 ↔ 存储 + 路径:Alist 上的库根存成 /@alist/<存储 id>/<Alist 内路径> */
|
||||||
|
const ALIST_PATH = /^\/@alist\/([0-9a-f-]{36})(\/.*)?$/i;
|
||||||
|
export function splitRootPath(p: string): { storage: string; path: string } {
|
||||||
|
const m = ALIST_PATH.exec(p);
|
||||||
|
return m ? { storage: m[1]!.toLowerCase(), path: m[2] || "/" } : { storage: "local", path: p };
|
||||||
|
}
|
||||||
|
export function joinRootPath(storage: string, path: string): string {
|
||||||
|
if (storage === "local") return path;
|
||||||
|
const p = `/${path.trim()}`.replace(/\/+/g, "/").replace(/\/$/, "");
|
||||||
|
return `/@alist/${storage}${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function LibraryRootsTable() {
|
export function LibraryRootsTable() {
|
||||||
|
const storageList = storages.useList();
|
||||||
|
const storageOptions = [
|
||||||
|
{ value: "local", label: "本机" },
|
||||||
|
...(storageList.data?.items ?? []).map((s) => ({ value: s.id, label: `${s.name}(Alist)` })),
|
||||||
|
];
|
||||||
|
const storageName = (id: string) => storageList.data?.items.find((s) => s.id === id)?.name ?? "Alist";
|
||||||
const list = libraryRoots.useList();
|
const list = libraryRoots.useList();
|
||||||
const create = libraryRoots.useCreate();
|
const create = libraryRoots.useCreate();
|
||||||
const update = libraryRoots.useUpdate();
|
const update = libraryRoots.useUpdate();
|
||||||
@@ -371,7 +389,8 @@ export function LibraryRootsTable() {
|
|||||||
hooks={{ list, create, update, remove } as never}
|
hooks={{ list, create, update, remove } as never}
|
||||||
config={{
|
config={{
|
||||||
noun: "媒体库目录",
|
noun: "媒体库目录",
|
||||||
description: "保存时会在目录里放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。",
|
description:
|
||||||
|
"本机目录保存时会放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。也可以选登记好的 Alist 存储,Alist 上的目录要先建好。",
|
||||||
deleteHint: "只删除登记,磁盘上的文件不动。",
|
deleteHint: "只删除登记,磁盘上的文件不动。",
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
@@ -380,7 +399,12 @@ export function LibraryRootsTable() {
|
|||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">{r.name}</p>
|
<p className="font-medium">{r.name}</p>
|
||||||
<p className="truncate font-mono text-meta text-muted-foreground">{r.localPath}</p>
|
<p className="truncate font-mono text-meta text-muted-foreground">
|
||||||
|
{(() => {
|
||||||
|
const sp = splitRootPath(r.localPath);
|
||||||
|
return sp.storage === "local" ? r.localPath : `${storageName(sp.storage)}:${sp.path}`;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -402,13 +426,20 @@ export function LibraryRootsTable() {
|
|||||||
fields: [
|
fields: [
|
||||||
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
|
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
|
||||||
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
|
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
|
||||||
|
{
|
||||||
|
name: "storage",
|
||||||
|
label: "存储",
|
||||||
|
kind: "select",
|
||||||
|
options: storageOptions,
|
||||||
|
hint: "本机目录,或在「存储」里登记的 Alist / OpenList",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "localPath",
|
name: "localPath",
|
||||||
label: "本地路径",
|
label: "路径",
|
||||||
kind: "text",
|
kind: "text",
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: "/media/movies",
|
placeholder: "/media/movies",
|
||||||
hint: "服务进程能访问的绝对路径",
|
hint: "本机:服务进程能访问的绝对路径;Alist:Alist 里的路径,如 /115/电影",
|
||||||
wide: true,
|
wide: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -428,6 +459,7 @@ export function LibraryRootsTable() {
|
|||||||
initial: {
|
initial: {
|
||||||
name: "",
|
name: "",
|
||||||
mediaKind: "movie",
|
mediaKind: "movie",
|
||||||
|
storage: "local",
|
||||||
localPath: "",
|
localPath: "",
|
||||||
managementMode: "register_only",
|
managementMode: "register_only",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -436,7 +468,8 @@ export function LibraryRootsTable() {
|
|||||||
fromRow: (r) => ({
|
fromRow: (r) => ({
|
||||||
name: r.name,
|
name: r.name,
|
||||||
mediaKind: r.mediaKind,
|
mediaKind: r.mediaKind,
|
||||||
localPath: r.localPath,
|
storage: splitRootPath(r.localPath).storage,
|
||||||
|
localPath: splitRootPath(r.localPath).path,
|
||||||
managementMode: r.managementMode,
|
managementMode: r.managementMode,
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
notes: r.notes ?? "",
|
notes: r.notes ?? "",
|
||||||
@@ -444,7 +477,7 @@ export function LibraryRootsTable() {
|
|||||||
toBody: (v) => ({
|
toBody: (v) => ({
|
||||||
name: str(v.name),
|
name: str(v.name),
|
||||||
mediaKind: str(v.mediaKind),
|
mediaKind: str(v.mediaKind),
|
||||||
localPath: str(v.localPath),
|
localPath: joinRootPath(str(v.storage) || "local", str(v.localPath)),
|
||||||
managementMode: str(v.managementMode),
|
managementMode: str(v.managementMode),
|
||||||
enabled: v.enabled === true,
|
enabled: v.enabled === true,
|
||||||
notes: nullStr(v.notes),
|
notes: nullStr(v.notes),
|
||||||
|
|||||||
@@ -2,13 +2,23 @@ import { LibraryRootsTable } from "./instances.tsx";
|
|||||||
import { NamingSection } from "./naming.tsx";
|
import { NamingSection } from "./naming.tsx";
|
||||||
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
|
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
|
||||||
import { SettingsBlock } from "./settings-block.tsx";
|
import { SettingsBlock } from "./settings-block.tsx";
|
||||||
|
import { StoragesTable } from "./storages.tsx";
|
||||||
|
|
||||||
// 媒体库与目录:媒体库目录(含标记文件检查)、整理来源、分类规则、命名模板(全局 + 目录覆盖)。
|
// 媒体库与目录:存储(Alist / OpenList)、媒体库目录(含标记文件检查)、整理来源、分类规则、命名模板(全局 + 目录覆盖)。
|
||||||
// 整理方式与刮削在「整理与刮削」。
|
// 整理方式与刮削在「整理与刮削」。
|
||||||
|
|
||||||
export function LibrarySection() {
|
export function LibrarySection() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
|
<SettingsBlock
|
||||||
|
section="library"
|
||||||
|
anchor="storages"
|
||||||
|
title="存储"
|
||||||
|
description="Alist / OpenList 网盘存储,登记后可作为媒体库目录、整理来源,并在文件管理里浏览。"
|
||||||
|
plain
|
||||||
|
>
|
||||||
|
<StoragesTable />
|
||||||
|
</SettingsBlock>
|
||||||
<SettingsBlock
|
<SettingsBlock
|
||||||
section="library"
|
section="library"
|
||||||
anchor="roots"
|
anchor="roots"
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ describe("设置页分组导航", () => {
|
|||||||
const first = (id: string) => SECTIONS.find((s) => s.id === id)?.anchors[0]?.id;
|
const first = (id: string) => SECTIONS.find((s) => s.id === id)?.anchors[0]?.id;
|
||||||
expect(first("downloaders")).toBe("instances");
|
expect(first("downloaders")).toBe("instances");
|
||||||
expect(first("media-servers")).toBe("instances");
|
expect(first("media-servers")).toBe("instances");
|
||||||
expect(first("library")).toBe("roots");
|
// 媒体库与目录:第一节是存储(Alist / OpenList 实例,媒体库目录可以登记在它上面),也是对象列表
|
||||||
|
expect(first("library")).toBe("storages");
|
||||||
expect(first("agent")).toBe("upstream");
|
expect(first("agent")).toBe("upstream");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { StorageInstanceCreate, StorageInstanceUpdate } from "@umt/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SECRET_CLEAR } from "~/lib/secret.ts";
|
||||||
|
import { joinRootPath, splitRootPath } from "./instances.tsx";
|
||||||
|
import { STORAGE_FIELDS, STORAGE_INITIAL, storageFromRow, storageToBody } from "./storages.tsx";
|
||||||
|
|
||||||
|
// 设置 → 存储(Alist / OpenList):认证方式决定显示哪个凭据框;凭据只写(留空不改、清除传 null);
|
||||||
|
// 高级选项留空用缺省;媒体库目录选 Alist 存储时路径存成 /@alist/<存储 id>/<Alist 内路径>。
|
||||||
|
|
||||||
|
const visible = (values: Record<string, unknown>) =>
|
||||||
|
STORAGE_FIELDS.filter((f) => !f.visible || f.visible(values as never)).map((f) => f.name);
|
||||||
|
|
||||||
|
const ROW = {
|
||||||
|
id: "00000000-0000-4000-8000-0000000000aa",
|
||||||
|
createdAt: "2026-10-06T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-10-06T00:00:00.000Z",
|
||||||
|
name: "家里的 OpenList",
|
||||||
|
kind: "alist" as const,
|
||||||
|
flavor: "openlist" as const,
|
||||||
|
baseUrl: "http://10.0.0.2:5244",
|
||||||
|
rootPath: "/115",
|
||||||
|
authKind: "password" as const,
|
||||||
|
username: "alice",
|
||||||
|
secretRef: "00000000-0000-4000-8000-0000000000bb",
|
||||||
|
pathPasswordRef: null,
|
||||||
|
enabled: true,
|
||||||
|
options: { qps: 2, uploadAsTask: true },
|
||||||
|
healthStatus: "healthy" as const,
|
||||||
|
lastHealthAt: null,
|
||||||
|
healthDetail: {},
|
||||||
|
versionInfo: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("设置:存储", () => {
|
||||||
|
it("认证方式决定凭据框:令牌 / 用户名和密码 / 访客", () => {
|
||||||
|
expect(visible({ authKind: "token" })).toContain("token");
|
||||||
|
expect(visible({ authKind: "token" })).not.toContain("password");
|
||||||
|
expect(visible({ authKind: "password" })).toEqual(expect.arrayContaining(["username", "password"]));
|
||||||
|
expect(visible({ authKind: "guest" })).not.toEqual(expect.arrayContaining(["token"]));
|
||||||
|
expect(visible({ authKind: "guest" })).not.toContain("username");
|
||||||
|
expect(visible({ authKind: "guest" })).toContain("pathPassword");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("新建:令牌写进 credential,高级选项留空不传,请求通过契约校验", () => {
|
||||||
|
const body = storageToBody({
|
||||||
|
...STORAGE_INITIAL,
|
||||||
|
name: "OL",
|
||||||
|
baseUrl: "http://10.0.0.2:5244",
|
||||||
|
token: " tok-123 ",
|
||||||
|
qps: "",
|
||||||
|
retries: "5",
|
||||||
|
});
|
||||||
|
expect(body).toMatchObject({ credential: "tok-123", rootPath: "/", username: null });
|
||||||
|
expect(body.options).toEqual({ retries: 5, uploadAsTask: false, snapshotCheckFolderModtime: true });
|
||||||
|
expect(StorageInstanceCreate.safeParse(body).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("编辑:凭据留空不改;清除路径密码传 null;改认证方式后已存凭据不算数", () => {
|
||||||
|
const v = storageFromRow(ROW);
|
||||||
|
expect(v).toMatchObject({ authKind: "password", username: "alice", qps: "2", uploadAsTask: true });
|
||||||
|
const keep = storageToBody(v);
|
||||||
|
expect(keep.credential).toBeUndefined();
|
||||||
|
expect(keep.pathPassword).toBeUndefined();
|
||||||
|
expect(StorageInstanceUpdate.safeParse(keep).success).toBe(true);
|
||||||
|
expect(storageToBody({ ...v, pathPassword: SECRET_CLEAR }).pathPassword).toBeNull();
|
||||||
|
const pw = STORAGE_FIELDS.find((f) => f.name === "password")!;
|
||||||
|
expect(pw.secret!.set(v)).toBe(true);
|
||||||
|
expect(pw.secret!.set({ ...v, authKind: "token" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体库目录的存储:本机路径原样,Alist 路径编码成 /@alist/<id>/…", () => {
|
||||||
|
const id = "00000000-0000-4000-8000-0000000000aa";
|
||||||
|
expect(splitRootPath("/media/movies")).toEqual({ storage: "local", path: "/media/movies" });
|
||||||
|
expect(splitRootPath(`/@alist/${id}/115/电影`)).toEqual({ storage: id, path: "/115/电影" });
|
||||||
|
expect(splitRootPath(`/@alist/${id}`)).toEqual({ storage: id, path: "/" });
|
||||||
|
expect(joinRootPath(id, "115/电影/")).toBe(`/@alist/${id}/115/电影`);
|
||||||
|
expect(joinRootPath(id, "/")).toBe(`/@alist/${id}`);
|
||||||
|
expect(joinRootPath("local", "/media/movies")).toBe("/media/movies");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
STORAGE_OPTION_DEFAULTS,
|
||||||
|
type StorageInstance,
|
||||||
|
StorageInstanceCreate,
|
||||||
|
StorageInstanceUpdate,
|
||||||
|
type StorageOptions,
|
||||||
|
} from "@umt/contracts";
|
||||||
|
import { storages } from "~/api/instances.ts";
|
||||||
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import { secretPatch } from "~/lib/secret.ts";
|
||||||
|
import { formatBytes } from "~/lib/utils.ts";
|
||||||
|
import { Health, TestButton } from "./instances.tsx";
|
||||||
|
import { type FieldSpec, type FormValues, nullStr, ResourceSection, str } from "./resource-section.tsx";
|
||||||
|
|
||||||
|
// 存储(Alist / OpenList,对照 MP 存储设置):地址、根路径、认证(永久令牌 / 账号密码 / 访客)、路径密码与限速。
|
||||||
|
// 令牌、密码、路径密码只写不读;测试连接显示版本、登录用户与容量(拿不到容量显示「未知」)。
|
||||||
|
|
||||||
|
const FLAVORS = [
|
||||||
|
{ value: "openlist", label: "OpenList" },
|
||||||
|
{ value: "alist", label: "Alist" },
|
||||||
|
];
|
||||||
|
const AUTH_KINDS = [
|
||||||
|
{ value: "token", label: "令牌" },
|
||||||
|
{ value: "password", label: "用户名和密码" },
|
||||||
|
{ value: "guest", label: "访客(只读公开目录)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 高级选项:表单里是字符串,空 = 缺省值 */
|
||||||
|
const NUM_OPTIONS: { name: keyof StorageOptions; label: string; hint: string }[] = [
|
||||||
|
{ name: "qps", label: "每秒请求数", hint: `缺省 ${STORAGE_OPTION_DEFAULTS.qps};挂了 115 等网盘时调低,0 不限` },
|
||||||
|
{ name: "concurrency", label: "并发数", hint: `缺省 ${STORAGE_OPTION_DEFAULTS.concurrency};上传下载也占并发` },
|
||||||
|
{ name: "timeoutSec", label: "请求超时(秒)", hint: `缺省 ${STORAGE_OPTION_DEFAULTS.timeoutSec}` },
|
||||||
|
{
|
||||||
|
name: "idleTimeoutSec",
|
||||||
|
label: "传输无进度超时(秒)",
|
||||||
|
hint: `缺省 ${STORAGE_OPTION_DEFAULTS.idleTimeoutSec};上传下载这么久没有进展就中止重试`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "taskTimeoutMin",
|
||||||
|
label: "等待 Alist 任务(分钟)",
|
||||||
|
hint: `缺省 ${STORAGE_OPTION_DEFAULTS.taskTimeoutMin};跨网盘复制、移动是 Alist 后台任务`,
|
||||||
|
},
|
||||||
|
{ name: "retries", label: "失败重试次数", hint: `缺省 ${STORAGE_OPTION_DEFAULTS.retries}` },
|
||||||
|
{
|
||||||
|
name: "snapshotMaxDepth",
|
||||||
|
label: "目录监控深度",
|
||||||
|
hint: `缺省 ${STORAGE_OPTION_DEFAULTS.snapshotMaxDepth};整理来源选 Alist 目录时往下看几层`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function optionsForm(o: StorageOptions): FormValues {
|
||||||
|
return {
|
||||||
|
...Object.fromEntries(NUM_OPTIONS.map((f) => [f.name, typeof o[f.name] === "number" ? String(o[f.name]) : ""])),
|
||||||
|
uploadAsTask: o.uploadAsTask ?? STORAGE_OPTION_DEFAULTS.uploadAsTask,
|
||||||
|
snapshotCheckFolderModtime: o.snapshotCheckFolderModtime ?? STORAGE_OPTION_DEFAULTS.snapshotCheckFolderModtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function optionsBody(v: FormValues): StorageOptions {
|
||||||
|
const out: Record<string, number | boolean> = {};
|
||||||
|
for (const f of NUM_OPTIONS) {
|
||||||
|
const raw = String(v[f.name] ?? "").trim();
|
||||||
|
if (raw !== "" && Number.isFinite(Number(raw))) out[f.name] = Number(raw);
|
||||||
|
}
|
||||||
|
out.uploadAsTask = v.uploadAsTask === true;
|
||||||
|
out.snapshotCheckFolderModtime = v.snapshotCheckFolderModtime === true;
|
||||||
|
return out as StorageOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已存凭据只对保存时的认证方式有效(改了认证方式就要重新填) */
|
||||||
|
const credSet = (v: FormValues) => v["secret:set"] === true && v["secret:authKind"] === v.authKind;
|
||||||
|
|
||||||
|
export const STORAGE_FIELDS: FieldSpec[] = [
|
||||||
|
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 OpenList" },
|
||||||
|
{ name: "flavor", label: "类型", kind: "select", options: FLAVORS, hint: "接口相同,只用于显示" },
|
||||||
|
{
|
||||||
|
name: "baseUrl",
|
||||||
|
label: "地址",
|
||||||
|
kind: "url",
|
||||||
|
required: true,
|
||||||
|
placeholder: "http://192.168.1.10:5244",
|
||||||
|
hint: "Alist / OpenList 的访问地址,带端口和子路径",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rootPath",
|
||||||
|
label: "根路径",
|
||||||
|
kind: "text",
|
||||||
|
placeholder: "/",
|
||||||
|
hint: "本工具只用这个路径下的内容,如 /115/影视",
|
||||||
|
},
|
||||||
|
{ name: "authKind", label: "认证方式", kind: "select", options: AUTH_KINDS },
|
||||||
|
{
|
||||||
|
name: "token",
|
||||||
|
label: "令牌",
|
||||||
|
kind: "secret",
|
||||||
|
secret: { set: credSet },
|
||||||
|
hint: "Alist 后台「设置 → 其他 → 令牌」;令牌失效会在运行状态里提示",
|
||||||
|
visible: (v) => v.authKind === "token",
|
||||||
|
},
|
||||||
|
{ name: "username", label: "用户名", kind: "text", visible: (v) => v.authKind === "password" },
|
||||||
|
{
|
||||||
|
name: "password",
|
||||||
|
label: "密码",
|
||||||
|
kind: "secret",
|
||||||
|
secret: { set: credSet },
|
||||||
|
hint: "用来换取临时令牌,约两天自动续一次",
|
||||||
|
visible: (v) => v.authKind === "password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pathPassword",
|
||||||
|
label: "路径密码",
|
||||||
|
kind: "secret",
|
||||||
|
secret: { set: (v) => v["secret:pathPassword"] === true, clearable: true },
|
||||||
|
hint: "Alist 里给目录设了访问密码时填写",
|
||||||
|
},
|
||||||
|
...NUM_OPTIONS.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
label: f.label,
|
||||||
|
kind: "number" as const,
|
||||||
|
placeholder: "留空用缺省值",
|
||||||
|
hint: f.hint,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: "uploadAsTask",
|
||||||
|
label: "上传交给 Alist 后台任务",
|
||||||
|
kind: "switch",
|
||||||
|
hint: "开启后上传由 Alist 排队完成,本工具等任务结束再继续;缺省关闭(同 MP)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snapshotCheckFolderModtime",
|
||||||
|
label: "目录监控按修改时间跳过",
|
||||||
|
kind: "switch",
|
||||||
|
hint: "子目录修改时间没变就不再列,能少请求网盘;网盘不更新目录时间时关闭",
|
||||||
|
},
|
||||||
|
{ name: "enabled", label: "启用", kind: "switch" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STORAGE_INITIAL: FormValues = {
|
||||||
|
name: "",
|
||||||
|
flavor: "openlist",
|
||||||
|
baseUrl: "",
|
||||||
|
rootPath: "/",
|
||||||
|
authKind: "token",
|
||||||
|
token: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
pathPassword: "",
|
||||||
|
...optionsForm({}),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function storageFromRow(r: StorageInstance): FormValues {
|
||||||
|
return {
|
||||||
|
name: r.name,
|
||||||
|
flavor: r.flavor,
|
||||||
|
baseUrl: r.baseUrl,
|
||||||
|
rootPath: r.rootPath,
|
||||||
|
authKind: r.authKind,
|
||||||
|
token: "",
|
||||||
|
username: r.username ?? "",
|
||||||
|
password: "",
|
||||||
|
pathPassword: "",
|
||||||
|
"secret:set": !!r.secretRef,
|
||||||
|
"secret:authKind": r.authKind,
|
||||||
|
"secret:pathPassword": !!r.pathPasswordRef,
|
||||||
|
...optionsForm(r.options),
|
||||||
|
enabled: r.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storageToBody(v: FormValues) {
|
||||||
|
return {
|
||||||
|
name: str(v.name),
|
||||||
|
flavor: str(v.flavor),
|
||||||
|
baseUrl: str(v.baseUrl),
|
||||||
|
rootPath: str(v.rootPath) || "/",
|
||||||
|
authKind: str(v.authKind),
|
||||||
|
username: v.authKind === "password" ? nullStr(v.username) : null,
|
||||||
|
credential:
|
||||||
|
v.authKind === "token" ? secretPatch(v.token) : v.authKind === "password" ? secretPatch(v.password) : undefined,
|
||||||
|
pathPassword: secretPatch(v.pathPassword),
|
||||||
|
options: optionsBody(v),
|
||||||
|
enabled: v.enabled === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoragesTable() {
|
||||||
|
const list = storages.useList();
|
||||||
|
const create = storages.useCreate();
|
||||||
|
const update = storages.useUpdate();
|
||||||
|
const remove = storages.useRemove();
|
||||||
|
const test = storages.useTest();
|
||||||
|
return (
|
||||||
|
<ResourceSection<StorageInstance>
|
||||||
|
hooks={{ list, create, update, remove } as never}
|
||||||
|
config={{
|
||||||
|
noun: "存储",
|
||||||
|
description:
|
||||||
|
"登记 Alist / OpenList 后,媒体库目录、整理来源和文件管理都能用它。网盘方向的上传、下载、复制和移动以接口成功、目标存在为准,不比对大小和哈希。",
|
||||||
|
emptyHint: "还没有登记 Alist / OpenList。",
|
||||||
|
deleteHint: "只删除登记和保存的凭据;被媒体库目录使用时不能删除。",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "名称",
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">
|
||||||
|
{r.name}
|
||||||
|
<span className="ml-1.5 font-normal text-meta text-muted-foreground">
|
||||||
|
{r.flavor === "alist" ? "Alist" : "OpenList"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-meta text-muted-foreground">
|
||||||
|
{r.baseUrl}
|
||||||
|
{r.rootPath !== "/" ? ` · ${r.rootPath}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auth",
|
||||||
|
header: "认证",
|
||||||
|
hideBelow: "md",
|
||||||
|
cell: (r) => AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "usage",
|
||||||
|
header: "容量",
|
||||||
|
hideBelow: "lg",
|
||||||
|
cell: (r) => {
|
||||||
|
const u = (r.healthDetail as { usage?: { total: number; free: number } | null }).usage;
|
||||||
|
return u ? `剩余 ${formatBytes(u.free)} / ${formatBytes(u.total)}` : "未知";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enabled",
|
||||||
|
header: "状态",
|
||||||
|
hideBelow: "sm",
|
||||||
|
cell: (r) => (
|
||||||
|
<StatusBadge tone={r.enabled ? "success" : "neutral"}>{r.enabled ? "启用" : "停用"}</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
|
||||||
|
],
|
||||||
|
rowActions: (r) => (
|
||||||
|
<TestButton
|
||||||
|
pending={test.isPending}
|
||||||
|
run={async () => {
|
||||||
|
const t = await test.mutateAsync(r.id);
|
||||||
|
const usage = t.usage ? `,剩余 ${formatBytes(t.usage.free)}` : ",容量未知";
|
||||||
|
return {
|
||||||
|
ok: t.ok,
|
||||||
|
message: t.message,
|
||||||
|
version: t.ok ? `${t.version ?? "版本未知"},用户 ${t.user ?? "—"}${usage}` : null,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
fields: STORAGE_FIELDS,
|
||||||
|
initial: STORAGE_INITIAL,
|
||||||
|
fromRow: storageFromRow,
|
||||||
|
toBody: storageToBody,
|
||||||
|
createSchema: StorageInstanceCreate,
|
||||||
|
updateSchema: StorageInstanceUpdate,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,10 @@ export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}):
|
|||||||
let reqBody: BodyInit | undefined;
|
let reqBody: BodyInit | undefined;
|
||||||
if (body instanceof FormData) {
|
if (body instanceof FormData) {
|
||||||
reqBody = body;
|
reqBody = body;
|
||||||
|
} else if (typeof Blob !== "undefined" && body instanceof Blob) {
|
||||||
|
// 原始字节(如 PUT /files/upload)
|
||||||
|
reqHeaders["Content-Type"] ??= "application/octet-stream";
|
||||||
|
reqBody = body;
|
||||||
} else if (body !== undefined && body !== null) {
|
} else if (body !== undefined && body !== null) {
|
||||||
reqHeaders["Content-Type"] ??= "application/json";
|
reqHeaders["Content-Type"] ??= "application/json";
|
||||||
reqBody = JSON.stringify(body);
|
reqBody = JSON.stringify(body);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowUpAZ,
|
ArrowUpAZ,
|
||||||
ClipboardCopy,
|
ClipboardCopy,
|
||||||
|
Cloud,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
isRootOffline,
|
isRootOffline,
|
||||||
type ListQuery,
|
type ListQuery,
|
||||||
refKey,
|
refKey,
|
||||||
|
refreshRemoteDir,
|
||||||
useFileList,
|
useFileList,
|
||||||
useFileRoots,
|
useFileRoots,
|
||||||
useProbe,
|
useProbe,
|
||||||
@@ -64,11 +66,13 @@ import {
|
|||||||
RenameDialog,
|
RenameDialog,
|
||||||
ScrapeDialog,
|
ScrapeDialog,
|
||||||
TransferDialog,
|
TransferDialog,
|
||||||
|
UploadButton,
|
||||||
} from "~/components/files/dialogs.tsx";
|
} from "~/components/files/dialogs.tsx";
|
||||||
import { DirTree } from "~/components/files/dir-tree.tsx";
|
import { DirTree } from "~/components/files/dir-tree.tsx";
|
||||||
import { FileDetail } from "~/components/files/file-detail.tsx";
|
import { FileDetail } from "~/components/files/file-detail.tsx";
|
||||||
import { DRAG_MIME, FileList } from "~/components/files/file-list.tsx";
|
import { DRAG_MIME, FileList } from "~/components/files/file-list.tsx";
|
||||||
import {
|
import {
|
||||||
|
displayPath,
|
||||||
emptySelection,
|
emptySelection,
|
||||||
FILTER_CHIPS,
|
FILTER_CHIPS,
|
||||||
groupRoots,
|
groupRoots,
|
||||||
@@ -221,7 +225,7 @@ function TreeRail({
|
|||||||
<span aria-hidden className="my-1 h-px w-6 bg-border" />
|
<span aria-hidden className="my-1 h-px w-6 bg-border" />
|
||||||
{groupRoots(roots).flatMap((g) =>
|
{groupRoots(roots).flatMap((g) =>
|
||||||
g.items.map((r) => {
|
g.items.map((r) => {
|
||||||
const Icon = r.kind === "download" ? Download : HardDrive;
|
const Icon = r.kind === "download" ? Download : r.kind === "storage" ? Cloud : HardDrive;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={r.id}
|
key={r.id}
|
||||||
@@ -429,7 +433,7 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
const copyPaths = async (list: FileEntry[]) => {
|
const copyPaths = async (list: FileEntry[]) => {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const text = list.map((e) => `${root.hostPath}${e.path ? `/${e.path}` : ""}`).join("\n");
|
const text = list.map((e) => displayPath(root, e.path)).join("\n");
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
toast.success(`已复制 ${list.length} 个路径`);
|
toast.success(`已复制 ${list.length} 个路径`);
|
||||||
@@ -1053,7 +1057,9 @@ export default function FilesPage() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
title="刷新"
|
title="刷新"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
|
// Alist:手动刷新才让 Alist 刷新目录缓存(穿透到网盘接口)
|
||||||
|
if (root?.storage) await refreshRemoteDir(root.id, path).catch(() => undefined);
|
||||||
void roots.refetch();
|
void roots.refetch();
|
||||||
void list.refetch();
|
void list.refetch();
|
||||||
}}
|
}}
|
||||||
@@ -1088,6 +1094,7 @@ export default function FilesPage() {
|
|||||||
新建目录
|
新建目录
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
|
{root ? <UploadButton root={root} path={path} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{searchBox}
|
{searchBox}
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ function Mounts({ d }: { d: SystemHealth }) {
|
|||||||
return (
|
return (
|
||||||
<Card title="挂载根" icon={HardDrive} level={d.mounts.length ? (bad ? "critical" : "ok") : undefined}>
|
<Card title="挂载根" icon={HardDrive} level={d.mounts.length ? (bad ? "critical" : "ok") : undefined}>
|
||||||
{d.mounts.length === 0 ? (
|
{d.mounts.length === 0 ? (
|
||||||
<p className="text-caption text-muted-foreground">没有启用的媒体库目录或下载目录映射。</p>
|
<p className="text-caption text-muted-foreground">没有启用的媒体库目录、下载目录映射或 Alist 存储。</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
{d.mounts.map((m) => (
|
{d.mounts.map((m) => (
|
||||||
@@ -481,18 +481,28 @@ function Mounts({ d }: { d: SystemHealth }) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate">
|
<p className="truncate">
|
||||||
<span className="mr-1.5 text-meta text-muted-foreground">
|
<span className="mr-1.5 text-meta text-muted-foreground">
|
||||||
{m.kind === "library_root" ? "媒体库目录" : "下载目录"}
|
{m.kind === "library_root"
|
||||||
|
? "媒体库目录"
|
||||||
|
: m.kind === "storage_instance"
|
||||||
|
? "Alist 存储"
|
||||||
|
: "下载目录"}
|
||||||
</span>
|
</span>
|
||||||
{m.name}
|
{m.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-meta text-muted-foreground" title={m.path}>
|
<p className="truncate font-mono text-meta text-muted-foreground" title={m.path}>
|
||||||
{m.path}
|
{m.path}
|
||||||
{m.sentinelChecked ? "" : " · 只查目录可读"}
|
{m.kind === "storage_instance" ? " · 连接与根路径" : m.sentinelChecked ? "" : " · 只查目录可读"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={m.status === "online" ? "success" : "destructive"}>
|
<StatusBadge tone={m.status === "online" ? "success" : "destructive"}>
|
||||||
{m.status === "online" ? "在线" : m.status === "timeout" ? "超时" : "离线"}
|
{m.status === "online" ? "在线" : m.status === "timeout" ? "超时" : "离线"}
|
||||||
{m.reason && m.status !== "timeout" ? ` · ${MOUNT_REASON[m.reason] ?? m.reason}` : ""}
|
{m.reason && m.status !== "timeout"
|
||||||
|
? ` · ${
|
||||||
|
m.kind === "storage_instance" && m.reason === "permission_denied"
|
||||||
|
? "令牌失效或没有权限"
|
||||||
|
: (MOUNT_REASON[m.reason] ?? m.reason)
|
||||||
|
}`
|
||||||
|
: ""}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1085,9 +1085,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
|||||||
id: "library",
|
id: "library",
|
||||||
group: "flow",
|
group: "flow",
|
||||||
label: "媒体库与目录",
|
label: "媒体库与目录",
|
||||||
description: "媒体库目录、整理来源、分类规则、命名模板",
|
description: "存储、媒体库目录、整理来源、分类规则、命名模板",
|
||||||
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
|
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
|
||||||
anchors: [
|
anchors: [
|
||||||
|
{ id: "storages", label: "存储", keywords: ["Alist", "OpenList", "网盘", "令牌"] },
|
||||||
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
|
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
|
||||||
{ id: "sources", label: "整理来源" },
|
{ id: "sources", label: "整理来源" },
|
||||||
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
|
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
|
||||||
@@ -1102,7 +1103,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
|||||||
"naming.variety",
|
"naming.variety",
|
||||||
"naming.lockFileNameForBound",
|
"naming.lockFileNameForBound",
|
||||||
],
|
],
|
||||||
resources: ["library-roots"],
|
resources: ["storage-instances", "library-roots"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "organize",
|
id: "organize",
|
||||||
|
|||||||
Reference in new issue
Block a user