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
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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 一律
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user