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:
rootandClaude Opus 5.5 committed 2026-10-06 23:57:33 +08:00
1 parent c1bc389058
commit cf364435eb
18 files changed
+618 -41

No files matched your search

+53 -13
View File
@@ -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 || "(登记目录本身)" },
];
+16 -2
View File
@@ -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>
);
+13 -4
View File
@@ -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 }]
: []),
]}
+10
View File
@@ -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: "" });
});
});