feat(admin): Phase 8 模型供应商管理 — provider/model CRUD + 启停 + 定价 + 设默认

后端:ModelConfig.is_default + migration;get_default_model 优先 is_default 否则回落最早 active(零回归);
adminpanel providers CRUD(api_key write-only 不回传)+ models CRUD(?provider/capability 筛)+ set-default(同 capability 清旧);
IsPlatformAdmin + 审计。
前端:adminApi providers/models/setDefault;Admin 模型供应商页(供应商表+模型表+启停+定价+设默认+供应商/模型弹窗)。
测试:adminpanel 51 单测过(api_key 隐藏+入库/CRUD/set-default 改 get_default_model);
apps.ai 仍仅 3 个进场前既有失败(零回归);无头 e2e _admin-p8.mjs 5 断言过 + 0 console error
(用一次性禁用 provider+model capability=export,无副作用,跑完删);tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 22:45:14 +08:00
co-authored by Claude Opus 4.8
parent 39b4467258
commit 4ce7e957d2
13 changed files with 673 additions and 5 deletions
@@ -5,6 +5,7 @@ import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config";
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
import { AdminInvitesPage } from "./admin-invites";
import { AdminModelsPage } from "./admin-models";
import { AdminQualityPage } from "./admin-quality";
import { AdminReviewsPage } from "./admin-reviews";
import { AdminTasksPage } from "./admin-tasks";
@@ -193,6 +194,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "quota") {
return <AdminQuotaPage notify={notify} />;
}
if (section.slug === "providers") {
return <AdminModelsPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />;
}
@@ -0,0 +1,267 @@
import { useCallback, useEffect, useState } from "react";
import { Server, X } from "lucide-react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminModel, AdminProvider } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
const CAPABILITIES = ["text", "image", "video", "vision", "audio", "export"];
function statusPill(active: boolean) {
return active
? <span className="pill ok"><span className="dot" /></span>
: <span className="pill neutral"><span className="dot" /></span>;
}
const EMPTY_PROVIDER = { id: "", name: "", display_name: "", base_url: "", api_key: "", status: "active" };
const EMPTY_MODEL = { id: "", provider: "", name: "", display_name: "", capability: "text", endpoint: "", unit_price: "", status: "active" };
export function AdminModelsPage({ notify }: { notify: Notify }) {
const [providers, setProviders] = useState<AdminProvider[]>([]);
const [models, setModels] = useState<AdminModel[]>([]);
const [loading, setLoading] = useState(true);
const [provModal, setProvModal] = useState<typeof EMPTY_PROVIDER | null>(null);
const [modelModal, setModelModal] = useState<typeof EMPTY_MODEL | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]);
setProviders(ps);
setModels(ms);
} catch {
notify("error", "加载模型供应商失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { void load(); }, [load]);
async function toggleProvider(p: AdminProvider) {
try {
await adminApi.updateProvider(p.id, { status: p.status === "active" ? "disabled" : "active" });
notify("success", "供应商状态已更新");
await load();
} catch { notify("error", "操作失败"); }
}
async function toggleModel(m: AdminModel) {
try {
await adminApi.updateModel(m.id, { status: m.status === "active" ? "disabled" : "active" });
notify("success", "模型状态已更新");
await load();
} catch { notify("error", "操作失败"); }
}
async function setDefault(m: AdminModel) {
try {
await adminApi.setDefaultModel(m.id);
notify("success", `已设为 ${m.capability} 默认模型`);
await load();
} catch { notify("error", "设默认失败"); }
}
async function delModel(m: AdminModel) {
try {
await adminApi.deleteModel(m.id);
notify("success", "模型已删除");
await load();
} catch { notify("error", "删除失败"); }
}
async function saveProvider() {
if (!provModal || saving) return;
if (!provModal.name.trim() || !provModal.display_name.trim()) { notify("error", "请填写名称"); return; }
setSaving(true);
try {
const payload: Record<string, unknown> = { display_name: provModal.display_name, base_url: provModal.base_url, status: provModal.status };
if (provModal.api_key) payload.api_key = provModal.api_key;
if (provModal.id) {
await adminApi.updateProvider(provModal.id, payload);
} else {
await adminApi.createProvider({ name: provModal.name, display_name: provModal.display_name, base_url: provModal.base_url, api_key: provModal.api_key || undefined, status: provModal.status });
}
notify("success", "供应商已保存");
setProvModal(null);
await load();
} catch (e) { notify("error", e instanceof Error ? e.message : "保存失败"); }
finally { setSaving(false); }
}
async function saveModel() {
if (!modelModal || saving) return;
if (!modelModal.provider || !modelModal.name.trim()) { notify("error", "请选择供应商并填写模型名"); return; }
setSaving(true);
try {
if (modelModal.id) {
await adminApi.updateModel(modelModal.id, { display_name: modelModal.display_name, endpoint: modelModal.endpoint, unit_price: modelModal.unit_price || "0", status: modelModal.status });
} else {
await adminApi.createModel({ provider: modelModal.provider, name: modelModal.name, display_name: modelModal.display_name || modelModal.name, capability: modelModal.capability, endpoint: modelModal.endpoint, unit_price: modelModal.unit_price || "0", status: modelModal.status });
}
notify("success", "模型已保存");
setModelModal(null);
await load();
} catch (e) { notify("error", e instanceof Error ? e.message : "保存失败"); }
finally { setSaving(false); }
}
return (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {providers.length} 供应商 · {models.length} 模型</span> · 热插拔中转站 · 定价 · 设默认</div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={() => setProvModal({ ...EMPTY_PROVIDER })}>+ </button>
<button className="btn btn-primary" type="button" onClick={() => setModelModal({ ...EMPTY_MODEL, provider: providers[0]?.id || "" })}>+ </button>
</div>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="server" size={24} /></div><h3></h3><p>// fetching providers</p></div>
) : (
<>
<div className="admin-detail-subhead"></div>
<div className="admin-table-wrap">
<table className="t admin-table">
<thead><tr><th></th><th></th><th>Base URL</th><th></th><th></th><th></th><th className="col-actions"></th></tr></thead>
<tbody>
{providers.map((p) => (
<tr key={p.id}>
<td className="mono admin-code">{p.name}</td>
<td>{p.display_name}</td>
<td className="mono col-time">{p.base_url || <span className="muted"></span>}</td>
<td>{p.has_api_key ? <span className="pill ok"><span className="dot" /></span> : <span className="pill neutral"><span className="dot" /></span>}</td>
<td className="num">{p.model_count}</td>
<td>{statusPill(p.status === "active")}</td>
<td className="col-actions">
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setProvModal({ id: p.id, name: p.name, display_name: p.display_name, base_url: p.base_url, api_key: "", status: p.status })}></button>
<button className={`btn btn-sm btn-ghost${p.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleProvider(p)}>{p.status === "active" ? "停用" : "启用"}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="admin-detail-subhead" style={{ marginTop: 28 }}></div>
<div className="admin-table-wrap">
<table className="t admin-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th className="col-actions"></th></tr></thead>
<tbody>
{models.map((m) => (
<tr key={m.id}>
<td className="mono">{m.provider_name}</td>
<td>{m.display_name || m.name}</td>
<td><span className="pill neutral"><span className="dot" />{m.capability}</span></td>
<td className="num mono">¥{m.unit_price}</td>
<td>{statusPill(m.status === "active")}</td>
<td>{m.is_default ? <span className="pill info"><span className="dot" /></span> : <span className="muted"></span>}</td>
<td className="col-actions">
{!m.is_default && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setDefault(m)}></button>}
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal({ id: m.id, provider: m.provider, name: m.name, display_name: m.display_name, capability: m.capability, endpoint: m.endpoint, unit_price: m.unit_price, status: m.status })}></button>
<button className={`btn btn-sm btn-ghost${m.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleModel(m)}>{m.status === "active" ? "停用" : "启用"}</button>
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => delModel(m)}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{provModal && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setProvModal(null); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="供应商">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Server size={16} /></div>
<div className="ti">{provModal.id ? "编辑供应商" : "新建供应商"}<span>// model provider</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setProvModal(null)}><X size={14} /></button>
</div>
<div className="modal-b">
<div className="field">
<label className="field-label">name <span className="field-hint"> · </span></label>
<input className="input" type="text" value={provModal.name} disabled={Boolean(provModal.id)} onChange={(e) => setProvModal((p) => p && ({ ...p, name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label"></label>
<input className="input" type="text" value={provModal.display_name} onChange={(e) => setProvModal((p) => p && ({ ...p, display_name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">Base URL</label>
<input className="input" type="text" placeholder="留空走 .env 回退" value={provModal.base_url} onChange={(e) => setProvModal((p) => p && ({ ...p, base_url: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">API Key <span className="field-hint">{provModal.id ? "留空=不改" : "可留空走 .env"}</span></label>
<input className="input" type="text" placeholder="••••" value={provModal.api_key} onChange={(e) => setProvModal((p) => p && ({ ...p, api_key: e.target.value }))} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setProvModal(null)}></button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void saveProvider()}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
</div>
)}
{modelModal && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModelModal(null); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="模型">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Server size={16} /></div>
<div className="ti">{modelModal.id ? "编辑模型" : "新建模型"}<span>// model config</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModelModal(null)}><X size={14} /></button>
</div>
<div className="modal-b">
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<select className="select" value={modelModal.provider} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, provider: e.target.value }))}>
<option value=""></option>
{providers.map((p) => <option key={p.id} value={p.id}>{p.display_name}</option>)}
</select>
</div>
<div className="field-row">
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<input className="input" type="text" value={modelModal.name} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label"></label>
<select className="select" value={modelModal.capability} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, capability: e.target.value }))}>
{CAPABILITIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
<div className="field">
<label className="field-label"></label>
<input className="input" type="text" value={modelModal.display_name} onChange={(e) => setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} />
</div>
<div className="field-row">
<div className="field">
<label className="field-label"></label>
<input className="input" type="text" placeholder="0" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">endpoint</label>
<input className="input" type="text" value={modelModal.endpoint} onChange={(e) => setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} />
</div>
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setModelModal(null)}></button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void saveModel()}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
</div>
)}
</>
);
}