feat(admin): configure model providers from database

This commit is contained in:
Jesse_Chen
2026-08-07 20:03:13 +08:00
parent d63f10dcc2
commit 3089316d2e
31 changed files with 871 additions and 1430 deletions
+4 -21
View File
@@ -2,7 +2,6 @@
import {
ApiOutlined,
ArrowLeftOutlined,
AuditOutlined,
ControlOutlined,
CreditCardOutlined,
@@ -17,10 +16,9 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { Authenticated, Refine } from "@refinedev/core";
import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd";
import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd";
import routerProvider from "@refinedev/nextjs-router";
import { App as AntdApp, ConfigProvider, Menu, Spin, theme } from "antd";
import Link from "next/link";
import { App as AntdApp, ConfigProvider, Spin, theme } from "antd";
import type { ReactNode } from "react";
import {
@@ -29,26 +27,11 @@ import {
adminDataProvider,
} from "@/lib/admin/providers";
function AdminSider() {
return (
<ThemedSider
render={({ items, collapsed }) => (
<>
{items}
<Menu.Item key="return-to-chat" icon={<ArrowLeftOutlined />} title="返回对话">
<Link href="/" aria-label="返回对话">{collapsed ? null : "返回对话"}</Link>
</Menu.Item>
</>
)}
/>
);
}
export function AdminApp({ children }: { children: ReactNode }) {
const notificationProvider = useNotificationProvider();
return (
<div className="admin-app-shell">
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm, token: { colorPrimary: "#c8a96b" } }}>
<ConfigProvider componentSize="large" theme={{ algorithm: theme.darkAlgorithm, token: { colorPrimary: "#c8a96b" } }}>
<AntdApp>
<Refine
routerProvider={routerProvider}
@@ -83,7 +66,7 @@ export function AdminApp({ children }: { children: ReactNode }) {
key="admin-authenticated"
loading={<div className="admin-loading"><Spin size="large" /><span></span></div>}
>
<ThemedLayout Sider={AdminSider}>{children}</ThemedLayout>
<ThemedLayout>{children}</ThemedLayout>
</Authenticated>
</Refine>
</AntdApp>
@@ -29,11 +29,13 @@ import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
type ProviderType = "openai" | "openai-compatible" | "anthropic";
type Provider = {
id: string;
code: string;
name: string;
providerType: "openai" | "openai-compatible";
providerType: ProviderType;
baseUrl: string | null;
secretConfigured: boolean;
enabled: boolean;
@@ -65,10 +67,10 @@ type ModelVersion = {
};
type ProviderForm = {
code: string;
name: string;
providerType: "openai" | "openai-compatible";
providerType: ProviderType;
baseUrl: string | null;
apiKey?: string;
enabled: boolean;
};
type ModelForm = Omit<ModelVersion, "id" | "configId" | "version" | "providerCode" | "status" | "createdAt" | "publishedAt" | "settings"> & {
@@ -78,15 +80,35 @@ type ModelForm = Omit<ModelVersion, "id" | "configId" | "version" | "providerCod
};
type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[] };
type DiscoveredModel = { id: string; label?: string };
type DiscoveredModelsPayload = { data: DiscoveredModel[] };
type ModelFilters = { q?: string; status?: string };
type VersionAction = { action: "publish" | "rollback"; model: ModelVersion };
const providerTypeLabels: Record<ProviderType, string> = {
openai: "OpenAI 官方",
anthropic: "Anthropic 官方",
"openai-compatible": "OpenAI 兼容接口",
};
const modelTierLabels: Record<ModelVersion["modelTier"], string> = {
standard: "标准",
premium: "高级",
internal: "内部",
};
const modelStatusLabels: Record<string, string> = {
draft: "草稿",
published: "已发布",
retired: "已下线",
};
export default function ModelManagement() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const [providerForm] = Form.useForm<ProviderForm>();
const [modelForm] = Form.useForm<ModelForm>();
const [filterForm] = Form.useForm<ModelFilters>();
const selectedProviderId = Form.useWatch("providerId", modelForm);
const selectedProviderModel = Form.useWatch("providerModel", modelForm);
const [models, setModels] = useState<ModelVersion[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [loading, setLoading] = useState(true);
@@ -95,6 +117,8 @@ export default function ModelManagement() {
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [editingModel, setEditingModel] = useState<ModelVersion | null>(null);
const [saving, setSaving] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
const [actingId, setActingId] = useState<string | null>(null);
const [versionAction, setVersionAction] = useState<VersionAction | null>(null);
const [pendingProvider, setPendingProvider] = useState<Record<string, unknown> | null>(null);
@@ -127,21 +151,26 @@ export default function ModelManagement() {
function openProvider(provider?: Provider) {
setEditingProvider(provider ?? null);
providerForm.resetFields();
providerForm.setFieldsValue(provider ? {
...provider,
name: provider.name,
providerType: provider.providerType,
baseUrl: provider.baseUrl,
apiKey: "",
enabled: provider.enabled,
} : {
code: "",
name: "",
providerType: "openai-compatible",
baseUrl: "https://",
apiKey: "",
enabled: false,
});
setProviderOpen(true);
}
function openModel(model?: ModelVersion) {
function openModel(model?: ModelVersion, providerId?: string) {
setEditingModel(model ?? null);
setDiscoveredModels([]);
modelForm.setFieldsValue(model ? {
modelId: model.modelId,
versionId: model.id,
@@ -161,7 +190,7 @@ export default function ModelManagement() {
reason: "",
} : {
modelId: "",
providerId: providers[0]?.id,
providerId: providerId ?? providers[0]?.id,
label: "",
description: "",
providerModel: "",
@@ -180,13 +209,14 @@ export default function ModelManagement() {
}
function prepareProviderSave(values: ProviderForm) {
const apiKey = values.apiKey?.trim();
setPendingProvider({
action: "saveProvider",
id: editingProvider?.id ?? null,
code: values.code.trim(),
name: values.name.trim(),
providerType: values.providerType,
baseUrl: values.providerType === "openai" ? null : values.baseUrl?.trim(),
baseUrl: values.providerType === "openai-compatible" ? values.baseUrl?.trim() : null,
...(apiKey ? { apiKey } : {}),
enabled: values.enabled,
});
}
@@ -202,12 +232,42 @@ export default function ModelManagement() {
message.success("供应商配置已保存");
setPendingProvider(null);
setProviderOpen(false);
providerForm.resetFields();
await load();
} finally {
setSaving(false);
}
}
async function discoverModels() {
if (!selectedProviderId) return;
setDiscovering(true);
try {
const payload = await adminRequestJson<DiscoveredModelsPayload>("/api/admin/models/discover", {
method: "POST",
body: JSON.stringify({ providerId: selectedProviderId }),
});
setDiscoveredModels(payload.data);
if (!payload.data.length) message.info("未发现可用模型,可继续手工输入");
} catch (error) {
message.error(error instanceof Error ? error.message : "获取模型列表失败");
} finally {
setDiscovering(false);
}
}
function selectDiscoveredModel(providerModel: string) {
const discovered = discoveredModels.find((item) => item.id === providerModel);
const values = modelForm.getFieldsValue(["modelId", "label"]);
modelForm.setFieldsValue({
providerModel,
...(!values.modelId ? {
modelId: providerModel.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "model",
} : {}),
...(!values.label ? { label: discovered?.label?.trim() || providerModel } : {}),
});
}
async function saveModel(values: ModelForm) {
setSaving(true);
try {
@@ -280,22 +340,22 @@ export default function ModelManagement() {
const providerColumns: TableColumnsType<Provider> = [
{ title: "供应商", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code}</Text></Space> },
{ title: "类型", dataIndex: "providerType" },
{ title: "地址", dataIndex: "baseUrl", render: (value) => value ?? "OpenAI 官方" },
{ title: "类型", dataIndex: "providerType", render: (value: ProviderType) => providerTypeLabels[value] },
{ title: "地址", dataIndex: "baseUrl", render: (value, item) => value ?? (item.providerType === "anthropic" ? "Anthropic 官方" : "OpenAI 官方") },
{ title: "密钥状态", render: (_, item) => <Tag color={item.secretConfigured ? "green" : "red"}>{item.secretConfigured ? "已配置" : "未配置"}</Tag> },
{ title: "状态", dataIndex: "enabled", render: (value) => value ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
{ title: "操作", render: (_, item) => canWrite ? <Button type="link" onClick={() => openProvider(item)}></Button> : null },
{ title: "操作", render: (_, item) => canWrite ? <Space><Button type="link" onClick={() => openProvider(item)}></Button><Button type="link" onClick={() => openModel(undefined, item.id)}></Button></Space> : null },
];
const modelColumns: TableColumnsType<ModelVersion> = [
{ title: "模型", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.label}</Text><Text type="secondary">{item.modelId} · v{item.version}</Text></Space> },
{ title: "供应商模型", render: (_, item) => `${item.providerCode} / ${item.providerModel}` },
{ title: "档位", dataIndex: "modelTier", render: (value) => <Tag>{value}</Tag> },
{ title: "档位", dataIndex: "modelTier", render: (value: ModelVersion["modelTier"]) => <Tag>{modelTierLabels[value]}</Tag> },
{ title: "点数", dataIndex: "creditCost" },
{ title: "成本/百万 Token", render: (_, item) => `$${(item.inputCostMicrousdPerMillion / 1_000_000).toFixed(4)} / $${(item.outputCostMicrousdPerMillion / 1_000_000).toFixed(4)}` },
{ title: "路由", render: (_, item) => <Space direction="vertical" size={0}>{item.isDefault && <Tag color="blue"></Tag>}<Text type="secondary">fallback: {item.fallbackModelId ?? "—"}</Text></Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue"></Tag> : <Tag></Tag>}</Space> },
{ title: "路由", render: (_, item) => <Space direction="vertical" size={0}>{item.isDefault && <Tag color="blue"></Tag>}<Text type="secondary">退{item.fallbackModelId ?? "—"}</Text></Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{modelStatusLabels[item.status] ?? item.status}</Tag>{item.enabled ? <Tag color="blue"></Tag> : <Tag></Tag>}</Space> },
{ title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate },
{
title: "操作",
@@ -314,7 +374,7 @@ export default function ModelManagement() {
<Card title="供应商" extra={canWrite ? <Button icon={<PlusOutlined />} onClick={() => openProvider()}></Button> : null}>
<Table rowKey="id" columns={providerColumns} dataSource={providers} loading={loading} pagination={false} scroll={{ x: "max-content" }} />
</Card>
<Card title="模型版本" extra={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openModel()} disabled={!providers.length}>稿</Button> : null}>
<Card title="模型版本" extra={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openModel()} disabled={!providers.length} title={providers.length ? undefined : "请先新增供应商"}></Button> : null}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form<ModelFilters>
form={filterForm}
@@ -333,7 +393,7 @@ export default function ModelManagement() {
allowClear
placeholder="全部状态"
style={{ minWidth: 140 }}
options={["draft", "published", "retired"].map((value) => ({ value, label: value }))}
options={Object.entries(modelStatusLabels).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
@@ -351,12 +411,12 @@ export default function ModelManagement() {
</Card>
</Space>
<Modal title={editingProvider ? "编辑供应商" : "新增供应商"} open={providerOpen} okText="继续验证" cancelText="取消" confirmLoading={saving} onOk={() => providerForm.submit()} onCancel={() => setProviderOpen(false)} destroyOnHidden>
<Modal title={editingProvider ? "编辑供应商" : "新增供应商"} open={providerOpen} okText="继续验证" cancelText="取消" confirmLoading={saving} onOk={() => providerForm.submit()} onCancel={() => { setProviderOpen(false); providerForm.resetFields(); }} destroyOnHidden>
<Form<ProviderForm> form={providerForm} layout="vertical" onFinish={prepareProviderSave}>
<Row gutter={16}><Col xs={24} md={12}><Form.Item name="code" label="代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_-]{1,63}$/ }]}><Input disabled={Boolean(editingProvider)} /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item></Col></Row>
<Form.Item name="providerType" label="类型" rules={[{ required: true }]}><Select options={[{ value: "openai", label: "OpenAI 官方" }, { value: "openai-compatible", label: "OpenAI Compatible" }]} /></Form.Item>
<Row gutter={16}><Col xs={24} md={12}><Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item label="代码预览(服务端生成)"><Input readOnly value={editingProvider?.code ?? "保存后由服务端自动生成"} /></Form.Item></Col></Row>
<Form.Item name="providerType" label="类型" rules={[{ required: true }]}><Select options={Object.entries(providerTypeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item>
<Form.Item noStyle shouldUpdate={(before, after) => before.providerType !== after.providerType}>{({ getFieldValue }) => getFieldValue("providerType") === "openai-compatible" ? <Form.Item name="baseUrl" label="Base URL" rules={[{ required: true }, { type: "url" }]}><Input /></Form.Item> : null}</Form.Item>
<Form.Item label="部署密钥"><Text type="secondary"></Text></Form.Item>
<Form.Item name="apiKey" label="API Key" rules={[{ required: !editingProvider, whitespace: true, message: "请输入 API Key" }]} extra={editingProvider ? "留空表示保留现有 API Key;系统不会回显已保存的密钥。" : "密钥只会提交给服务端,不会在后台回显。"}><Input.Password autoComplete="new-password" placeholder={editingProvider ? "留空保留现有密钥" : "请输入 API Key"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
@@ -366,11 +426,23 @@ export default function ModelManagement() {
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="modelId" label="模型 ID" rules={[{ required: true }, { pattern: /^[a-z0-9][a-z0-9._-]{0,63}$/ }]}><Input disabled={Boolean(editingModel)} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="label" label="显示名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="providerId" label="供应商" rules={[{ required: true }]}><Select options={providers.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item label="供应商" required><Space.Compact block><Form.Item name="providerId" noStyle rules={[{ required: true, message: "请选择供应商" }]}><Select onChange={() => setDiscoveredModels([])} options={providers.map((item) => ({ value: item.id, label: `${item.name} (${item.code})` }))} /></Form.Item><Button loading={discovering} disabled={!selectedProviderId} onClick={() => void discoverModels()}></Button></Space.Compact></Form.Item></Col>
</Row>
<Form.Item label="发现的模型" extra="选择后会填入供应商模型名;模型 ID 和显示名称仅在为空时自动补全。">
<Select
showSearch
allowClear
optionFilterProp="label"
placeholder={selectedProviderId ? "先获取模型列表,或在下方手工输入" : "请先选择供应商"}
disabled={!selectedProviderId || !discoveredModels.length}
value={discoveredModels.some((item) => item.id === selectedProviderModel) ? selectedProviderModel : undefined}
options={discoveredModels.map((item) => ({ value: item.id, label: item.label ? `${item.label} (${item.id})` : item.id }))}
onSelect={selectDiscoveredModel}
/>
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}><Form.Item name="providerModel" label="供应商模型名" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={24} md={6}><Form.Item name="modelTier" label="模型档位" rules={[{ required: true }]}><Select options={["standard", "premium", "internal"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={12}><Form.Item name="providerModel" label="供应商模型名(可手工输入)" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={24} md={6}><Form.Item name="modelTier" label="模型档位" rules={[{ required: true }]}><Select options={Object.entries(modelTierLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></Col>
<Col xs={24} md={6}><Form.Item name="creditCost" label="单次点数" rules={[{ required: true }]}><InputNumber min={1} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
@@ -379,7 +451,7 @@ export default function ModelManagement() {
<Col xs={24} md={8}><Form.Item name="inputCostMicrousdPerMillion" label="输入成本(微美元/百万 Token" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="outputCostMicrousdPerMillion" label="输出成本(微美元/百万 Token" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Form.Item name="fallbackModelId" label="Fallback 模型 ID"><Input allowClear /></Form.Item>
<Form.Item name="fallbackModelId" label="回退模型 ID"><Input allowClear /></Form.Item>
<Form.Item name="settingsJson" label="设置 JSON" rules={[{ required: true }]}><Input.TextArea rows={5} spellCheck={false} /></Form.Item>
<Space size="large"><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item><Form.Item name="isDefault" label="默认模型" valuePropName="checked" dependencies={["enabled"]} rules={[({ getFieldValue }) => ({ validator(_, value) { return value && !getFieldValue("enabled") ? Promise.reject(new Error("默认模型必须启用")) : Promise.resolve(); } })]}><Switch /></Form.Item></Space>
<Form.Item name="reason" label="修改原因" rules={[{ required: true }, { max: 500 }]}><Input.TextArea rows={2} /></Form.Item>
@@ -1,13 +1,10 @@
"use client";
import { Alert, Button, Form, Input, Modal, Radio, Space, Spin, Typography } from "antd";
import Link from "next/link";
import { Alert, Button, Form, Input, Modal, Space } from "antd";
import { useState } from "react";
import type { AdminPermission } from "@/lib/admin/auth-policy";
const { Text } = Typography;
type ReasonActionModalProps = {
open: boolean;
title: string;
@@ -19,22 +16,15 @@ type ReasonActionModalProps = {
onSubmit: (reason: string) => void | Promise<void>;
};
type FormValues = { reason: string; otp?: string; mfaCode?: string };
type MfaFactor = "totp" | "backup";
type MfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
highRiskWritesEnabled: boolean;
};
type FormValues = { reason: string; otp?: string };
async function adminSecurityRequest<T>(url: string, body?: object): Promise<T> {
async function adminSecurityRequest<T>(url: string, body: object): Promise<T> {
const response = await fetch(url, {
method: body ? "POST" : "GET",
method: "POST",
cache: "no-store",
credentials: "same-origin",
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const value = await response.json().catch(() => null) as {
data?: T;
@@ -57,59 +47,12 @@ export function ReasonActionModal({
onSubmit,
}: ReasonActionModalProps) {
const [form] = Form.useForm<FormValues>();
const [mfaStatus, setMfaStatus] = useState<MfaStatus>();
const [mfaFactor, setMfaFactor] = useState<MfaFactor>("totp");
const [mfaLoading, setMfaLoading] = useState(false);
const [otpSent, setOtpSent] = useState(false);
const [reauthLoading, setReauthLoading] = useState(false);
const [reauthError, setReauthError] = useState<string>();
const mfaReady = !reauthPermission
|| (mfaStatus !== undefined && (!mfaStatus.required || mfaStatus.verified));
async function loadMfaStatus() {
if (!reauthPermission) return;
setMfaLoading(true);
setReauthError(undefined);
try {
setMfaStatus(await adminSecurityRequest<MfaStatus>("/api/admin/mfa"));
} catch (error) {
setMfaStatus(undefined);
setReauthError(error instanceof Error ? error.message : "无法读取 MFA 状态");
} finally {
setMfaLoading(false);
}
}
async function verifyMfa() {
const code = form.getFieldValue("mfaCode")?.trim();
if (!code) {
setReauthError(mfaFactor === "totp" ? "请输入 6 位认证器验证码" : "请输入恢复码");
return;
}
if (mfaFactor === "totp" && !/^\d{6}$/.test(code)) {
setReauthError("请输入 6 位认证器验证码");
return;
}
setMfaLoading(true);
setReauthError(undefined);
try {
const status = await adminSecurityRequest<MfaStatus>("/api/admin/mfa", {
action: mfaFactor === "totp" ? "verify" : "recover",
code,
});
setMfaStatus(status);
form.setFieldValue("mfaCode", undefined);
} catch (error) {
setReauthError(error instanceof Error ? error.message : "MFA 验证失败");
} finally {
setMfaLoading(false);
}
}
async function requestOtp() {
if (!reauthPermission || !mfaReady) return;
if (!reauthPermission) return;
setReauthLoading(true);
setReauthError(undefined);
try {
@@ -152,17 +95,12 @@ export function ReasonActionModal({
confirmLoading={confirmLoading || reauthLoading}
okButtonProps={{
danger,
disabled: Boolean(reauthPermission && (!mfaReady || !otpSent)),
disabled: Boolean(reauthPermission && !otpSent),
}}
onOk={() => form.submit()}
onCancel={onCancel}
afterOpenChange={(visible) => {
if (visible) {
form.resetFields();
void loadMfaStatus();
}
setMfaStatus(undefined);
setMfaFactor("totp");
if (visible) form.resetFields();
setOtpSent(false);
setReauthError(undefined);
}}
@@ -173,64 +111,9 @@ export function ReasonActionModal({
<Input.TextArea rows={3} showCount maxLength={500} autoFocus />
</Form.Item>
{reauthPermission && mfaLoading && !mfaStatus ? <Space>
<Spin size="small" />
<Text type="secondary"> session MFA </Text>
</Space> : null}
{reauthPermission && mfaStatus?.required && !mfaStatus.enrolled ? <Alert
type="error"
showIcon
message="当前角色必须启用 MFA"
description={<span>
fail closed <Link href="/admin/security"></Link> enrollment
</span>}
style={{ marginBottom: 16 }}
/> : null}
{reauthPermission && mfaStatus?.required && mfaStatus.enrolled && !mfaStatus.verified ? <Space
direction="vertical"
size="middle"
style={{ width: "100%", marginBottom: 16 }}
>
<Alert
type="warning"
showIcon
message="先验证真实第二因素"
description="当前 session 完成 TOTP 或一次性恢复码验证后,才能发送权限范围内的邮箱验证码。"
/>
<Radio.Group
value={mfaFactor}
onChange={(event) => {
setMfaFactor(event.target.value as MfaFactor);
form.setFieldValue("mfaCode", undefined);
}}
>
<Radio.Button value="totp"></Radio.Button>
<Radio.Button value="backup"></Radio.Button>
</Radio.Group>
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="mfaCode" noStyle>
<Input
inputMode={mfaFactor === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
maxLength={mfaFactor === "totp" ? 6 : 128}
placeholder={mfaFactor === "totp" ? "6 位认证器验证码" : "一次性恢复码"}
onPressEnter={(event) => {
event.preventDefault();
void verifyMfa();
}}
/>
</Form.Item>
<Button type="default" loading={mfaLoading} onClick={() => void verifyMfa()}>
MFA
</Button>
</Space.Compact>
</Space> : null}
{reauthPermission && mfaReady ? <Form.Item
{reauthPermission ? <Form.Item
label="邮箱验证码"
extra="真实第二因素完成后,验证码发送到当前登录管理员邮箱,5 分钟内有效且只授权本次 permission。"
extra="验证码发送到当前登录管理员邮箱,5 分钟内有效且只授权本次操作权限。"
required
>
<Space.Compact style={{ width: "100%" }}>
@@ -239,7 +122,7 @@ export function ReasonActionModal({
noStyle
rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}
>
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="6 位验证码" />
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="请输入 6 位验证码" />
</Form.Item>
<Button type="default" loading={reauthLoading} onClick={() => void requestOtp()}>
{otpSent ? "重新发送" : "发送验证码"}