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:
1 parent
c1bc389058
commit
cf364435eb
18 files changed
+618
-41
No files matched your search
@@ -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 || "(登记目录本身)" },
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user