Files
ttstd-infinite-canvas/web/src/pages/admin/settings/storage-settings-page.tsx

202 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Form, Input, Segmented, Space } from "antd";
import { Cloud, Globe, HardDrive, LocateFixed } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAdminOSSSetting, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type StorageMode = "local" | "aliyun" | "tencent" | "qiniu";
type OSSFormValues = {
mode: StorageMode;
publicBaseUrl?: string;
region?: string;
endpoint?: string;
cdnBaseUrl?: string;
bucket?: string;
accessKeyId?: string;
accessKeySecret?: string;
pathPrefix?: string;
};
export default function StorageSettingsPage() {
const { message } = App.useApp();
const { references } = useAdminContext();
const [setting, setSetting] = useState<AdminOSSSetting | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<OSSFormValues>();
const mode = Form.useWatch("mode", form) || "local";
const isObjectStorage = mode !== "local";
const isTencentCOS = mode === "tencent";
const isQiniuKodo = mode === "qiniu";
const accessKeyIdLabel = isTencentCOS ? "SecretId" : isQiniuKodo ? "AccessKey" : "AccessKey ID";
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : isQiniuKodo ? "SecretKey" : "AccessKey Secret";
const hasCurrentProviderSecret = Boolean(setting && setting.provider === mode && setting.hasAccessKeySecret);
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
useEffect(() => {
void getAdminOSSSetting()
.then(({ setting: value }) => {
setSetting(value);
form.setFieldsValue(formValues(value));
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取对象存储配置失败"))
.finally(() => setLoading(false));
}, [form, message]);
const save = async () => {
await form.validateFields();
const values = form.getFieldsValue(true);
if (values.mode === "local" && !values.publicBaseUrl?.trim()) return message.error("请填写服务器访问地址");
if (values.mode !== "local" && !values.accessKeySecret?.trim() && !hasCurrentProviderSecret) return message.error(`请填写 ${accessKeySecretLabel}`);
if (values.mode !== "local" && !values.bucket?.trim()) return message.error("请填写对象存储 Bucket");
if (values.mode !== "local" && !values.accessKeyId?.trim()) return message.error(`请填写 ${accessKeyIdLabel}`);
if (values.mode === "aliyun" && !values.endpoint?.trim()) return message.error("请填写阿里云 OSS Endpoint");
if (values.mode === "tencent" && !values.endpoint?.trim() && !values.region?.trim()) return message.error("请填写腾讯云 COS Region 或 Endpoint");
if (values.mode === "qiniu" && !values.endpoint?.trim()) return message.error("请填写七牛云 Kodo 上传 Endpoint");
setSaving(true);
try {
const result = await updateAdminOSSSetting({
enabled: values.mode !== "local",
provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode,
region: values.region?.trim() || "",
endpoint: values.endpoint?.trim() || "",
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
publicBaseUrl: values.publicBaseUrl?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || "",
});
setSetting(result.setting);
form.setFieldsValue(formValues(result.setting));
message.success("存储配置已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存存储配置失败");
} finally {
setSaving(false);
}
};
return (
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
<div className="space-y-4 pt-4">
<SettingsSectionCard
layout="stacked"
contentClassName="px-4 pb-4"
icon={<Cloud className="size-4" />}
title="平台存储"
status={
<Space size={6}>
<AdminStatusBadge label={setting?.enabled ? storageProviderLabel(setting.provider) : "服务器本地"} tone="neutral" />
{setting?.enabled ? <AdminStatusBadge label={setting.hasAccessKeySecret ? configuredSecretText : "未保存密钥"} tone={setting.hasAccessKeySecret ? "success" : "warning"} /> : null}
</Space>
}
footer={
<>
<div className="text-xs text-foreground/45">
{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "尚未保存平台存储配置"}
</div>
<Button type="primary" loading={saving} onClick={() => void save()}>保存存储配置</Button>
</>
}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} className="px-5 pb-2">
<Form.Item name="mode" label="存储类型" rules={[{ required: true, message: "请选择存储类型" }]}>
<Segmented<StorageMode>
block
options={[
{ label: <span className="inline-flex items-center gap-2"><HardDrive className="size-4" />服务器本地</span>, value: "local" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />阿里云 OSS</span>, value: "aliyun" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />腾讯云 COS</span>, value: "tencent" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />七牛云 Kodo</span>, value: "qiniu" },
]}
onChange={(value) => {
const nextMode = value as StorageMode;
const switchingProvider = nextMode !== "local" && ((mode !== "local" && mode !== nextMode) || (mode === "local" && setting?.provider !== nextMode));
if (switchingProvider) form.setFieldsValue({ region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
}}
/>
</Form.Item>
{isObjectStorage ? (
<div className="space-y-1">
<div className="grid gap-x-4 gap-y-1 md:grid-cols-2 xl:grid-cols-3">
<Form.Item name="region" label="Region">
<Input autoComplete="off" placeholder={isTencentCOS ? "例如:ap-guangzhou" : isQiniuKodo ? "例如:z0 / cn-east-1" : "例如:oss-cn-hangzhou"} />
</Form.Item>
<Form.Item name="bucket" label="Bucket">
<Input autoComplete="off" placeholder={isQiniuKodo ? "七牛云存储空间名称" : "对象存储 Bucket"} />
</Form.Item>
<Form.Item name="pathPrefix" label="路径前缀">
<Input autoComplete="off" placeholder="可选,例如:canvas" />
</Form.Item>
</div>
<div className="grid gap-x-4 gap-y-1 md:grid-cols-2 xl:grid-cols-3">
<Form.Item className="xl:col-span-2" name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"}>
<Input autoComplete="off" inputMode="url" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
</Form.Item>
<Form.Item
name="cdnBaseUrl"
label={isQiniuKodo ? "绑定域名(可选)" : "CDN 加速域名"}
extra={isQiniuKodo ? "可选。填写后浏览器直连七牛私有下载地址;留空时采用“浏览器 → 当前后端 /api/resources/:id/file → 七牛 S3 Endpoint”的代理链路,后端使用 AK/SK 读取并返回文件,无需绑定域名。" : undefined}
rules={[{ type: "url", message: "请填写完整的 http/https 地址" }]}
>
<Input autoComplete="off" inputMode="url" placeholder="https://media.example.com" />
</Form.Item>
</div>
<div className="grid gap-x-4 gap-y-1 md:grid-cols-2">
<Form.Item name="accessKeyId" label={accessKeyIdLabel}>
<Input autoComplete="off" placeholder={isQiniuKodo ? "七牛云 AccessKey" : accessKeyIdLabel} />
</Form.Item>
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(${configuredSecretText})` : accessKeySecretLabel}>
<Input.Password autoComplete="new-password" placeholder={hasCurrentProviderSecret ? "留空保留原密钥" : accessKeySecretLabel} />
</Form.Item>
</div>
</div>
) : (
<Form.Item
label="服务器访问地址"
required
tooltip="用于生成本地资源的短时访问链接。"
name="publicBaseUrl"
rules={[{ required: true, message: "请填写服务器访问地址" }, { type: "url", message: "请填写完整的 http/https 地址" }]}
>
<Space.Compact className="w-full">
<Input className="min-w-0" autoComplete="off" placeholder="https://canvas.example.com" prefix={<Globe className="size-4 text-foreground/35" />} />
<Button icon={<LocateFixed className="size-4" />} onClick={() => form.setFieldValue("publicBaseUrl", window.location.origin)}>使用当前地址</Button>
</Space.Compact>
</Form.Item>
)}
</Form>
</SettingsSectionCard>
</div>
</AdminPageFrame>
);
}
function formValues(setting?: AdminOSSSetting | null): OSSFormValues {
return {
mode: setting?.enabled ? setting.provider : "local",
publicBaseUrl: setting?.publicBaseUrl || "",
region: setting?.region || "",
endpoint: setting?.endpoint || "",
cdnBaseUrl: setting?.cdnBaseUrl || "",
bucket: setting?.bucket || "",
accessKeyId: setting?.accessKeyId || "",
accessKeySecret: "",
pathPrefix: setting?.pathPrefix || "",
};
}
function storageProviderLabel(provider?: AdminOSSSetting["provider"] | StorageMode) {
return provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}