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
+33 -1
View File
@@ -3,7 +3,7 @@ from decimal import Decimal
from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, QualityWord
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy
@@ -133,3 +133,35 @@ class AdminQuotaPolicySerializer(serializers.ModelSerializer):
"monthly_limit", "project_limit", "per_task_limit", "is_active", "created_at",
]
read_only_fields = ["id", "team_name", "created_at"]
class AdminModelProviderSerializer(serializers.ModelSerializer):
has_api_key = serializers.SerializerMethodField()
model_count = serializers.SerializerMethodField()
class Meta:
model = ModelProvider
fields = ["id", "name", "display_name", "status", "base_url", "api_key", "metadata", "has_api_key", "model_count", "created_at"]
# api_key 只写不读(密钥不回传前端)
extra_kwargs = {"api_key": {"write_only": True, "required": False}}
read_only_fields = ["id", "has_api_key", "model_count", "created_at"]
def get_has_api_key(self, obj) -> bool:
return bool(obj.api_key)
def get_model_count(self, obj) -> int:
anno = getattr(obj, "model_count_anno", None)
return anno if anno is not None else obj.models.count()
class AdminModelConfigSerializer(serializers.ModelSerializer):
provider_name = serializers.CharField(source="provider.name", read_only=True, default=None)
class Meta:
model = ModelConfig
fields = [
"id", "provider", "provider_name", "name", "display_name", "capability",
"endpoint", "unit_price", "status", "is_default", "rate_limit_per_minute", "metadata", "created_at",
]
# is_default 只经 set-default 端点改,不在普通编辑里直接写
read_only_fields = ["id", "provider_name", "is_default", "created_at"]
+78
View File
@@ -453,3 +453,81 @@ class AdminBillingTests(TestCase):
# setUp 本月已有 CHARGE 10 → 再 +1 超 10 → 拦
with self.assertRaises(ValueError):
_enforce_quota_policy(team=self.team, project=None, amount=Decimal("1"))
class AdminModelProviderTests(TestCase):
"""Phase 8:模型供应商 / 模型 CRUD + 启停 + 定价 + 设默认(改变 get_default_model)+ 权限。"""
def setUp(self):
from apps.ai.models import ModelConfig, ModelProvider
self.admin = User.objects.create_user(username="padmin8", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal8", password="x")
self.prov = ModelProvider.objects.create(name="prov8", display_name="P8", api_key="secret-key")
self.m1 = ModelConfig.objects.create(provider=self.prov, name="m8a", display_name="M8A", capability=ModelConfig.Capability.IMAGE)
self.m2 = ModelConfig.objects.create(provider=self.prov, name="m8b", display_name="M8B", capability=ModelConfig.Capability.IMAGE)
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_providers_list_permission_and_api_key_hidden(self):
self.assertEqual(self.nc.get("/api/admin/providers/").status_code, 403)
r = self.ac.get("/api/admin/providers/")
self.assertEqual(r.status_code, 200)
row = next(p for p in r.data if p["name"] == "prov8")
self.assertNotIn("api_key", row) # 密钥不回传
self.assertTrue(row["has_api_key"])
self.assertEqual(row["model_count"], 2)
def test_provider_create_key_writeonly_persisted(self):
from apps.ai.models import ModelProvider
r = self.ac.post("/api/admin/providers/", {"name": "newp8", "display_name": "New", "api_key": "k123"}, format="json")
self.assertEqual(r.status_code, 201)
self.assertTrue(r.data["has_api_key"])
self.assertNotIn("api_key", r.data)
self.assertEqual(ModelProvider.objects.get(name="newp8").api_key, "k123")
def test_provider_toggle_status_and_delete(self):
r = self.ac.patch(f"/api/admin/providers/{self.prov.id}/", {"status": "disabled"}, format="json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["status"], "disabled")
def test_models_list_filter(self):
r = self.ac.get(f"/api/admin/models/?provider={self.prov.id}")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.data), 2)
cap = self.ac.get("/api/admin/models/?capability=image")
self.assertTrue(all(m["capability"] == "image" for m in cap.data))
def test_model_create_update_delete(self):
c = self.ac.post(
"/api/admin/models/",
{"provider": str(self.prov.id), "name": "m8c", "display_name": "C", "capability": "text"},
format="json",
)
self.assertEqual(c.status_code, 201)
mid = c.data["id"]
up = self.ac.patch(f"/api/admin/models/{mid}/", {"unit_price": "2.5", "status": "disabled"}, format="json")
self.assertEqual(up.data["unit_price"], "2.5000")
self.assertEqual(up.data["status"], "disabled")
self.assertEqual(self.ac.delete(f"/api/admin/models/{mid}/").status_code, 204)
def test_set_default_changes_get_default_model(self):
from apps.ai.services import get_default_model
r = self.ac.post(f"/api/admin/models/{self.m2.id}/set-default/")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.data["is_default"])
self.assertEqual(get_default_model("image").id, self.m2.id)
# 改设 m1 → m2 清默认
self.ac.post(f"/api/admin/models/{self.m1.id}/set-default/")
self.assertEqual(get_default_model("image").id, self.m1.id)
self.m2.refresh_from_db()
self.assertFalse(self.m2.is_default)
self.assertTrue(AdminAuditLog.objects.filter(action="model.set_default").exists())
def test_write_requires_admin(self):
self.assertEqual(self.nc.post("/api/admin/providers/", {"name": "x", "display_name": "x"}, format="json").status_code, 403)
self.assertEqual(self.nc.post(f"/api/admin/models/{self.m1.id}/set-default/").status_code, 403)
+10
View File
@@ -7,6 +7,11 @@ from .views import (
admin_invitations,
admin_ledger_adjust,
admin_ledgers,
admin_model_detail,
admin_model_set_default,
admin_models,
admin_provider_detail,
admin_providers,
admin_quota_policies,
admin_quota_policy_detail,
admin_task_detail,
@@ -45,4 +50,9 @@ urlpatterns = [
path("ledgers/adjust/", admin_ledger_adjust, name="admin-ledger-adjust"),
path("quota-policies/", admin_quota_policies, name="admin-quota-policies"),
path("quota-policies/<uuid:policy_id>/", admin_quota_policy_detail, name="admin-quota-policy-detail"),
path("providers/", admin_providers, name="admin-providers"),
path("providers/<uuid:provider_id>/", admin_provider_detail, name="admin-provider-detail"),
path("models/", admin_models, name="admin-models"),
path("models/<uuid:model_id>/", admin_model_detail, name="admin-model-detail"),
path("models/<uuid:model_id>/set-default/", admin_model_set_default, name="admin-model-set-default"),
]
+89 -1
View File
@@ -12,7 +12,7 @@ from apps.accounts.audit import log_admin_action
from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import AITask, QualityWord
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.billing.models import CreditLedger, QuotaPolicy
@@ -22,6 +22,8 @@ from apps.common.pagination import DefaultPagination
from .serializers import (
COST_ANOMALY_RATIO,
AdminLedgerSerializer,
AdminModelConfigSerializer,
AdminModelProviderSerializer,
AdminQuotaPolicySerializer,
AdminReviewAssetSerializer,
AdminTaskDetailSerializer,
@@ -497,3 +499,89 @@ def admin_quota_policy_detail(request, policy_id):
serializer.save()
log_admin_action(request, "quota_policy.update", target_type="quota_policy", target_id=obj.id, target_name=str(obj.team_id), after=serializer.data)
return Response(AdminQuotaPolicySerializer(obj).data)
# ─────────────────────────── 模型供应商 / 模型 ───────────────────────────
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_providers(request):
if request.method == "GET":
qs = ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).order_by("created_at")
return Response(AdminModelProviderSerializer(qs, many=True).data)
serializer = AdminModelProviderSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
log_admin_action(request, "provider.create", target_type="model_provider", target_id=obj.id, target_name=obj.name)
return Response(AdminModelProviderSerializer(obj).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsPlatformAdmin])
def admin_provider_detail(request, provider_id):
obj = ModelProvider.objects.filter(id=provider_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if request.method == "DELETE":
log_admin_action(request, "provider.delete", target_type="model_provider", target_id=obj.id, target_name=obj.name)
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminModelProviderSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(request, "provider.update", target_type="model_provider", target_id=obj.id, target_name=obj.name)
return Response(AdminModelProviderSerializer(ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).get(id=obj.id)).data)
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_models(request):
if request.method == "GET":
qs = ModelConfig.objects.select_related("provider").order_by("provider__name", "capability", "created_at")
prov = request.query_params.get("provider")
if prov:
qs = qs.filter(provider_id=prov)
cap = request.query_params.get("capability")
if cap in dict(ModelConfig.Capability.choices):
qs = qs.filter(capability=cap)
return Response(AdminModelConfigSerializer(qs, many=True).data)
serializer = AdminModelConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
log_admin_action(request, "model.create", target_type="model_config", target_id=obj.id, target_name=f"{obj.provider_id}:{obj.name}")
return Response(AdminModelConfigSerializer(obj).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsPlatformAdmin])
def admin_model_detail(request, model_id):
obj = ModelConfig.objects.select_related("provider").filter(id=model_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if request.method == "DELETE":
log_admin_action(request, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name)
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(request, "model.update", target_type="model_config", target_id=obj.id, target_name=obj.name)
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_model_set_default(request, model_id):
"""把某模型设为其 capability 的默认模型(同 capability 其余清默认)。"""
from django.db import transaction
obj = ModelConfig.objects.filter(id=model_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
with transaction.atomic():
ModelConfig.objects.filter(capability=obj.capability).exclude(id=obj.id).update(is_default=False)
obj.is_default = True
obj.save(update_fields=["is_default", "updated_at"])
log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}")
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-06-19 14:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0009_qualityword'),
]
operations = [
migrations.AddField(
model_name='modelconfig',
name='is_default',
field=models.BooleanField(default=False),
),
]
+2
View File
@@ -42,6 +42,8 @@ class ModelConfig(TimeStampedModel):
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
rate_limit_per_minute = models.PositiveIntegerField(default=60)
# 平台超管显式钦定的「该能力默认模型」。get_default_model 优先取它,未设则回落最早 active(零回归)。
is_default = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
+3 -3
View File
@@ -37,12 +37,12 @@ from apps.projects.models import (
def get_default_model(capability: str) -> ModelConfig:
return (
qs = (
ModelConfig.objects.select_related("provider")
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
.order_by("created_at")
.first()
)
# 优先平台超管钦定的默认模型;未钦定则回落「最早创建的 active」(原行为,零回归)
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
+33
View File
@@ -1,5 +1,7 @@
import type {
AdminLedger,
AdminModel,
AdminProvider,
AdminQualityWord,
AdminQuotaPolicy,
AdminReviewAsset,
@@ -648,5 +650,36 @@ export const adminApi = {
},
deleteQuotaPolicy(id: string) {
return request<void>(`/api/admin/quota-policies/${id}/`, { method: "DELETE" });
},
providers() {
return request<AdminProvider[]>("/api/admin/providers/");
},
createProvider(payload: { name: string; display_name: string; base_url?: string; api_key?: string; status?: string }) {
return request<AdminProvider>("/api/admin/providers/", { method: "POST", body: JSON.stringify(payload) });
},
updateProvider(id: string, payload: Record<string, unknown>) {
return request<AdminProvider>(`/api/admin/providers/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteProvider(id: string) {
return request<void>(`/api/admin/providers/${id}/`, { method: "DELETE" });
},
models(params?: { provider?: string; capability?: string }) {
const qs = new URLSearchParams();
if (params?.provider) qs.set("provider", params.provider);
if (params?.capability) qs.set("capability", params.capability);
const q = qs.toString();
return request<AdminModel[]>(`/api/admin/models/${q ? `?${q}` : ""}`);
},
createModel(payload: { provider: string; name: string; display_name: string; capability: string; endpoint?: string; unit_price?: string; status?: string }) {
return request<AdminModel>("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) });
},
updateModel(id: string, payload: Record<string, unknown>) {
return request<AdminModel>(`/api/admin/models/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteModel(id: string) {
return request<void>(`/api/admin/models/${id}/`, { method: "DELETE" });
},
setDefaultModel(id: string) {
return request<AdminModel>(`/api/admin/models/${id}/set-default/`, { method: "POST" });
}
};
@@ -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>
)}
</>
);
}
+26
View File
@@ -138,6 +138,32 @@ export type AdminQuotaPolicy = {
is_active: boolean;
created_at: string;
};
export type AdminProvider = {
id: string;
name: string;
display_name: string;
status: string;
base_url: string;
metadata: Record<string, unknown>;
has_api_key: boolean;
model_count: number;
created_at: string;
};
export type AdminModel = {
id: string;
provider: string;
provider_name: string | null;
name: string;
display_name: string;
capability: string;
endpoint: string;
unit_price: string;
status: string;
is_default: boolean;
rate_limit_per_minute: number;
metadata: Record<string, unknown>;
created_at: string;
};
export type Paginated<T> = {
count: number;
+92
View File
@@ -0,0 +1,92 @@
// Phase 8 e2e:模型供应商 —— 列表渲染 + UI 设默认 / 编辑定价 / 启停。
// ⚠ 该页驱动真实生成路由:用一次性「禁用」供应商 + 模型(capability=export,不碰 image/text/video 默认),
// 全程不动真模型,跑完 API 删除。set-default 作用在禁用模型上(get_default_model 只取 active,无副作用)。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://127.0.0.1:5188";
const API = process.env.API || "http://127.0.0.1:8010";
const OUT = path.resolve("output/admin");
fs.mkdirSync(OUT, { recursive: true });
const stamp = Date.now();
const PNAME = `e2eP${stamp}`;
const MNAME = `e2eM${stamp}`;
async function apiLogin(u, p) {
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
return res.json();
}
const admin = await apiLogin("admin", "admin123");
const authH = { "Content-Type": "application/json", Authorization: `Token ${admin.token}` };
// 一次性禁用供应商 + 模型
const prov = await (await fetch(`${API}/api/admin/providers/`, { method: "POST", headers: authH, body: JSON.stringify({ name: PNAME, display_name: PNAME, status: "disabled" }) })).json();
const model = await (await fetch(`${API}/api/admin/models/`, { method: "POST", headers: authH, body: JSON.stringify({ provider: prov.id, name: MNAME, display_name: MNAME, capability: "export", unit_price: "1", status: "disabled" }) })).json();
const modelId = model.id;
const getModel = async () => (await (await fetch(`${API}/api/admin/models/`, { headers: authH })).json()).find((m) => m.id === modelId);
const r = { page: {}, setDefault: {}, edit: {}, toggle: {}, consoleErrors: [], pass: false };
const browser = await chromium.launch({ headless: true });
const hook = (p, tag) => {
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
};
try {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
const p = await ctx.newPage(); hook(p, "models");
await p.goto(BASE + "/admin/providers", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForSelector(".admin-table", { timeout: 12000 }).catch(() => {});
await p.waitForTimeout(500);
r.page.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
r.page.provRow = (await p.locator(`.admin-table tbody tr:has-text("${PNAME}")`).count()) >= 1;
r.page.modelRow = (await p.locator(`.admin-table tbody tr:has-text("${MNAME}")`).count()) >= 1;
await p.screenshot({ path: path.join(OUT, "p8-models.png"), fullPage: true });
const modelRow = () => p.locator(`.admin-table tbody tr:has-text("${MNAME}")`).first();
// UI 设默认(作用在禁用模型,安全)
await modelRow().locator('button:has-text("设默认")').click();
await p.waitForTimeout(900);
r.setDefault.ok = (await getModel()).is_default === true;
// UI 编辑定价 → 9
await modelRow().locator('button:has-text("编辑")').click();
await p.waitForSelector(".modal", { timeout: 6000 });
await p.locator('.modal input').nth(2).fill("9"); // 单价(供应商下拉/模型名/能力select 之后的输入… 用占位更稳)
// 更稳:用单价占位定位
await p.locator('.modal input[placeholder="0"]').fill("9");
await p.locator(".modal-f .btn-primary").click();
await p.waitForTimeout(900);
r.edit.price = (await getModel()).unit_price;
// UI 启停:禁用 → 点「启用」→ active
await modelRow().locator('button:has-text("启用")').click();
await p.waitForTimeout(900);
r.toggle.active = (await getModel()).status === "active";
// 再停用,保持收尾安全(禁用)
await modelRow().locator('button:has-text("停用")').click();
await p.waitForTimeout(700);
await ctx.close();
} finally {
await browser.close();
// 清理一次性模型 + 供应商
await fetch(`${API}/api/admin/models/${modelId}/`, { method: "DELETE", headers: authH });
await fetch(`${API}/api/admin/providers/${prov.id}/`, { method: "DELETE", headers: authH });
}
const checks = {
pageLoads: r.page.title === "模型供应商" && r.page.provRow === true && r.page.modelRow === true,
setDefault: r.setDefault.ok === true,
editPrice: r.edit.price === "9.0000",
toggle: r.toggle.active === true,
zeroConsoleErrors: r.consoleErrors.length === 0,
};
r.checks = checks;
r.pass = Object.values(checks).every(Boolean);
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "p8-summary.json"), JSON.stringify(r, null, 2));
process.exit(r.pass ? 0 : 1);
+18
View File
@@ -184,3 +184,21 @@
- 后端单测:`apps.adminpanel` **44 项 + billing + accounts = 70 OK**(流水筛/调额±+审计/超额拒/额度策略 CRUD/**额度拦截 per_task+monthly 生效 + 无策略·停用不拦(零回归)**/权限 403);billing 既有测试全绿(reserve_credit 改动零回归)。
- 无头 e2e:`_admin-p7.mjs`(5188;用一次性 P7 团队,不动 demo 余额;建的策略跑完删)—— **6 断言全过 + 0 console error**:计费页(80 流水)+ 手动调额→API 验持久化、额度策略页 + 新建→API 验 + UI 删→API 验空。截图 `output/admin/p7-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
---
## ✅ Phase 8 · 模型供应商管理(Admin)— 完成 2026-06-19
**后端**
- `ModelConfig.is_default`(bool)+ migration `0010`;`get_default_model` 优先取 `is_default=True` 的 active 模型,**未钦定回落原「最早 active」(零回归)**。
- providers:`GET/POST providers/` + `PATCH/DELETE providers/<id>/`(启停 / 改 base_url / 改 key);**api_key write-only**(只写不回传,列表给 has_api_key 布尔)。
- models:`GET/POST models/`(?provider/capability 筛)+ `PATCH/DELETE models/<id>/`(改定价 / 启停)+ `POST models/<id>/set-default/`(同 capability 其余清默认)。IsPlatformAdmin + 审计。
**前端**
- `adminApi` providers/models/setDefault 全套;`AdminProvider`/`AdminModel` 类型。
- Admin「模型供应商」页:供应商表(名/Base URL/密钥已配/模型数/启停/编辑)+ 模型表(供应商/能力/定价/状态/默认标/设默认/编辑/启停/删除)+ 供应商弹窗 + 模型弹窗。
**测试底座(全过)**
- 后端单测:`apps.adminpanel` **51 项 OK**(供应商列权限/api_key 隐藏+入库/启停/模型列筛/模型 CRUD/**set-default 改变 get_default_model + 清旧默认**/权限 403);`apps.ai` 仍只 3 个进场前既有失败(get_default_model 改动零回归)。
- 无头 e2e:`_admin-p8.mjs`(5188;⚠ 该页驱动真实路由 → 用一次性「禁用」供应商+模型 capability=export,set-default 作用在禁用模型无副作用,跑完 API 删)—— **5 断言全过 + 0 console error**:页面渲染、UI 设默认、UI 改定价→9、UI 启停。截图 `output/admin/p8-*.png`
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。