支持 S3 兼容对象存储 (#336)
* feat(storage): 支持 S3 对象存储 * fix(storage): 补齐 S3 对象文件后缀
This commit is contained in:
@@ -2,19 +2,23 @@ import { App, Button, Form, Input, Select, Switch, Tag } from "antd";
|
||||
import { Cloud, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getUserOSSSetting, updateUserOSSSetting, type UserOSSSetting } from "@/services/api/resources";
|
||||
import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type OSSProvider, type S3Preset } from "@/lib/oss-settings";
|
||||
import { getUserOSSSetting, testUserOSSConnection, updateUserOSSSetting, type UserOSSSetting } from "@/services/api/resources";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type OSSFormValues = {
|
||||
enabled?: boolean;
|
||||
provider: "aliyun" | "tencent" | "qiniu";
|
||||
provider: OSSProvider;
|
||||
s3Preset?: S3Preset;
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
cdnBaseUrl?: string;
|
||||
bucket?: string;
|
||||
accessKeyId?: string;
|
||||
accessKeySecret?: string;
|
||||
sessionToken?: string;
|
||||
pathPrefix?: string;
|
||||
pathStyle?: boolean;
|
||||
};
|
||||
|
||||
export function UserOSSSettingsForm() {
|
||||
@@ -24,10 +28,15 @@ export function UserOSSSettingsForm() {
|
||||
const [setting, setSetting] = useState<UserOSSSetting | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<OSSConnectionTestResult | null>(null);
|
||||
const [testStale, setTestStale] = useState(false);
|
||||
const savedAt = formatSavedAt(setting?.updatedAt);
|
||||
const provider = Form.useWatch("provider", form) || "aliyun";
|
||||
const isTencentCOS = provider === "tencent";
|
||||
const isQiniuKodo = provider === "qiniu";
|
||||
const isS3 = provider === "s3";
|
||||
const s3Preset = Form.useWatch("s3Preset", form) || "custom";
|
||||
const hasCurrentProviderSecret = Boolean(setting && setting.provider === provider && setting.hasAccessKeySecret);
|
||||
const accessKeyIdLabel = isTencentCOS ? "SecretId" : isQiniuKodo ? "AccessKey" : "AccessKey ID";
|
||||
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : isQiniuKodo ? "SecretKey" : "AccessKey Secret";
|
||||
@@ -41,6 +50,7 @@ export function UserOSSSettingsForm() {
|
||||
if (!active) return;
|
||||
setSetting(data.setting);
|
||||
form.setFieldsValue(toFormValues(data.setting));
|
||||
setTestResult(data.setting.testedAt ? { ok: true, testedAt: data.setting.testedAt, testedDigest: data.setting.testedDigest } : null);
|
||||
})
|
||||
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人对象存储配置失败"))
|
||||
.finally(() => active && setLoading(false));
|
||||
@@ -55,21 +65,27 @@ export function UserOSSSettingsForm() {
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
if (values.enabled && values.provider === "s3" && !setting?.allowUserS3) return message.error("平台管理员尚未允许个人 S3 兼容存储");
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = await updateUserOSSSetting({
|
||||
enabled: values.enabled === true,
|
||||
provider: values.provider || "aliyun",
|
||||
s3Preset: values.s3Preset || "custom",
|
||||
region: values.region?.trim() || "",
|
||||
endpoint: values.endpoint?.trim() || "",
|
||||
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
|
||||
bucket: values.bucket?.trim() || "",
|
||||
accessKeyId: values.accessKeyId?.trim() || "",
|
||||
accessKeySecret: values.accessKeySecret?.trim() || "",
|
||||
pathPrefix: values.pathPrefix?.trim() || "",
|
||||
sessionToken: values.sessionToken?.trim() || "",
|
||||
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
|
||||
pathStyle: values.pathStyle === true,
|
||||
});
|
||||
setSetting(data.setting);
|
||||
form.setFieldsValue(toFormValues(data.setting));
|
||||
setTestResult(data.setting.testedAt ? { ok: true, testedAt: data.setting.testedAt, testedDigest: data.setting.testedDigest } : null);
|
||||
setTestStale(false);
|
||||
message.success(data.setting.enabled ? "个人对象存储已启用,后续上传将优先使用该存储" : "个人对象存储已停用,后续上传将使用平台存储");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存个人对象存储配置失败");
|
||||
@@ -78,8 +94,24 @@ export function UserOSSSettingsForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
const values = await form.validateFields();
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testUserOSSConnection(connectionInput(values));
|
||||
setTestResult(result);
|
||||
setTestStale(false);
|
||||
result.ok ? message.success(result.message || "连接测试通过") : message.error(result.message || "连接测试失败");
|
||||
} catch (error) {
|
||||
setTestResult({ ok: false, message: error instanceof Error ? error.message : "连接测试失败" });
|
||||
setTestStale(false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={(changed) => changesRequireOSSRetest(changed) && setTestStale(true)}>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
@@ -102,21 +134,26 @@ export function UserOSSSettingsForm() {
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="存储服务" rules={[{ required: true, message: "请选择存储服务" }]} className="mb-3">
|
||||
<Select
|
||||
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }]}
|
||||
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }, { label: "S3 兼容存储", value: "s3", disabled: setting?.allowUserS3 === false }]}
|
||||
onChange={(nextProvider: OSSFormValues["provider"]) => {
|
||||
if (nextProvider !== provider) form.setFieldsValue({ region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
if (nextProvider !== provider) form.setFieldsValue({ s3Preset: "custom", region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "", sessionToken: "", pathStyle: false });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isS3 ? (
|
||||
<Form.Item name="s3Preset" label="S3 预设" className="mb-3">
|
||||
<Select options={S3_PRESET_OPTIONS} onChange={(preset: S3Preset) => form.setFieldsValue(getS3PresetHints(preset))} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="region" label="Region" className="mb-3">
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "ap-guangzhou" : isQiniuKodo ? "z0 / cn-east-1" : "oss-cn-hangzhou"} />
|
||||
<Input spellCheck={false} placeholder={isS3 ? getS3PresetHints(s3Preset).region : isTencentCOS ? "ap-guangzhou" : isQiniuKodo ? "z0 / cn-east-1" : "oss-cn-hangzhou"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"} extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
|
||||
<Input inputMode="url" spellCheck={false} placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
|
||||
<Form.Item name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"} extra={isS3 ? getS3PresetHints(s3Preset).help : isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
|
||||
<Input inputMode="url" spellCheck={false} placeholder={isS3 ? getS3PresetHints(s3Preset).endpoint : 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 加速域名"}
|
||||
label={isQiniuKodo ? "绑定域名(可选)" : isS3 ? "公开 CDN(可选)" : "CDN 加速域名"}
|
||||
extra={isTencentCOS
|
||||
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"
|
||||
: isQiniuKodo
|
||||
@@ -131,7 +168,7 @@ export function UserOSSSettingsForm() {
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : isQiniuKodo ? "七牛云存储空间名称" : "my-canvas-assets"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pathPrefix" label="路径前缀" className="mb-3">
|
||||
<Input spellCheck={false} placeholder="infinite-canvas" />
|
||||
<Input spellCheck={false} placeholder={DEFAULT_OSS_PATH_PREFIX} />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessKeyId" label={accessKeyIdLabel} className="mb-3 xl:col-span-1">
|
||||
<Input autoComplete="off" spellCheck={false} placeholder={isTencentCOS ? "腾讯云 SecretId" : isQiniuKodo ? "七牛云 AccessKey" : "阿里云 AccessKey ID"} />
|
||||
@@ -139,13 +176,21 @@ export function UserOSSSettingsForm() {
|
||||
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(留空保留)` : accessKeySecretLabel} className="mb-3 xl:col-span-2">
|
||||
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={hasCurrentProviderSecret ? "留空保留已加密密钥" : isTencentCOS ? "腾讯云 SecretKey" : isQiniuKodo ? "七牛云 SecretKey" : "阿里云 AccessKey Secret"} />
|
||||
</Form.Item>
|
||||
{isS3 ? (
|
||||
<>
|
||||
<Form.Item name="sessionToken" label={setting?.hasSessionToken ? "Session Token(留空保留)" : "Session Token(可选)"} className="mb-3">
|
||||
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={setting?.hasSessionToken ? "留空保留已加密 Token" : "临时凭证使用的 Session Token"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pathStyle" label="Path Style" valuePropName="checked" extra="开启后强制 path-style;关闭时自动选择。" className="mb-3">
|
||||
<Switch checkedChildren="强制" unCheckedChildren="自动" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
|
||||
<span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人对象存储配置"}</span>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存个人对象存储
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人对象存储配置"}</span><ConnectionTestStatus result={testResult} stale={testStale} /></div>
|
||||
<div className="flex flex-wrap gap-2"><Button loading={testing} onClick={() => void testConnection()}>测试连接</Button><Button type="primary" loading={saving} onClick={() => void save()}>保存个人对象存储</Button></div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
@@ -162,12 +207,37 @@ function toFormValues(setting: UserOSSSetting): OSSFormValues {
|
||||
return {
|
||||
enabled: setting.enabled,
|
||||
provider: setting.provider || "aliyun",
|
||||
s3Preset: setting.s3Preset || "custom",
|
||||
region: setting.region,
|
||||
endpoint: setting.endpoint,
|
||||
cdnBaseUrl: setting.cdnBaseUrl,
|
||||
bucket: setting.bucket,
|
||||
accessKeyId: setting.accessKeyId,
|
||||
accessKeySecret: "",
|
||||
pathPrefix: setting.pathPrefix,
|
||||
sessionToken: "",
|
||||
pathPrefix: setting.pathPrefix || DEFAULT_OSS_PATH_PREFIX,
|
||||
pathStyle: setting.pathStyle === true,
|
||||
};
|
||||
}
|
||||
|
||||
function connectionInput(values: OSSFormValues) {
|
||||
return {
|
||||
provider: values.provider,
|
||||
s3Preset: values.s3Preset,
|
||||
region: values.region?.trim() || "",
|
||||
endpoint: values.endpoint?.trim() || "",
|
||||
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
|
||||
bucket: values.bucket?.trim() || "",
|
||||
accessKeyId: values.accessKeyId?.trim() || "",
|
||||
accessKeySecret: values.accessKeySecret?.trim() || "",
|
||||
sessionToken: values.sessionToken?.trim() || "",
|
||||
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
|
||||
pathStyle: values.pathStyle === true,
|
||||
};
|
||||
}
|
||||
|
||||
function ConnectionTestStatus({ result, stale }: { result: OSSConnectionTestResult | null; stale: boolean }) {
|
||||
if (stale) return <Tag color="warning">需重新测试</Tag>;
|
||||
if (!result) return null;
|
||||
return <Tag color={result.ok ? "success" : "error"}>{result.ok ? `测试通过${result.testedAt ? ` · ${formatSavedAt(result.testedAt)}` : ""}` : result.message || "测试失败"}</Tag>;
|
||||
}
|
||||
|
||||
84
web/src/lib/oss-settings.ts
Normal file
84
web/src/lib/oss-settings.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type OSSProvider = "aliyun" | "tencent" | "qiniu" | "s3";
|
||||
|
||||
export type S3Preset = "aws" | "r2" | "b2" | "rustfs" | "custom";
|
||||
|
||||
export const DEFAULT_OSS_PATH_PREFIX = "open-ai-canvas";
|
||||
|
||||
export type OSSConnectionTestInput = {
|
||||
provider: OSSProvider;
|
||||
s3Preset?: S3Preset;
|
||||
region: string;
|
||||
endpoint: string;
|
||||
cdnBaseUrl: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret?: string;
|
||||
sessionToken?: string;
|
||||
pathPrefix: string;
|
||||
pathStyle?: boolean;
|
||||
};
|
||||
|
||||
export type OSSConnectionTestResult = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
testedAt?: string;
|
||||
testedDigest?: string;
|
||||
};
|
||||
|
||||
export const S3_PRESET_OPTIONS: Array<{ label: string; value: S3Preset }> = [
|
||||
{ label: "AWS S3", value: "aws" },
|
||||
{ label: "Cloudflare R2", value: "r2" },
|
||||
{ label: "Backblaze B2", value: "b2" },
|
||||
{ label: "RustFS", value: "rustfs" },
|
||||
{ label: "自定义", value: "custom" },
|
||||
];
|
||||
|
||||
const S3_PRESET_HINTS: Record<S3Preset, { region: string; endpoint: string; help: string }> = {
|
||||
aws: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://s3.us-east-1.amazonaws.com",
|
||||
help: "填写 AWS 区域对应的 S3 服务根 URL。",
|
||||
},
|
||||
r2: {
|
||||
region: "auto",
|
||||
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
||||
help: "Endpoint 使用 R2 控制台提供的账户级 S3 API 根 URL。",
|
||||
},
|
||||
b2: {
|
||||
region: "us-west-004",
|
||||
endpoint: "https://s3.us-west-004.backblazeb2.com",
|
||||
help: "Region 和 Endpoint 应与 Backblaze B2 Bucket 所在区域一致。",
|
||||
},
|
||||
rustfs: {
|
||||
region: "us-east-1",
|
||||
endpoint: "http://127.0.0.1:9000",
|
||||
help: "填写后端可访问的 RustFS S3 服务根 URL。",
|
||||
},
|
||||
custom: {
|
||||
region: "us-east-1",
|
||||
endpoint: "https://s3.example.com",
|
||||
help: "填写兼容 S3 API 的服务根 URL,不要包含 Bucket 或对象路径。",
|
||||
},
|
||||
};
|
||||
|
||||
const CONNECTION_FIELDS = new Set([
|
||||
"provider",
|
||||
"s3Preset",
|
||||
"region",
|
||||
"endpoint",
|
||||
"cdnBaseUrl",
|
||||
"bucket",
|
||||
"accessKeyId",
|
||||
"accessKeySecret",
|
||||
"sessionToken",
|
||||
"pathPrefix",
|
||||
"pathStyle",
|
||||
]);
|
||||
|
||||
export function getS3PresetHints(preset?: S3Preset) {
|
||||
return S3_PRESET_HINTS[preset || "custom"];
|
||||
}
|
||||
|
||||
export function changesRequireOSSRetest(changedValues: Record<string, unknown>) {
|
||||
return Object.keys(changedValues).some((key) => CONNECTION_FIELDS.has(key));
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { App, Button, Form, Input, Segmented, Space } from "antd";
|
||||
import { Cloud, Globe, HardDrive, LocateFixed } from "lucide-react";
|
||||
import { App, Button, Form, Input, Segmented, Select, Space, Switch, Tag } from "antd";
|
||||
import { Cloud, Globe, HardDrive, LocateFixed, Wifi } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAdminOSSSetting, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
|
||||
import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type S3Preset } from "@/lib/oss-settings";
|
||||
import { getAdminOSSSetting, testAdminOSSConnection, 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 StorageMode = "local" | AdminOSSSetting["provider"];
|
||||
type OSSFormValues = {
|
||||
mode: StorageMode;
|
||||
s3Preset?: S3Preset;
|
||||
publicBaseUrl?: string;
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
@@ -17,7 +19,10 @@ type OSSFormValues = {
|
||||
bucket?: string;
|
||||
accessKeyId?: string;
|
||||
accessKeySecret?: string;
|
||||
sessionToken?: string;
|
||||
pathPrefix?: string;
|
||||
pathStyle?: boolean;
|
||||
allowUserS3?: boolean;
|
||||
};
|
||||
|
||||
export default function StorageSettingsPage() {
|
||||
@@ -26,11 +31,16 @@ export default function StorageSettingsPage() {
|
||||
const [setting, setSetting] = useState<AdminOSSSetting | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<OSSConnectionTestResult | null>(null);
|
||||
const [testStale, setTestStale] = 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 isS3 = mode === "s3";
|
||||
const s3Preset = Form.useWatch("s3Preset", form) || "custom";
|
||||
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);
|
||||
@@ -41,6 +51,7 @@ export default function StorageSettingsPage() {
|
||||
.then(({ setting: value }) => {
|
||||
setSetting(value);
|
||||
form.setFieldsValue(formValues(value));
|
||||
setTestResult(value.testedAt ? { ok: true, testedAt: value.testedAt, testedDigest: value.testedDigest } : null);
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取对象存储配置失败"))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -56,23 +67,31 @@ export default function StorageSettingsPage() {
|
||||
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");
|
||||
if (values.mode === "s3" && !values.region?.trim()) return message.error("请填写 S3 Region");
|
||||
if (values.mode === "s3" && !values.endpoint?.trim()) return message.error("请填写 S3 Endpoint 服务根 URL");
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateAdminOSSSetting({
|
||||
enabled: values.mode !== "local",
|
||||
provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode,
|
||||
s3Preset: values.s3Preset || "custom",
|
||||
region: values.region?.trim() || "",
|
||||
endpoint: values.endpoint?.trim() || "",
|
||||
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
|
||||
bucket: values.bucket?.trim() || "",
|
||||
accessKeyId: values.accessKeyId?.trim() || "",
|
||||
accessKeySecret: values.accessKeySecret?.trim() || "",
|
||||
sessionToken: values.sessionToken?.trim() || "",
|
||||
pathStyle: values.pathStyle === true,
|
||||
allowUserS3: values.allowUserS3 === true,
|
||||
publicBaseUrl: values.publicBaseUrl?.trim() || "",
|
||||
pathPrefix: values.pathPrefix?.trim() || "",
|
||||
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
|
||||
});
|
||||
setSetting(result.setting);
|
||||
form.setFieldsValue(formValues(result.setting));
|
||||
setTestResult(result.setting.testedAt ? { ok: true, testedAt: result.setting.testedAt, testedDigest: result.setting.testedDigest } : null);
|
||||
setTestStale(false);
|
||||
message.success("存储配置已保存");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存存储配置失败");
|
||||
@@ -81,6 +100,23 @@ export default function StorageSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
const values = await form.validateFields();
|
||||
if (values.mode === "local") return;
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAdminOSSConnection(connectionInput(values));
|
||||
setTestResult(result);
|
||||
setTestStale(false);
|
||||
result.ok ? message.success(result.message || "连接测试通过") : message.error(result.message || "连接测试失败");
|
||||
} catch (error) {
|
||||
setTestResult({ ok: false, message: error instanceof Error ? error.message : "连接测试失败" });
|
||||
setTestStale(false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
|
||||
<div className="space-y-4 pt-4">
|
||||
@@ -104,7 +140,7 @@ export default function StorageSettingsPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} className="px-5 pb-2">
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} className="px-5 pb-2" onValuesChange={(changed) => changesRequireOSSRetest(changed) && setTestStale(true)}>
|
||||
<Form.Item name="mode" label="存储类型" rules={[{ required: true, message: "请选择存储类型" }]}>
|
||||
<Segmented<StorageMode>
|
||||
block
|
||||
@@ -113,35 +149,47 @@ export default function StorageSettingsPage() {
|
||||
{ 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" },
|
||||
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />S3 兼容存储</span>, value: "s3" },
|
||||
]}
|
||||
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: "" });
|
||||
if (switchingProvider) form.setFieldsValue({ s3Preset: "custom", region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "", sessionToken: "", pathStyle: false });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{isObjectStorage ? (
|
||||
<div className="space-y-1">
|
||||
{isS3 ? (
|
||||
<Form.Item name="s3Preset" label="S3 预设">
|
||||
<Select
|
||||
options={S3_PRESET_OPTIONS}
|
||||
onChange={(preset: S3Preset) => {
|
||||
const hints = getS3PresetHints(preset);
|
||||
form.setFieldsValue({ region: hints.region, endpoint: hints.endpoint });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<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"} />
|
||||
<Input autoComplete="off" placeholder={isS3 ? getS3PresetHints(s3Preset).region : 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" />
|
||||
<Input autoComplete="off" placeholder={DEFAULT_OSS_PATH_PREFIX} />
|
||||
</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 className="xl:col-span-2" name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"} extra={isS3 ? getS3PresetHints(s3Preset).help : undefined}>
|
||||
<Input autoComplete="off" inputMode="url" placeholder={isS3 ? getS3PresetHints(s3Preset).endpoint : 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 加速域名"}
|
||||
label={isQiniuKodo ? "绑定域名(可选)" : isS3 ? "公开 CDN(可选)" : "CDN 加速域名"}
|
||||
extra={isQiniuKodo ? "可选。填写后浏览器直连七牛私有下载地址;留空时采用“浏览器 → 当前后端 /api/resources/:id/file → 七牛 S3 Endpoint”的代理链路,后端使用 AK/SK 读取并返回文件,无需绑定域名。" : undefined}
|
||||
rules={[{ type: "url", message: "请填写完整的 http/https 地址" }]}
|
||||
>
|
||||
@@ -156,6 +204,20 @@ export default function StorageSettingsPage() {
|
||||
<Input.Password autoComplete="new-password" placeholder={hasCurrentProviderSecret ? "留空保留原密钥" : accessKeySecretLabel} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{isS3 ? (
|
||||
<div className="grid gap-x-4 gap-y-1 md:grid-cols-2">
|
||||
<Form.Item name="sessionToken" label={setting?.hasSessionToken ? `Session Token(${configuredSecretText},留空保留)` : "Session Token(可选)"}>
|
||||
<Input.Password autoComplete="new-password" placeholder={setting?.hasSessionToken ? "留空保留原 Token" : "临时凭证使用的 Session Token"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pathStyle" label="Path Style" valuePropName="checked" extra="开启后强制使用 path-style;关闭时由后端自动选择。">
|
||||
<Switch checkedChildren="强制" unCheckedChildren="自动" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Button icon={<Wifi className="size-4" />} loading={testing} onClick={() => void testConnection()}>测试连接</Button>
|
||||
<ConnectionTestStatus result={testResult} stale={testStale} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Form.Item
|
||||
@@ -171,6 +233,9 @@ export default function StorageSettingsPage() {
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="allowUserS3" label="允许个人 S3 兼容存储" valuePropName="checked" extra="开启后,用户可配置个人 S3;个人配置启用时优先于平台存储,停用时回退平台存储。">
|
||||
<Switch checkedChildren="允许" unCheckedChildren="不允许" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
@@ -181,6 +246,7 @@ export default function StorageSettingsPage() {
|
||||
function formValues(setting?: AdminOSSSetting | null): OSSFormValues {
|
||||
return {
|
||||
mode: setting?.enabled ? setting.provider : "local",
|
||||
s3Preset: setting?.s3Preset || "custom",
|
||||
publicBaseUrl: setting?.publicBaseUrl || "",
|
||||
region: setting?.region || "",
|
||||
endpoint: setting?.endpoint || "",
|
||||
@@ -188,14 +254,39 @@ function formValues(setting?: AdminOSSSetting | null): OSSFormValues {
|
||||
bucket: setting?.bucket || "",
|
||||
accessKeyId: setting?.accessKeyId || "",
|
||||
accessKeySecret: "",
|
||||
pathPrefix: setting?.pathPrefix || "",
|
||||
sessionToken: "",
|
||||
pathPrefix: setting?.pathPrefix || DEFAULT_OSS_PATH_PREFIX,
|
||||
pathStyle: setting?.pathStyle === true,
|
||||
allowUserS3: setting?.allowUserS3 === true,
|
||||
};
|
||||
}
|
||||
|
||||
function storageProviderLabel(provider?: AdminOSSSetting["provider"] | StorageMode) {
|
||||
return provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
|
||||
return provider === "s3" ? "S3 兼容存储" : provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
}
|
||||
|
||||
function connectionInput(values: OSSFormValues) {
|
||||
return {
|
||||
provider: values.mode === "local" ? "aliyun" as const : values.mode,
|
||||
s3Preset: values.s3Preset,
|
||||
region: values.region?.trim() || "",
|
||||
endpoint: values.endpoint?.trim() || "",
|
||||
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
|
||||
bucket: values.bucket?.trim() || "",
|
||||
accessKeyId: values.accessKeyId?.trim() || "",
|
||||
accessKeySecret: values.accessKeySecret?.trim() || "",
|
||||
sessionToken: values.sessionToken?.trim() || "",
|
||||
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
|
||||
pathStyle: values.pathStyle === true,
|
||||
};
|
||||
}
|
||||
|
||||
function ConnectionTestStatus({ result, stale }: { result: OSSConnectionTestResult | null; stale: boolean }) {
|
||||
if (stale) return <Tag color="warning">关键配置已变更,需重新测试</Tag>;
|
||||
if (!result) return <span className="text-xs text-foreground/50">尚未测试连接</span>;
|
||||
return <Tag color={result.ok ? "success" : "error"}>{result.ok ? `测试通过${result.testedAt ? ` · ${formatTime(result.testedAt)}` : ""}` : result.message || "测试失败"}</Tag>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-eng
|
||||
import type { FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
import type { PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
|
||||
|
||||
const api = apiClient;
|
||||
|
||||
@@ -257,7 +258,8 @@ export type UserPromptPreference = {
|
||||
|
||||
export type AdminOSSSetting = {
|
||||
enabled: boolean;
|
||||
provider: "aliyun" | "tencent" | "qiniu";
|
||||
provider: OSSProvider;
|
||||
s3Preset: S3Preset;
|
||||
region: string;
|
||||
endpoint: string;
|
||||
cdnBaseUrl: string;
|
||||
@@ -265,8 +267,16 @@ export type AdminOSSSetting = {
|
||||
accessKeyId: string;
|
||||
accessKeySecret?: string;
|
||||
hasAccessKeySecret: boolean;
|
||||
sessionToken?: string;
|
||||
hasSessionToken: boolean;
|
||||
pathStyle: boolean;
|
||||
allowUserS3: boolean;
|
||||
publicBaseUrl: string;
|
||||
pathPrefix: string;
|
||||
testedAt?: string;
|
||||
testedDigest?: string;
|
||||
historyCount?: number;
|
||||
referencedResourceCount?: number;
|
||||
updatedBy?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -501,6 +511,10 @@ export function updateAdminOSSSetting(input: Partial<AdminOSSSetting>) {
|
||||
return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input));
|
||||
}
|
||||
|
||||
export function testAdminOSSConnection(input: OSSConnectionTestInput) {
|
||||
return request<OSSConnectionTestResult>(api.post("/admin/settings/oss/test", input));
|
||||
}
|
||||
|
||||
export function getAdminArkPrivateAssetSetting() {
|
||||
return request<{ setting: AdminArkPrivateAssetSetting }>(api.get("/admin/settings/ark-private-assets"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import axios from "axios";
|
||||
import { apiBaseURL, apiClient, request, type BackendEnvelope } from "@/services/api/request";
|
||||
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
|
||||
|
||||
export type RemoteResource = {
|
||||
id: string;
|
||||
@@ -25,20 +26,30 @@ export type RemoteResource = {
|
||||
|
||||
export type UserOSSSetting = {
|
||||
enabled: boolean;
|
||||
provider: "aliyun" | "tencent" | "qiniu";
|
||||
provider: OSSProvider;
|
||||
s3Preset: S3Preset;
|
||||
region: string;
|
||||
endpoint: string;
|
||||
cdnBaseUrl: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
hasAccessKeySecret: boolean;
|
||||
sessionToken?: string;
|
||||
hasSessionToken: boolean;
|
||||
pathStyle: boolean;
|
||||
allowUserS3: boolean;
|
||||
publicBaseUrl: string;
|
||||
pathPrefix: string;
|
||||
testedAt?: string;
|
||||
testedDigest?: string;
|
||||
historyCount?: number;
|
||||
referencedResourceCount?: number;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type UserOSSSettingInput = Pick<UserOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "pathPrefix"> & {
|
||||
export type UserOSSSettingInput = Pick<UserOSSSetting, "enabled" | "provider" | "s3Preset" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "pathPrefix" | "pathStyle"> & {
|
||||
accessKeySecret?: string;
|
||||
sessionToken?: string;
|
||||
};
|
||||
|
||||
export type AccountFileStorageUsage = {
|
||||
@@ -68,6 +79,10 @@ export function updateUserOSSSetting(input: UserOSSSettingInput) {
|
||||
return request<{ setting: UserOSSSetting }>(api.patch("/settings/oss", input));
|
||||
}
|
||||
|
||||
export function testUserOSSConnection(input: OSSConnectionTestInput) {
|
||||
return request<OSSConnectionTestResult>(api.post("/settings/oss/test", input));
|
||||
}
|
||||
|
||||
export async function getAccountFileStorageUsage() {
|
||||
const data = await request<{ usage: AccountFileStorageUsage }>(api.get("/resources/storage-usage"));
|
||||
return data.usage;
|
||||
|
||||
Reference in New Issue
Block a user