feat(web): add me storage page

This commit is contained in:
kris
2026-03-29 16:20:25 +08:00
parent 3307f79162
commit 8273340f7f
3 changed files with 266 additions and 0 deletions

View File

@@ -21,6 +21,11 @@ export default async function MePage() {
<HeaderTitle title="我的" />
<div className="flex flex-col gap-3 px-[18px] pb-5">
<ProfileHero user={state.user} />
<MenuRow
href="/me/storage"
title="附件与存储"
description="当前附件存储模式、服务器文件存储与阿里 OSS"
/>
<MenuRow
href="/me/security"
title="账号与安全"

View File

@@ -0,0 +1,20 @@
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { AttachmentStorageClient } from "@/components/attachment-storage-client";
import { requirePageSession } from "@/lib/boss-auth";
import { getAttachmentStorageConfig } from "@/lib/boss-data";
import { sanitizeAttachmentStorageConfig } from "@/lib/boss-storage";
export const dynamic = "force-dynamic";
export default async function StoragePage() {
const session = await requirePageSession();
const config = sanitizeAttachmentStorageConfig(await getAttachmentStorageConfig(session.account));
return (
<AppShell bottomNav={false}>
<StatusBar />
<PageNav title="附件与存储" backHref="/me" />
<AttachmentStorageClient key={`${config.mode}:${config.updatedAt}`} config={config} />
</AppShell>
);
}

View File

@@ -0,0 +1,241 @@
"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 [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 };
setBusyKey(null);
setMessage(result.ok ? "附件存储配置已保存。" : result.message ?? "保存失败。");
if (result.ok) {
router.refresh();
}
}
const modeLabel = draft.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 />
{config.account}
<br />
{draft.mode === "oss"
? `OSS 提供方:阿里 OSS · 密钥${config.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]">
OSSAccessKey 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]">
BucketEndpoint Region
</div>
) : null}
</div>
);
}