feat(platform): 模型管理与后台能力 - 新增逻辑模型路由和响应拦截
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { App, Button, Input, Segmented, Tag } from "antd";
|
||||
import { App, Button, Input, Segmented } from "antd";
|
||||
import { Brush, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { drawingEngineLabel, isDrawingEngineAvailable, type CanvasDrawingEngine
|
||||
import { getAdminDrawingEngineSetting, updateAdminDrawingEngineSetting } from "@/services/api/auth";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { SettingsSectionCard } from "../components/admin-ui";
|
||||
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
export default function DrawingEngineSettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
@@ -60,7 +60,7 @@ export default function DrawingEngineSettingsPage() {
|
||||
icon={<Brush className="size-4" />}
|
||||
title="默认绘图引擎"
|
||||
description="已有绘图保持原引擎,仅新建绘图使用这里的选择。"
|
||||
status={<Tag variant="filled" color="blue">{drawingEngineLabel(current.defaultEngine)}</Tag>}
|
||||
status={<AdminStatusBadge label={drawingEngineLabel(current.defaultEngine)} tone="info" />}
|
||||
footer={(
|
||||
<>
|
||||
<span className={`text-xs ${tldrawAvailable ? "text-foreground/45" : "text-amber-600 dark:text-amber-400"}`}>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { App, Button, Input, Switch } from "antd";
|
||||
import { Plus, Save, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getAdminResponseInterceptionSetting, updateAdminResponseInterceptionSetting, type ResponseInterceptionRule } from "@/services/api/response-interception";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
const emptyRule = (): ResponseInterceptionRule => ({ contains: "", replace: "" });
|
||||
|
||||
export default function ResponseInterceptionSettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [rules, setRules] = useState<ResponseInterceptionRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAdminResponseInterceptionSetting()
|
||||
.then(({ setting }) => {
|
||||
if (cancelled) return;
|
||||
setEnabled(setting.enabled);
|
||||
setRules(setting.rules || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) message.error(error instanceof Error ? error.message : "读取模型响应拦截配置失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
const updateRule = (index: number, field: keyof ResponseInterceptionRule, value: string) => {
|
||||
setRules((current) => current.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, [field]: value } : rule)));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const normalizedRules = rules.map((rule) => ({ contains: rule.contains.trim(), replace: rule.replace.trim() }));
|
||||
const invalidIndex = normalizedRules.findIndex((rule) => !rule.contains || !rule.replace);
|
||||
if (invalidIndex >= 0) {
|
||||
message.error(`请完整填写第 ${invalidIndex + 1} 条规则`);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { setting } = await updateAdminResponseInterceptionSetting({ enabled, rules: normalizedRules });
|
||||
setEnabled(setting.enabled);
|
||||
setRules(setting.rules || []);
|
||||
message.success("模型响应拦截配置已保存");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存模型响应拦截配置失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="模型响应拦截">
|
||||
<div className="pt-4">
|
||||
<SettingsSectionCard
|
||||
layout="stacked"
|
||||
contentClassName="px-4 pb-4"
|
||||
icon={<ShieldAlert className="size-4" />}
|
||||
title="用户可见错误"
|
||||
status={<AdminStatusBadge label={enabled ? "已启用" : "未启用"} tone={enabled ? "success" : "neutral"} />}
|
||||
footer={
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-foreground/55">
|
||||
<Switch size="small" checked={enabled} disabled={loading || saving} onChange={setEnabled} />
|
||||
启用拦截规则
|
||||
</div>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading} onClick={() => void save()}>
|
||||
保存配置
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium text-foreground/48">
|
||||
<span className="min-w-0 flex-1">上游响应包含</span>
|
||||
<span className="min-w-0 flex-1">替换为</span>
|
||||
<span className="size-6 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
{rules.map((rule, index) => (
|
||||
<div key={index} className="flex flex-col gap-2 rounded-md border border-border/50 bg-muted/5 p-2 sm:flex-row sm:items-center">
|
||||
<Input className="min-w-0 flex-1" size="small" value={rule.contains} disabled={loading || saving} onChange={(event) => updateRule(index, "contains", event.target.value)} placeholder="例如:余额不足、429" />
|
||||
<Input className="min-w-0 flex-1" size="small" value={rule.replace} disabled={loading || saving} onChange={(event) => updateRule(index, "replace", event.target.value)} placeholder="例如:网络异常,请重试" />
|
||||
<Button
|
||||
className="shrink-0 self-end sm:self-auto"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
aria-label={`删除第 ${index + 1} 条规则`}
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
disabled={loading || saving}
|
||||
onClick={() => setRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button type="text" size="small" icon={<Plus className="size-3.5" />} disabled={loading || saving} onClick={() => setRules((current) => [...current, emptyRule()])}>
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App, Button, Form, InputNumber, Tag } from "antd";
|
||||
import { App, Button, Form, InputNumber } from "antd";
|
||||
import { Database, Gauge, Infinity as InfinityIcon, Network, RotateCcw, Save, ShieldCheck, TimerReset } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/services/api/auth";
|
||||
import { useAdminContext } from "../admin-context";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { SettingsSectionCard } from "../components/admin-ui";
|
||||
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
type PolicyGroup = "resource" | "task" | "request";
|
||||
type PolicyField = {
|
||||
@@ -167,7 +167,7 @@ export default function RuntimePolicySettingsPage() {
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={() => setDirty(true)}>
|
||||
<div className="space-y-3 pt-4">
|
||||
<PolicySection icon={<Database className="size-4" />} title="资源与账号配额" description="上传、文件容量、结构化数据和历史记录上限。" fields={resourceFields} />
|
||||
<PolicySection icon={<Gauge className="size-4" />} title="任务与并发" description="后台任务消费、渠道调度和单账号活动任务上限。" fields={concurrencyFields} status={<Tag variant="filled" color="blue">热更新</Tag>} />
|
||||
<PolicySection icon={<Gauge className="size-4" />} title="任务与并发" description="后台任务消费、渠道调度和单账号活动任务上限。" fields={concurrencyFields} status={<AdminStatusBadge label="热更新" tone="info" />} />
|
||||
<PolicySection icon={<TimerReset className="size-4" />} title="任务超时" description="不同生成类型的最长执行时间。" fields={timeoutFields} />
|
||||
<PolicySection icon={<ShieldCheck className="size-4" />} title="业务频控" description="账号与 IP 维度的固定窗口请求限制。" fields={rateFields} />
|
||||
<PolicySection icon={<Network className="size-4" />} title="渠道中转与熔断" description="请求体、响应体、并发、超时和上游故障保护。" fields={relayFields} />
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { App, Button, Form, Input, Segmented, Space, Tag } from "antd";
|
||||
import { Cloud, Globe, HardDrive, Info, KeyRound, LocateFixed, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
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 { configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
|
||||
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
type StorageMode = "local" | "aliyun" | "tencent";
|
||||
type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; cdnBaseUrl?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string };
|
||||
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();
|
||||
@@ -18,35 +28,50 @@ export default function StorageSettingsPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm<OSSFormValues>();
|
||||
const mode = Form.useWatch("mode", form) || "local";
|
||||
const cdnBaseUrl = Form.useWatch("cdnBaseUrl", form);
|
||||
const isObjectStorage = mode !== "local";
|
||||
const isTencentCOS = mode === "tencent";
|
||||
const selectedProviderLabel = isTencentCOS ? "腾讯云 COS" : "阿里云 OSS";
|
||||
const accessKeyIdLabel = isTencentCOS ? "SecretId" : "AccessKey ID";
|
||||
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : "AccessKey Secret";
|
||||
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)); })
|
||||
.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();
|
||||
// 本地模式会卸载对象存储字段,读取完整 store 才能保留已保存的配置。
|
||||
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(`请填写${values.mode === "tencent" ? " SecretKey" : " AccessKey Secret"}`);
|
||||
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 !== "local" && !values.bucket?.trim()) return message.error("请填写对象存储 Bucket");
|
||||
if (values.mode !== "local" && !values.accessKeyId?.trim()) return message.error(`请填写${values.mode === "tencent" ? " SecretId" : " AccessKey ID"}`);
|
||||
if (values.mode === "qiniu" && !values.endpoint?.trim()) return message.error("请填写七牛云 Kodo 上传 Endpoint");
|
||||
if (values.mode === "qiniu" && !values.cdnBaseUrl?.trim()) return message.error("请填写七牛云 Kodo 绑定域名");
|
||||
|
||||
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() || "" });
|
||||
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("存储配置已保存");
|
||||
@@ -60,81 +85,113 @@ export default function StorageSettingsPage() {
|
||||
return (
|
||||
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置">
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="border-b border-border px-1 pb-4 text-foreground/75">
|
||||
<div className="flex items-start gap-3"><span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-md bg-muted/60"><Info className="size-4" /></span><div><div className="text-sm font-semibold text-foreground">资源存储规则</div><p className="mt-1 text-xs leading-6 text-foreground/55">存储类型只影响之后新增的媒体资源,不迁移或改写历史文件。日常读取继续校验登录态,仅在模型必须通过 URL 拉取本地素材时生成短时签名链接。</p></div></div>
|
||||
</div>
|
||||
<SettingsSectionCard
|
||||
layout="stacked"
|
||||
contentClassName="px-4 pb-4"
|
||||
icon={<Cloud className="size-4" />}
|
||||
title="平台存储"
|
||||
description="选择平台新增媒体资源的默认写入方式。"
|
||||
status={<Space size={6}><Tag variant="filled" color={setting?.enabled ? "blue" : "default"}>{setting?.enabled ? storageProviderLabel(setting.provider) : "服务器本地"}</Tag>{setting?.enabled ? <Tag variant="filled" color={setting.hasAccessKeySecret ? "success" : "warning"}>{setting.hasAccessKeySecret ? configuredSecretText : "未保存密钥"}</Tag> : 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></>}
|
||||
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}>
|
||||
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2">
|
||||
<Form.Item className="md:col-span-2" 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" },
|
||||
]}
|
||||
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 ? (
|
||||
<>
|
||||
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:ap-guangzhou" : "例如:oss-cn-hangzhou"} /></Form.Item>
|
||||
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined}><Input autoComplete="off" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} /></Form.Item>
|
||||
<Form.Item name="cdnBaseUrl" label="CDN 加速域名" extra={isTencentCOS ? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。" : "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源。CDN URL 不附带 OSS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"} rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}>
|
||||
<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 加速域名"} rules={[{ type: "url", message: "请填写完整的 http/https 地址" }]}>
|
||||
<Input autoComplete="off" inputMode="url" placeholder="https://media.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:my-canvas-assets-1250000000" : "例如:my-canvas-assets"} /></Form.Item>
|
||||
<Form.Item name="pathPrefix" label="路径前缀"><Input autoComplete="off" placeholder="例如:uploads/infinite-canvas" /></Form.Item>
|
||||
<Form.Item name="accessKeyId" label={accessKeyIdLabel}><Input autoComplete="off" placeholder={isTencentCOS ? "腾讯云 SecretId" : "阿里云 AccessKey ID"} /></Form.Item>
|
||||
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(${configuredSecretText})` : accessKeySecretLabel}><Input.Password autoComplete="new-password" placeholder={hasCurrentProviderSecret ? "留空保留原密钥" : isTencentCOS ? "腾讯云 SecretKey" : "阿里云 AccessKey Secret"} /></Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item className="md:col-span-2" label="服务器访问地址" required tooltip="后端可从公网或模型服务所在网络访问的根地址,用于生成本地资源的短时签名链接。">
|
||||
<Space.Compact className="w-full">
|
||||
<Form.Item
|
||||
name="publicBaseUrl"
|
||||
noStyle
|
||||
rules={[
|
||||
{ required: true, message: "请填写服务器访问地址" },
|
||||
{ type: "url", message: "请填写完整的 http/https 地址" },
|
||||
]}
|
||||
>
|
||||
<Input className="min-w-0" autoComplete="off" placeholder="https://canvas.example.com 或 http://103.242.14.110:300" prefix={<Globe className="size-4 text-foreground/35" />} />
|
||||
</Form.Item>
|
||||
<Button icon={<LocateFixed className="size-4" />} onClick={() => form.setFieldValue("publicBaseUrl", window.location.origin)}>使用当前地址</Button>
|
||||
</Space.Compact>
|
||||
</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>
|
||||
<div className="md:col-span-2 border-t border-border py-4 text-xs leading-6 text-foreground/60">
|
||||
<div className="font-medium text-foreground/80">地址怎么设置</div>
|
||||
<div className="mt-1">域名已反向代理到本服务时,可点击“使用当前地址”,例如 <code className="text-foreground">https://ddcat.pronhubcn.com</code>。</div>
|
||||
<div>直接开放服务器端口时,填写完整的公网 IP 和端口,例如 <code className="text-foreground">http://103.242.14.110:300</code>。</div>
|
||||
<div>地址末尾不要填写 <code className="text-foreground">/api</code>;保存前可在浏览器访问 <code className="text-foreground"><服务器访问地址>/api/health</code>,看到 <code className="text-foreground">status: ok</code> 即表示入口正确。</div>
|
||||
<div className="mt-1 text-foreground/45">新增资源写入 <code>CANVAS_BACKEND_DATA_DIR/resources/</code>,部署时必须持久化该数据卷。供外部模型使用时,以上地址也必须能被模型服务访问,生产环境建议使用 HTTPS。</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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 className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={isObjectStorage ? <Cloud className="size-3.5" /> : <HardDrive className="size-3.5" />} text={isObjectStorage ? `新资源写入${selectedProviderLabel}` : "新资源写入服务器数据卷"} /><Notice icon={<ShieldCheck className="size-3.5" />} text="历史资源位置保持不变" /><Notice icon={<KeyRound className="size-3.5" />} text={isObjectStorage && cdnBaseUrl?.trim() ? "CDN 链接依赖 CDN 自身访问控制" : "外部链接仅在签名有效期内可用"} /></div>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? setting.provider === "tencent" ? "tencent" : "aliyun" : "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"]) { return provider === "tencent" ? "腾讯云 COS" : "阿里云 OSS"; }
|
||||
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
|
||||
function Notice({ icon, text }: { icon: ReactNode; text: string }) { return <div className="flex items-center gap-2 px-3 py-2.5"><span className="text-foreground/40">{icon}</span><span>{text}</span></div>; }
|
||||
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 }) : "--";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user