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 || "(登记目录本身)" },
];