@@ -102,21 +134,26 @@ export function UserOSSSettingsForm() {
+ {isS3 ? (
+
+
+ ) : null}
-
+
-
-
+
+
-
+
@@ -139,13 +176,21 @@ export function UserOSSSettingsForm() {
+ {isS3 ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
-
{savedAt ? `上次保存:${savedAt}` : "尚未保存个人对象存储配置"}
-
+
{savedAt ? `上次保存:${savedAt}` : "尚未保存个人对象存储配置"}
+
);
@@ -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
需重新测试;
+ if (!result) return null;
+ return
{result.ok ? `测试通过${result.testedAt ? ` · ${formatSavedAt(result.testedAt)}` : ""}` : result.message || "测试失败"};
+}
diff --git a/web/src/lib/oss-settings.ts b/web/src/lib/oss-settings.ts
new file mode 100644
index 000000000..7053d5710
--- /dev/null
+++ b/web/src/lib/oss-settings.ts
@@ -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
= {
+ aws: {
+ region: "us-east-1",
+ endpoint: "https://s3.us-east-1.amazonaws.com",
+ help: "填写 AWS 区域对应的 S3 服务根 URL。",
+ },
+ r2: {
+ region: "auto",
+ endpoint: "https://.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) {
+ return Object.keys(changedValues).some((key) => CONNECTION_FIELDS.has(key));
+}
diff --git a/web/src/pages/admin/settings/storage-settings-page.tsx b/web/src/pages/admin/settings/storage-settings-page.tsx
index 7314a0209..7a2fd3bdd 100644
--- a/web/src/pages/admin/settings/storage-settings-page.tsx
+++ b/web/src/pages/admin/settings/storage-settings-page.tsx
@@ -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(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [testResult, setTestResult] = useState(null);
+ const [testStale, setTestStale] = useState(false);
const [form] = Form.useForm();
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 (
@@ -104,7 +140,7 @@ export default function StorageSettingsPage() {
>
}
>
-
block
@@ -113,35 +149,47 @@ export default function StorageSettingsPage() {
{ label: 阿里云 OSS, value: "aliyun" },
{ label: 腾讯云 COS, value: "tencent" },
{ label: 七牛云 Kodo, value: "qiniu" },
+ { label: S3 兼容存储, 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 });
}}
/>
{isObjectStorage ? (
) : (
)}
+
+
+
@@ -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 关键配置已变更,需重新测试;
+ if (!result) return 尚未测试连接;
+ return {result.ok ? `测试通过${result.testedAt ? ` · ${formatTime(result.testedAt)}` : ""}` : result.message || "测试失败"};
+}
diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts
index ef1c425eb..21d97973c 100644
--- a/web/src/services/api/auth.ts
+++ b/web/src/services/api/auth.ts
@@ -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) {
return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input));
}
+export function testAdminOSSConnection(input: OSSConnectionTestInput) {
+ return request(api.post("/admin/settings/oss/test", input));
+}
+
export function getAdminArkPrivateAssetSetting() {
return request<{ setting: AdminArkPrivateAssetSetting }>(api.get("/admin/settings/ark-private-assets"));
}
diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts
index 836514ef1..fb2910ed4 100644
--- a/web/src/services/api/resources.ts
+++ b/web/src/services/api/resources.ts
@@ -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 & {
+export type UserOSSSettingInput = Pick & {
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(api.post("/settings/oss/test", input));
+}
+
export async function getAccountFileStorageUsage() {
const data = await request<{ usage: AccountFileStorageUsage }>(api.get("/resources/storage-usage"));
return data.usage;
diff --git a/web/test/oss-settings.test.ts b/web/test/oss-settings.test.ts
new file mode 100644
index 000000000..e2127e2a9
--- /dev/null
+++ b/web/test/oss-settings.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test";
+
+import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints } from "../src/lib/oss-settings";
+
+describe("OSS settings helpers", () => {
+ test("provides editable S3 endpoint hints for known presets", () => {
+ expect(getS3PresetHints("r2")).toMatchObject({ region: "auto" });
+ expect(getS3PresetHints("b2").endpoint).toContain("backblazeb2.com");
+ });
+
+ test("only connection fields invalidate a previous test", () => {
+ expect(changesRequireOSSRetest({ endpoint: "https://s3.example.com" })).toBe(true);
+ expect(changesRequireOSSRetest({ enabled: true })).toBe(false);
+ expect(changesRequireOSSRetest({ allowUserS3: true })).toBe(false);
+ });
+
+ test("uses the product path prefix by default", () => {
+ expect(DEFAULT_OSS_PATH_PREFIX).toBe("open-ai-canvas");
+ });
+});