diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts index 9fdf076b..73535867 100644 --- a/apps/web/app/api/files.ts +++ b/apps/web/app/api/files.ts @@ -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(fn: (b: B) => Promise, 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 = () => diff --git a/apps/web/app/api/instances.ts b/apps/web/app/api/instances.ts index 9742fe42..7890c7dc 100644 --- a/apps/web/app/api/instances.ts +++ b/apps/web/app/api/instances.ts @@ -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(area: QueryArea, path: string) { }; } +/** Alist / OpenList 存储实例 */ +export const storages = { + ...crudHooks( + "storage-instances", + "/storage-instances", + ), + useTest: actionHook("storage-instances", "/storage-instances/{id}/test"), +}; + export const downloaders = { ...crudHooks( "downloader-instances", diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx index f7a41863..643bca6b 100644 --- a/apps/web/app/components/files/dialogs.tsx +++ b/apps/web/app/components/files/dialogs.tsx @@ -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(v: T, ms: number): T { return d; } +// ─── 上传 ─────────────────────────────────────────────────────── + +/** + * 上传到当前目录(对照 MP 文件管理的上传;Alist 根经服务端转传):选多个文件逐个上传,同名不覆盖并提示。 + * 只读目录与下载目录不能上传。 + */ +export function UploadButton({ root, path }: { root: FileRoot; path: string }) { + const upload = useUpload(); + const input = useRef(null); + const [busy, setBusy] = useState(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 ( + + void run(e.currentTarget.files)} /> + + + ); +} + // ─── 新建目录 ─────────────────────────────────────────────────── export function MkdirDialog({ @@ -157,10 +200,7 @@ export function MkdirDialog({
新建目录 - - {root.hostPath} - {path ? `/${path}` : ""}/ - + {displayPath(root, path)}/ { setTarget(loc); runPreview(loc, mode); }} className="max-h-72 md:max-h-96" /> -

只能选可写媒体库目录下的目录。

+

+ 可选可写的媒体库目录或 Alist 存储;本机和 Alist 之间会上传或下载。 +

{target && targetRoot ? (

目标目录

-

- {targetRoot.hostPath} - {target.path ? `/${target.path}` : ""} -

+

{displayPath(targetRoot, target.path)}

) : ( 在左侧选择目标目录。 @@ -669,8 +709,8 @@ export function TransferDialog({ {formatBytes(p.size, 1)}
@@ -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 || "(登记目录本身)" }, ]; diff --git a/apps/web/app/components/files/dir-picker.tsx b/apps/web/app/components/files/dir-picker.tsx index f71c67d3..fd3fd2de 100644 --- a/apps/web/app/components/files/dir-picker.tsx +++ b/apps/web/app/components/files/dir-picker.tsx @@ -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>( + 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 (
{writable.length ? ( @@ -30,7 +42,9 @@ export function DirPicker({ onSelect={onChange} /> ) : ( -

没有可写入的媒体库目录。先在设置里添加。

+

+ 没有可写入的{includeStorage ? "目录" : "媒体库目录"}。先在设置里添加。 +

)}
); diff --git a/apps/web/app/components/files/dir-tree.tsx b/apps/web/app/components/files/dir-tree.tsx index 9f482013..2e35ec99 100644 --- a/apps/web/app/components/files/dir-tree.tsx +++ b/apps/web/app/components/files/dir-tree.tsx @@ -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 (
onDropTo({ root: root.id, path: "" }, e) : undefined} - title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath} + title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : displayPath(root)} > diff --git a/apps/web/app/components/files/file-detail.tsx b/apps/web/app/components/files/file-detail.tsx index 7916a78e..2815b966 100644 --- a/apps/web/app/components/files/file-detail.tsx +++ b/apps/web/app/components/files/file-detail.tsx @@ -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 = { 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 }] : []), ]} diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index 6c47df57..81e44dd6 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -524,9 +524,19 @@ export function groupRoots>( 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, 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(); diff --git a/apps/web/app/components/files/storage-switch.test.ts b/apps/web/app/components/files/storage-switch.test.ts new file mode 100644 index 00000000..b0fcb1b1 --- /dev/null +++ b/apps/web/app/components/files/storage-switch.test.ts @@ -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 = {}) => ({ + 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: "" }); + }); +}); diff --git a/apps/web/app/components/logs/labels.ts b/apps/web/app/components/logs/labels.ts index eeb2ac08..3adc2970 100644 --- a/apps/web/app/components/logs/labels.ts +++ b/apps/web/app/components/logs/labels.ts @@ -96,6 +96,7 @@ const NOUN: Record = { downloader_instance: "下载器", media_server_instance: "媒体服务器", path_mapping: "路径映射", + storage_instance: "存储", pi_upstream: "模型服务", schedule: "定时任务", automation_grant: "持续授权", diff --git a/apps/web/app/components/settings/instances.tsx b/apps/web/app/components/settings/instances.tsx index 28d9ca89..202b7086 100644 --- a/apps/web/app/components/settings/instances.tsx +++ b/apps/web/app/components/settings/instances.tsx @@ -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>/ */ +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) => (

{r.name}

-

{r.localPath}

+

+ {(() => { + const sp = splitRootPath(r.localPath); + return sp.storage === "local" ? r.localPath : `${storageName(sp.storage)}:${sp.path}`; + })()} +

), }, @@ -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), diff --git a/apps/web/app/components/settings/library.tsx b/apps/web/app/components/settings/library.tsx index fd9ac570..df6f32ac 100644 --- a/apps/web/app/components/settings/library.tsx +++ b/apps/web/app/components/settings/library.tsx @@ -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 (
+ + + { 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"); }); }); diff --git a/apps/web/app/components/settings/storages.test.ts b/apps/web/app/components/settings/storages.test.ts new file mode 100644 index 00000000..8e094039 --- /dev/null +++ b/apps/web/app/components/settings/storages.test.ts @@ -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>/。 + +const visible = (values: Record) => + 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//…", () => { + 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"); + }); +}); diff --git a/apps/web/app/components/settings/storages.tsx b/apps/web/app/components/settings/storages.tsx new file mode 100644 index 00000000..cdfabfda --- /dev/null +++ b/apps/web/app/components/settings/storages.tsx @@ -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 = {}; + 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 ( + + hooks={{ list, create, update, remove } as never} + config={{ + noun: "存储", + description: + "登记 Alist / OpenList 后,媒体库目录、整理来源和文件管理都能用它。网盘方向的上传、下载、复制和移动以接口成功、目标存在为准,不比对大小和哈希。", + emptyHint: "还没有登记 Alist / OpenList。", + deleteHint: "只删除登记和保存的凭据;被媒体库目录使用时不能删除。", + columns: [ + { + key: "name", + header: "名称", + cell: (r) => ( +
+

+ {r.name} + + {r.flavor === "alist" ? "Alist" : "OpenList"} + +

+

+ {r.baseUrl} + {r.rootPath !== "/" ? ` · ${r.rootPath}` : ""} +

+
+ ), + }, + { + 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) => ( + {r.enabled ? "启用" : "停用"} + ), + }, + { key: "health", header: "健康", cell: (r) => }, + ], + rowActions: (r) => ( + { + 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, + }} + /> + ); +} diff --git a/apps/web/app/lib/api.ts b/apps/web/app/lib/api.ts index f17faf8a..335433e8 100644 --- a/apps/web/app/lib/api.ts +++ b/apps/web/app/lib/api.ts @@ -112,6 +112,10 @@ export async function apiFetch(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); diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index 76ef9bda..802b685e 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -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({ {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 ( + {root ? : null}
{searchBox} diff --git a/apps/web/app/routes/console/status.tsx b/apps/web/app/routes/console/status.tsx index 7dc1d24b..74db75b6 100644 --- a/apps/web/app/routes/console/status.tsx +++ b/apps/web/app/routes/console/status.tsx @@ -473,7 +473,7 @@ function Mounts({ d }: { d: SystemHealth }) { return ( {d.mounts.length === 0 ? ( -

没有启用的媒体库目录或下载目录映射。

+

没有启用的媒体库目录、下载目录映射或 Alist 存储。

) : (
    {d.mounts.map((m) => ( @@ -481,18 +481,28 @@ function Mounts({ d }: { d: SystemHealth }) {

    - {m.kind === "library_root" ? "媒体库目录" : "下载目录"} + {m.kind === "library_root" + ? "媒体库目录" + : m.kind === "storage_instance" + ? "Alist 存储" + : "下载目录"} {m.name}

    {m.path} - {m.sentinelChecked ? "" : " · 只查目录可读"} + {m.kind === "storage_instance" ? " · 连接与根路径" : m.sentinelChecked ? "" : " · 只查目录可读"}

    {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) + }` + : ""} ))} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1aa346ab..d9d6a85e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -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",