Simplify provider configuration UI

This commit is contained in:
2026-04-27 17:41:20 +08:00
parent d3aa8da406
commit 5eb32b32a6
3 changed files with 609 additions and 69 deletions

View File

@@ -1,75 +1,254 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, PlugZap } from "lucide-react";
import { useState } from "react";
import { api } from "@/lib/api";
const fieldLabels: Record<string, string> = {
provider_code: "供应商编码",
provider_name: "供应商名称",
api_format: "接口协议",
base_url: "接口地址",
api_key: "接口密钥",
api_secret: "接口密钥补充",
webhook_secret: "回调密钥",
timeout_seconds: "超时时间(秒)",
max_retries: "最大重试次数",
status: "状态",
remark: "备注",
type ProviderAccountRow = {
id: number;
providerCode: string;
providerName: string;
apiFormat: string;
baseUrl: string;
timeoutSeconds: number;
maxRetries: number;
status: number;
remark: string;
updatedAt: string;
};
const protocolOptions = [
{
value: "openai_official_video",
label: "OpenAI 兼容视频",
note: "/v1/videos",
},
{
value: "seedance_video_generation",
label: "Seedance 旧版",
note: "/v1/video/generations",
},
];
export default function ProviderAccountsPage() {
const [form, setForm] = useState({
provider_code: "openai-backup",
provider_name: "OpenAI 备用账号",
api_format: "openai_official_video",
base_url: "mock://openai",
api_key: "mock",
api_secret: "",
webhook_secret: "",
timeout_seconds: 120,
max_retries: 3,
providerCode: "newapi-seedance",
providerName: "NewAPI Seedance",
apiFormat: "openai_official_video",
baseUrl: "https://your-newapi-domain.com",
apiKey: "",
status: 1,
remark: "备用线路",
remark: "NewAPI 上游",
});
const [feedback, setFeedback] = useState("");
const query = useQuery({
queryKey: ["provider-accounts"],
queryFn: () => api.get("/api/v1/admin/provider-accounts"),
queryFn: () => api.get<ProviderAccountRow[]>("/api/v1/admin/provider-accounts"),
});
const createMutation = useMutation({
mutationFn: () => api.post("/api/v1/admin/provider-accounts", form),
onSuccess: () => query.refetch(),
mutationFn: () =>
api.post("/api/v1/admin/provider-accounts", {
provider_code: form.providerCode.trim(),
provider_name: form.providerName.trim(),
api_format: form.apiFormat,
base_url: form.baseUrl.trim().replace(/\/+$/, ""),
api_key: form.apiKey.trim(),
api_secret: "",
webhook_secret: "",
timeout_seconds: 120,
max_retries: 3,
status: form.status,
remark: form.remark.trim(),
}),
async onSuccess() {
setFeedback("渠道已创建。");
await query.refetch();
},
onError() {
setFeedback("创建失败,请检查渠道编码是否重复或密钥是否为空。");
},
});
return (
<div className="two-col-grid">
<section className="panel">
<h3></h3>
<div className="form-stack">
{Object.entries(form).map(([key, value]) => (
<label className="field-label" key={key}>
{fieldLabels[key] ?? key}
<div className="toolbar">
<div>
<span className="header-kicker"></span>
<h3></h3>
<p className="muted"> NewAPI </p>
</div>
<PlugZap size={22} />
</div>
<div className="form-stack admin-simple-form">
<div className="admin-form-grid">
<label className="field-label">
<input
value={String(value)}
value={form.providerName}
onChange={(event) =>
setForm((previous) => ({
...previous,
[key]:
typeof value === "number" ? Number(event.target.value) : event.target.value,
providerName: event.target.value,
}))
}
/>
</label>
))}
<button className="primary-button" onClick={() => createMutation.mutate()}>
<label className="field-label">
<input
value={form.providerCode}
onChange={(event) =>
setForm((previous) => ({
...previous,
providerCode: event.target.value,
}))
}
/>
</label>
</div>
<label className="field-label">
<select
value={form.apiFormat}
onChange={(event) =>
setForm((previous) => ({
...previous,
apiFormat: event.target.value,
}))
}
>
{protocolOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} - {option.note}
</option>
))}
</select>
</label>
<label className="field-label">
<input
value={form.baseUrl}
onChange={(event) =>
setForm((previous) => ({
...previous,
baseUrl: event.target.value,
}))
}
/>
</label>
<label className="field-label">
<input
placeholder="sk-..."
type="password"
value={form.apiKey}
onChange={(event) =>
setForm((previous) => ({
...previous,
apiKey: event.target.value,
}))
}
/>
</label>
<div className="admin-form-grid">
<label className="field-label">
<select
value={form.status}
onChange={(event) =>
setForm((previous) => ({
...previous,
status: Number(event.target.value),
}))
}
>
<option value={1}></option>
<option value={0}></option>
</select>
</label>
<label className="field-label">
<input
value={form.remark}
onChange={(event) =>
setForm((previous) => ({
...previous,
remark: event.target.value,
}))
}
/>
</label>
</div>
{feedback ? <div className="inline-feedback">{feedback}</div> : null}
<button
className="primary-button"
disabled={
createMutation.isPending ||
!form.providerCode.trim() ||
!form.providerName.trim() ||
!form.baseUrl.trim() ||
!form.apiKey.trim()
}
onClick={() => createMutation.mutate()}
type="button"
>
</button>
</div>
</section>
<section className="panel">
<h3></h3>
<pre className="code-block">{JSON.stringify(query.data ?? [], null, 2)}</pre>
<div className="toolbar">
<div>
<span className="header-kicker"></span>
<h3></h3>
</div>
<span className="mini-note">{query.data?.length ?? 0} </span>
</div>
<div className="admin-card-list">
{query.data?.length ? (
query.data.map((item) => (
<article className="admin-data-card" key={item.id}>
<div className="toolbar">
<div>
<strong>{item.providerName}</strong>
<div className="muted">{item.providerCode}</div>
</div>
<span className={item.status === 1 ? "status-badge tone-success" : "status-badge tone-ghost"}>
{item.status === 1 ? "启用" : "停用"}
</span>
</div>
<div className="admin-meta-grid">
<span>{protocolOptions.find((option) => option.value === item.apiFormat)?.label ?? item.apiFormat}</span>
<span>{item.baseUrl}</span>
<span> {item.timeoutSeconds}s</span>
</div>
{item.remark ? <p className="muted">{item.remark}</p> : null}
</article>
))
) : (
<div className="empty-state"></div>
)}
</div>
<div className="inline-feedback admin-helper-note">
<CheckCircle2 size={16} />
NewAPI OpenAI
</div>
</section>
</div>
);

