252 lines
8.9 KiB
TypeScript
252 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import { useRouter } from "next/navigation";
|
||
import { useState } from "react";
|
||
import clsx from "clsx";
|
||
import type { SanitizedUserAttachmentStorageConfig } from "@/lib/boss-storage";
|
||
|
||
type StorageMode = SanitizedUserAttachmentStorageConfig["mode"];
|
||
|
||
type StorageDraft = {
|
||
mode: StorageMode;
|
||
ossProvider: "aliyun_oss";
|
||
accessKeyId: string;
|
||
accessKeySecret: string;
|
||
bucket: string;
|
||
endpoint: string;
|
||
region: string;
|
||
prefix: string;
|
||
};
|
||
|
||
function draftFromConfig(config: SanitizedUserAttachmentStorageConfig): StorageDraft {
|
||
return {
|
||
mode: config.mode,
|
||
ossProvider: config.ossProvider ?? "aliyun_oss",
|
||
accessKeyId: config.aliyunOss?.accessKeyId ?? "",
|
||
accessKeySecret: "",
|
||
bucket: config.aliyunOss?.bucket ?? "",
|
||
endpoint: config.aliyunOss?.endpoint ?? "",
|
||
region: config.aliyunOss?.region ?? "",
|
||
prefix: config.aliyunOss?.prefix ?? "",
|
||
};
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
type = "text",
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder?: string;
|
||
type?: "text" | "password";
|
||
}) {
|
||
return (
|
||
<label className="space-y-1">
|
||
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
|
||
<input
|
||
type={type}
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[14px] text-[#111111] outline-none"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function AttachmentStorageClient({
|
||
config,
|
||
}: {
|
||
config: SanitizedUserAttachmentStorageConfig;
|
||
}) {
|
||
const router = useRouter();
|
||
const [currentConfig, setCurrentConfig] = useState<SanitizedUserAttachmentStorageConfig>(config);
|
||
const [draft, setDraft] = useState<StorageDraft>(() => draftFromConfig(config));
|
||
const [busyKey, setBusyKey] = useState<"save" | "validate" | null>(null);
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function submit(kind: "save" | "validate") {
|
||
setBusyKey(kind);
|
||
|
||
const body =
|
||
draft.mode === "oss"
|
||
? {
|
||
mode: "oss" as const,
|
||
ossProvider: "aliyun_oss" as const,
|
||
aliyunOss: {
|
||
accessKeyId: draft.accessKeyId,
|
||
bucket: draft.bucket,
|
||
endpoint: draft.endpoint,
|
||
region: draft.region,
|
||
prefix: draft.prefix,
|
||
...(draft.accessKeySecret.trim() ? { accessKeySecret: draft.accessKeySecret } : {}),
|
||
},
|
||
}
|
||
: {
|
||
mode: "server_file" as const,
|
||
};
|
||
|
||
const response = await fetch(
|
||
kind === "validate" ? "/api/v1/storage/config/validate" : "/api/v1/storage/config",
|
||
{
|
||
method: kind === "validate" ? "POST" : "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
},
|
||
);
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message?: string;
|
||
config?: SanitizedUserAttachmentStorageConfig;
|
||
};
|
||
setBusyKey(null);
|
||
if (result.ok) {
|
||
const nextConfig = result.config ?? currentConfig;
|
||
setCurrentConfig(nextConfig);
|
||
setDraft(draftFromConfig(nextConfig));
|
||
setMessage("附件存储配置已保存。");
|
||
router.refresh();
|
||
return;
|
||
}
|
||
setMessage(result.message ?? "保存失败。");
|
||
}
|
||
|
||
const modeLabel = currentConfig.mode === "server_file" ? "服务器文件存储" : "OSS";
|
||
const buttonLabel =
|
||
draft.mode === "server_file"
|
||
? "切回服务器文件存储"
|
||
: busyKey === "validate"
|
||
? "测试中"
|
||
: "测试并保存";
|
||
|
||
return (
|
||
<div className="space-y-3 px-[18px] pb-6">
|
||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="text-[16px] font-semibold text-[#111111]">当前用户附件存储模式</div>
|
||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||
当前模式:<span className="font-semibold text-[#111111]">{modeLabel}</span>
|
||
<br />
|
||
绑定账号:{currentConfig.account}
|
||
<br />
|
||
{currentConfig.mode === "oss"
|
||
? `OSS 提供方:阿里 OSS · 密钥${currentConfig.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}`
|
||
: "附件将继续写入服务器文件存储。"}
|
||
</div>
|
||
<div className="mt-3 flex gap-2">
|
||
{(["server_file", "oss"] as const).map((mode) => {
|
||
const active = draft.mode === mode;
|
||
return (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() => setDraft((current) => ({ ...current, mode }))}
|
||
className={clsx(
|
||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||
active ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{mode === "server_file" ? "服务器文件存储" : "OSS"}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{draft.mode === "oss" ? (
|
||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="text-[16px] font-semibold text-[#111111]">阿里 OSS 配置</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
当前仅支持阿里 OSS。AccessKey Secret 不会回显,留空表示沿用已保存的密钥。
|
||
</div>
|
||
<Field
|
||
label="AccessKey ID"
|
||
value={draft.accessKeyId}
|
||
onChange={(value) => setDraft((current) => ({ ...current, accessKeyId: value }))}
|
||
placeholder="请输入 AccessKey ID"
|
||
/>
|
||
<Field
|
||
label="AccessKey Secret"
|
||
value={draft.accessKeySecret}
|
||
onChange={(value) => setDraft((current) => ({ ...current, accessKeySecret: value }))}
|
||
placeholder="请输入 AccessKey Secret"
|
||
type="password"
|
||
/>
|
||
<Field
|
||
label="Bucket"
|
||
value={draft.bucket}
|
||
onChange={(value) => setDraft((current) => ({ ...current, bucket: value }))}
|
||
placeholder="例如 boss-attachments"
|
||
/>
|
||
<Field
|
||
label="Endpoint"
|
||
value={draft.endpoint}
|
||
onChange={(value) => setDraft((current) => ({ ...current, endpoint: value }))}
|
||
placeholder="例如 oss-cn-hangzhou.aliyuncs.com"
|
||
/>
|
||
<Field
|
||
label="Region"
|
||
value={draft.region}
|
||
onChange={(value) => setDraft((current) => ({ ...current, region: value }))}
|
||
placeholder="例如 oss-cn-hangzhou"
|
||
/>
|
||
<Field
|
||
label="Prefix(可选)"
|
||
value={draft.prefix}
|
||
onChange={(value) => setDraft((current) => ({ ...current, prefix: value }))}
|
||
placeholder="例如 boss/"
|
||
/>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => void submit("validate")}
|
||
disabled={busyKey !== null}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||
>
|
||
{buttonLabel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void submit("save")}
|
||
disabled={busyKey !== null}
|
||
className="rounded-full border border-[#E5E5EA] bg-white px-4 py-2 text-[13px] font-semibold text-[#111111] disabled:opacity-60"
|
||
>
|
||
{busyKey === "save" ? "保存中" : "仅保存"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<div className="text-[16px] font-semibold text-[#111111]">服务器文件存储</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
切回后附件继续保存在服务器本地文件存储,不会走 OSS 配置校验。
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void submit("save")}
|
||
disabled={busyKey !== null}
|
||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||
>
|
||
{busyKey === "save" ? "保存中" : buttonLabel}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{message ? (
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
|
||
{draft.mode === "oss" ? (
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
保存前请确认 Bucket、Endpoint 和 Region 都完整可用,测试通过后会同步写回当前账号配置。
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|