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

+15 -1
View File
@@ -50,7 +50,7 @@ import {
type TmdbCandidate,
} from "@umt/contracts";
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";
// 文件管理(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;
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 = () =>
useFilesMutation((b: FilesRenameRequest) => post(FilesRenameResult, "/files/rename", b), { invalidate: notDry });
export const useMove = () =>
+13
View File
@@ -21,6 +21,10 @@ import type {
SiteCredentialsPut,
SiteCredentialsResult,
SiteUpdate,
StorageInstance,
StorageInstanceCreate,
StorageInstanceUpdate,
StorageTestResult,
} from "@umt/contracts";
import { apiData } from "~/lib/api.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 = {
...crudHooks<DownloaderInstance, DownloaderInstanceCreate, DownloaderInstanceUpdate>(
"downloader-instances",
+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: "" });
});
});
+1
View File
@@ -96,6 +96,7 @@ const NOUN: Record<string, string> = {
downloader_instance: "下载器",
media_server_instance: "媒体服务器",
path_mapping: "路径映射",
storage_instance: "存储",
pi_upstream: "模型服务",
schedule: "定时任务",
automation_grant: "持续授权",
+40 -7
View File
@@ -11,7 +11,7 @@ import {
} from "@umt/contracts";
import { PlugZap } from "lucide-react";
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 { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -360,7 +360,25 @@ const MEDIA_KIND = [
{ 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() {
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 create = libraryRoots.useCreate();
const update = libraryRoots.useUpdate();
@@ -371,7 +389,8 @@ export function LibraryRootsTable() {
hooks={{ list, create, update, remove } as never}
config={{
noun: "媒体库目录",
description: "保存时会在目录里放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。",
description:
"本机目录保存时会放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。也可以选登记好的 Alist 存储,Alist 上的目录要先建好。",
deleteHint: "只删除登记,磁盘上的文件不动。",
columns: [
{
@@ -380,7 +399,12 @@ export function LibraryRootsTable() {
cell: (r) => (
<div className="min-w-0">
<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>
),
},
@@ -402,13 +426,20 @@ export function LibraryRootsTable() {
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
{
name: "storage",
label: "存储",
kind: "select",
options: storageOptions,
hint: "本机目录,或在「存储」里登记的 Alist / OpenList",
},
{
name: "localPath",
label: "本地路径",
label: "路径",
kind: "text",
required: true,
placeholder: "/media/movies",
hint: "服务进程能访问的绝对路径",
hint: "本机:服务进程能访问的绝对路径;Alist:Alist 里的路径,如 /115/电影",
wide: true,
},
{
@@ -428,6 +459,7 @@ export function LibraryRootsTable() {
initial: {
name: "",
mediaKind: "movie",
storage: "local",
localPath: "",
managementMode: "register_only",
enabled: true,
@@ -436,7 +468,8 @@ export function LibraryRootsTable() {
fromRow: (r) => ({
name: r.name,
mediaKind: r.mediaKind,
localPath: r.localPath,
storage: splitRootPath(r.localPath).storage,
localPath: splitRootPath(r.localPath).path,
managementMode: r.managementMode,
enabled: r.enabled,
notes: r.notes ?? "",
@@ -444,7 +477,7 @@ export function LibraryRootsTable() {
toBody: (v) => ({
name: str(v.name),
mediaKind: str(v.mediaKind),
localPath: str(v.localPath),
localPath: joinRootPath(str(v.storage) || "local", str(v.localPath)),
managementMode: str(v.managementMode),
enabled: v.enabled === true,
notes: nullStr(v.notes),
+11 -1
View File
@@ -2,13 +2,23 @@ import { LibraryRootsTable } from "./instances.tsx";
import { NamingSection } from "./naming.tsx";
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { StoragesTable } from "./storages.tsx";
// 媒体库与目录:媒体库目录(含标记文件检查)、整理来源、分类规则、命名模板(全局 + 目录覆盖)。
// 媒体库与目录:存储(Alist / OpenList)、媒体库目录(含标记文件检查)、整理来源、分类规则、命名模板(全局 + 目录覆盖)。
// 整理方式与刮削在「整理与刮削」。
export function LibrarySection() {
return (
<div className="grid grid-cols-1 gap-8">
<SettingsBlock
section="library"
anchor="storages"
title="存储"
description="Alist / OpenList 网盘存储,登记后可作为媒体库目录、整理来源,并在文件管理里浏览。"
plain
>
<StoragesTable />
</SettingsBlock>
<SettingsBlock
section="library"
anchor="roots"
@@ -87,7 +87,8 @@ describe("设置页分组导航", () => {
const first = (id: string) => SECTIONS.find((s) => s.id === id)?.anchors[0]?.id;
expect(first("downloaders")).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");
});
});
@@ -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,
}}
/>
);
}
+4
View File
@@ -112,6 +112,10 @@ export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}):
let reqBody: BodyInit | undefined;
if (body instanceof FormData) {
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) {
reqHeaders["Content-Type"] ??= "application/json";
reqBody = JSON.stringify(body);
+10 -3
View File
@@ -5,6 +5,7 @@ import {
ArrowUp,
ArrowUpAZ,
ClipboardCopy,
Cloud,
Copy,
Download,
Eye,
@@ -47,6 +48,7 @@ import {
isRootOffline,
type ListQuery,
refKey,
refreshRemoteDir,
useFileList,
useFileRoots,
useProbe,
@@ -64,11 +66,13 @@ import {
RenameDialog,
ScrapeDialog,
TransferDialog,
UploadButton,
} from "~/components/files/dialogs.tsx";
import { DirTree } from "~/components/files/dir-tree.tsx";
import { FileDetail } from "~/components/files/file-detail.tsx";
import { DRAG_MIME, FileList } from "~/components/files/file-list.tsx";
import {
displayPath,
emptySelection,
FILTER_CHIPS,
groupRoots,
@@ -221,7 +225,7 @@ function TreeRail({
<span aria-hidden className="my-1 h-px w-6 bg-border" />
{groupRoots(roots).flatMap((g) =>
g.items.map((r) => {
const Icon = r.kind === "download" ? Download : HardDrive;
const Icon = r.kind === "download" ? Download : r.kind === "storage" ? Cloud : HardDrive;
return (
<button
key={r.id}
@@ -429,7 +433,7 @@ export default function FilesPage() {
const copyPaths = async (list: FileEntry[]) => {
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 {
await navigator.clipboard.writeText(text);
toast.success(`已复制 ${list.length} 个路径`);
@@ -1053,7 +1057,9 @@ export default function FilesPage() {
size="icon-sm"
aria-label="刷新"
title="刷新"
onClick={() => {
onClick={async () => {
// Alist:手动刷新才让 Alist 刷新目录缓存(穿透到网盘接口)
if (root?.storage) await refreshRemoteDir(root.id, path).catch(() => undefined);
void roots.refetch();
void list.refetch();
}}
@@ -1088,6 +1094,7 @@ export default function FilesPage() {
新建目录
</Button>
</span>
{root ? <UploadButton root={root} path={path} /> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{searchBox}
+14 -4
View File
@@ -473,7 +473,7 @@ function Mounts({ d }: { d: SystemHealth }) {
return (
<Card title="挂载根" icon={HardDrive} level={d.mounts.length ? (bad ? "critical" : "ok") : undefined}>
{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">
{d.mounts.map((m) => (
@@ -481,18 +481,28 @@ function Mounts({ d }: { d: SystemHealth }) {
<div className="min-w-0 flex-1">
<p className="truncate">
<span className="mr-1.5 text-meta text-muted-foreground">
{m.kind === "library_root" ? "媒体库目录" : "下载目录"}
{m.kind === "library_root"
? "媒体库目录"
: m.kind === "storage_instance"
? "Alist 存储"
: "下载目录"}
</span>
{m.name}
</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={m.path}>
{m.path}
{m.sentinelChecked ? "" : " · 只查目录可读"}
{m.kind === "storage_instance" ? " · 连接与根路径" : m.sentinelChecked ? "" : " · 只查目录可读"}
</p>
</div>
<StatusBadge tone={m.status === "online" ? "success" : "destructive"}>
{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>
</li>
))}
+3 -2
View File
@@ -1085,9 +1085,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
id: "library",
group: "flow",
label: "媒体库与目录",
description: "媒体库目录、整理来源、分类规则、命名模板",
description: "存储、媒体库目录、整理来源、分类规则、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "storages", label: "存储", keywords: ["Alist", "OpenList", "网盘", "令牌"] },
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
{ id: "sources", label: "整理来源" },
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
@@ -1102,7 +1103,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"naming.variety",
"naming.lockFileNameForBound",
],
resources: ["library-roots"],
resources: ["storage-instances", "library-roots"],
},
{
id: "organize",