View File

@@ -1,49 +1,300 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Boxes, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/lib/api";
type ProviderAccountRow = {
id: number;
providerName: string;
providerCode: string;
status: number;
};
type ProviderModelRow = {
id: number;
providerAccountId: number;
providerName: string;
modelCode: string;
modelName: string;
defaultRatio: string;
defaultResolution: string;
minDuration: number;
maxDuration: number;
status: number;
};
function parseModelCodes(value: string) {
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
}
export default function ProviderModelsPage() {
const form = {
provider_account_id: 1,
model_code: "sora-2-pro",
model_name: "Sora 2 Pro",
request_content_type: "multipart/form-data",
supports_text_to_video: true,
supports_image_to_video: true,
supports_video_reference: false,
supports_audio_reference: false,
supports_generate_audio: true,
supports_remix: false,
supports_webhook: true,
min_duration: 4,
max_duration: 12,
default_ratio: "16:9",
default_resolution: "1280x720",
const [form, setForm] = useState({
providerAccountId: 0,
modelCodes: "seedance, seedance-fast",
defaultRatio: "16:9",
defaultResolution: "1280x720",
minDuration: 4,
maxDuration: 12,
status: 1,
};
const query = useQuery({
queryKey: ["provider-models"],
queryFn: () => api.get("/api/v1/admin/provider-models"),
});
const [feedback, setFeedback] = useState("");
const accountsQuery = useQuery({
queryKey: ["provider-accounts"],
queryFn: () => api.get<ProviderAccountRow[]>("/api/v1/admin/provider-accounts"),
});
const modelsQuery = useQuery({
queryKey: ["provider-models"],
queryFn: () => api.get<ProviderModelRow[]>("/api/v1/admin/provider-models"),
});
const enabledAccounts = useMemo(
() => (accountsQuery.data ?? []).filter((item) => item.status === 1),
[accountsQuery.data],
);
const activeProviderAccountId =
form.providerAccountId || enabledAccounts[0]?.id || accountsQuery.data?.[0]?.id || 0;
const modelCodes = parseModelCodes(form.modelCodes);
const uniqueModelCodes = Array.from(new Set(modelCodes));
const createMutation = useMutation({
mutationFn: () => api.post("/api/v1/admin/provider-models", form),
onSuccess: () => query.refetch(),
mutationFn: async () => {
return Promise.all(
uniqueModelCodes.map((modelCode) =>
api.post("/api/v1/admin/provider-models", {
provider_account_id: activeProviderAccountId,
model_code: modelCode,
model_name: modelCode,
request_content_type: "application/json",
supports_text_to_video: true,
supports_image_to_video: true,
supports_video_reference: true,
supports_audio_reference: false,
supports_generate_audio: true,
supports_remix: false,
supports_webhook: false,
min_duration: form.minDuration,
max_duration: form.maxDuration,
default_ratio: form.defaultRatio,
default_resolution: form.defaultResolution,
status: form.status,
}),
),
);
},
async onSuccess() {
setFeedback(`已创建 ${uniqueModelCodes.length} 个模型。`);
await modelsQuery.refetch();
},
onError() {
setFeedback("创建失败,请检查模型名是否重复或渠道是否可用。");
},
});
return (
<div className="two-col-grid">
<section className="panel">
<h3></h3>
<pre className="code-block">{JSON.stringify(form, null, 2)}</pre>
<button className="primary-button" style={{ marginTop: 16 }} onClick={() => createMutation.mutate()}>
</button>
<div className="toolbar">
<div>
<span className="header-kicker"></span>
<h3></h3>
<p className="muted"> NewAPI </p>
</div>
<Boxes size={22} />
</div>
<div className="form-stack admin-simple-form">
<label className="field-label">
<select
value={activeProviderAccountId}
onChange={(event) =>
setForm((previous) => ({
...previous,
providerAccountId: Number(event.target.value),
}))
}
>
{accountsQuery.data?.length ? null : (
<option value={0} disabled>
</option>
)}
{(accountsQuery.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.providerName}{account.providerCode}
</option>
))}
</select>
</label>
<label className="field-label">
<textarea
className="prompt-editor admin-model-textarea"
value={form.modelCodes}
onChange={(event) =>
setForm((previous) => ({
...previous,
modelCodes: event.target.value,
}))
}
/>
</label>
<label className="field-label">
<select
value={form.status}
onChange={(event) =>
setForm((previous) => ({
...previous,
status: Number(event.target.value),
}))
}
>
<option value={1}></option>
<option value={0}></option>
</select>
</label>
<details className="admin-advanced-block">
<summary></summary>
<div className="admin-form-grid">
<label className="field-label">
<select
value={form.defaultRatio}
onChange={(event) =>
setForm((previous) => ({
...previous,
defaultRatio: event.target.value,
}))
}
>
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="1:1">1:1</option>
</select>
</label>
<label className="field-label">
<select
value={form.defaultResolution}
onChange={(event) =>
setForm((previous) => ({
...previous,
defaultResolution: event.target.value,
}))
}
>
<option value="1280x720">1280x720</option>
<option value="1920x1080">1920x1080</option>
<option value="720x1280">720x1280</option>
<option value="1080x1920">1080x1920</option>
</select>
</label>
</div>
<div className="admin-form-grid">
<label className="field-label">
<input
type="number"
value={form.minDuration}
onChange={(event) =>
setForm((previous) => ({
...previous,
minDuration: Number(event.target.value),
}))
}
/>
</label>
<label className="field-label">
<input
type="number"
value={form.maxDuration}
onChange={(event) =>
setForm((previous) => ({
...previous,
maxDuration: Number(event.target.value),
}))
}
/>
</label>
</div>
</details>
<div className="admin-model-preview">
{modelCodes.length ? (
uniqueModelCodes.map((modelCode) => <span key={modelCode}>{modelCode}</span>)
) : (
<span></span>
)}
</div>
{feedback ? <div className="inline-feedback">{feedback}</div> : null}
<button
className="primary-button"
disabled={
createMutation.isPending ||
!activeProviderAccountId ||
!modelCodes.length
}
onClick={() => createMutation.mutate()}
type="button"
>
<Plus size={16} />
</button>
</div>
</section>
<section className="panel">
<h3></h3>
<pre className="code-block">{JSON.stringify(query.data ?? [], null, 2)}</pre>
<div className="toolbar">
<div>
<span className="header-kicker"></span>
<h3></h3>
</div>
<span className="mini-note">{modelsQuery.data?.length ?? 0} </span>
</div>
<div className="admin-card-list">
{modelsQuery.data?.length ? (
modelsQuery.data.map((item) => (
<article className="admin-data-card" key={item.id}>
<div className="toolbar">
<div>
<strong>{item.modelName || item.modelCode}</strong>
<div className="muted">{item.modelCode}</div>
</div>
<span className={item.status === 1 ? "status-badge tone-success" : "status-badge tone-ghost"}>
{item.status === 1 ? "启用" : "停用"}
</span>
</div>
<div className="admin-meta-grid">
<span>{item.providerName}</span>
<span>{item.defaultResolution}</span>
<span>{item.defaultRatio}</span>
<span>{item.minDuration}-{item.maxDuration}s</span>
</div>
</article>
))
) : (
<div className="empty-state"></div>
)}
</div>
</section>
</div>
);

View File

@@ -1841,7 +1841,7 @@ p {
.admin-app-shell .inline-feedback.is-error {
border-color: rgba(255, 111, 54, 0.34);
background: rgba(255, 111, 54, 0.09);
color: #ffd3c0;
color: #9b3411;
}
.admin-app-shell {
@@ -1991,6 +1991,115 @@ p {
gap: 18px;
}
.admin-simple-form {
margin-top: 18px;
}
.admin-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.admin-card-list {
display: grid;
gap: 12px;
margin-top: 18px;
}
.admin-data-card {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid var(--admin-border);
border-radius: var(--radius-card);
background: #ffffff;
}
.admin-data-card strong {
font-size: 16px;
}
.admin-data-card p {
margin: 0;
}
.admin-meta-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-meta-grid span {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid var(--admin-border);
background: #f7f3ea;
color: var(--admin-text-soft);
font-size: 12px;
font-weight: 750;
overflow-wrap: anywhere;
}
.admin-helper-note {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
color: var(--admin-text-soft);
}
.admin-model-textarea {
min-height: 112px;
}
.admin-model-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-model-preview span {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 7px 11px;
border-radius: 999px;
background: #f0f7f5;
border: 1px solid #c7ddd9;
color: var(--admin-accent);
font-size: 13px;
font-weight: 850;
}
.admin-advanced-block {
display: grid;
gap: 14px;
padding: 14px;
border: 1px solid var(--admin-border);
border-radius: var(--radius-card);
background: #fbf8f1;
}
.admin-advanced-block summary {
cursor: pointer;
color: var(--admin-text-soft);
font-size: 13px;
font-weight: 850;
}
.admin-advanced-block[open] summary {
margin-bottom: 14px;
}
.admin-advanced-block .admin-form-grid + .admin-form-grid {
margin-top: 14px;
}
.admin-app-shell .panel,
.admin-app-shell .stat-card,
.admin-app-shell .pulse-card,
@@ -2180,7 +2289,8 @@ p {
.marketing-stat-row,
.upload-row,
.admin-auth-grid,
.marketing-ops-grid {
.marketing-ops-grid,
.admin-form-grid {
grid-template-columns: 1fr;
}