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),