feat(admin): configure model providers from database
This commit is contained in:
@@ -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 ? "重新发送" : "发送验证码"}
|
||||
|
||||
Reference in New Issue
Block a user