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:
co-authored by
Claude Opus 4.8
parent
39b4467258
commit
4ce7e957d2
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user