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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user