484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
"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 <apiKey>
|
||
</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>
|
||
);
|
||
}
|