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

+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,
}}
/>
);
}