ai-chatbot/components/admin/external-image-api-settings-card.tsx

484 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { KeyRound, Plus, Save, Trash2 } from "lucide-react";
import {
LocalAiSectionShell,
LocalAiSectionSummary,
} from "@/app/(admin)/settings/_components/local-ai-section-shell";
import { formatBeijingTime } from "@/lib/utils";
import {
defaultExternalImageApiConfig,
EXTERNAL_IMAGE_API_CONFIG_KEY,
maskExternalImageApiKey,
normalizeExternalImageApiConfig,
type ExternalImageApiConfig,
} from "@/lib/external-image-api/config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
const fetcher = async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error || payload?.message || "请求失败");
}
return response.json();
};
type AdminExternalImageApiLog = {
id: string;
apiKeyId: string | null;
apiKeyName: string | null;
ownerUserEmail: string | null;
modelDisplayName: string | null;
modelRuntimeId: string | null;
prompt: string | null;
negativePrompt: string | null;
imageCount: number;
aspectRatio: string | null;
imageSize: string | null;
status: string;
generatedImageUrls: string[] | null;
errorMessage: string | null;
durationMs: number | null;
requesterIp: string | null;
createdAt: string;
completedAt: string | null;
};
function buildKeyDraft() {
return {
id: `external-key-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: "",
apiKey: "",
enabled: true,
notes: "",
};
}
export function ExternalImageApiSettingsCard({
expanded,
onToggle,
sectionId = "local-ai-section-external-image-api",
}: {
expanded: boolean;
onToggle: () => void;
sectionId?: string;
}) {
const { data: configData, mutate: mutateConfig } = useSWR<{
setting?: { value?: unknown };
}>(`/api/admin/settings?key=${EXTERNAL_IMAGE_API_CONFIG_KEY}`, fetcher);
const { data: logsData, mutate: mutateLogs } = useSWR<{
success: boolean;
data: { logs: AdminExternalImageApiLog[] };
}>("/api/admin/external-image-api/logs?limit=50", fetcher);
const [config, setConfig] = useState<ExternalImageApiConfig>(
defaultExternalImageApiConfig,
);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setConfig(
normalizeExternalImageApiConfig(configData?.setting?.value),
);
}, [configData]);
const enabledKeyCount = useMemo(
() => config.apiKeys.filter((item) => item.enabled).length,
[config.apiKeys],
);
const handleAddKey = () => {
setConfig((current) => ({
...current,
apiKeys: [...current.apiKeys, buildKeyDraft()],
}));
};
const handleRemoveKey = (id: string) => {
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.filter((item) => item.id !== id),
}));
};
const handleSave = async () => {
setIsSaving(true);
try {
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: EXTERNAL_IMAGE_API_CONFIG_KEY,
value: normalizeExternalImageApiConfig(config),
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
String(payload?.message || payload?.error || "保存失败"),
);
}
toast.success("第三方生图 API 配置已保存");
await Promise.all([mutateConfig(), mutateLogs()]);
} catch (error) {
console.error("[External Image API Settings] Save failed:", error);
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
};
return (
<LocalAiSectionShell
description={
<>
对外开放当前系统里已启用的生图模型。第三方先调
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/models
</code>
获取模型名称,再调用
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/generate
</code>
生图。结果会继续走我们的储存桶并落到数据库。
</>
}
expanded={expanded}
id={sectionId}
onToggle={onToggle}
summary={
<LocalAiSectionSummary
items={[
{
label: config.enabled ? "开放接口已启用" : "开放接口已关闭",
tone: config.enabled ? "enabled" : "disabled",
},
{
label: `已配置 ${config.apiKeys.length} 个 Key`,
tone: config.apiKeys.length > 0 ? "enabled" : "warning",
},
{
label: `启用中 ${enabledKeyCount} 个`,
tone: enabledKeyCount > 0 ? "enabled" : "neutral",
},
]}
/>
}
title={
<span className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
第三方生图 API
</span>
}
>
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-4 rounded-xl border p-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用第三方生图 API</Label>
<div className="text-xs text-muted-foreground">
关闭后第三方 API Key 会全部失效。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.enabled}
onCheckedChange={(checked) =>
setConfig((current) => ({ ...current, enabled: checked }))
}
/>
<span className="min-w-[3rem] text-sm font-medium text-muted-foreground">
{config.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="externalImageApiOwnerEmail">
生成结果归属邮箱
</Label>
<Input
id="externalImageApiOwnerEmail"
value={config.defaultOwnerEmail}
onChange={(event) =>
setConfig((current) => ({
...current,
defaultOwnerEmail: event.target.value,
}))
}
placeholder="external-image-api@system.local"
/>
<div className="text-xs text-muted-foreground">
外部 API 生成图片会统一挂到这个用户下,方便我们保留生成记录、媒体资产和后续分析数据。
</div>
</div>
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
当前共 {config.apiKeys.length} 个 Key,启用中 {enabledKeyCount} 个。
外部调用支持
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
Authorization: Bearer &lt;apiKey&gt;
</code>
和
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
x-api-key
</code>
两种方式。
</div>
<div className="space-y-3">
{config.apiKeys.map((item, index) => (
<div
key={item.id}
className="space-y-3 rounded-xl border p-4"
>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
Key #{index + 1}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveKey(item.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>名称</Label>
<Input
value={item.name}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, name: event.target.value }
: record,
),
}))
}
placeholder="渠道 A / 合作方 B"
/>
</div>
<div className="space-y-2">
<Label>Key ID</Label>
<Input
value={item.id}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, id: event.target.value }
: record,
),
}))
}
placeholder="partner-a"
/>
</div>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<Input
value={item.apiKey}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, apiKey: event.target.value }
: record,
),
}))
}
placeholder="请输入调用第三方生图 API 的密钥"
/>
<div className="text-xs text-muted-foreground">
当前显示:{maskExternalImageApiKey(item.apiKey)}
</div>
</div>
<div className="space-y-2">
<Label>备注</Label>
<Textarea
rows={2}
value={item.notes}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, notes: event.target.value }
: record,
),
}))
}
placeholder="可以记录对接方、用途、流量限制等"
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="text-xs text-muted-foreground">
单独停用后,这个 Key 会立即失效,但不会影响其他 Key。
</div>
<div className="flex items-center gap-2">
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, enabled: checked }
: record,
),
}))
}
/>
<span className="text-sm text-muted-foreground">
{item.enabled ? "已启用" : "已停用"}
</span>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleAddKey}>
<Plus className="mr-2 h-4 w-4" />
新增 API Key
</Button>
<Button type="button" onClick={handleSave} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="space-y-1">
<div className="text-base font-medium">调用说明</div>
<div className="text-sm text-muted-foreground">
这条第三方接口直接复用我们系统里启用的生图模型、模型名称和存储链路。
</div>
</div>
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm">
<div className="font-medium">接口 1:获取模型</div>
<code className="mt-2 block rounded bg-background px-3 py-2 text-xs">
GET /api/external/image/models
</code>
</div>
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm">
<div className="font-medium">接口 2:发起生图</div>
<code className="mt-2 block whitespace-pre-wrap rounded bg-background px-3 py-2 text-xs">
{`POST /api/external/image/generate
{
"model": "模型 UUID / 模型ID / 显示名称",
"prompt": "请生成一张暖色家居客厅图",
"negativePrompt": "不要出现人物",
"imageCount": 2,
"aspectRatio": "16:9",
"imageSize": "2K"
}`}
</code>
</div>
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground">
返回图片地址会优先上传到我们的储存桶,然后再写入
<code className="mx-1 rounded bg-background px-1 py-0.5 text-[11px]">
GeneratedImage
</code>
和媒体资产表。
</div>
</div>
</div>
<div className="space-y-3 rounded-xl border p-4">
<div className="space-y-1">
<div className="text-base font-medium">最近 50 条请求日志</div>
<div className="text-sm text-muted-foreground">
这里会记录 API Key、模型、提示词、生成张数、耗时和结果地址,方便排查对接问题。
</div>
</div>
<div className="space-y-3">
{logsData?.data?.logs?.length ? (
logsData.data.logs.map((log) => (
<div key={log.id} className="rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">
{log.modelDisplayName || log.modelRuntimeId || "未记录模型"}
</div>
<span
className={
log.status === "succeeded"
? "rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-600"
: log.status === "failed"
? "rounded-full bg-rose-500/10 px-2 py-0.5 text-xs text-rose-600"
: "rounded-full bg-slate-500/10 px-2 py-0.5 text-xs text-slate-600"
}
>
{log.status}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{log.apiKeyName || log.apiKeyId || "未命名 Key"}
</span>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{log.prompt || "无提示词"}
</div>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground md:grid-cols-2 xl:grid-cols-4">
<div>归属用户:{log.ownerUserEmail || "-"}</div>
<div>生成张数:{log.imageCount}</div>
<div>
规格:{log.aspectRatio || "-"} / {log.imageSize || "-"}
</div>
<div>
耗时:{typeof log.durationMs === "number" ? `${log.durationMs}ms` : "-"}
</div>
<div>请求 IP:{log.requesterIp || "-"}</div>
<div>生成图片:{log.generatedImageUrls?.length || 0} 张</div>
<div>开始时间:{formatBeijingTime(log.createdAt)}</div>
<div>
完成时间:
{log.completedAt ? formatBeijingTime(log.completedAt) : "-"}
</div>
</div>
{log.errorMessage ? (
<div className="mt-3 rounded-lg bg-rose-500/5 px-3 py-2 text-sm text-rose-600">
{log.errorMessage}
</div>
) : null}
</div>
))
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
还没有第三方生图 API 请求记录。
</div>
)}
</div>
</div>
</div>
</LocalAiSectionShell>
);
}