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
@@ -10,6 +10,7 @@ import {
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Undo2,
|
||||
Upload,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
useScrapeBatch,
|
||||
useScrapeRecords,
|
||||
useSuggestName,
|
||||
useUpload,
|
||||
} from "~/api/files.ts";
|
||||
import { useSettings } from "~/api/settings.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
@@ -58,6 +60,7 @@ import { DirPicker } from "./dir-picker.tsx";
|
||||
import {
|
||||
baseName,
|
||||
deleteBlockText,
|
||||
displayPath,
|
||||
nameProblem,
|
||||
nameTaken,
|
||||
newIdempotencyKey,
|
||||
@@ -115,6 +118,46 @@ function useDebounced<T>(v: T, ms: number): T {
|
||||
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({
|
||||
@@ -157,10 +200,7 @@ export function MkdirDialog({
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建目录</DialogTitle>
|
||||
<DialogDescription className="font-mono break-all">
|
||||
{root.hostPath}
|
||||
{path ? `/${path}` : ""}/
|
||||
</DialogDescription>
|
||||
<DialogDescription className="font-mono break-all">{displayPath(root, path)}/</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormField label="目录名" htmlFor="mkdir-name" error={err}>
|
||||
<Input
|
||||
@@ -589,22 +629,22 @@ export function TransferDialog({
|
||||
<DirPicker
|
||||
roots={roots}
|
||||
value={target}
|
||||
includeStorage
|
||||
onChange={(loc) => {
|
||||
setTarget(loc);
|
||||
runPreview(loc, mode);
|
||||
}}
|
||||
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 className="min-w-0 space-y-3">
|
||||
{target && targetRoot ? (
|
||||
<div className="rounded-xl border border-border px-3 py-2">
|
||||
<p className="text-meta text-muted-foreground">目标目录</p>
|
||||
<p className="font-mono text-caption break-all">
|
||||
{targetRoot.hostPath}
|
||||
{target.path ? `/${target.path}` : ""}
|
||||
</p>
|
||||
<p className="font-mono text-caption break-all">{displayPath(targetRoot, target.path)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Notice tone="neutral">在左侧选择目标目录。</Notice>
|
||||
@@ -669,8 +709,8 @@ export function TransferDialog({
|
||||
<span className="text-meta text-muted-foreground">{formatBytes(p.size, 1)}</span>
|
||||
</div>
|
||||
<PathPair
|
||||
from={`${sr?.hostPath ?? p.source.root}/${p.source.path}`}
|
||||
to={`${tr?.hostPath ?? p.target.root}/${p.target.path}`}
|
||||
from={sr ? displayPath(sr, p.source.path) : `${p.source.root}/${p.source.path}`}
|
||||
to={tr ? displayPath(tr, p.target.path) : `${p.target.root}/${p.target.path}`}
|
||||
tone={p.conflict ? "conflict" : undefined}
|
||||
/>
|
||||
</li>
|
||||
@@ -947,9 +987,9 @@ export function DeleteDialog({
|
||||
|
||||
export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) {
|
||||
const stat = useFileStat(root.id, entry.path);
|
||||
const local = `${root.hostPath}${entry.path ? `/${entry.path}` : ""}`;
|
||||
const local = displayPath(root, entry.path);
|
||||
const rows = [
|
||||
{ label: "本机路径", value: local },
|
||||
{ label: root.storage ? "Alist 路径" : "本机路径", value: local },
|
||||
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
|
||||
{ label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
|
||||
];
|
||||
|
||||
@@ -2,6 +2,16 @@ import type { FileRef, FileRoot } from "~/api/files.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
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,只能选可写库根下的目录。
|
||||
* 移动/复制到…、回收站"还原到…"、整理向导"指定目录"共用。
|
||||
@@ -11,13 +21,15 @@ export function DirPicker({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
includeStorage = false,
|
||||
}: {
|
||||
roots: FileRoot[];
|
||||
value: FileRef | null;
|
||||
onChange: (ref: FileRef) => void;
|
||||
className?: string;
|
||||
includeStorage?: boolean;
|
||||
}) {
|
||||
const writable = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
|
||||
const writable = pickableRoots(roots, includeStorage);
|
||||
return (
|
||||
<div className={cn("overflow-y-auto rounded-xl border border-border p-1.5", className)}>
|
||||
{writable.length ? (
|
||||
@@ -30,7 +42,9 @@ export function DirPicker({
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 { type FileRoot, useDirChildren } from "~/api/files.ts";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { healthLabel } from "~/lib/labels.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 DropHandler = (target: Loc, e: React.DragEvent) => void;
|
||||
@@ -115,7 +124,7 @@ function RootNode({
|
||||
const isOpen = open.has(k) && !offline;
|
||||
const active = current?.root === root.id && current.path === "";
|
||||
const h = healthLabel(root.health);
|
||||
const Icon = root.kind === "download" ? Download : HardDrive;
|
||||
const Icon = root.kind === "download" ? Download : root.kind === "storage" ? Cloud : HardDrive;
|
||||
return (
|
||||
<div>
|
||||
<TreeRow
|
||||
@@ -131,7 +140,7 @@ function RootNode({
|
||||
if (!open.has(k)) toggle(k);
|
||||
}}
|
||||
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" />
|
||||
<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 { FilePreviewSection } from "./file-preview.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> = {
|
||||
copy: "复制",
|
||||
@@ -122,8 +122,13 @@ export function FileDetail({
|
||||
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
|
||||
: 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 }]
|
||||
: []),
|
||||
]}
|
||||
|
||||
@@ -524,9 +524,19 @@ export function groupRoots<T extends Pick<FileRoot, "kind">>(
|
||||
return [
|
||||
{ title: "下载目录", kind: "download", items: roots.filter((r) => r.kind === "download") },
|
||||
{ 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);
|
||||
}
|
||||
|
||||
/** 根 + 相对路径的显示:本机给本机路径;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 {
|
||||
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: "" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user